From 50c2d13d9070bc6190809ee0496b14339b2ef271 Mon Sep 17 00:00:00 2001 From: Mustafa Cetin Date: Wed, 29 Jul 2026 10:53:22 +0000 Subject: [PATCH] Round 14: Self-Assessment, Learning Goals, Portfolio, Homework, Announcements --- prisma/schema.prisma | 183 +++ src/app/api/announcements/[id]/route.ts | 144 +++ src/app/api/announcements/route.ts | 157 +++ src/app/api/homework/[id]/route.ts | 160 +++ .../[id]/submissions/[submissionId]/route.ts | 103 ++ .../api/homework/[id]/submissions/route.ts | 170 +++ src/app/api/homework/route.ts | 160 +++ src/app/api/learning-goals/[id]/route.ts | 147 +++ src/app/api/learning-goals/route.ts | 123 ++ src/app/api/portfolio/[id]/route.ts | 145 +++ src/app/api/portfolio/route.ts | 123 ++ src/app/api/self-assessments/[id]/route.ts | 149 +++ src/app/api/self-assessments/route.ts | 134 +++ src/app/globals.css | 70 ++ src/components/app-layout.tsx | 111 ++ src/components/dashboard-view.tsx | 390 +++++++ src/components/homework-view.tsx | 1019 +++++++++++++++++ src/components/portfolio-view.tsx | 605 ++++++++++ src/components/student-detail-view.tsx | 661 ++++++++++- src/lib/i18n.ts | 356 ++++++ src/lib/store.ts | 4 +- worklog.md | 120 ++ 22 files changed, 5232 insertions(+), 2 deletions(-) create mode 100644 src/app/api/announcements/[id]/route.ts create mode 100644 src/app/api/announcements/route.ts create mode 100644 src/app/api/homework/[id]/route.ts create mode 100644 src/app/api/homework/[id]/submissions/[submissionId]/route.ts create mode 100644 src/app/api/homework/[id]/submissions/route.ts create mode 100644 src/app/api/homework/route.ts create mode 100644 src/app/api/learning-goals/[id]/route.ts create mode 100644 src/app/api/learning-goals/route.ts create mode 100644 src/app/api/portfolio/[id]/route.ts create mode 100644 src/app/api/portfolio/route.ts create mode 100644 src/app/api/self-assessments/[id]/route.ts create mode 100644 src/app/api/self-assessments/route.ts create mode 100644 src/components/homework-view.tsx create mode 100644 src/components/portfolio-view.tsx diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 425e453..44a41b9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -44,6 +44,11 @@ model School { backups Backup[] behaviorInterventions BehaviorIntervention[] reportSchedules ReportSchedule[] + homeworks Homework[] + announcements Announcement[] + selfAssessments SelfAssessment[] + learningGoals LearningGoal[] + portfolioEntries PortfolioEntry[] district SchoolDistrict? @relation(fields: [districtId], references: [id]) districtId String? } @@ -84,6 +89,8 @@ model User { calendarEvents CalendarEvent[] parentLinks ParentStudentLink[] assignedInterventions BehaviorIntervention[] @relation("InterventionAssignee") + homeworks Homework[] + announcements Announcement[] } model SchoolYear { @@ -130,6 +137,9 @@ model ClassGroup { drawings Drawing[] calendarEvents CalendarEvent[] reportSchedules ReportSchedule[] + homeworks Homework[] + announcements Announcement[] + selfAssessments SelfAssessment[] } model ClassGroupTeacher { @@ -168,6 +178,10 @@ model Student { behaviorIncidents BehaviorIncident[] behaviorInterventions BehaviorIntervention[] parentStudentLinks ParentStudentLink[] + homeworkSubmissions HomeworkSubmission[] + selfAssessments SelfAssessment[] + learningGoals LearningGoal[] + portfolioEntries PortfolioEntry[] } model Enrollment { @@ -206,6 +220,7 @@ model Subject { drawings Drawing[] calendarEvents CalendarEvent[] curriculumStandards CurriculumStandard[] + homeworks Homework[] } model CompetencyTemplate { @@ -254,6 +269,9 @@ model Competency { learningProgressEntries LearningProgressEntry[] assessmentCompetencyLinks AssessmentCompetencyLink[] curriculumStandardLinks CurriculumStandardLink[] + selfAssessments SelfAssessment[] + learningGoals LearningGoal[] + portfolioEntries PortfolioEntry[] } model MasteryLevelDefinition { @@ -1055,3 +1073,168 @@ model SchoolDistrict { @@map("school_districts") } + +// ─── Homework Management ────────────────────────────────────────── + +model Homework { + id String @id @default(cuid()) + schoolId String + classGroupId String + subjectId String? + teacherId String + title String + description String? + dueDate DateTime + homeworkType String @default("assignment") // assignment, reading, project, practice, research + maxPoints Float? + attachments String? // JSON array of attachment URLs + isPublished Boolean @default(true) + isDemo Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + school School @relation(fields: [schoolId], references: [id]) + classGroup ClassGroup @relation(fields: [classGroupId], references: [id]) + subject Subject? @relation(fields: [subjectId], references: [id]) + teacher User @relation(fields: [teacherId], references: [id]) + submissions HomeworkSubmission[] + + @@index([schoolId, classGroupId, dueDate]) + @@map("homework") +} + +model HomeworkSubmission { + id String @id @default(cuid()) + homeworkId String + studentId String + content String? // text submission + attachments String? // JSON array of attachment URLs + status String @default("pending") // pending, submitted, graded, late + score Float? + feedback String? + submittedAt DateTime? + gradedAt DateTime? + isDemo Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + homework Homework @relation(fields: [homeworkId], references: [id], onDelete: Cascade) + student Student @relation(fields: [studentId], references: [id]) + + @@unique([homeworkId, studentId]) + @@index([homeworkId, status]) + @@map("homework_submissions") +} + +// ─── Announcements ──────────────────────────────────────────────── + +model Announcement { + id String @id @default(cuid()) + schoolId String + authorId String + title String + content String + priority String @default("normal") // low, normal, high, urgent + targetAudience String @default("all") // all, teachers, students, parents, class + classGroupId String? // if target is specific class + isPinned Boolean @default(false) + expiresAt DateTime? + isDemo Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + school School @relation(fields: [schoolId], references: [id]) + author User @relation(fields: [authorId], references: [id]) + classGroup ClassGroup? @relation(fields: [classGroupId], references: [id]) + + @@index([schoolId, createdAt]) + @@map("announcements") +} + +// ─── Student Self-Assessment ────────────────────────────────────────── + +model SelfAssessment { + id String @id @default(cuid()) + schoolId String + studentId String + competencyId String + classGroupId String? + selfLevel Int // 1-6 self-assessed mastery + confidence Int? // 1-5 confidence level + reflection String? // student's self-reflection text + evidence String? // what evidence supports this assessment + goalId String? // linked learning goal + isDemo Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + school School @relation(fields: [schoolId], references: [id]) + student Student @relation(fields: [studentId], references: [id]) + competency Competency @relation(fields: [competencyId], references: [id]) + classGroup ClassGroup? @relation(fields: [classGroupId], references: [id]) + goal LearningGoal? @relation(fields: [goalId], references: [id]) + + @@index([schoolId, studentId]) + @@map("self_assessments") +} + +// ─── Learning Goals ──────────────────────────────────────────────── + +model LearningGoal { + id String @id @default(cuid()) + schoolId String + studentId String + competencyId String? + title String + description String? + targetLevel Int? // target mastery level + currentLevel Int? // current mastery level + deadline DateTime? + status String @default("active") // active, completed, abandoned + progress Int @default(0) // 0-100 + isDemo Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + school School @relation(fields: [schoolId], references: [id]) + student Student @relation(fields: [studentId], references: [id]) + competency Competency? @relation(fields: [competencyId], references: [id]) + selfAssessments SelfAssessment[] + + @@index([schoolId, studentId, status]) + @@map("learning_goals") +} + +// ─── Portfolio Entries ────────────────────────────────────────────── + +model PortfolioEntry { + id String @id @default(cuid()) + schoolId String + studentId String + title String + description String? + entryType String // "artwork", "writing", "project", "presentation", "achievement", "reflection" + competencyId String? + content String? // text content or description + mediaUrls String? // JSON array of media URLs + notebookPageId String? // link to notebook page + drawingId String? // link to drawing + isPublic Boolean @default(false) + tags String? // JSON array of tags + isDemo Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + school School @relation(fields: [schoolId], references: [id]) + student Student @relation(fields: [studentId], references: [id]) + competency Competency? @relation(fields: [competencyId], references: [id]) + + @@index([schoolId, studentId]) + @@map("portfolio_entries") +} diff --git a/src/app/api/announcements/[id]/route.ts b/src/app/api/announcements/[id]/route.ts new file mode 100644 index 0000000..644ef53 --- /dev/null +++ b/src/app/api/announcements/[id]/route.ts @@ -0,0 +1,144 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +const priorityEnum = z.enum(['low', 'normal', 'high', 'urgent']); +const targetAudienceEnum = z.enum(['all', 'teachers', 'students', 'parents', 'class']); + +const updateAnnouncementSchema = z.object({ + title: z.string().min(1).max(200).optional(), + content: z.string().min(1).max(10000).optional(), + priority: priorityEnum.optional(), + targetAudience: targetAudienceEnum.optional(), + classGroupId: z.string().optional().nullable(), + isPinned: z.boolean().optional(), + expiresAt: z.string().optional().nullable(), +}); + +function isTeacherOrAdmin(role: string | undefined): boolean { + return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN'; +} + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const announcement = await db.announcement.findUnique({ + where: { id, deletedAt: null }, + include: { + author: { select: { id: true, firstName: true, lastName: true } }, + classGroup: { select: { id: true, name: true } }, + }, + }); + + if (!announcement) { + return NextResponse.json({ error: 'Announcement not found' }, { status: 404 }); + } + + return NextResponse.json(announcement); + } catch (error) { + console.error('Announcement GET [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + if (!isTeacherOrAdmin(session.user?.role)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { id } = await params; + const body = await request.json(); + const parsed = updateAnnouncementSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + const existing = await db.announcement.findUnique({ where: { id, deletedAt: null } }); + if (!existing) { + return NextResponse.json({ error: 'Announcement not found' }, { status: 404 }); + } + + // Only the author or admins can edit + if (session.user?.role === 'TEACHER' && existing.authorId !== session.userId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const updateData: Record = {}; + if (parsed.data.title !== undefined) updateData.title = parsed.data.title; + if (parsed.data.content !== undefined) updateData.content = parsed.data.content; + if (parsed.data.priority !== undefined) updateData.priority = parsed.data.priority; + if (parsed.data.targetAudience !== undefined) updateData.targetAudience = parsed.data.targetAudience; + if (parsed.data.classGroupId !== undefined) updateData.classGroupId = parsed.data.classGroupId || null; + if (parsed.data.isPinned !== undefined) updateData.isPinned = parsed.data.isPinned; + if (parsed.data.expiresAt !== undefined) updateData.expiresAt = parsed.data.expiresAt ? new Date(parsed.data.expiresAt) : null; + + const announcement = await db.announcement.update({ + where: { id }, + data: updateData, + include: { + author: { select: { id: true, firstName: true, lastName: true } }, + classGroup: { select: { id: true, name: true } }, + }, + }); + + return NextResponse.json(announcement); + } catch (error) { + console.error('Announcement PUT [id] 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 }); + } + if (!isTeacherOrAdmin(session.user?.role)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { id } = await params; + const existing = await db.announcement.findUnique({ where: { id, deletedAt: null } }); + if (!existing) { + return NextResponse.json({ error: 'Announcement not found' }, { status: 404 }); + } + + if (session.user?.role === 'TEACHER' && existing.authorId !== session.userId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + await db.announcement.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Announcement DELETE [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/announcements/route.ts b/src/app/api/announcements/route.ts new file mode 100644 index 0000000..cf096a9 --- /dev/null +++ b/src/app/api/announcements/route.ts @@ -0,0 +1,157 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +const priorityEnum = z.enum(['low', 'normal', 'high', 'urgent']); +const targetAudienceEnum = z.enum(['all', 'teachers', 'students', 'parents', 'class']); + +const createAnnouncementSchema = z.object({ + schoolId: z.string().min(1), + title: z.string().min(1).max(200), + content: z.string().min(1).max(10000), + priority: priorityEnum.optional().default('normal'), + targetAudience: targetAudienceEnum.optional().default('all'), + classGroupId: z.string().optional().nullable(), + isPinned: z.boolean().optional().default(false), + expiresAt: z.string().optional().nullable(), +}); + +function isTeacherOrAdmin(role: string | undefined): boolean { + return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN'; +} + +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 schoolIdParam = searchParams.get('schoolId'); + const priority = searchParams.get('priority'); + const targetAudience = searchParams.get('targetAudience'); + const classGroupId = searchParams.get('classGroupId'); + const isPinned = searchParams.get('isPinned'); + const limit = parseInt(searchParams.get('limit') || '50', 10); + + let schoolId: string | undefined; + if (session.user?.role === 'SCHOOL_ADMIN') { + schoolId = session.user.schoolId ?? undefined; + } else { + schoolId = schoolIdParam ?? session.user?.schoolId ?? undefined; + } + + if (!schoolId) { + return NextResponse.json([]); + } + + const where: Record = { + schoolId, + deletedAt: null, + OR: [ + { expiresAt: null }, + { expiresAt: { gt: new Date() } }, + ], + }; + if (priority && priority !== 'all') where.priority = priority; + if (targetAudience && targetAudience !== 'all') where.targetAudience = targetAudience; + if (classGroupId && classGroupId !== 'all') where.classGroupId = classGroupId; + if (isPinned === 'true') where.isPinned = true; + + // Filter by role-based target audience + if (session.user?.role === 'STUDENT') { + where.OR = [ + { targetAudience: 'all' }, + { targetAudience: 'students' }, + ]; + } else if (session.user?.role === 'PARENT') { + where.OR = [ + { targetAudience: 'all' }, + { targetAudience: 'parents' }, + ]; + } + + const announcements = await db.announcement.findMany({ + where, + orderBy: [ + { isPinned: 'desc' }, + { createdAt: 'desc' }, + ], + take: limit, + include: { + author: { select: { id: true, firstName: true, lastName: true } }, + classGroup: { select: { id: true, name: true } }, + }, + }); + + return NextResponse.json(announcements); + } catch (error) { + console.error('Announcements 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 }); + } + if (!isTeacherOrAdmin(session.user?.role)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const parsed = createAnnouncementSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + const { schoolId, classGroupId, expiresAt, ...rest } = parsed.data; + + // Verify school + const school = await db.school.findUnique({ where: { id: schoolId } }); + if (!school) { + return NextResponse.json({ error: 'School not found' }, { status: 404 }); + } + if (session.user?.role === 'SCHOOL_ADMIN' && session.user.schoolId && school.id !== session.user.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + // Verify classGroup if provided + if (classGroupId) { + const cg = await db.classGroup.findUnique({ where: { id: classGroupId } }); + if (!cg || cg.schoolId !== schoolId) { + return NextResponse.json({ error: 'Class group not found in this school' }, { status: 404 }); + } + } + + const announcement = await db.announcement.create({ + data: { + schoolId, + authorId: session.userId, + title: rest.title, + content: rest.content, + priority: rest.priority, + targetAudience: rest.targetAudience, + classGroupId: classGroupId || null, + isPinned: rest.isPinned, + expiresAt: expiresAt ? new Date(expiresAt) : null, + }, + include: { + author: { select: { id: true, firstName: true, lastName: true } }, + classGroup: { select: { id: true, name: true } }, + }, + }); + + return NextResponse.json(announcement, { status: 201 }); + } catch (error) { + console.error('Announcements POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/homework/[id]/route.ts b/src/app/api/homework/[id]/route.ts new file mode 100644 index 0000000..840218b --- /dev/null +++ b/src/app/api/homework/[id]/route.ts @@ -0,0 +1,160 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +const homeworkTypeEnum = z.enum(['assignment', 'reading', 'project', 'practice', 'research']); + +const updateHomeworkSchema = z.object({ + title: z.string().min(1).max(200).optional(), + description: z.string().max(5000).optional().nullable(), + dueDate: z.string().min(1).optional(), + homeworkType: homeworkTypeEnum.optional(), + maxPoints: z.number().min(0).optional().nullable(), + attachments: z.array(z.string()).optional().nullable(), + isPublished: z.boolean().optional(), + subjectId: z.string().optional().nullable(), + classGroupId: z.string().optional(), +}); + +function isTeacherOrAdmin(role: string | undefined): boolean { + return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN'; +} + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const homework = await db.homework.findUnique({ + where: { id, deletedAt: null }, + include: { + classGroup: { select: { id: true, name: true, gradeLevel: true } }, + subject: { select: { id: true, name: true } }, + teacher: { select: { id: true, firstName: true, lastName: true } }, + submissions: { + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + }, + orderBy: { createdAt: 'desc' }, + }, + }, + }); + + if (!homework) { + return NextResponse.json({ error: 'Homework not found' }, { status: 404 }); + } + + // Students can only see published homework + if (session.user?.role === 'STUDENT' && !homework.isPublished) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + return NextResponse.json(homework); + } catch (error) { + console.error('Homework GET [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + if (!isTeacherOrAdmin(session.user?.role)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { id } = await params; + const body = await request.json(); + const parsed = updateHomeworkSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + const existing = await db.homework.findUnique({ where: { id, deletedAt: null } }); + if (!existing) { + return NextResponse.json({ error: 'Homework not found' }, { status: 404 }); + } + + // Only the teacher who created it or admins can edit + if (session.user?.role === 'TEACHER' && existing.teacherId !== session.userId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const updateData: Record = {}; + if (parsed.data.title !== undefined) updateData.title = parsed.data.title; + if (parsed.data.description !== undefined) updateData.description = parsed.data.description; + if (parsed.data.dueDate !== undefined) updateData.dueDate = new Date(parsed.data.dueDate); + if (parsed.data.homeworkType !== undefined) updateData.homeworkType = parsed.data.homeworkType; + if (parsed.data.maxPoints !== undefined) updateData.maxPoints = parsed.data.maxPoints; + if (parsed.data.attachments !== undefined) updateData.attachments = parsed.data.attachments ? JSON.stringify(parsed.data.attachments) : null; + if (parsed.data.isPublished !== undefined) updateData.isPublished = parsed.data.isPublished; + if (parsed.data.subjectId !== undefined) updateData.subjectId = parsed.data.subjectId || null; + if (parsed.data.classGroupId !== undefined) updateData.classGroupId = parsed.data.classGroupId; + + const homework = await db.homework.update({ + where: { id }, + data: updateData, + include: { + classGroup: { select: { id: true, name: true, gradeLevel: true } }, + subject: { select: { id: true, name: true } }, + teacher: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + return NextResponse.json(homework); + } catch (error) { + console.error('Homework PUT [id] 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 }); + } + if (!isTeacherOrAdmin(session.user?.role)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { id } = await params; + const existing = await db.homework.findUnique({ where: { id, deletedAt: null } }); + if (!existing) { + return NextResponse.json({ error: 'Homework not found' }, { status: 404 }); + } + + if (session.user?.role === 'TEACHER' && existing.teacherId !== session.userId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + await db.homework.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Homework DELETE [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/homework/[id]/submissions/[submissionId]/route.ts b/src/app/api/homework/[id]/submissions/[submissionId]/route.ts new file mode 100644 index 0000000..ffe7d67 --- /dev/null +++ b/src/app/api/homework/[id]/submissions/[submissionId]/route.ts @@ -0,0 +1,103 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +const gradeSchema = z.object({ + score: z.number().min(0).optional().nullable(), + feedback: z.string().max(5000).optional().nullable(), + status: z.enum(['graded', 'submitted', 'late', 'pending']).optional(), +}); + +function isTeacherOrAdmin(role: string | undefined): boolean { + return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN'; +} + +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string; submissionId: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + if (!isTeacherOrAdmin(session.user?.role)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { submissionId } = await params; + + const body = await request.json(); + const parsed = gradeSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + const existing = await db.homeworkSubmission.findUnique({ + where: { id: submissionId, deletedAt: null }, + }); + if (!existing) { + return NextResponse.json({ error: 'Submission not found' }, { status: 404 }); + } + + const updateData: Record = {}; + if (parsed.data.score !== undefined) { + updateData.score = parsed.data.score; + updateData.gradedAt = new Date(); + if (!parsed.data.status) updateData.status = 'graded'; + } + if (parsed.data.feedback !== undefined) updateData.feedback = parsed.data.feedback; + if (parsed.data.status !== undefined) updateData.status = parsed.data.status; + + const submission = await db.homeworkSubmission.update({ + where: { id: submissionId }, + data: updateData, + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + homework: { select: { id: true, title: true, maxPoints: true } }, + }, + }); + + return NextResponse.json(submission); + } catch (error) { + console.error('HomeworkSubmission PUT error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ id: string; submissionId: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + if (!isTeacherOrAdmin(session.user?.role)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { submissionId } = await params; + const existing = await db.homeworkSubmission.findUnique({ + where: { id: submissionId, deletedAt: null }, + }); + if (!existing) { + return NextResponse.json({ error: 'Submission not found' }, { status: 404 }); + } + + await db.homeworkSubmission.update({ + where: { id: submissionId }, + data: { deletedAt: new Date() }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('HomeworkSubmission DELETE error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/homework/[id]/submissions/route.ts b/src/app/api/homework/[id]/submissions/route.ts new file mode 100644 index 0000000..afea7af --- /dev/null +++ b/src/app/api/homework/[id]/submissions/route.ts @@ -0,0 +1,170 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +const submitSchema = z.object({ + content: z.string().max(10000).optional().nullable(), + attachments: z.array(z.string()).optional().nullable(), + status: z.enum(['submitted', 'late']).optional().default('submitted'), +}); + +function isTeacherOrAdmin(role: string | undefined): boolean { + return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN'; +} + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const { searchParams } = new URL(request.url); + const status = searchParams.get('status'); + + const where: Record = { homeworkId: id, deletedAt: null }; + if (status && status !== 'all') where.status = status; + + const submissions = await db.homeworkSubmission.findMany({ + where, + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + homework: { select: { id: true, title: true, maxPoints: true } }, + }, + orderBy: { createdAt: 'desc' }, + }); + + return NextResponse.json(submissions); + } catch (error) { + console.error('HomeworkSubmissions GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function POST( + 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; + + // Verify homework exists and is published + const homework = await db.homework.findUnique({ + where: { id, deletedAt: null }, + }); + if (!homework) { + return NextResponse.json({ error: 'Homework not found' }, { status: 404 }); + } + + const body = await request.json(); + const parsed = submitSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + // For students, we need to find the student record + let studentId: string | undefined; + if (session.user?.role === 'STUDENT') { + // Student users need to find their student record via enrollment + const student = await db.student.findFirst({ + where: { + schoolId: homework.schoolId, + deletedAt: null, + enrollments: { + some: { classGroupId: homework.classGroupId }, + }, + }, + }); + if (!student) { + return NextResponse.json({ error: 'Student not found in this class' }, { status: 404 }); + } + studentId = student.id; + } else if (isTeacherOrAdmin(session.user?.role)) { + // Teachers can submit on behalf of a student + studentId = body.studentId; + if (!studentId) { + return NextResponse.json({ error: 'studentId is required' }, { status: 400 }); + } + } + + if (!studentId) { + return NextResponse.json({ error: 'Cannot determine student' }, { status: 400 }); + } + + // Check for existing submission + const existing = await db.homeworkSubmission.findUnique({ + where: { homeworkId_studentId: { homeworkId: id, studentId } }, + }); + + if (existing && existing.deletedAt === null) { + // Update existing submission + const isLate = new Date() > homework.dueDate; + const submission = await db.homeworkSubmission.update({ + where: { id: existing.id }, + data: { + content: parsed.data.content ?? null, + attachments: parsed.data.attachments ? JSON.stringify(parsed.data.attachments) : null, + status: isLate ? 'late' : (parsed.data.status ?? 'submitted'), + submittedAt: new Date(), + }, + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + return NextResponse.json(submission); + } + + // If there's a soft-deleted submission, restore it + if (existing && existing.deletedAt !== null) { + const isLate = new Date() > homework.dueDate; + const submission = await db.homeworkSubmission.update({ + where: { id: existing.id }, + data: { + content: parsed.data.content ?? null, + attachments: parsed.data.attachments ? JSON.stringify(parsed.data.attachments) : null, + status: isLate ? 'late' : (parsed.data.status ?? 'submitted'), + submittedAt: new Date(), + deletedAt: null, + }, + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + return NextResponse.json(submission); + } + + // Create new submission + const isLate = new Date() > homework.dueDate; + const submission = await db.homeworkSubmission.create({ + data: { + homeworkId: id, + studentId, + content: parsed.data.content ?? null, + attachments: parsed.data.attachments ? JSON.stringify(parsed.data.attachments) : null, + status: isLate ? 'late' : (parsed.data.status ?? 'submitted'), + submittedAt: new Date(), + }, + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + return NextResponse.json(submission, { status: 201 }); + } catch (error) { + console.error('HomeworkSubmission POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/homework/route.ts b/src/app/api/homework/route.ts new file mode 100644 index 0000000..676d7a1 --- /dev/null +++ b/src/app/api/homework/route.ts @@ -0,0 +1,160 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +const homeworkTypeEnum = z.enum(['assignment', 'reading', 'project', 'practice', 'research']); + +const createHomeworkSchema = z.object({ + schoolId: z.string().min(1), + classGroupId: z.string().min(1), + subjectId: z.string().optional().nullable(), + title: z.string().min(1).max(200), + description: z.string().max(5000).optional().nullable(), + dueDate: z.string().min(1), + homeworkType: homeworkTypeEnum.optional().default('assignment'), + maxPoints: z.number().min(0).optional().nullable(), + attachments: z.array(z.string()).optional().nullable(), + isPublished: z.boolean().optional().default(true), +}); + +function isTeacherOrAdmin(role: string | undefined): boolean { + return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN'; +} + +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 schoolIdParam = searchParams.get('schoolId'); + const classGroupId = searchParams.get('classGroupId'); + const subjectId = searchParams.get('subjectId'); + const teacherId = searchParams.get('teacherId'); + const homeworkType = searchParams.get('homeworkType'); + const isPublished = searchParams.get('isPublished'); + const dueDateFrom = searchParams.get('dueDateFrom'); + const dueDateTo = searchParams.get('dueDateTo'); + + let schoolId: string | undefined; + if (session.user?.role === 'SCHOOL_ADMIN') { + schoolId = session.user.schoolId ?? undefined; + } else { + schoolId = schoolIdParam ?? session.user?.schoolId ?? undefined; + } + + if (!schoolId) { + return NextResponse.json([]); + } + + const where: Record = { schoolId, deletedAt: null }; + if (classGroupId && classGroupId !== 'all') where.classGroupId = classGroupId; + if (subjectId && subjectId !== 'all') where.subjectId = subjectId; + if (teacherId) where.teacherId = teacherId; + if (homeworkType && homeworkType !== 'all') where.homeworkType = homeworkType; + if (isPublished === 'true') where.isPublished = true; + if (isPublished === 'false') where.isPublished = false; + if (dueDateFrom || dueDateTo) { + const dateFilter: Record = {}; + if (dueDateFrom) dateFilter.gte = new Date(dueDateFrom); + if (dueDateTo) dateFilter.lte = new Date(dueDateTo); + where.dueDate = dateFilter; + } + + // For students, only show published homework + if (session.user?.role === 'STUDENT') { + where.isPublished = true; + } + + const homeworks = await db.homework.findMany({ + where, + orderBy: { dueDate: 'asc' }, + include: { + classGroup: { select: { id: true, name: true, gradeLevel: true } }, + subject: { select: { id: true, name: true } }, + teacher: { select: { id: true, firstName: true, lastName: true } }, + _count: { select: { submissions: true } }, + }, + }); + + return NextResponse.json(homeworks); + } catch (error) { + console.error('Homework 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 }); + } + if (!isTeacherOrAdmin(session.user?.role)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const parsed = createHomeworkSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + const { schoolId, classGroupId, subjectId, dueDate, attachments, ...rest } = parsed.data; + + // Verify school + const school = await db.school.findUnique({ where: { id: schoolId } }); + if (!school) { + return NextResponse.json({ error: 'School not found' }, { status: 404 }); + } + if (session.user?.role === 'SCHOOL_ADMIN' && session.user.schoolId && school.id !== session.user.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + // Verify classGroup + const classGroup = await db.classGroup.findUnique({ where: { id: classGroupId } }); + if (!classGroup || classGroup.schoolId !== schoolId) { + return NextResponse.json({ error: 'Class group not found in this school' }, { status: 404 }); + } + + // Verify subject if provided + if (subjectId) { + const subject = await db.subject.findUnique({ where: { id: subjectId } }); + if (!subject) { + return NextResponse.json({ error: 'Subject not found' }, { status: 404 }); + } + } + + const homework = await db.homework.create({ + data: { + schoolId, + classGroupId, + subjectId: subjectId || null, + teacherId: session.userId, + title: rest.title, + description: rest.description ?? null, + dueDate: new Date(dueDate), + homeworkType: rest.homeworkType, + maxPoints: rest.maxPoints ?? null, + attachments: attachments ? JSON.stringify(attachments) : null, + isPublished: rest.isPublished, + }, + include: { + classGroup: { select: { id: true, name: true, gradeLevel: true } }, + subject: { select: { id: true, name: true } }, + teacher: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + return NextResponse.json(homework, { status: 201 }); + } catch (error) { + console.error('Homework POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/learning-goals/[id]/route.ts b/src/app/api/learning-goals/[id]/route.ts new file mode 100644 index 0000000..eaa37d9 --- /dev/null +++ b/src/app/api/learning-goals/[id]/route.ts @@ -0,0 +1,147 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const goal = await db.learningGoal.findFirst({ + where: { id, deletedAt: null }, + include: { + student: { + select: { id: true, firstName: true, lastName: true }, + }, + competency: { + select: { id: true, code: true, title: true }, + }, + selfAssessments: { + where: { deletedAt: null }, + select: { id: true, selfLevel: true, confidence: true, createdAt: true }, + orderBy: { createdAt: 'desc' }, + take: 5, + }, + }, + }); + + if (!goal) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== goal.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + return NextResponse.json(goal); + } catch (error) { + console.error('LearningGoal GET [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const existing = await db.learningGoal.findFirst({ + where: { id, deletedAt: null }, + }); + + if (!existing) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== existing.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const { + title, + description, + competencyId, + targetLevel, + currentLevel, + deadline, + status, + progress, + } = body; + + const updated = await db.learningGoal.update({ + where: { id }, + data: { + ...(title !== undefined && { title }), + ...(description !== undefined && { description }), + ...(competencyId !== undefined && { competencyId: competencyId || null }), + ...(targetLevel !== undefined && { targetLevel }), + ...(currentLevel !== undefined && { currentLevel }), + ...(deadline !== undefined && { deadline: deadline ? new Date(deadline) : null }), + ...(status !== undefined && { status }), + ...(progress !== undefined && { progress }), + }, + include: { + student: { + select: { id: true, firstName: true, lastName: true }, + }, + competency: { + select: { id: true, code: true, title: true }, + }, + }, + }); + + return NextResponse.json(updated); + } catch (error) { + console.error('LearningGoal PUT [id] 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.learningGoal.findFirst({ + where: { id, deletedAt: null }, + }); + + if (!existing) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== existing.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + // Soft delete + await db.learningGoal.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('LearningGoal DELETE [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/learning-goals/route.ts b/src/app/api/learning-goals/route.ts new file mode 100644 index 0000000..05f2c29 --- /dev/null +++ b/src/app/api/learning-goals/route.ts @@ -0,0 +1,123 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +export async function GET(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const schoolId = searchParams.get('schoolId'); + const studentId = searchParams.get('studentId'); + const competencyId = searchParams.get('competencyId'); + const status = searchParams.get('status'); + + if (!schoolId) { + return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const where: Record = { + schoolId, + deletedAt: null, + }; + + if (studentId) where.studentId = studentId; + if (competencyId) where.competencyId = competencyId; + if (status) where.status = status; + + const goals = await db.learningGoal.findMany({ + where, + orderBy: { createdAt: 'desc' }, + include: { + student: { + select: { id: true, firstName: true, lastName: true }, + }, + competency: { + select: { id: true, code: true, title: true }, + }, + selfAssessments: { + where: { deletedAt: null }, + select: { id: true, selfLevel: true, confidence: true, createdAt: true }, + orderBy: { createdAt: 'desc' }, + take: 5, + }, + }, + }); + + return NextResponse.json(goals); + } catch (error) { + console.error('LearningGoal GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function POST(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const body = await request.json(); + const { + schoolId, + studentId, + competencyId, + title, + description, + targetLevel, + currentLevel, + deadline, + status, + progress, + isDemo, + } = body; + + if (!schoolId || !studentId || !title) { + return NextResponse.json( + { error: 'schoolId, studentId, and title are required' }, + { status: 400 } + ); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const goal = await db.learningGoal.create({ + data: { + schoolId, + studentId, + competencyId: competencyId || null, + title, + description: description || null, + targetLevel: targetLevel || null, + currentLevel: currentLevel || null, + deadline: deadline ? new Date(deadline) : null, + status: status || 'active', + progress: progress || 0, + isDemo: isDemo ?? false, + }, + include: { + student: { + select: { id: true, firstName: true, lastName: true }, + }, + competency: { + select: { id: true, code: true, title: true }, + }, + }, + }); + + return NextResponse.json(goal, { status: 201 }); + } catch (error) { + console.error('LearningGoal POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/portfolio/[id]/route.ts b/src/app/api/portfolio/[id]/route.ts new file mode 100644 index 0000000..519a3e9 --- /dev/null +++ b/src/app/api/portfolio/[id]/route.ts @@ -0,0 +1,145 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const entry = await db.portfolioEntry.findFirst({ + where: { id, deletedAt: null }, + include: { + student: { + select: { id: true, firstName: true, lastName: true }, + }, + competency: { + select: { id: true, code: true, title: true }, + }, + }, + }); + + if (!entry) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== entry.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + return NextResponse.json(entry); + } catch (error) { + console.error('PortfolioEntry GET [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const existing = await db.portfolioEntry.findFirst({ + where: { id, deletedAt: null }, + }); + + if (!existing) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== existing.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const { + title, + description, + entryType, + competencyId, + content, + mediaUrls, + notebookPageId, + drawingId, + isPublic, + tags, + } = body; + + const updated = await db.portfolioEntry.update({ + where: { id }, + data: { + ...(title !== undefined && { title }), + ...(description !== undefined && { description }), + ...(entryType !== undefined && { entryType }), + ...(competencyId !== undefined && { competencyId: competencyId || null }), + ...(content !== undefined && { content }), + ...(mediaUrls !== undefined && { mediaUrls: mediaUrls ? JSON.stringify(mediaUrls) : null }), + ...(notebookPageId !== undefined && { notebookPageId: notebookPageId || null }), + ...(drawingId !== undefined && { drawingId: drawingId || null }), + ...(isPublic !== undefined && { isPublic }), + ...(tags !== undefined && { tags: tags ? JSON.stringify(tags) : null }), + }, + include: { + student: { + select: { id: true, firstName: true, lastName: true }, + }, + competency: { + select: { id: true, code: true, title: true }, + }, + }, + }); + + return NextResponse.json(updated); + } catch (error) { + console.error('PortfolioEntry PUT [id] 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.portfolioEntry.findFirst({ + where: { id, deletedAt: null }, + }); + + if (!existing) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== existing.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + // Soft delete + await db.portfolioEntry.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('PortfolioEntry DELETE [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/portfolio/route.ts b/src/app/api/portfolio/route.ts new file mode 100644 index 0000000..dd6f6c6 --- /dev/null +++ b/src/app/api/portfolio/route.ts @@ -0,0 +1,123 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +export async function GET(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const schoolId = searchParams.get('schoolId'); + const studentId = searchParams.get('studentId'); + const entryType = searchParams.get('entryType'); + const competencyId = searchParams.get('competencyId'); + const isPublic = searchParams.get('isPublic'); + + if (!schoolId) { + return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const where: Record = { + schoolId, + deletedAt: null, + }; + + if (studentId) where.studentId = studentId; + if (entryType) where.entryType = entryType; + if (competencyId) where.competencyId = competencyId; + if (isPublic !== null && isPublic !== undefined) where.isPublic = isPublic === 'true'; + + const entries = await db.portfolioEntry.findMany({ + where, + orderBy: { createdAt: 'desc' }, + include: { + student: { + select: { id: true, firstName: true, lastName: true }, + }, + competency: { + select: { id: true, code: true, title: true }, + }, + }, + }); + + return NextResponse.json(entries); + } catch (error) { + console.error('PortfolioEntry GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function POST(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const body = await request.json(); + const { + schoolId, + studentId, + title, + description, + entryType, + competencyId, + content, + mediaUrls, + notebookPageId, + drawingId, + isPublic, + tags, + isDemo, + } = body; + + if (!schoolId || !studentId || !title || !entryType) { + return NextResponse.json( + { error: 'schoolId, studentId, title, and entryType are required' }, + { status: 400 } + ); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const entry = await db.portfolioEntry.create({ + data: { + schoolId, + studentId, + title, + description: description || null, + entryType, + competencyId: competencyId || null, + content: content || null, + mediaUrls: mediaUrls ? JSON.stringify(mediaUrls) : null, + notebookPageId: notebookPageId || null, + drawingId: drawingId || null, + isPublic: isPublic ?? false, + tags: tags ? JSON.stringify(tags) : null, + isDemo: isDemo ?? false, + }, + include: { + student: { + select: { id: true, firstName: true, lastName: true }, + }, + competency: { + select: { id: true, code: true, title: true }, + }, + }, + }); + + return NextResponse.json(entry, { status: 201 }); + } catch (error) { + console.error('PortfolioEntry POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/self-assessments/[id]/route.ts b/src/app/api/self-assessments/[id]/route.ts new file mode 100644 index 0000000..0e56dcb --- /dev/null +++ b/src/app/api/self-assessments/[id]/route.ts @@ -0,0 +1,149 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const assessment = await db.selfAssessment.findFirst({ + where: { id, deletedAt: null }, + include: { + student: { + select: { id: true, firstName: true, lastName: true }, + }, + competency: { + select: { id: true, code: true, title: true }, + }, + classGroup: { + select: { id: true, name: true }, + }, + goal: { + select: { id: true, title: true, status: true }, + }, + }, + }); + + if (!assessment) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== assessment.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + return NextResponse.json(assessment); + } catch (error) { + console.error('SelfAssessment GET [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const existing = await db.selfAssessment.findFirst({ + where: { id, deletedAt: null }, + }); + + if (!existing) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== existing.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const { + selfLevel, + confidence, + reflection, + evidence, + goalId, + classGroupId, + } = body; + + const updated = await db.selfAssessment.update({ + where: { id }, + data: { + ...(selfLevel !== undefined && { selfLevel }), + ...(confidence !== undefined && { confidence: confidence || null }), + ...(reflection !== undefined && { reflection }), + ...(evidence !== undefined && { evidence }), + ...(goalId !== undefined && { goalId: goalId || null }), + ...(classGroupId !== undefined && { classGroupId: classGroupId || null }), + }, + include: { + student: { + select: { id: true, firstName: true, lastName: true }, + }, + competency: { + select: { id: true, code: true, title: true }, + }, + classGroup: { + select: { id: true, name: true }, + }, + goal: { + select: { id: true, title: true, status: true }, + }, + }, + }); + + return NextResponse.json(updated); + } catch (error) { + console.error('SelfAssessment PUT [id] 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.selfAssessment.findFirst({ + where: { id, deletedAt: null }, + }); + + if (!existing) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== existing.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + // Soft delete + await db.selfAssessment.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('SelfAssessment DELETE [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/self-assessments/route.ts b/src/app/api/self-assessments/route.ts new file mode 100644 index 0000000..7a285f4 --- /dev/null +++ b/src/app/api/self-assessments/route.ts @@ -0,0 +1,134 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +export async function GET(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const schoolId = searchParams.get('schoolId'); + const studentId = searchParams.get('studentId'); + const competencyId = searchParams.get('competencyId'); + const classGroupId = searchParams.get('classGroupId'); + + if (!schoolId) { + return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const where: Record = { + schoolId, + deletedAt: null, + }; + + if (studentId) where.studentId = studentId; + if (competencyId) where.competencyId = competencyId; + if (classGroupId) where.classGroupId = classGroupId; + + const assessments = await db.selfAssessment.findMany({ + where, + orderBy: { createdAt: 'desc' }, + include: { + student: { + select: { id: true, firstName: true, lastName: true }, + }, + competency: { + select: { id: true, code: true, title: true }, + }, + classGroup: { + select: { id: true, name: true }, + }, + goal: { + select: { id: true, title: true, status: true }, + }, + }, + }); + + return NextResponse.json(assessments); + } catch (error) { + console.error('SelfAssessment GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function POST(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const body = await request.json(); + const { + schoolId, + studentId, + competencyId, + classGroupId, + selfLevel, + confidence, + reflection, + evidence, + goalId, + isDemo, + } = body; + + if (!schoolId || !studentId || !competencyId || selfLevel === undefined) { + return NextResponse.json( + { error: 'schoolId, studentId, competencyId, and selfLevel are required' }, + { status: 400 } + ); + } + + if (selfLevel < 1 || selfLevel > 6) { + return NextResponse.json( + { error: 'selfLevel must be between 1 and 6' }, + { status: 400 } + ); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const assessment = await db.selfAssessment.create({ + data: { + schoolId, + studentId, + competencyId, + classGroupId: classGroupId || null, + selfLevel, + confidence: confidence || null, + reflection: reflection || null, + evidence: evidence || null, + goalId: goalId || null, + isDemo: isDemo ?? false, + }, + include: { + student: { + select: { id: true, firstName: true, lastName: true }, + }, + competency: { + select: { id: true, code: true, title: true }, + }, + classGroup: { + select: { id: true, name: true }, + }, + goal: { + select: { id: true, title: true, status: true }, + }, + }, + }); + + return NextResponse.json(assessment, { status: 201 }); + } catch (error) { + console.error('SelfAssessment POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index be920db..7b472dc 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -186,6 +186,76 @@ background-size: 24px 24px; } + /* Announcement banner styling */ + .announcement-banner { + transition: all 0.3s ease; + } + .announcement-banner:hover { + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); + } + + /* Homework card styling */ + .homework-card { + transition: all 0.2s ease; + } + .homework-card:hover { + transform: translateY(-1px); + } + + /* Priority color classes */ + .priority-urgent { + border-color: oklch(0.577 0.245 27.325); + background-color: oklch(0.95 0.05 27); + } + .priority-high { + border-color: oklch(0.72 0.19 85); + background-color: oklch(0.95 0.05 85); + } + .priority-normal { + border-color: oklch(0.55 0.17 155); + background-color: oklch(0.95 0.05 155); + } + .priority-low { + border-color: oklch(0.53 0.14 175); + background-color: oklch(0.95 0.05 175); + } + .dark .priority-urgent { + border-color: oklch(0.577 0.245 27.325); + background-color: oklch(0.18 0.05 27); + } + .dark .priority-high { + border-color: oklch(0.72 0.19 85); + background-color: oklch(0.18 0.05 85); + } + .dark .priority-normal { + border-color: oklch(0.55 0.17 155); + background-color: oklch(0.18 0.05 155); + } + .dark .priority-low { + border-color: oklch(0.53 0.14 175); + background-color: oklch(0.18 0.05 175); + } + + /* Due date color classes */ + .due-overdue { + border-color: oklch(0.577 0.245 27.325); + } + .due-today { + border-color: oklch(0.72 0.19 85); + } + .due-upcoming { + border-color: oklch(0.55 0.17 155); + } + .dark .due-overdue { + border-color: oklch(0.577 0.245 27.325); + } + .dark .due-today { + border-color: oklch(0.72 0.19 85); + } + .dark .due-upcoming { + border-color: oklch(0.55 0.17 155); + } + /* Smooth transitions for all interactive elements */ .interactive-transition { transition: all 0.2s ease-out; diff --git a/src/components/app-layout.tsx b/src/components/app-layout.tsx index 80ad546..cd0a667 100644 --- a/src/components/app-layout.tsx +++ b/src/components/app-layout.tsx @@ -52,6 +52,9 @@ import { UserCheck, BarChart3, Trash2, + BookCheck, + Megaphone, + Briefcase, } from 'lucide-react'; import { Sidebar, @@ -101,6 +104,7 @@ import { ScrollArea } from '@/components/ui/scroll-area'; import { useAppStore, type ViewName } from '@/lib/store'; import { t } from '@/lib/i18n'; import { fetchSchoolYears, fetchStudents, fetchClasses, fetchDBNotifications, markAllNotificationsRead, markSingleNotificationRead, type SchoolYear, type Student, type ClassGroup, type DBNotification, type DBNotificationData } from '@/lib/api'; +import { apiGet } from '@/lib/api'; import OnboardingTour, { isOnboardingCompleted } from '@/components/onboarding-tour'; import KeyboardShortcutsDialog from '@/components/keyboard-shortcuts-dialog'; import { @@ -139,6 +143,8 @@ import RubricLibraryView from './rubric-library-view'; import CommentBankView from './comment-bank-view'; import NotebooksView from './notebooks-view'; import DrawingView from './drawing-view'; +import HomeworkView from './homework-view'; +import PortfolioView from './portfolio-view'; type NavItem = { key: ViewName; icon: React.ElementType; labelKey: string }; type NavSection = { id: string; labelKey: string; items: NavItem[] }; @@ -169,10 +175,12 @@ const navSections: NavSection[] = [ { key: 'lesson-plans', icon: CalendarDays, labelKey: 'nav.lesson_plans' }, { key: 'parents', icon: Mail, labelKey: 'nav.parents' }, { key: 'behavior', icon: Shield, labelKey: 'nav.behavior' }, + { key: 'homework', icon: BookCheck, labelKey: 'nav.homework' }, { key: 'rubrics', icon: Ruler, labelKey: 'nav.rubrics' }, { key: 'comments', icon: MessageSquareText, labelKey: 'nav.comments' }, { key: 'notebooks', icon: BookOpen, labelKey: 'nav.notebooks' }, { key: 'drawing', icon: Palette, labelKey: 'nav.drawing' }, + { key: 'portfolio', icon: Briefcase, labelKey: 'nav.portfolio' }, ], }, { @@ -195,7 +203,9 @@ const studentNavSections: NavSection[] = [ { key: 'notebooks', icon: BookOpen, labelKey: 'nav.student_notebooks' }, { key: 'flower', icon: Flower2, labelKey: 'nav.student_competencies' }, { key: 'grading', icon: Calculator, labelKey: 'nav.student_grades' }, + { key: 'homework', icon: BookCheck, labelKey: 'nav.homework' }, { key: 'attendance', icon: CalendarCheck, labelKey: 'nav.student_attendance' }, + { key: 'portfolio', icon: Briefcase, labelKey: 'nav.portfolio' }, { key: 'calendar', icon: CalendarIconNav, labelKey: 'nav.calendar' }, ], }, @@ -215,6 +225,103 @@ const parentNavSections: NavSection[] = [ }, ]; +/* ── Announcement Banner ────────────────────────────────────────── */ + +interface AnnouncementItem { + id: string; + title: string; + content: string; + priority: string; + isPinned: boolean; + author: { id: string; firstName: string; lastName: string }; + classGroup: { id: string; name: string } | null; + createdAt: string; +} + +function getPriorityColor(priority: string): string { + switch (priority) { + case 'urgent': return 'bg-rose-50 border-rose-300 dark:bg-rose-950/50 dark:border-rose-800'; + case 'high': return 'bg-amber-50 border-amber-300 dark:bg-amber-950/50 dark:border-amber-800'; + case 'normal': return 'bg-emerald-50 border-emerald-300 dark:bg-emerald-950/50 dark:border-emerald-800'; + case 'low': return 'bg-teal-50 border-teal-300 dark:bg-teal-950/50 dark:border-teal-800'; + default: return 'bg-emerald-50 border-emerald-300 dark:bg-emerald-950/50 dark:border-emerald-800'; + } +} + +function getPriorityIcon(priority: string) { + switch (priority) { + case 'urgent': return AlertTriangle; + case 'high': return Info; + default: return Megaphone; + } +} + +function AnnouncementBanner({ schoolId }: { schoolId: string | null }) { + const [announcements, setAnnouncements] = useState([]); + const [dismissed, setDismissed] = useState>(new Set()); + + useEffect(() => { + try { + const stored = localStorage.getItem('ct_dismissed_announcements'); + if (stored) setDismissed(new Set(JSON.parse(stored))); + } catch { /* ignore */ } + }, []); + + useEffect(() => { + if (!schoolId) return; + apiGet(`/api/announcements?schoolId=${schoolId}&isPinned=true&limit=5`) + .then((data) => setAnnouncements(data)) + .catch(() => {}); + }, [schoolId]); + + const visibleAnnouncements = announcements.filter((a) => !dismissed.has(a.id)); + + const handleDismiss = (id: string) => { + const newDismissed = new Set(dismissed); + newDismissed.add(id); + setDismissed(newDismissed); + try { + localStorage.setItem('ct_dismissed_announcements', JSON.stringify([...newDismissed])); + } catch { /* ignore */ } + }; + + if (visibleAnnouncements.length === 0) return null; + + return ( +
+ + {visibleAnnouncements.map((announcement) => { + const PriorityIcon = getPriorityIcon(announcement.priority); + return ( + + +
+ {announcement.title} + {announcement.content.slice(0, 100)}{announcement.content.length > 100 ? '...' : ''} +
+ +
+ ); + })} +
+
+ ); +} + function renderView(view: ViewName) { switch (view) { case 'dashboard': return ; @@ -239,6 +346,8 @@ function renderView(view: ViewName) { case 'comments': return ; case 'notebooks': return ; case 'drawing': return ; + case 'homework': return ; + case 'portfolio': return ; default: return ; } } @@ -775,6 +884,8 @@ export default function AppLayout() { + {/* Announcement Banner */} +
diff --git a/src/components/dashboard-view.tsx b/src/components/dashboard-view.tsx index fe4d4fa..1ed4c1c 100644 --- a/src/components/dashboard-view.tsx +++ b/src/components/dashboard-view.tsx @@ -30,6 +30,7 @@ import { PartyPopper, Lightbulb, ChevronRight, + ChevronDown, RefreshCw, GraduationCap, UserCheck, @@ -47,6 +48,11 @@ import { Calendar as CalendarIconNav, BookMarked, Heart, + BookCheck, + Megaphone, + Pin, + X, + Send, } from 'lucide-react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -55,6 +61,25 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; import { Separator } from '@/components/ui/separator'; +import { Textarea } from '@/components/ui/textarea'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { toast } from 'sonner'; import { Tooltip, TooltipContent, @@ -64,6 +89,7 @@ import { import { useAppStore, type ViewName, type CurrentUser } from '@/lib/store'; import { t } from '@/lib/i18n'; import { fetchDashboard, type DashboardData, getStoredNotifications, type AppNotification, addNotification, markNotificationsRead, fetchParentLinks, type ParentStudentLinkData, fetchStudents } from '@/lib/api'; +import { apiGet, apiPost } from '@/lib/api'; const containerVariants = { hidden: { opacity: 0 }, @@ -1727,6 +1753,15 @@ export default function DashboardView() { + {/* ===== ANNOUNCEMENTS & HOMEWORK DUE ===== */} +
+ {/* Announcements Card */} + + + {/* Homework Due Soon Card */} + +
+ {/* ===== PAPER SAVED & ENVIRONMENTAL SECTION ===== */}
{/* Paper Saved Counter */} @@ -1812,3 +1847,358 @@ export default function DashboardView() { ); } + +/* ── Dashboard Announcements Card ──────────────────────────────── */ + +interface DashboardAnnouncement { + id: string; + title: string; + content: string; + priority: string; + isPinned: boolean; + targetAudience: string; + author: { id: string; firstName: string; lastName: string }; + classGroup: { id: string; name: string } | null; + createdAt: string; +} + +function DashboardAnnouncementsCard({ currentUser, locale }: { currentUser: CurrentUser | null; locale: string }) { + const setCurrentView = useAppStore((s) => s.setCurrentView); + const [announcements, setAnnouncements] = useState([]); + const [loading, setLoading] = useState(true); + const [expanded, setExpanded] = useState(null); + const [createOpen, setCreateOpen] = useState(false); + const [createForm, setCreateForm] = useState({ title: '', content: '', priority: 'normal', targetAudience: 'all' }); + const [creating, setCreating] = useState(false); + + const isTeacherOrAdmin = currentUser?.role === 'TEACHER' || currentUser?.role === 'SCHOOL_ADMIN' || currentUser?.role === 'SUPER_ADMIN'; + + useEffect(() => { + if (!currentUser?.schoolId) return; + apiGet(`/api/announcements?schoolId=${currentUser.schoolId}&limit=5`) + .then((data) => { setAnnouncements(data); setLoading(false); }) + .catch(() => setLoading(false)); + }, [currentUser?.schoolId]); + + const handleCreate = async () => { + if (!currentUser?.schoolId || !createForm.title || !createForm.content) return; + setCreating(true); + try { + await apiPost('/api/announcements', { + schoolId: currentUser.schoolId, + title: createForm.title, + content: createForm.content, + priority: createForm.priority, + targetAudience: createForm.targetAudience, + }); + toast.success(t('announcement.create_success')); + setCreateOpen(false); + setCreateForm({ title: '', content: '', priority: 'normal', targetAudience: 'all' }); + // Reload + const data = await apiGet(`/api/announcements?schoolId=${currentUser.schoolId}&limit=5`); + setAnnouncements(data); + } catch (err) { + toast.error(err instanceof Error ? err.message : t('announcement.create_error')); + } finally { + setCreating(false); + } + }; + + const getPriorityBorder = (priority: string) => { + switch (priority) { + case 'urgent': return 'border-l-rose-500'; + case 'high': return 'border-l-amber-500'; + case 'normal': return 'border-l-emerald-500'; + case 'low': return 'border-l-teal-500'; + default: return 'border-l-emerald-500'; + } + }; + + const getPriorityBadge = (priority: string) => { + switch (priority) { + case 'urgent': return 'bg-rose-100 text-rose-700 dark:bg-rose-950 dark:text-rose-300'; + case 'high': return 'bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-300'; + case 'normal': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300'; + case 'low': return 'bg-teal-100 text-teal-700 dark:bg-teal-950 dark:text-teal-300'; + default: return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300'; + } + }; + + const relativeTime = (dateStr: string) => { + const now = new Date(); + const date = new Date(dateStr); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / (1000 * 60)); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + if (diffMins < 1) return locale === 'de' ? 'Gerade eben' : 'Just now'; + if (diffMins < 60) return locale === 'de' ? `Vor ${diffMins} Min.` : `${diffMins}m ago`; + if (diffHours < 24) return locale === 'de' ? `Vor ${diffHours} Std.` : `${diffHours}h ago`; + if (diffDays === 1) return t('date.yesterday'); + if (diffDays < 7) return t('date.days_ago', { count: diffDays }); + return date.toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', { day: '2-digit', month: 'short' }); + }; + + return ( + + + +
+ +
+ +
+ {t('announcement.title')} +
+ {isTeacherOrAdmin && ( + + + + + + + {t('announcement.create_title')} + {t('announcement.create_description')} + +
+
+ + setCreateForm({ ...createForm, title: e.target.value })} placeholder={t('announcement.field_title_placeholder')} /> +
+
+ +