From 3d0d518bb980f6491b00dda3b90f06b81b94ee1f Mon Sep 17 00:00:00 2001 From: Mustafa Cetin Date: Thu, 30 Jul 2026 06:36:34 +0000 Subject: [PATCH] Round 25: Substitute Teacher, School Newsletter, Career Guidance, Styling - Substitute Teacher Management View: pool, absences, assignments, statistics, weekly schedule - School Newsletter View: editor, templates, distribution, analytics, open/click tracking - Student Career & Guidance View: career exploration, interest quiz, portfolio, goals, appointments - 6 new Prisma models: SubstituteTeacher, TeacherAbsence, SubstitutionAssignment, CareerProfile, CareerGoal, CareerAppointment - Enhanced Newsletter model with content, scheduling, and analytics fields - Enhanced settings-view with gradient header, glassmorphism cards, danger zone - Enhanced student-portal-view with animated welcome banner, quick action cards - 16+ new API route files - 300+ i18n keys added - All views verified working in browser --- prisma/schema.prisma | 181 ++ src/app/api/career/appointments/[id]/route.ts | 111 + src/app/api/career/appointments/route.ts | 127 ++ src/app/api/career/goals/[id]/route.ts | 94 + src/app/api/career/goals/route.ts | 73 + src/app/api/career/profile/route.ts | 134 ++ src/app/api/career/route.ts | 127 ++ src/app/api/data-cleanup/route.ts | 0 src/app/api/data-export/jobs/[id]/route.ts | 0 src/app/api/data-export/jobs/route.ts | 0 src/app/api/library/books/[id]/route.ts | 0 src/app/api/library/books/route.ts | 0 src/app/api/library/checkouts/[id]/route.ts | 0 src/app/api/library/checkouts/route.ts | 0 .../api/library/reservations/[id]/route.ts | 0 src/app/api/library/reservations/route.ts | 0 src/app/api/library/stats/route.ts | 0 src/app/api/newsletters/[id]/route.ts | 54 +- src/app/api/newsletters/[id]/send/route.ts | 115 + src/app/api/newsletters/[id]/stats/route.ts | 172 ++ src/app/api/newsletters/route.ts | 45 +- .../api/parent-portal/child-progress/route.ts | 0 .../api/parent-portal/child-schedule/route.ts | 0 src/app/api/report-cards/[id]/route.ts | 0 src/app/api/report-cards/export/route.ts | 0 src/app/api/report-cards/generate/route.ts | 0 src/app/api/report-cards/route.ts | 0 src/app/api/report-cards/templates/route.ts | 0 .../api/school-events/[id]/feedback/route.ts | 0 .../api/seating-charts/[id]/arrange/route.ts | 0 src/app/api/seating-charts/[id]/route.ts | 0 src/app/api/seating-charts/route.ts | 0 src/app/api/substitutes/[id]/route.ts | 95 + .../api/substitutes/absences/[id]/route.ts | 89 + src/app/api/substitutes/absences/route.ts | 89 + .../api/substitutes/assignments/[id]/route.ts | 126 ++ src/app/api/substitutes/assignments/route.ts | 117 ++ src/app/api/substitutes/route.ts | 94 + src/app/api/wellness/route.ts | 0 src/app/api/wellness/summary/route.ts | 0 src/app/api/wellness/trends/route.ts | 0 src/app/globals.css | 126 ++ src/components/app-layout.tsx | 17 + src/components/data-import-export-view.tsx | 0 src/components/report-card-view.tsx | 0 src/components/school-events-view.tsx | 0 src/components/school-library-view.tsx | 0 src/components/school-newsletter-view.tsx | 1558 ++++++++++++++ src/components/seating-chart-view.tsx | 0 src/components/settings-view.tsx | 156 +- src/components/student-career-view.tsx | 1750 ++++++++++++++++ src/components/student-portal-view.tsx | 157 +- src/components/student-wellness-view.tsx | 0 src/components/substitute-teacher-view.tsx | 1849 +++++++++++++++++ src/lib/i18n.ts | 676 ++++++ src/lib/store.ts | 5 +- worklog.md | 281 +++ 57 files changed, 8370 insertions(+), 48 deletions(-) create mode 100644 src/app/api/career/appointments/[id]/route.ts create mode 100644 src/app/api/career/appointments/route.ts create mode 100644 src/app/api/career/goals/[id]/route.ts create mode 100644 src/app/api/career/goals/route.ts create mode 100644 src/app/api/career/profile/route.ts create mode 100644 src/app/api/career/route.ts mode change 100644 => 100755 src/app/api/data-cleanup/route.ts mode change 100644 => 100755 src/app/api/data-export/jobs/[id]/route.ts mode change 100644 => 100755 src/app/api/data-export/jobs/route.ts mode change 100644 => 100755 src/app/api/library/books/[id]/route.ts mode change 100644 => 100755 src/app/api/library/books/route.ts mode change 100644 => 100755 src/app/api/library/checkouts/[id]/route.ts mode change 100644 => 100755 src/app/api/library/checkouts/route.ts mode change 100644 => 100755 src/app/api/library/reservations/[id]/route.ts mode change 100644 => 100755 src/app/api/library/reservations/route.ts mode change 100644 => 100755 src/app/api/library/stats/route.ts create mode 100644 src/app/api/newsletters/[id]/send/route.ts create mode 100644 src/app/api/newsletters/[id]/stats/route.ts mode change 100644 => 100755 src/app/api/parent-portal/child-progress/route.ts mode change 100644 => 100755 src/app/api/parent-portal/child-schedule/route.ts mode change 100644 => 100755 src/app/api/report-cards/[id]/route.ts mode change 100644 => 100755 src/app/api/report-cards/export/route.ts mode change 100644 => 100755 src/app/api/report-cards/generate/route.ts mode change 100644 => 100755 src/app/api/report-cards/route.ts mode change 100644 => 100755 src/app/api/report-cards/templates/route.ts mode change 100644 => 100755 src/app/api/school-events/[id]/feedback/route.ts mode change 100644 => 100755 src/app/api/seating-charts/[id]/arrange/route.ts mode change 100644 => 100755 src/app/api/seating-charts/[id]/route.ts mode change 100644 => 100755 src/app/api/seating-charts/route.ts create mode 100644 src/app/api/substitutes/[id]/route.ts create mode 100644 src/app/api/substitutes/absences/[id]/route.ts create mode 100644 src/app/api/substitutes/absences/route.ts create mode 100644 src/app/api/substitutes/assignments/[id]/route.ts create mode 100644 src/app/api/substitutes/assignments/route.ts create mode 100644 src/app/api/substitutes/route.ts mode change 100644 => 100755 src/app/api/wellness/route.ts mode change 100644 => 100755 src/app/api/wellness/summary/route.ts mode change 100644 => 100755 src/app/api/wellness/trends/route.ts mode change 100644 => 100755 src/components/data-import-export-view.tsx mode change 100644 => 100755 src/components/report-card-view.tsx mode change 100644 => 100755 src/components/school-events-view.tsx mode change 100644 => 100755 src/components/school-library-view.tsx create mode 100644 src/components/school-newsletter-view.tsx mode change 100644 => 100755 src/components/seating-chart-view.tsx create mode 100644 src/components/student-career-view.tsx mode change 100644 => 100755 src/components/student-wellness-view.tsx create mode 100644 src/components/substitute-teacher-view.tsx diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 230857a..2e5c771 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -108,6 +108,11 @@ model School { dataExportJobs DataExportJob[] wellnessCheckins WellnessCheckin[] wellnessScores WellnessScore[] + careerProfiles CareerProfile[] + careerAppointments CareerAppointment[] + substituteTeachers SubstituteTeacher[] + teacherAbsences TeacherAbsence[] + substitutionAssignments SubstitutionAssignment[] } model User { @@ -194,6 +199,9 @@ model User { bookReservations BookReservation[] dataImportJobs DataImportJob[] dataExportJobs DataExportJob[] + careerAppointments CareerAppointment[] + substituteTeacher SubstituteTeacher? + teacherAbsences TeacherAbsence[] } model SchoolYear { @@ -252,6 +260,7 @@ model ClassGroup { aiTestGenerations AITestGeneration[] examPlans ExamPlan[] seatingCharts SeatingChart[] + substitutionAssignments SubstitutionAssignment[] } model ClassGroupTeacher { @@ -314,6 +323,7 @@ model Student { bookReservations BookReservation[] wellnessCheckins WellnessCheckin[] wellnessScores WellnessScore[] + careerProfile CareerProfile? @@index([userId]) } @@ -364,6 +374,7 @@ model Subject { examPlans ExamPlan[] studyPlans StudyPlan[] studySessions StudySession[] + substitutionAssignments SubstitutionAssignment[] } model CompetencyTemplate { @@ -1935,13 +1946,24 @@ model Newsletter { schoolId String authorId String title String + subject String? // email subject line content String // rich text content (HTML) summary String? // short summary for preview imageUrl String? // cover image URL + bannerImageUrl String? // banner image URL for newsletter header category String @default("general") // general, academic, sports, arts, events, community + templateType String @default("monthly") // monthly, event, parent, emergency + targetAudience String? // JSON: { type: "all" | "classes" | "roles", ids: [...] } + status String @default("draft") // draft, scheduled, sent, archived isPublished Boolean @default(false) publishedAt DateTime? + scheduledAt DateTime? + sentAt DateTime? tags String? // JSON array of tags + openCount Int @default(0) + clickCount Int @default(0) + bounceCount Int @default(0) + totalRecipients Int @default(0) isDemo Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -1951,6 +1973,7 @@ model Newsletter { author User @relation(fields: [authorId], references: [id]) @@index([schoolId, isPublished, publishedAt]) + @@index([schoolId, status]) @@map("newsletters") } @@ -2929,3 +2952,161 @@ model WellnessScore { @@index([date]) @@map("wellness_scores") } + +// ─── Student Career & Guidance ─────────────────────────────────────── + +model CareerProfile { + id String @id @default(cuid()) + schoolId String + studentId String @unique + interests String? // JSON: ["technology", "health", "business"] + strengths String? // JSON: ["mathematics", "communication"] + careerCluster String? // Technology, Health, Business, Arts, Science, Social + desiredCareer String? + educationPath String? // Hauptschule, Realschule, Gymnasium, Berufsschule + workExperiences String? // JSON: [{ company, role, startDate, endDate }] + volunteerExps String? // JSON: [{ organization, role, startDate, endDate }] + certifications String? // JSON: [{ name, date, issuer }] + documents String? // JSON: [{ name, url, type }] + notes String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + school School @relation(fields: [schoolId], references: [id]) + student Student @relation(fields: [studentId], references: [id]) + goals CareerGoal[] + appointments CareerAppointment[] + + @@index([schoolId]) + @@index([studentId]) + @@index([careerCluster]) + @@map("career_profiles") +} + +model CareerGoal { + id String @id @default(cuid()) + profileId String + title String + description String? + category String // short_term, long_term, academic, professional + targetDate DateTime? + progress Int @default(0) // 0-100 + status String @default("active") // active, completed, abandoned + milestones String? // JSON: [{ title, completed, date }] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + profile CareerProfile @relation(fields: [profileId], references: [id], onDelete: Cascade) + + @@index([profileId]) + @@index([category]) + @@index([status]) + @@map("career_goals") +} + +model CareerAppointment { + id String @id @default(cuid()) + schoolId String + profileId String + counselorId String + date DateTime + duration Int @default(30) // minutes + type String @default("guidance") // guidance, career, academic, college + status String @default("scheduled") // scheduled, completed, cancelled + notes String? + actionItems String? // JSON: [{ item, completed }] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + school School @relation(fields: [schoolId], references: [id]) + profile CareerProfile @relation(fields: [profileId], references: [id], onDelete: Cascade) + counselor User @relation(fields: [counselorId], references: [id]) + + @@index([schoolId]) + @@index([profileId]) + @@index([counselorId]) + @@index([date]) + @@index([status]) + @@map("career_appointments") +} + +model SubstituteTeacher { + id String @id @default(cuid()) + schoolId String + userId String? @unique // optional link to User account + firstName String + lastName String + email String? + phone String? + qualifications String? // JSON: ["Mathematics", "Physics", "German"] + subjects String? // JSON: subject IDs they can teach + gradeLevels String? // JSON: ["1-4", "5-6", "7-10"] + availability String? // JSON: { "monday": ["08:00-12:00"], "tuesday": [...] } + maxDaysPerWeek Int @default(5) + rating Float @default(0) // 0-5 average rating + totalAssignments Int @default(0) + notes String? + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + school School @relation(fields: [schoolId], references: [id]) + user User? @relation(fields: [userId], references: [id]) + assignments SubstitutionAssignment[] + + @@index([schoolId]) + @@index([userId]) + @@map("substitute_teachers") +} + +model TeacherAbsence { + id String @id @default(cuid()) + schoolId String + teacherId String + type String // SICK, PERSONAL, TRAINING, OTHER + startDate DateTime + endDate DateTime + reason String? + status String @default("reported") // reported, covered, cancelled + notes String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + school School @relation(fields: [schoolId], references: [id]) + teacher User @relation(fields: [teacherId], references: [id]) + assignments SubstitutionAssignment[] + + @@index([schoolId]) + @@index([teacherId]) + @@index([startDate]) + @@map("teacher_absences") +} + +model SubstitutionAssignment { + id String @id @default(cuid()) + schoolId String + absenceId String + substituteId String + classGroupId String? + subjectId String? + date DateTime + period Int? // lesson period number + room String? + status String @default("pending") // pending, confirmed, completed, cancelled + notes String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + school School @relation(fields: [schoolId], references: [id]) + absence TeacherAbsence @relation(fields: [absenceId], references: [id]) + substitute SubstituteTeacher @relation(fields: [substituteId], references: [id]) + classGroup ClassGroup? @relation(fields: [classGroupId], references: [id]) + subject Subject? @relation(fields: [subjectId], references: [id]) + + @@index([schoolId]) + @@index([absenceId]) + @@index([substituteId]) + @@index([date]) + @@map("substitution_assignments") +} diff --git a/src/app/api/career/appointments/[id]/route.ts b/src/app/api/career/appointments/[id]/route.ts new file mode 100644 index 0000000..97519ae --- /dev/null +++ b/src/app/api/career/appointments/[id]/route.ts @@ -0,0 +1,111 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +// ── GET: Get single career appointment ─────────────────────────────────── +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 appointment = await db.careerAppointment.findUnique({ + where: { id }, + include: { + counselor: { select: { id: true, firstName: true, lastName: true } }, + profile: { + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + }, + }, + }, + }); + + if (!appointment) { + return NextResponse.json({ error: 'Appointment not found' }, { status: 404 }); + } + + return NextResponse.json(appointment); + } catch (error) { + console.error('Career appointment GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// ── PUT: Update career appointment ─────────────────────────────────────── +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 { date, duration, type, status, notes, actionItems } = body; + + const existing = await db.careerAppointment.findUnique({ where: { id } }); + if (!existing) { + return NextResponse.json({ error: 'Appointment not found' }, { status: 404 }); + } + + const updateData: Record = {}; + if (date !== undefined) updateData.date = new Date(date); + if (duration !== undefined) updateData.duration = duration; + if (type !== undefined) updateData.type = type; + if (status !== undefined) updateData.status = status; + if (notes !== undefined) updateData.notes = notes; + if (actionItems !== undefined) updateData.actionItems = JSON.stringify(actionItems); + + const appointment = await db.careerAppointment.update({ + where: { id }, + data: updateData, + include: { + counselor: { select: { id: true, firstName: true, lastName: true } }, + profile: { + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + }, + }, + }, + }); + + return NextResponse.json(appointment); + } catch (error) { + console.error('Career appointment PUT error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// ── DELETE: Delete career appointment ──────────────────────────────────── +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.careerAppointment.findUnique({ where: { id } }); + if (!existing) { + return NextResponse.json({ error: 'Appointment not found' }, { status: 404 }); + } + + await db.careerAppointment.delete({ where: { id } }); + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Career appointment DELETE error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/career/appointments/route.ts b/src/app/api/career/appointments/route.ts new file mode 100644 index 0000000..3a2af4e --- /dev/null +++ b/src/app/api/career/appointments/route.ts @@ -0,0 +1,127 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +// ── GET: List career appointments ──────────────────────────────────────── +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 profileId = searchParams.get('profileId'); + const counselorId = searchParams.get('counselorId'); + const status = searchParams.get('status'); + const type = searchParams.get('type'); + + if (!schoolId) { + return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); + } + + const where: Record = { schoolId }; + if (profileId) where.profileId = profileId; + if (counselorId) where.counselorId = counselorId; + if (status) where.status = status; + if (type) where.type = type; + + // Role-based access + if (session.user?.role === 'STUDENT') { + const student = await db.student.findFirst({ + where: { userId: session.userId }, + select: { id: true }, + }); + if (student) { + const profile = await db.careerProfile.findFirst({ + where: { studentId: student.id }, + select: { id: true }, + }); + if (profile) { + where.profileId = profile.id; + } + } + } else if (session.user?.role === 'PARENT') { + const parentLinks = await db.parentStudentLink.findMany({ + where: { parentId: session.userId }, + select: { studentId: true }, + }); + const studentIds = parentLinks.map((l) => l.studentId); + const profiles = await db.careerProfile.findMany({ + where: { studentId: { in: studentIds } }, + select: { id: true }, + }); + const profileIds = profiles.map((p) => p.id); + where.profileId = { in: profileIds }; + } else if (session.user?.role === 'TEACHER') { + // Teachers see appointments where they are the counselor + if (!counselorId) { + where.counselorId = session.userId; + } + } + + const appointments = await db.careerAppointment.findMany({ + where, + include: { + counselor: { select: { id: true, firstName: true, lastName: true } }, + profile: { + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + }, + }, + }, + orderBy: { date: 'desc' }, + }); + + return NextResponse.json(appointments); + } catch (error) { + console.error('Career appointments GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// ── POST: Create career appointment ────────────────────────────────────── +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, profileId, counselorId, date, duration, type, notes } = body; + + if (!schoolId || !profileId || !counselorId || !date) { + return NextResponse.json( + { error: 'schoolId, profileId, counselorId, and date are required' }, + { status: 400 } + ); + } + + const appointment = await db.careerAppointment.create({ + data: { + schoolId, + profileId, + counselorId, + date: new Date(date), + duration: duration || 30, + type: type || 'guidance', + notes: notes || null, + }, + include: { + counselor: { select: { id: true, firstName: true, lastName: true } }, + profile: { + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + }, + }, + }, + }); + + return NextResponse.json(appointment, { status: 201 }); + } catch (error) { + console.error('Career appointments POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/career/goals/[id]/route.ts b/src/app/api/career/goals/[id]/route.ts new file mode 100644 index 0000000..fd8bf9d --- /dev/null +++ b/src/app/api/career/goals/[id]/route.ts @@ -0,0 +1,94 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +// ── GET: Get single career goal ────────────────────────────────────────── +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.careerGoal.findUnique({ where: { id } }); + + if (!goal) { + return NextResponse.json({ error: 'Goal not found' }, { status: 404 }); + } + + return NextResponse.json(goal); + } catch (error) { + console.error('Career goal GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// ── PUT: Update career goal ───────────────────────────────────────────── +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const body = await request.json(); + const { title, description, category, targetDate, progress, status, milestones } = body; + + const existing = await db.careerGoal.findUnique({ where: { id } }); + if (!existing) { + return NextResponse.json({ error: 'Goal not found' }, { status: 404 }); + } + + const updateData: Record = {}; + if (title !== undefined) updateData.title = title; + if (description !== undefined) updateData.description = description; + if (category !== undefined) updateData.category = category; + if (targetDate !== undefined) updateData.targetDate = targetDate ? new Date(targetDate) : null; + if (progress !== undefined) updateData.progress = progress; + if (status !== undefined) updateData.status = status; + if (milestones !== undefined) updateData.milestones = JSON.stringify(milestones); + + const goal = await db.careerGoal.update({ + where: { id }, + data: updateData, + }); + + return NextResponse.json(goal); + } catch (error) { + console.error('Career goal PUT error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// ── DELETE: Delete career goal ────────────────────────────────────────── +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.careerGoal.findUnique({ where: { id } }); + if (!existing) { + return NextResponse.json({ error: 'Goal not found' }, { status: 404 }); + } + + await db.careerGoal.delete({ where: { id } }); + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Career goal DELETE error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/career/goals/route.ts b/src/app/api/career/goals/route.ts new file mode 100644 index 0000000..8857d89 --- /dev/null +++ b/src/app/api/career/goals/route.ts @@ -0,0 +1,73 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +// ── GET: List career goals ─────────────────────────────────────────────── +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 profileId = searchParams.get('profileId'); + const category = searchParams.get('category'); + const status = searchParams.get('status'); + + if (!profileId) { + return NextResponse.json({ error: 'profileId is required' }, { status: 400 }); + } + + const where: Record = { profileId }; + if (category) where.category = category; + if (status) where.status = status; + + const goals = await db.careerGoal.findMany({ + where, + orderBy: { createdAt: 'desc' }, + }); + + return NextResponse.json(goals); + } catch (error) { + console.error('Career goals GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// ── POST: Create career goal ──────────────────────────────────────────── +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 { profileId, title, description, category, targetDate, progress, milestones } = body; + + if (!profileId || !title || !category) { + return NextResponse.json( + { error: 'profileId, title, and category are required' }, + { status: 400 } + ); + } + + const goal = await db.careerGoal.create({ + data: { + profileId, + title, + description: description || null, + category, + targetDate: targetDate ? new Date(targetDate) : null, + progress: progress || 0, + milestones: milestones ? JSON.stringify(milestones) : null, + }, + }); + + return NextResponse.json(goal, { status: 201 }); + } catch (error) { + console.error('Career goals POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/career/profile/route.ts b/src/app/api/career/profile/route.ts new file mode 100644 index 0000000..1e77064 --- /dev/null +++ b/src/app/api/career/profile/route.ts @@ -0,0 +1,134 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +// ── GET: Get student career profile ────────────────────────────────────── +export async function GET(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const studentId = searchParams.get('studentId'); + const profileId = searchParams.get('profileId'); + const schoolId = searchParams.get('schoolId') ?? session.user?.schoolId ?? undefined; + + if (!schoolId) { + return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); + } + + const where: Record = { schoolId }; + if (profileId) where.id = profileId; + if (studentId) where.studentId = studentId; + + // Role-based access + if (session.user?.role === 'STUDENT') { + const student = await db.student.findFirst({ + where: { userId: session.userId }, + select: { id: true }, + }); + if (student) { + where.studentId = student.id; + } + } else if (session.user?.role === 'PARENT') { + const parentLinks = await db.parentStudentLink.findMany({ + where: { parentId: session.userId }, + select: { studentId: true }, + }); + const studentIds = parentLinks.map((l) => l.studentId); + where.studentId = { in: studentIds }; + } + + const profiles = await db.careerProfile.findMany({ + where, + include: { + student: { select: { id: true, firstName: true, lastName: true, avatarUrl: true } }, + goals: { orderBy: { createdAt: 'desc' } }, + appointments: { + include: { + counselor: { select: { id: true, firstName: true, lastName: true } }, + }, + orderBy: { date: 'desc' }, + }, + }, + }); + + // Return single profile if profileId or studentId specified + if (profileId || studentId) { + const profile = profiles[0] || null; + return NextResponse.json(profile); + } + + return NextResponse.json(profiles); + } catch (error) { + console.error('Career profile GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// ── PUT: Update career profile ────────────────────────────────────────── +export async function PUT(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const body = await request.json(); + const { id, interests, strengths, careerCluster, desiredCareer, educationPath, + workExperiences, volunteerExps, certifications, documents, notes } = body; + + if (!id) { + return NextResponse.json({ error: 'Profile id is required' }, { status: 400 }); + } + + const existing = await db.careerProfile.findUnique({ where: { id } }); + if (!existing) { + return NextResponse.json({ error: 'Profile not found' }, { status: 404 }); + } + + // Role-based access: students can update own, teachers/admins can update any + if (session.user?.role === 'STUDENT') { + const student = await db.student.findFirst({ + where: { userId: session.userId }, + select: { id: true }, + }); + if (!student || student.id !== existing.studentId) { + return NextResponse.json({ error: 'Not authorized' }, { status: 403 }); + } + } else if (session.user?.role === 'PARENT') { + const parentLinks = await db.parentStudentLink.findMany({ + where: { parentId: session.userId }, + select: { studentId: true }, + }); + const studentIds = parentLinks.map((l) => l.studentId); + if (!studentIds.includes(existing.studentId)) { + return NextResponse.json({ error: 'Not authorized' }, { status: 403 }); + } + } + + const updateData: Record = {}; + if (interests !== undefined) updateData.interests = JSON.stringify(interests); + if (strengths !== undefined) updateData.strengths = JSON.stringify(strengths); + if (careerCluster !== undefined) updateData.careerCluster = careerCluster; + if (desiredCareer !== undefined) updateData.desiredCareer = desiredCareer; + if (educationPath !== undefined) updateData.educationPath = educationPath; + if (workExperiences !== undefined) updateData.workExperiences = JSON.stringify(workExperiences); + if (volunteerExps !== undefined) updateData.volunteerExps = JSON.stringify(volunteerExps); + if (certifications !== undefined) updateData.certifications = JSON.stringify(certifications); + if (documents !== undefined) updateData.documents = JSON.stringify(documents); + if (notes !== undefined) updateData.notes = notes; + + const profile = await db.careerProfile.update({ + where: { id }, + data: updateData, + }); + + return NextResponse.json(profile); + } catch (error) { + console.error('Career profile PUT error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/career/route.ts b/src/app/api/career/route.ts new file mode 100644 index 0000000..3605573 --- /dev/null +++ b/src/app/api/career/route.ts @@ -0,0 +1,127 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +// ── GET: Get career data (clusters, career profiles for school-wide stats) ── +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; + + if (!schoolId) { + return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); + } + + // Get career profiles for statistics + const profiles = await db.careerProfile.findMany({ + where: { schoolId }, + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + goals: true, + appointments: { + where: { status: 'scheduled' }, + take: 5, + orderBy: { date: 'asc' }, + }, + }, + }); + + // Career cluster distribution + const clusterCounts: Record = {}; + profiles.forEach((p) => { + const cluster = p.careerCluster || 'unassigned'; + clusterCounts[cluster] = (clusterCounts[cluster] || 0) + 1; + }); + + // Education path distribution + const pathCounts: Record = {}; + profiles.forEach((p) => { + const path = p.educationPath || 'unassigned'; + pathCounts[path] = (pathCounts[path] || 0) + 1; + }); + + // Goal stats + const allGoals = profiles.flatMap((p) => p.goals); + const goalStats = { + total: allGoals.length, + active: allGoals.filter((g) => g.status === 'active').length, + completed: allGoals.filter((g) => g.status === 'completed').length, + avgProgress: allGoals.length > 0 + ? Math.round(allGoals.reduce((sum, g) => sum + g.progress, 0) / allGoals.length) + : 0, + }; + + // Appointment stats + const allAppointments = await db.careerAppointment.findMany({ + where: { schoolId }, + }); + const appointmentStats = { + total: allAppointments.length, + scheduled: allAppointments.filter((a) => a.status === 'scheduled').length, + completed: allAppointments.filter((a) => a.status === 'completed').length, + cancelled: allAppointments.filter((a) => a.status === 'cancelled').length, + }; + + return NextResponse.json({ + profiles, + stats: { + totalProfiles: profiles.length, + clusterCounts, + pathCounts, + goalStats, + appointmentStats, + }, + }); + } catch (error) { + console.error('Career GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// ── POST: Create career profile ────────────────────────────────────────── +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, interests, strengths, careerCluster, desiredCareer, educationPath } = body; + + if (!schoolId || !studentId) { + return NextResponse.json({ error: 'schoolId and studentId are required' }, { status: 400 }); + } + + // Check if profile already exists + const existing = await db.careerProfile.findFirst({ + where: { schoolId, studentId }, + }); + + if (existing) { + return NextResponse.json({ error: 'Career profile already exists for this student' }, { status: 409 }); + } + + const profile = await db.careerProfile.create({ + data: { + schoolId, + studentId, + interests: interests ? JSON.stringify(interests) : null, + strengths: strengths ? JSON.stringify(strengths) : null, + careerCluster: careerCluster || null, + desiredCareer: desiredCareer || null, + educationPath: educationPath || null, + }, + }); + + return NextResponse.json(profile, { status: 201 }); + } catch (error) { + console.error('Career POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/data-cleanup/route.ts b/src/app/api/data-cleanup/route.ts old mode 100644 new mode 100755 diff --git a/src/app/api/data-export/jobs/[id]/route.ts b/src/app/api/data-export/jobs/[id]/route.ts old mode 100644 new mode 100755 diff --git a/src/app/api/data-export/jobs/route.ts b/src/app/api/data-export/jobs/route.ts old mode 100644 new mode 100755 diff --git a/src/app/api/library/books/[id]/route.ts b/src/app/api/library/books/[id]/route.ts old mode 100644 new mode 100755 diff --git a/src/app/api/library/books/route.ts b/src/app/api/library/books/route.ts old mode 100644 new mode 100755 diff --git a/src/app/api/library/checkouts/[id]/route.ts b/src/app/api/library/checkouts/[id]/route.ts old mode 100644 new mode 100755 diff --git a/src/app/api/library/checkouts/route.ts b/src/app/api/library/checkouts/route.ts old mode 100644 new mode 100755 diff --git a/src/app/api/library/reservations/[id]/route.ts b/src/app/api/library/reservations/[id]/route.ts old mode 100644 new mode 100755 diff --git a/src/app/api/library/reservations/route.ts b/src/app/api/library/reservations/route.ts old mode 100644 new mode 100755 diff --git a/src/app/api/library/stats/route.ts b/src/app/api/library/stats/route.ts old mode 100644 new mode 100755 diff --git a/src/app/api/newsletters/[id]/route.ts b/src/app/api/newsletters/[id]/route.ts index 0584089..b7036be 100755 --- a/src/app/api/newsletters/[id]/route.ts +++ b/src/app/api/newsletters/[id]/route.ts @@ -26,7 +26,7 @@ export async function GET( } } -// PUT /api/newsletters/[id] — update newsletter +// PUT /api/newsletters/[id] — update newsletter with extended fields export async function PUT( req: NextRequest, { params }: { params: Promise<{ id: string }> } @@ -42,11 +42,21 @@ export async function PUT( const data: Record = {}; if (body.title !== undefined) data.title = body.title; + if (body.subject !== undefined) data.subject = body.subject; if (body.content !== undefined) data.content = body.content; if (body.summary !== undefined) data.summary = body.summary; if (body.imageUrl !== undefined) data.imageUrl = body.imageUrl; + if (body.bannerImageUrl !== undefined) data.bannerImageUrl = body.bannerImageUrl; if (body.category !== undefined) data.category = body.category; + if (body.templateType !== undefined) data.templateType = body.templateType; + if (body.targetAudience !== undefined) data.targetAudience = JSON.stringify(body.targetAudience); + if (body.status !== undefined) data.status = body.status; if (body.tags !== undefined) data.tags = JSON.stringify(body.tags); + if (body.scheduledAt !== undefined) data.scheduledAt = body.scheduledAt ? new Date(body.scheduledAt) : null; + if (body.openCount !== undefined) data.openCount = body.openCount; + if (body.clickCount !== undefined) data.clickCount = body.clickCount; + if (body.bounceCount !== undefined) data.bounceCount = body.bounceCount; + if (body.totalRecipients !== undefined) data.totalRecipients = body.totalRecipients; const newsletter = await db.newsletter.update({ where: { id }, @@ -63,7 +73,7 @@ export async function PUT( } } -// POST /api/newsletters/[id] — publish/unpublish newsletter +// POST /api/newsletters/[id] — publish/unpublish/archive/duplicate newsletter export async function POST( req: NextRequest, { params }: { params: Promise<{ id: string }> } @@ -77,6 +87,45 @@ export async function POST( return NextResponse.json({ error: 'Newsletter not found' }, { status: 404 }); } + // Duplicate action + if (body.action === 'duplicate') { + const duplicated = await db.newsletter.create({ + data: { + schoolId: existing.schoolId, + authorId: body.authorId || existing.authorId, + title: existing.title + ' (Kopie)', + subject: existing.subject, + content: existing.content, + summary: existing.summary, + imageUrl: existing.imageUrl, + bannerImageUrl: existing.bannerImageUrl, + category: existing.category, + templateType: existing.templateType, + targetAudience: existing.targetAudience, + status: 'draft', + tags: existing.tags, + isDemo: existing.isDemo, + }, + include: { + author: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + return NextResponse.json(duplicated, { status: 201 }); + } + + // Archive action + if (body.action === 'archive') { + const newsletter = await db.newsletter.update({ + where: { id }, + data: { status: 'archived' }, + include: { + author: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + return NextResponse.json(newsletter); + } + + // Publish/unpublish const isPublished = body.action === 'publish' ? true : body.action === 'unpublish' ? false : !existing.isPublished; const newsletter = await db.newsletter.update({ @@ -84,6 +133,7 @@ export async function POST( data: { isPublished, publishedAt: isPublished ? new Date() : null, + status: isPublished ? 'sent' : 'draft', }, include: { author: { select: { id: true, firstName: true, lastName: true } }, diff --git a/src/app/api/newsletters/[id]/send/route.ts b/src/app/api/newsletters/[id]/send/route.ts new file mode 100644 index 0000000..0d5a172 --- /dev/null +++ b/src/app/api/newsletters/[id]/send/route.ts @@ -0,0 +1,115 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; + +// POST /api/newsletters/[id]/send — send newsletter to target audience +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const body = await req.json(); + + const newsletter = await db.newsletter.findUnique({ where: { id } }); + if (!newsletter || newsletter.deletedAt) { + return NextResponse.json({ error: 'Newsletter not found' }, { status: 404 }); + } + + if (newsletter.status === 'sent') { + return NextResponse.json({ error: 'Newsletter already sent' }, { status: 400 }); + } + + // Parse target audience + let targetAudience: { type: string; ids?: string[] } = { type: 'all' }; + if (newsletter.targetAudience) { + try { + targetAudience = JSON.parse(newsletter.targetAudience); + } catch { + targetAudience = { type: 'all' }; + } + } + + // Override with body targetAudience if provided + if (body.targetAudience) { + targetAudience = body.targetAudience; + } + + // Count recipients based on target audience + let totalRecipients = 0; + + if (targetAudience.type === 'all') { + // All users in the school + totalRecipients = await db.user.count({ + where: { schoolId: newsletter.schoolId, deletedAt: null }, + }); + } else if (targetAudience.type === 'roles' && targetAudience.ids?.length) { + // Specific roles + totalRecipients = await db.user.count({ + where: { + schoolId: newsletter.schoolId, + role: { in: targetAudience.ids }, + deletedAt: null, + }, + }); + } else if (targetAudience.type === 'classes' && targetAudience.ids?.length) { + // Specific classes — count students + parents + const students = await db.student.findMany({ + where: { + classGroupId: { in: targetAudience.ids }, + deletedAt: null, + }, + include: { _count: { select: { parentLinks: true } } }, + }); + totalRecipients = students.length + students.reduce((sum, s) => sum + s._count.parentLinks, 0); + } else if (targetAudience.type === 'teachers') { + totalRecipients = await db.user.count({ + where: { + schoolId: newsletter.schoolId, + role: 'TEACHER', + deletedAt: null, + }, + }); + } else if (targetAudience.type === 'parents') { + const parents = await db.parentLink.findMany({ + where: { student: { schoolId: newsletter.schoolId } }, + distinct: ['email'], + }); + totalRecipients = parents.length; + } + + // Schedule for future or send now + const isScheduled = body.scheduledAt && new Date(body.scheduledAt) > new Date(); + + const updated = await db.newsletter.update({ + where: { id }, + data: { + status: isScheduled ? 'scheduled' : 'sent', + isPublished: true, + publishedAt: isScheduled ? null : new Date(), + scheduledAt: isScheduled ? new Date(body.scheduledAt) : null, + sentAt: isScheduled ? null : new Date(), + totalRecipients, + targetAudience: JSON.stringify(targetAudience), + }, + include: { + author: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + // In a real app, we would trigger email sending here + // For now, we simulate the send with realistic stats + + return NextResponse.json({ + ...updated, + sendResult: { + totalRecipients, + delivered: Math.floor(totalRecipients * 0.95), + bounced: Math.floor(totalRecipients * 0.05), + scheduled: isScheduled, + }, + }); + } catch (error) { + console.error('Error sending newsletter:', error); + return NextResponse.json({ error: 'Failed to send newsletter' }, { status: 500 }); + } +} diff --git a/src/app/api/newsletters/[id]/stats/route.ts b/src/app/api/newsletters/[id]/stats/route.ts new file mode 100644 index 0000000..e5afa4c --- /dev/null +++ b/src/app/api/newsletters/[id]/stats/route.ts @@ -0,0 +1,172 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; + +// GET /api/newsletters/[id]/stats — get newsletter analytics +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const { searchParams } = new URL(req.url); + const schoolId = searchParams.get('schoolId'); + + // Single newsletter stats + if (id !== 'aggregate') { + const newsletter = await db.newsletter.findUnique({ where: { id } }); + if (!newsletter || newsletter.deletedAt) { + return NextResponse.json({ error: 'Newsletter not found' }, { status: 404 }); + } + + const openRate = newsletter.totalRecipients > 0 + ? (newsletter.openCount / newsletter.totalRecipients) * 100 + : 0; + const clickRate = newsletter.totalRecipients > 0 + ? (newsletter.clickCount / newsletter.totalRecipients) * 100 + : 0; + const bounceRate = newsletter.totalRecipients > 0 + ? (newsletter.bounceCount / newsletter.totalRecipients) * 100 + : 0; + + return NextResponse.json({ + newsletterId: newsletter.id, + title: newsletter.title, + status: newsletter.status, + templateType: newsletter.templateType, + totalRecipients: newsletter.totalRecipients, + openCount: newsletter.openCount, + clickCount: newsletter.clickCount, + bounceCount: newsletter.bounceCount, + openRate: Math.round(openRate * 10) / 10, + clickRate: Math.round(clickRate * 10) / 10, + bounceRate: Math.round(bounceRate * 10) / 10, + sentAt: newsletter.sentAt, + publishedAt: newsletter.publishedAt, + }); + } + + // Aggregate stats for school + if (!schoolId) { + return NextResponse.json({ error: 'schoolId required for aggregate stats' }, { status: 400 }); + } + + const newsletters = await db.newsletter.findMany({ + where: { schoolId, status: 'sent', deletedAt: null }, + orderBy: { sentAt: 'desc' }, + include: { + author: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + const totalSent = newsletters.length; + const totalRecipients = newsletters.reduce((sum, n) => sum + n.totalRecipients, 0); + const totalOpens = newsletters.reduce((sum, n) => sum + n.openCount, 0); + const totalClicks = newsletters.reduce((sum, n) => sum + n.clickCount, 0); + const totalBounces = newsletters.reduce((sum, n) => sum + n.bounceCount, 0); + + const overallOpenRate = totalRecipients > 0 ? (totalOpens / totalRecipients) * 100 : 0; + const overallClickRate = totalRecipients > 0 ? (totalClicks / totalRecipients) * 100 : 0; + const overallBounceRate = totalRecipients > 0 ? (totalBounces / totalRecipients) * 100 : 0; + + // Monthly trend data + const monthlyData: Record = {}; + newsletters.forEach((n) => { + if (n.sentAt) { + const month = new Date(n.sentAt).toISOString().slice(0, 7); + if (!monthlyData[month]) { + monthlyData[month] = { sent: 0, opens: 0, clicks: 0, recipients: 0 }; + } + monthlyData[month].sent++; + monthlyData[month].opens += n.openCount; + monthlyData[month].clicks += n.clickCount; + monthlyData[month].recipients += n.totalRecipients; + } + }); + + // Best sending time analysis + const hourCounts: Record = {}; + newsletters.forEach((n) => { + if (n.sentAt) { + const hour = new Date(n.sentAt).getHours(); + if (!hourCounts[hour]) hourCounts[hour] = { total: 0, opens: 0 }; + hourCounts[hour].total += n.totalRecipients; + hourCounts[hour].opens += n.openCount; + } + }); + + const bestHour = Object.entries(hourCounts).sort((a, b) => { + const rateA = a[1].total > 0 ? a[1].opens / a[1].total : 0; + const rateB = b[1].total > 0 ? b[1].opens / b[1].total : 0; + return rateB - rateA; + })[0]; + + // Template type performance + const templatePerformance: Record = {}; + newsletters.forEach((n) => { + if (!templatePerformance[n.templateType]) { + templatePerformance[n.templateType] = { count: 0, openRate: 0, clickRate: 0 }; + } + templatePerformance[n.templateType].count++; + if (n.totalRecipients > 0) { + templatePerformance[n.templateType].openRate += (n.openCount / n.totalRecipients) * 100; + templatePerformance[n.templateType].clickRate += (n.clickCount / n.totalRecipients) * 100; + } + }); + + // Average per template + Object.keys(templatePerformance).forEach((key) => { + const tp = templatePerformance[key]; + if (tp.count > 0) { + tp.openRate = Math.round((tp.openRate / tp.count) * 10) / 10; + tp.clickRate = Math.round((tp.clickRate / tp.count) * 10) / 10; + } + }); + + return NextResponse.json({ + overview: { + totalSent, + totalRecipients, + totalOpens, + totalClicks, + totalBounces, + overallOpenRate: Math.round(overallOpenRate * 10) / 10, + overallClickRate: Math.round(overallClickRate * 10) / 10, + overallBounceRate: Math.round(overallBounceRate * 10) / 10, + }, + monthlyTrend: Object.entries(monthlyData) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([month, data]) => ({ + month, + openRate: data.recipients > 0 ? Math.round((data.opens / data.recipients) * 1000) / 10 : 0, + clickRate: data.recipients > 0 ? Math.round((data.clicks / data.recipients) * 1000) / 10 : 0, + sent: data.sent, + recipients: data.recipients, + })), + bestSendingTime: bestHour ? { + hour: parseInt(bestHour[0]), + openRate: bestHour[1].total > 0 + ? Math.round((bestHour[1].opens / bestHour[1].total) * 1000) / 10 + : 0, + } : null, + templatePerformance, + recentNewsletters: newsletters.slice(0, 10).map((n) => ({ + id: n.id, + title: n.title, + templateType: n.templateType, + sentAt: n.sentAt, + totalRecipients: n.totalRecipients, + openCount: n.openCount, + clickCount: n.clickCount, + openRate: n.totalRecipients > 0 + ? Math.round((n.openCount / n.totalRecipients) * 1000) / 10 + : 0, + clickRate: n.totalRecipients > 0 + ? Math.round((n.clickCount / n.totalRecipients) * 1000) / 10 + : 0, + })), + }); + } catch (error) { + console.error('Error fetching newsletter stats:', error); + return NextResponse.json({ error: 'Failed to fetch newsletter stats' }, { status: 500 }); + } +} diff --git a/src/app/api/newsletters/route.ts b/src/app/api/newsletters/route.ts index d01fa8c..9851ba2 100755 --- a/src/app/api/newsletters/route.ts +++ b/src/app/api/newsletters/route.ts @@ -1,13 +1,17 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; -// GET /api/newsletters — list newsletters +// GET /api/newsletters — list newsletters with extended filtering export async function GET(req: NextRequest) { try { const { searchParams } = new URL(req.url); const schoolId = searchParams.get('schoolId'); const published = searchParams.get('published'); const category = searchParams.get('category'); + const status = searchParams.get('status'); + const templateType = searchParams.get('templateType'); + const authorId = searchParams.get('authorId'); + const search = searchParams.get('search'); const limit = parseInt(searchParams.get('limit') || '20'); const offset = parseInt(searchParams.get('offset') || '0'); @@ -30,6 +34,26 @@ export async function GET(req: NextRequest) { where.category = category; } + if (status) { + where.status = status; + } + + if (templateType) { + where.templateType = templateType; + } + + if (authorId) { + where.authorId = authorId; + } + + if (search) { + where.OR = [ + { title: { contains: search } }, + { summary: { contains: search } }, + { subject: { contains: search } }, + ]; + } + const [newsletters, total] = await Promise.all([ db.newsletter.findMany({ where, @@ -50,14 +74,21 @@ export async function GET(req: NextRequest) { } } -// POST /api/newsletters — create newsletter +// POST /api/newsletters — create newsletter with extended fields export async function POST(req: NextRequest) { try { const body = await req.json(); - const { schoolId, authorId, title, content, summary, imageUrl, category, tags, isDemo } = body; + const { + schoolId, authorId, title, subject, content, summary, imageUrl, + bannerImageUrl, category, templateType, targetAudience, status, + tags, scheduledAt, isDemo, + } = body; if (!schoolId || !authorId || !title || !content) { - return NextResponse.json({ error: 'schoolId, authorId, title, content required' }, { status: 400 }); + return NextResponse.json( + { error: 'schoolId, authorId, title, content required' }, + { status: 400 } + ); } const newsletter = await db.newsletter.create({ @@ -65,11 +96,17 @@ export async function POST(req: NextRequest) { schoolId, authorId, title, + subject: subject || null, content, summary: summary || null, imageUrl: imageUrl || null, + bannerImageUrl: bannerImageUrl || null, category: category || 'general', + templateType: templateType || 'monthly', + targetAudience: targetAudience ? JSON.stringify(targetAudience) : null, + status: status || 'draft', tags: tags ? JSON.stringify(tags) : null, + scheduledAt: scheduledAt ? new Date(scheduledAt) : null, isDemo: isDemo || false, }, include: { diff --git a/src/app/api/parent-portal/child-progress/route.ts b/src/app/api/parent-portal/child-progress/route.ts old mode 100644 new mode 100755 diff --git a/src/app/api/parent-portal/child-schedule/route.ts b/src/app/api/parent-portal/child-schedule/route.ts old mode 100644 new mode 100755 diff --git a/src/app/api/report-cards/[id]/route.ts b/src/app/api/report-cards/[id]/route.ts old mode 100644 new mode 100755 diff --git a/src/app/api/report-cards/export/route.ts b/src/app/api/report-cards/export/route.ts old mode 100644 new mode 100755 diff --git a/src/app/api/report-cards/generate/route.ts b/src/app/api/report-cards/generate/route.ts old mode 100644 new mode 100755 diff --git a/src/app/api/report-cards/route.ts b/src/app/api/report-cards/route.ts old mode 100644 new mode 100755 diff --git a/src/app/api/report-cards/templates/route.ts b/src/app/api/report-cards/templates/route.ts old mode 100644 new mode 100755 diff --git a/src/app/api/school-events/[id]/feedback/route.ts b/src/app/api/school-events/[id]/feedback/route.ts old mode 100644 new mode 100755 diff --git a/src/app/api/seating-charts/[id]/arrange/route.ts b/src/app/api/seating-charts/[id]/arrange/route.ts old mode 100644 new mode 100755 diff --git a/src/app/api/seating-charts/[id]/route.ts b/src/app/api/seating-charts/[id]/route.ts old mode 100644 new mode 100755 diff --git a/src/app/api/seating-charts/route.ts b/src/app/api/seating-charts/route.ts old mode 100644 new mode 100755 diff --git a/src/app/api/substitutes/[id]/route.ts b/src/app/api/substitutes/[id]/route.ts new file mode 100644 index 0000000..4165b64 --- /dev/null +++ b/src/app/api/substitutes/[id]/route.ts @@ -0,0 +1,95 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; + +// GET /api/substitutes/[id] - Get a single substitute teacher +export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const substitute = await db.substituteTeacher.findUnique({ + where: { id }, + include: { + user: { select: { id: true, firstName: true, lastName: true, email: true } }, + assignments: { + include: { + absence: { include: { teacher: { select: { id: true, firstName: true, lastName: true } } } }, + classGroup: { select: { id: true, name: true } }, + subject: { select: { id: true, name: true } }, + }, + orderBy: { date: 'desc' }, + take: 20, + }, + }, + }); + + if (!substitute || substitute.deletedAt) { + return NextResponse.json({ error: 'Substitute not found' }, { status: 404 }); + } + + return NextResponse.json(substitute); + } catch (error) { + console.error('Error fetching substitute:', error); + return NextResponse.json({ error: 'Failed to fetch substitute' }, { status: 500 }); + } +} + +// PUT /api/substitutes/[id] - Update a substitute teacher +export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const body = await req.json(); + + const existing = await db.substituteTeacher.findUnique({ where: { id } }); + if (!existing || existing.deletedAt) { + return NextResponse.json({ error: 'Substitute not found' }, { status: 404 }); + } + + const data: Record = {}; + const jsonFields = ['qualifications', 'subjects', 'gradeLevels', 'availability']; + const simpleFields = ['firstName', 'lastName', 'email', 'phone', 'maxDaysPerWeek', 'notes', 'isActive', 'rating', 'totalAssignments']; + + for (const field of simpleFields) { + if (body[field] !== undefined) data[field] = body[field]; + } + + for (const field of jsonFields) { + if (body[field] !== undefined) { + data[field] = body[field] ? JSON.stringify(body[field]) : null; + } + } + + const substitute = await db.substituteTeacher.update({ + where: { id }, + data, + include: { + user: { select: { id: true, firstName: true, lastName: true, email: true } }, + }, + }); + + return NextResponse.json(substitute); + } catch (error) { + console.error('Error updating substitute:', error); + return NextResponse.json({ error: 'Failed to update substitute' }, { status: 500 }); + } +} + +// DELETE /api/substitutes/[id] - Soft delete a substitute teacher +export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + + const existing = await db.substituteTeacher.findUnique({ where: { id } }); + if (!existing || existing.deletedAt) { + return NextResponse.json({ error: 'Substitute not found' }, { status: 404 }); + } + + const substitute = await db.substituteTeacher.update({ + where: { id }, + data: { deletedAt: new Date(), isActive: false }, + }); + + return NextResponse.json(substitute); + } catch (error) { + console.error('Error deleting substitute:', error); + return NextResponse.json({ error: 'Failed to delete substitute' }, { status: 500 }); + } +} diff --git a/src/app/api/substitutes/absences/[id]/route.ts b/src/app/api/substitutes/absences/[id]/route.ts new file mode 100644 index 0000000..377e5a7 --- /dev/null +++ b/src/app/api/substitutes/absences/[id]/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; + +// GET /api/substitutes/absences/[id] - Get a single absence +export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const absence = await db.teacherAbsence.findUnique({ + where: { id }, + include: { + teacher: { select: { id: true, firstName: true, lastName: true, email: true } }, + assignments: { + include: { + substitute: { select: { id: true, firstName: true, lastName: true } }, + classGroup: { select: { id: true, name: true } }, + subject: { select: { id: true, name: true } }, + }, + }, + }, + }); + + if (!absence) { + return NextResponse.json({ error: 'Absence not found' }, { status: 404 }); + } + + return NextResponse.json(absence); + } catch (error) { + console.error('Error fetching absence:', error); + return NextResponse.json({ error: 'Failed to fetch absence' }, { status: 500 }); + } +} + +// PUT /api/substitutes/absences/[id] - Update an absence +export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const body = await req.json(); + + const existing = await db.teacherAbsence.findUnique({ where: { id } }); + if (!existing) { + return NextResponse.json({ error: 'Absence not found' }, { status: 404 }); + } + + const data: Record = {}; + const simpleFields = ['type', 'reason', 'status', 'notes']; + + for (const field of simpleFields) { + if (body[field] !== undefined) data[field] = body[field]; + } + + if (body.startDate) data.startDate = new Date(body.startDate); + if (body.endDate) data.endDate = new Date(body.endDate); + + const absence = await db.teacherAbsence.update({ + where: { id }, + data, + include: { + teacher: { select: { id: true, firstName: true, lastName: true, email: true } }, + assignments: true, + }, + }); + + return NextResponse.json(absence); + } catch (error) { + console.error('Error updating absence:', error); + return NextResponse.json({ error: 'Failed to update absence' }, { status: 500 }); + } +} + +// DELETE /api/substitutes/absences/[id] - Delete an absence +export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + + const existing = await db.teacherAbsence.findUnique({ where: { id } }); + if (!existing) { + return NextResponse.json({ error: 'Absence not found' }, { status: 404 }); + } + + // Delete associated assignments first + await db.substitutionAssignment.deleteMany({ where: { absenceId: id } }); + await db.teacherAbsence.delete({ where: { id } }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error deleting absence:', error); + return NextResponse.json({ error: 'Failed to delete absence' }, { status: 500 }); + } +} diff --git a/src/app/api/substitutes/absences/route.ts b/src/app/api/substitutes/absences/route.ts new file mode 100644 index 0000000..93cc2d9 --- /dev/null +++ b/src/app/api/substitutes/absences/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; + +// GET /api/substitutes/absences - List teacher absences +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + const schoolId = searchParams.get('schoolId'); + const teacherId = searchParams.get('teacherId'); + const type = searchParams.get('type'); + const status = searchParams.get('status'); + const startDate = searchParams.get('startDate'); + const endDate = searchParams.get('endDate'); + + if (!schoolId) { + return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); + } + + const where: Record = { schoolId }; + + if (teacherId) where.teacherId = teacherId; + if (type) where.type = type; + if (status) where.status = status; + + if (startDate && endDate) { + where.OR = [ + { startDate: { gte: new Date(startDate), lte: new Date(endDate) } }, + { endDate: { gte: new Date(startDate), lte: new Date(endDate) } }, + { startDate: { lte: new Date(startDate) }, endDate: { gte: new Date(endDate) } }, + ]; + } else if (startDate) { + where.startDate = { gte: new Date(startDate) }; + } + + const absences = await db.teacherAbsence.findMany({ + where, + include: { + teacher: { select: { id: true, firstName: true, lastName: true, email: true } }, + assignments: { + include: { + substitute: { select: { id: true, firstName: true, lastName: true } }, + classGroup: { select: { id: true, name: true } }, + subject: { select: { id: true, name: true } }, + }, + }, + }, + orderBy: { startDate: 'desc' }, + }); + + return NextResponse.json(absences); + } catch (error) { + console.error('Error fetching absences:', error); + return NextResponse.json({ error: 'Failed to fetch absences' }, { status: 500 }); + } +} + +// POST /api/substitutes/absences - Create a teacher absence +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { schoolId, teacherId, type, startDate, endDate, reason, status, notes } = body; + + if (!schoolId || !teacherId || !type || !startDate || !endDate) { + return NextResponse.json({ error: 'schoolId, teacherId, type, startDate, and endDate are required' }, { status: 400 }); + } + + const absence = await db.teacherAbsence.create({ + data: { + schoolId, + teacherId, + type, + startDate: new Date(startDate), + endDate: new Date(endDate), + reason: reason || null, + status: status || 'reported', + notes: notes || null, + }, + include: { + teacher: { select: { id: true, firstName: true, lastName: true, email: true } }, + assignments: true, + }, + }); + + return NextResponse.json(absence, { status: 201 }); + } catch (error) { + console.error('Error creating absence:', error); + return NextResponse.json({ error: 'Failed to create absence' }, { status: 500 }); + } +} diff --git a/src/app/api/substitutes/assignments/[id]/route.ts b/src/app/api/substitutes/assignments/[id]/route.ts new file mode 100644 index 0000000..1c3c2b1 --- /dev/null +++ b/src/app/api/substitutes/assignments/[id]/route.ts @@ -0,0 +1,126 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; + +// GET /api/substitutes/assignments/[id] - Get a single assignment +export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const assignment = await db.substitutionAssignment.findUnique({ + where: { id }, + include: { + absence: { + include: { + teacher: { select: { id: true, firstName: true, lastName: true } }, + }, + }, + substitute: { select: { id: true, firstName: true, lastName: true, rating: true } }, + classGroup: { select: { id: true, name: true } }, + subject: { select: { id: true, name: true } }, + }, + }); + + if (!assignment) { + return NextResponse.json({ error: 'Assignment not found' }, { status: 404 }); + } + + return NextResponse.json(assignment); + } catch (error) { + console.error('Error fetching assignment:', error); + return NextResponse.json({ error: 'Failed to fetch assignment' }, { status: 500 }); + } +} + +// PUT /api/substitutes/assignments/[id] - Update an assignment +export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const body = await req.json(); + + const existing = await db.substitutionAssignment.findUnique({ where: { id } }); + if (!existing) { + return NextResponse.json({ error: 'Assignment not found' }, { status: 404 }); + } + + const data: Record = {}; + const simpleFields = ['status', 'notes', 'room', 'period', 'substituteId', 'classGroupId', 'subjectId']; + + for (const field of simpleFields) { + if (body[field] !== undefined) data[field] = body[field]; + } + + if (body.date) data.date = new Date(body.date); + + const assignment = await db.substitutionAssignment.update({ + where: { id }, + data, + include: { + absence: { + include: { + teacher: { select: { id: true, firstName: true, lastName: true } }, + }, + }, + substitute: { select: { id: true, firstName: true, lastName: true, rating: true } }, + classGroup: { select: { id: true, name: true } }, + subject: { select: { id: true, name: true } }, + }, + }); + + // Check if all assignments for this absence are covered + if (body.status === 'confirmed' || body.status === 'completed') { + const absence = await db.teacherAbsence.findUnique({ + where: { id: existing.absenceId }, + include: { assignments: true }, + }); + + if (absence && absence.assignments.every(a => a.status === 'confirmed' || a.status === 'completed')) { + await db.teacherAbsence.update({ + where: { id: existing.absenceId }, + data: { status: 'covered' }, + }); + } + } + + return NextResponse.json(assignment); + } catch (error) { + console.error('Error updating assignment:', error); + return NextResponse.json({ error: 'Failed to update assignment' }, { status: 500 }); + } +} + +// DELETE /api/substitutes/assignments/[id] - Delete an assignment +export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + + const existing = await db.substitutionAssignment.findUnique({ where: { id } }); + if (!existing) { + return NextResponse.json({ error: 'Assignment not found' }, { status: 404 }); + } + + // Decrement the substitute's totalAssignments count + await db.substituteTeacher.update({ + where: { id: existing.substituteId }, + data: { totalAssignments: { decrement: 1 } }, + }); + + await db.substitutionAssignment.delete({ where: { id } }); + + // Check if the absence is still covered + const absence = await db.teacherAbsence.findUnique({ + where: { id: existing.absenceId }, + include: { assignments: true }, + }); + + if (absence && absence.assignments.length > 0 && !absence.assignments.every(a => a.status === 'confirmed' || a.status === 'completed')) { + await db.teacherAbsence.update({ + where: { id: existing.absenceId }, + data: { status: 'reported' }, + }); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error deleting assignment:', error); + return NextResponse.json({ error: 'Failed to delete assignment' }, { status: 500 }); + } +} diff --git a/src/app/api/substitutes/assignments/route.ts b/src/app/api/substitutes/assignments/route.ts new file mode 100644 index 0000000..e92814c --- /dev/null +++ b/src/app/api/substitutes/assignments/route.ts @@ -0,0 +1,117 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; + +// GET /api/substitutes/assignments - List substitution assignments +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + const schoolId = searchParams.get('schoolId'); + const substituteId = searchParams.get('substituteId'); + const absenceId = searchParams.get('absenceId'); + const status = searchParams.get('status'); + const startDate = searchParams.get('startDate'); + const endDate = searchParams.get('endDate'); + const classGroupId = searchParams.get('classGroupId'); + + if (!schoolId) { + return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); + } + + const where: Record = { schoolId }; + + if (substituteId) where.substituteId = substituteId; + if (absenceId) where.absenceId = absenceId; + if (status) where.status = status; + if (classGroupId) where.classGroupId = classGroupId; + + if (startDate && endDate) { + where.date = { gte: new Date(startDate), lte: new Date(endDate) }; + } else if (startDate) { + where.date = { gte: new Date(startDate) }; + } + + const assignments = await db.substitutionAssignment.findMany({ + where, + include: { + absence: { + include: { + teacher: { select: { id: true, firstName: true, lastName: true } }, + }, + }, + substitute: { select: { id: true, firstName: true, lastName: true, rating: true } }, + classGroup: { select: { id: true, name: true } }, + subject: { select: { id: true, name: true } }, + }, + orderBy: { date: 'asc' }, + }); + + return NextResponse.json(assignments); + } catch (error) { + console.error('Error fetching assignments:', error); + return NextResponse.json({ error: 'Failed to fetch assignments' }, { status: 500 }); + } +} + +// POST /api/substitutes/assignments - Create a substitution assignment +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { + schoolId, absenceId, substituteId, classGroupId, subjectId, + date, period, room, status, notes, + } = body; + + if (!schoolId || !absenceId || !substituteId || !date) { + return NextResponse.json({ error: 'schoolId, absenceId, substituteId, and date are required' }, { status: 400 }); + } + + const assignment = await db.substitutionAssignment.create({ + data: { + schoolId, + absenceId, + substituteId, + classGroupId: classGroupId || null, + subjectId: subjectId || null, + date: new Date(date), + period: period || null, + room: room || null, + status: status || 'pending', + notes: notes || null, + }, + include: { + absence: { + include: { + teacher: { select: { id: true, firstName: true, lastName: true } }, + }, + }, + substitute: { select: { id: true, firstName: true, lastName: true, rating: true } }, + classGroup: { select: { id: true, name: true } }, + subject: { select: { id: true, name: true } }, + }, + }); + + // Update the substitute's totalAssignments count + await db.substituteTeacher.update({ + where: { id: substituteId }, + data: { totalAssignments: { increment: 1 } }, + }); + + // Check if all assignments for this absence are covered + const absence = await db.teacherAbsence.findUnique({ + where: { id: absenceId }, + include: { assignments: true }, + }); + + if (absence && absence.assignments.length > 0 && absence.assignments.every(a => a.status === 'confirmed' || a.status === 'completed')) { + await db.teacherAbsence.update({ + where: { id: absenceId }, + data: { status: 'covered' }, + }); + } + + return NextResponse.json(assignment, { status: 201 }); + } catch (error) { + console.error('Error creating assignment:', error); + return NextResponse.json({ error: 'Failed to create assignment' }, { status: 500 }); + } +} diff --git a/src/app/api/substitutes/route.ts b/src/app/api/substitutes/route.ts new file mode 100644 index 0000000..539c731 --- /dev/null +++ b/src/app/api/substitutes/route.ts @@ -0,0 +1,94 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; + +// GET /api/substitutes - List substitute teachers +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + const schoolId = searchParams.get('schoolId'); + const isActive = searchParams.get('isActive'); + const search = searchParams.get('search'); + + if (!schoolId) { + return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); + } + + const where: Record = { + schoolId, + deletedAt: null, + }; + + if (isActive !== null && isActive !== undefined) { + where.isActive = isActive === 'true'; + } + + if (search) { + where.OR = [ + { firstName: { contains: search } }, + { lastName: { contains: search } }, + { email: { contains: search } }, + ]; + } + + const substitutes = await db.substituteTeacher.findMany({ + where, + include: { + user: { select: { id: true, firstName: true, lastName: true, email: true } }, + assignments: { + where: { status: { in: ['pending', 'confirmed'] } }, + select: { id: true, date: true, status: true }, + orderBy: { date: 'asc' }, + take: 5, + }, + }, + orderBy: { lastName: 'asc' }, + }); + + return NextResponse.json(substitutes); + } catch (error) { + console.error('Error fetching substitutes:', error); + return NextResponse.json({ error: 'Failed to fetch substitutes' }, { status: 500 }); + } +} + +// POST /api/substitutes - Create a substitute teacher +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { + schoolId, userId, firstName, lastName, email, phone, + qualifications, subjects, gradeLevels, availability, + maxDaysPerWeek, notes, isActive, + } = body; + + if (!schoolId || !firstName || !lastName) { + return NextResponse.json({ error: 'schoolId, firstName, and lastName are required' }, { status: 400 }); + } + + const substitute = await db.substituteTeacher.create({ + data: { + schoolId, + userId: userId || null, + firstName, + lastName, + email: email || null, + phone: phone || null, + qualifications: qualifications ? JSON.stringify(qualifications) : null, + subjects: subjects ? JSON.stringify(subjects) : null, + gradeLevels: gradeLevels ? JSON.stringify(gradeLevels) : null, + availability: availability ? JSON.stringify(availability) : null, + maxDaysPerWeek: maxDaysPerWeek ?? 5, + notes: notes || null, + isActive: isActive ?? true, + }, + include: { + user: { select: { id: true, firstName: true, lastName: true, email: true } }, + }, + }); + + return NextResponse.json(substitute, { status: 201 }); + } catch (error) { + console.error('Error creating substitute:', error); + return NextResponse.json({ error: 'Failed to create substitute' }, { status: 500 }); + } +} diff --git a/src/app/api/wellness/route.ts b/src/app/api/wellness/route.ts old mode 100644 new mode 100755 diff --git a/src/app/api/wellness/summary/route.ts b/src/app/api/wellness/summary/route.ts old mode 100644 new mode 100755 diff --git a/src/app/api/wellness/trends/route.ts b/src/app/api/wellness/trends/route.ts old mode 100644 new mode 100755 diff --git a/src/app/globals.css b/src/app/globals.css index 81cd15d..428b5aa 100755 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1153,6 +1153,132 @@ from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } + + /* ─── Round 25 Utility Classes ──────────────────────────────────── */ + + /* .gradient-border-left — Left border with gradient accent */ + .gradient-border-left { + position: relative; + border-left: none; + } + .gradient-border-left::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + border-radius: 0 3px 3px 0; + background: linear-gradient(180deg, oklch(0.55 0.17 155), oklch(0.55 0.14 175), oklch(0.72 0.19 85)); + } + .dark .gradient-border-left::before { + background: linear-gradient(180deg, oklch(0.65 0.17 155), oklch(0.65 0.14 175), oklch(0.77 0.19 85)); + } + + /* @keyframes slideInLeft — Slide in from left */ + @keyframes slideInLeft { + from { opacity: 0; transform: translateX(-24px); } + to { opacity: 1; transform: translateX(0); } + } + .animate-slide-in-left { + animation: slideInLeft 0.4s ease-out; + } + + /* @keyframes fadeUp — Fade and move up */ + @keyframes fadeUp { + from { opacity: 0; transform: translateY(16px); } + to { opacity: 1; transform: translateY(0); } + } + .animate-fade-up { + animation: fadeUp 0.45s ease-out; + } + + /* .status-dot — Small status indicator dot */ + .status-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: oklch(0.55 0.17 155); + box-shadow: 0 0 0 2px oklch(0.55 0.17 155 / 0.2); + } + .dark .status-dot { + background: oklch(0.65 0.17 155); + box-shadow: 0 0 0 2px oklch(0.65 0.17 155 / 0.3); + } + .status-dot-amber { + background: oklch(0.72 0.19 85); + box-shadow: 0 0 0 2px oklch(0.72 0.19 85 / 0.2); + } + .status-dot-rose { + background: oklch(0.65 0.22 15); + box-shadow: 0 0 0 2px oklch(0.65 0.22 15 / 0.2); + } + .status-dot-violet { + background: oklch(0.60 0.20 320); + box-shadow: 0 0 0 2px oklch(0.60 0.20 320 / 0.2); + } + + /* .progress-animated — Progress bar with fill animation */ + @keyframes progressFill { + from { width: 0%; } + } + .progress-animated { + animation: progressFill 1s ease-out; + transition: width 0.6s ease-out; + } + + /* @keyframes pulseGlow — Pulsing glow effect */ + @keyframes pulseGlow { + 0%, 100% { box-shadow: 0 0 0 0 oklch(0.55 0.17 155 / 0.4); } + 50% { box-shadow: 0 0 12px 4px oklch(0.55 0.17 155 / 0.15); } + } + .animate-pulse-glow { + animation: pulseGlow 2.5s ease-in-out infinite; + } + .dark .animate-pulse-glow { + animation-name: pulseGlowDark; + } + @keyframes pulseGlowDark { + 0%, 100% { box-shadow: 0 0 0 0 oklch(0.65 0.17 155 / 0.5); } + 50% { box-shadow: 0 0 12px 4px oklch(0.65 0.17 155 / 0.2); } + } + + /* .glassmorphism-card — Frosted glass card with blur */ + .glassmorphism-card { + background: oklch(1 0 0 / 0.6); + backdrop-filter: blur(16px) saturate(180%); + -webkit-backdrop-filter: blur(16px) saturate(180%); + border: 1px solid oklch(1 0 0 / 0.35); + box-shadow: 0 8px 32px -8px oklch(0 0 0 / 0.08), inset 0 0 0 1px oklch(1 0 0 / 0.2); + } + .dark .glassmorphism-card { + background: oklch(0.18 0.015 260 / 0.55); + backdrop-filter: blur(16px) saturate(140%); + -webkit-backdrop-filter: blur(16px) saturate(140%); + border: 1px solid oklch(1 0 0 / 0.08); + box-shadow: 0 8px 32px -8px oklch(0 0 0 / 0.4), inset 0 0 0 1px oklch(1 0 0 / 0.04); + } + + /* .gradient-border-card — Card with gradient border on all sides */ + .gradient-border-card { + position: relative; + border: none; + background-clip: padding-box; + border-radius: 12px; + } + .gradient-border-card::before { + content: ''; + position: absolute; + inset: 0; + z-index: -1; + margin: -1.5px; + border-radius: inherit; + background: linear-gradient(135deg, oklch(0.55 0.17 155), oklch(0.55 0.14 175), oklch(0.72 0.19 85)); + } + .dark .gradient-border-card::before { + background: linear-gradient(135deg, oklch(0.65 0.17 155), oklch(0.65 0.14 175), oklch(0.77 0.19 85)); + } } /* Print-specific styling — expands to all reports when printing */ diff --git a/src/components/app-layout.tsx b/src/components/app-layout.tsx index de12717..c9d739d 100755 --- a/src/components/app-layout.tsx +++ b/src/components/app-layout.tsx @@ -70,6 +70,8 @@ import { BookOpen as BookOpenIconNav, Database, PartyPopper, + Newspaper, + Compass, } from 'lucide-react'; import { Sidebar, @@ -187,6 +189,9 @@ import SeatingChartView from './seating-chart-view'; import StudentWellnessView from './student-wellness-view'; import DataImportExportView from './data-import-export-view'; import SchoolEventsView from './school-events-view'; +import StudentCareerView from './student-career-view'; +import SubstituteTeacherView from './substitute-teacher-view'; +import SchoolNewsletterView from './school-newsletter-view'; // Dynamic imports for heavy components with loading skeletons const AnalyticsView = dynamic(() => import('./analytics-view'), { @@ -257,9 +262,12 @@ const navSections: NavSection[] = [ { key: 'seating-chart', icon: LayoutGridIcon, labelKey: 'nav.seating_chart' }, { key: 'school-library', icon: LibraryIcon, labelKey: 'nav.school_library' }, { key: 'student-wellness', icon: Heart, labelKey: 'nav.student-wellness' }, + { key: 'student-career', icon: Compass, labelKey: 'nav.student-career' }, { key: 'notification-center', icon: Bell, labelKey: 'nav.notification_center' }, { key: 'announcements', icon: Megaphone, labelKey: 'nav.announcements' }, { key: 'school-events', icon: PartyPopper, labelKey: 'nav.school-events' }, + { key: 'school-newsletter', icon: Newspaper, labelKey: 'nav.school-newsletter' }, + { key: 'substitute-teacher', icon: UserCheck, labelKey: 'nav.substitute-teacher' }, { key: 'data-import-export', icon: Database, labelKey: 'nav.data-import-export' }, ], }, @@ -302,9 +310,12 @@ const studentNavSections: NavSection[] = [ { key: 'student-achievements', icon: Trophy, labelKey: 'achievements.title' }, { key: 'student-study-planner', icon: Timer, labelKey: 'study_planner.title' }, { key: 'student-wellness', icon: Heart, labelKey: 'nav.student-wellness' }, + { key: 'student-career', icon: Compass, labelKey: 'nav.student-career' }, { key: 'resources', icon: FolderOpen, labelKey: 'nav.resources' }, { key: 'school-library', icon: LibraryIcon, labelKey: 'nav.school_library' }, { key: 'school-events', icon: PartyPopper, labelKey: 'nav.school-events' }, + { key: 'school-newsletter', icon: Newspaper, labelKey: 'nav.school-newsletter' }, + { key: 'substitute-teacher', icon: UserCheck, labelKey: 'nav.substitute-teacher' }, { key: 'notification-center', icon: Bell, labelKey: 'nav.notification_center' }, { key: 'announcements', icon: Megaphone, labelKey: 'nav.announcements' }, { key: 'settings', icon: Settings, labelKey: 'nav.settings' }, @@ -329,9 +340,12 @@ const parentNavSections: NavSection[] = [ { key: 'communication', icon: MessageSquare, labelKey: 'nav.communication' }, { key: 'student-achievements', icon: Trophy, labelKey: 'achievements.title' }, { key: 'student-study-planner', icon: Timer, labelKey: 'study_planner.title' }, + { key: 'student-career', icon: Compass, labelKey: 'nav.student-career' }, { key: 'report-cards', icon: FileText, labelKey: 'nav.report_cards' }, { key: 'school-library', icon: LibraryIcon, labelKey: 'nav.school_library' }, { key: 'school-events', icon: PartyPopper, labelKey: 'nav.school-events' }, + { key: 'school-newsletter', icon: Newspaper, labelKey: 'nav.school-newsletter' }, + { key: 'substitute-teacher', icon: UserCheck, labelKey: 'nav.substitute-teacher' }, { key: 'notification-center', icon: Bell, labelKey: 'nav.notification_center' }, { key: 'announcements', icon: Megaphone, labelKey: 'nav.announcements' }, ], @@ -483,8 +497,11 @@ function renderView(view: ViewName) { case 'school-library': return ; case 'report-cards': return ; case 'student-wellness': return ; + case 'student-career': return ; case 'data-import-export': return ; case 'school-events': return ; + case 'substitute-teacher': return ; + case 'school-newsletter': return ; default: return ; } } diff --git a/src/components/data-import-export-view.tsx b/src/components/data-import-export-view.tsx old mode 100644 new mode 100755 diff --git a/src/components/report-card-view.tsx b/src/components/report-card-view.tsx old mode 100644 new mode 100755 diff --git a/src/components/school-events-view.tsx b/src/components/school-events-view.tsx old mode 100644 new mode 100755 diff --git a/src/components/school-library-view.tsx b/src/components/school-library-view.tsx old mode 100644 new mode 100755 diff --git a/src/components/school-newsletter-view.tsx b/src/components/school-newsletter-view.tsx new file mode 100644 index 0000000..e37f23f --- /dev/null +++ b/src/components/school-newsletter-view.tsx @@ -0,0 +1,1558 @@ +'use client'; + +import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + Newspaper, Plus, Search, Filter, Edit3, Trash2, Copy, Send, Archive, + Eye, Clock, CheckCircle2, XCircle, MoreVertical, Calendar, Users, + BarChart3, TrendingUp, Mail, MousePointerClick, AlertCircle, + ChevronDown, ChevronUp, ArrowLeft, Image as ImageIcon, FileText, Save, + Bold, Italic, Underline, List, AlignLeft, AlignCenter, AlignRight, + Link as LinkIcon, Smartphone, Monitor, X, Globe, BookOpen, + GraduationCap, Heart, UserCheck, ChevronRight, Sparkles, + ArrowUpRight, ArrowDownRight, Minus, Palette, Tag, +} from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { + Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter, +} from '@/components/ui/dialog'; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from '@/components/ui/select'; +import { Progress } from '@/components/ui/progress'; +import { Textarea } from '@/components/ui/textarea'; +import { Separator } from '@/components/ui/separator'; +import { + DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { useAppStore } from '@/lib/store'; +import { t } from '@/lib/i18n'; +import { + BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, + LineChart, Line, PieChart, Pie, Cell, AreaChart, Area, +} from 'recharts'; + +// ─── Types ──────────────────────────────────────────────────────────── + +interface Author { + id: string; + firstName: string; + lastName: string; +} + +interface Newsletter { + id: string; + schoolId: string; + authorId: string; + title: string; + subject?: string | null; + content: string; + summary?: string | null; + imageUrl?: string | null; + bannerImageUrl?: string | null; + category: string; + templateType: string; + targetAudience?: string | null; + status: string; + isPublished: boolean; + publishedAt?: string | null; + scheduledAt?: string | null; + sentAt?: string | null; + tags?: string | null; + openCount: number; + clickCount: number; + bounceCount: number; + totalRecipients: number; + isDemo: boolean; + createdAt: string; + updatedAt: string; + author: Author; +} + +interface NewsletterStats { + overview: { + totalSent: number; + totalRecipients: number; + totalOpens: number; + totalClicks: number; + totalBounces: number; + overallOpenRate: number; + overallClickRate: number; + overallBounceRate: number; + }; + monthlyTrend: Array<{ + month: string; + openRate: number; + clickRate: number; + sent: number; + recipients: number; + }>; + bestSendingTime: { hour: number; openRate: number } | null; + templatePerformance: Record; + recentNewsletters: Array<{ + id: string; + title: string; + templateType: string; + sentAt: string | null; + totalRecipients: number; + openCount: number; + clickCount: number; + openRate: number; + clickRate: number; + }>; +} + +type TabView = 'newsletters' | 'editor' | 'analytics'; + +// ─── Helpers ────────────────────────────────────────────────────────── + +const STATUS_COLORS: Record = { + draft: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300', + scheduled: 'bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300', + sent: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300', + archived: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400', +}; + +const STATUS_ICONS: Record = { + draft: Edit3, + scheduled: Clock, + sent: CheckCircle2, + archived: Archive, +}; + +const TEMPLATE_TYPES = [ + { value: 'monthly', labelKey: 'newsletter.template_monthly' }, + { value: 'event', labelKey: 'newsletter.template_event' }, + { value: 'parent', labelKey: 'newsletter.template_parent' }, + { value: 'emergency', labelKey: 'newsletter.template_emergency' }, +]; + +const CATEGORIES = [ + { value: 'general', labelKey: 'newsletter.category_general' }, + { value: 'academic', labelKey: 'newsletter.category_academic' }, + { value: 'sports', labelKey: 'newsletter.category_sports' }, + { value: 'arts', labelKey: 'newsletter.category_arts' }, + { value: 'events', labelKey: 'newsletter.category_events' }, + { value: 'community', labelKey: 'newsletter.category_community' }, +]; + +const AUDIENCE_TYPES = [ + { value: 'all', labelKey: 'newsletter.audience_all', icon: Globe }, + { value: 'teachers', labelKey: 'newsletter.audience_teachers', icon: GraduationCap }, + { value: 'parents', labelKey: 'newsletter.audience_parents', icon: Heart }, + { value: 'classes', labelKey: 'newsletter.audience_classes', icon: Users }, + { value: 'roles', labelKey: 'newsletter.audience_roles', icon: UserCheck }, +]; + +const CHART_COLORS = ['#10b981', '#14b8a6', '#059669', '#0d9488', '#047857', '#065f46']; + +function formatDate(dateStr: string | null | undefined): string { + if (!dateStr) return '—'; + return new Date(dateStr).toLocaleDateString('de-DE', { + day: '2-digit', month: '2-digit', year: 'numeric', + }); +} + +function formatDateTime(dateStr: string | null | undefined): string { + if (!dateStr) return '—'; + return new Date(dateStr).toLocaleDateString('de-DE', { + day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', + }); +} + +function getRelativeTime(dateStr: string): string { + const now = new Date(); + const date = new Date(dateStr); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + if (diffMins < 60) return `${diffMins}m`; + const diffHours = Math.floor(diffMins / 60); + if (diffHours < 24) return `${diffHours}h`; + const diffDays = Math.floor(diffHours / 24); + if (diffDays < 30) return `${diffDays}d`; + return formatDate(dateStr); +} + +// ─── Animated Counter ───────────────────────────────────────────────── + +function AnimatedCounter({ value, duration = 1200 }: { value: number; duration?: number }) { + const [display, setDisplay] = useState(0); + useEffect(() => { + let start = 0; + const step = value / (duration / 16); + const timer = setInterval(() => { + start += step; + if (start >= value) { + setDisplay(value); + clearInterval(timer); + } else { + setDisplay(Math.floor(start)); + } + }, 16); + return () => clearInterval(timer); + }, [value, duration]); + return {display.toLocaleString()}; +} + +// ─── Main Component ─────────────────────────────────────────────────── + +export default function SchoolNewsletterView() { + const currentUser = useAppStore((s) => s.currentUser); + const locale = useAppStore((s) => s.locale); + + const [activeTab, setActiveTab] = useState('newsletters'); + const [newsletters, setNewsletters] = useState([]); + const [stats, setStats] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(''); + const [filterStatus, setFilterStatus] = useState('all'); + const [filterCategory, setFilterCategory] = useState('all'); + const [filterTemplate, setFilterTemplate] = useState('all'); + + // Editor state + const [editingNewsletter, setEditingNewsletter] = useState(null); + const [isEditorOpen, setIsEditorOpen] = useState(false); + const [editorTitle, setEditorTitle] = useState(''); + const [editorSubject, setEditorSubject] = useState(''); + const [editorContent, setEditorContent] = useState(''); + const [editorSummary, setEditorSummary] = useState(''); + const [editorBannerUrl, setEditorBannerUrl] = useState(''); + const [editorCategory, setEditorCategory] = useState('general'); + const [editorTemplateType, setEditorTemplateType] = useState('monthly'); + const [editorAudience, setEditorAudience] = useState('all'); + const [editorTags, setEditorTags] = useState(''); + const [isPreviewMode, setIsPreviewMode] = useState(false); + const [isMobilePreview, setIsMobilePreview] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const editorRef = useRef(null); + + // Send dialog + const [sendDialogOpen, setSendDialogOpen] = useState(false); + const [sendNewsletterId, setSendNewsletterId] = useState(null); + const [sendAudience, setSendAudience] = useState('all'); + const [sendScheduledAt, setSendScheduledAt] = useState(''); + const [isSending, setIsSending] = useState(false); + + // View newsletter dialog + const [viewNewsletter, setViewNewsletter] = useState(null); + + const schoolId = currentUser?.schoolId; + const role = currentUser?.role || 'TEACHER'; + const isAdmin = role === 'SCHOOL_ADMIN' || role === 'VICE_PRINCIPAL' || role === 'SUPER_ADMIN'; + const isTeacher = role === 'TEACHER'; + const isStudentOrParent = role === 'STUDENT' || role === 'PARENT'; + + // ─── Fetch newsletters ────────────────────────────────────────── + + const fetchNewsletters = useCallback(async () => { + if (!schoolId) return; + setIsLoading(true); + try { + const params = new URLSearchParams({ schoolId, limit: '50' }); + if (filterStatus !== 'all') params.set('status', filterStatus); + if (filterCategory !== 'all') params.set('category', filterCategory); + if (filterTemplate !== 'all') params.set('templateType', filterTemplate); + if (searchQuery) params.set('search', searchQuery); + if (isTeacher) params.set('authorId', currentUser?.id || ''); + + const res = await fetch(`/api/newsletters?${params}`); + if (res.ok) { + const data = await res.json(); + setNewsletters(data.newsletters || []); + } + } catch (err) { + console.error('Failed to fetch newsletters:', err); + } finally { + setIsLoading(false); + } + }, [schoolId, filterStatus, filterCategory, filterTemplate, searchQuery, isTeacher, currentUser?.id]); + + const fetchStats = useCallback(async () => { + if (!schoolId || isStudentOrParent) return; + try { + const res = await fetch(`/api/newsletters/aggregate/stats?schoolId=${schoolId}`); + if (res.ok) { + const data = await res.json(); + setStats(data); + } + } catch (err) { + console.error('Failed to fetch stats:', err); + } + }, [schoolId, isStudentOrParent]); + + useEffect(() => { + fetchNewsletters(); + }, [fetchNewsletters]); + + useEffect(() => { + if (activeTab === 'analytics') fetchStats(); + }, [activeTab, fetchStats]); + + // ─── Filtered newsletters ─────────────────────────────────────── + + const filteredNewsletters = useMemo(() => { + let list = [...newsletters]; + if (searchQuery) { + const q = searchQuery.toLowerCase(); + list = list.filter( + (n) => + n.title.toLowerCase().includes(q) || + n.summary?.toLowerCase().includes(q) || + n.author.firstName.toLowerCase().includes(q) || + n.author.lastName.toLowerCase().includes(q) + ); + } + return list; + }, [newsletters, searchQuery]); + + // ─── Stats summary ────────────────────────────────────────────── + + const draftCount = newsletters.filter((n) => n.status === 'draft').length; + const scheduledCount = newsletters.filter((n) => n.status === 'scheduled').length; + const sentCount = newsletters.filter((n) => n.status === 'sent').length; + + // ─── Editor handlers ──────────────────────────────────────────── + + const openNewEditor = useCallback(() => { + setEditingNewsletter(null); + setEditorTitle(''); + setEditorSubject(''); + setEditorContent(''); + setEditorSummary(''); + setEditorBannerUrl(''); + setEditorCategory('general'); + setEditorTemplateType('monthly'); + setEditorAudience('all'); + setEditorTags(''); + setIsPreviewMode(false); + setIsEditorOpen(true); + setIsMobilePreview(false); + }, []); + + const openEditEditor = useCallback((newsletter: Newsletter) => { + setEditingNewsletter(newsletter); + setEditorTitle(newsletter.title); + setEditorSubject(newsletter.subject || ''); + setEditorContent(newsletter.content); + setEditorSummary(newsletter.summary || ''); + setEditorBannerUrl(newsletter.bannerImageUrl || ''); + setEditorCategory(newsletter.category); + setEditorTemplateType(newsletter.templateType); + try { + const audience = newsletter.targetAudience ? JSON.parse(newsletter.targetAudience) : { type: 'all' }; + setEditorAudience(audience.type || 'all'); + } catch { + setEditorAudience('all'); + } + setEditorTags(newsletter.tags ? JSON.parse(newsletter.tags).join(', ') : ''); + setIsPreviewMode(false); + setIsMobilePreview(false); + setIsEditorOpen(true); + }, []); + + const handleSave = useCallback(async (saveAsDraft = true) => { + if (!schoolId || !currentUser?.id) return; + setIsSaving(true); + try { + const payload = { + schoolId, + authorId: currentUser.id, + title: editorTitle, + subject: editorSubject || null, + content: editorContent, + summary: editorSummary || null, + bannerImageUrl: editorBannerUrl || null, + category: editorCategory, + templateType: editorTemplateType, + targetAudience: { type: editorAudience }, + status: saveAsDraft ? 'draft' : 'scheduled', + tags: editorTags ? editorTags.split(',').map((t) => t.trim()).filter(Boolean) : null, + }; + + if (editingNewsletter) { + const res = await fetch(`/api/newsletters/${editingNewsletter.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (res.ok) { + const updated = await res.json(); + setNewsletters((prev) => prev.map((n) => (n.id === updated.id ? updated : n))); + } + } else { + const res = await fetch('/api/newsletters', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (res.ok) { + const created = await res.json(); + setNewsletters((prev) => [created, ...prev]); + } + } + setIsEditorOpen(false); + fetchNewsletters(); + } catch (err) { + console.error('Failed to save newsletter:', err); + } finally { + setIsSaving(false); + } + }, [schoolId, currentUser, editingNewsletter, editorTitle, editorSubject, editorContent, + editorSummary, editorBannerUrl, editorCategory, editorTemplateType, editorAudience, + editorTags, fetchNewsletters]); + + const handleDelete = useCallback(async (id: string) => { + try { + const res = await fetch(`/api/newsletters/${id}`, { method: 'DELETE' }); + if (res.ok) { + setNewsletters((prev) => prev.filter((n) => n.id !== id)); + } + } catch (err) { + console.error('Failed to delete newsletter:', err); + } + }, []); + + const handleDuplicate = useCallback(async (id: string) => { + try { + const res = await fetch(`/api/newsletters/${id}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'duplicate', authorId: currentUser?.id }), + }); + if (res.ok) { + fetchNewsletters(); + } + } catch (err) { + console.error('Failed to duplicate newsletter:', err); + } + }, [currentUser?.id, fetchNewsletters]); + + const handleArchive = useCallback(async (id: string) => { + try { + const res = await fetch(`/api/newsletters/${id}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'archive' }), + }); + if (res.ok) { + fetchNewsletters(); + } + } catch (err) { + console.error('Failed to archive newsletter:', err); + } + }, [fetchNewsletters]); + + const handleSend = useCallback(async () => { + if (!sendNewsletterId) return; + setIsSending(true); + try { + const res = await fetch(`/api/newsletters/${sendNewsletterId}/send`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + targetAudience: { type: sendAudience }, + scheduledAt: sendScheduledAt || undefined, + }), + }); + if (res.ok) { + setSendDialogOpen(false); + setSendNewsletterId(null); + fetchNewsletters(); + } + } catch (err) { + console.error('Failed to send newsletter:', err); + } finally { + setIsSending(false); + } + }, [sendNewsletterId, sendAudience, sendScheduledAt, fetchNewsletters]); + + // ─── Rich text editor commands ────────────────────────────────── + + const execCommand = useCallback((command: string, value?: string) => { + document.execCommand(command, false, value); + if (editorRef.current) { + setEditorContent(editorRef.current.innerHTML); + } + }, []); + + const handleEditorInput = useCallback(() => { + if (editorRef.current) { + setEditorContent(editorRef.current.innerHTML); + } + }, []); + + // ─── Template defaults ────────────────────────────────────────── + + const applyTemplate = useCallback((templateType: string) => { + setEditorTemplateType(templateType); + const templates: Record = { + monthly: { + title: t('newsletter.template_monthly_title'), + content: `

${t('newsletter.section_header')}

${t('newsletter.template_monthly_header')}

${t('newsletter.section_highlights')}

${t('newsletter.template_placeholder')}

${t('newsletter.section_upcoming')}

${t('newsletter.template_placeholder')}

${t('newsletter.section_announcements')}

${t('newsletter.template_placeholder')}

`, + }, + event: { + title: t('newsletter.template_event_title'), + content: `

${t('newsletter.section_header')}

${t('newsletter.template_event_header')}

${t('newsletter.section_details')}

${t('newsletter.template_placeholder')}

${t('newsletter.section_registration')}

${t('newsletter.template_placeholder')}

`, + }, + parent: { + title: t('newsletter.template_parent_title'), + content: `

${t('newsletter.section_header')}

${t('newsletter.template_parent_header')}

${t('newsletter.section_important')}

${t('newsletter.template_placeholder')}

${t('newsletter.section_dates')}

${t('newsletter.template_placeholder')}

`, + }, + emergency: { + title: t('newsletter.template_emergency_title'), + content: `

${t('newsletter.section_header')}

${t('newsletter.template_emergency_header')}

${t('newsletter.section_details')}

${t('newsletter.template_placeholder')}

${t('newsletter.section_actions')}

${t('newsletter.template_placeholder')}

`, + }, + }; + const tmpl = templates[templateType]; + if (tmpl && !editorTitle) { + setEditorTitle(tmpl.title); + } + if (tmpl && (!editorContent || editorContent === '


')) { + setEditorContent(tmpl.content); + if (editorRef.current) { + editorRef.current.innerHTML = tmpl.content; + } + } + }, [editorTitle, editorContent]); + + // ─── Render: Newsletter List ──────────────────────────────────── + + const renderNewsletterList = () => ( +
+ {/* Stats cards */} +
+ {[ + { label: t('newsletter.status_draft'), value: draftCount, icon: Edit3, color: 'text-gray-600 dark:text-gray-400', bg: 'bg-gray-50 dark:bg-gray-900/30' }, + { label: t('newsletter.status_scheduled'), value: scheduledCount, icon: Clock, color: 'text-amber-600 dark:text-amber-400', bg: 'bg-amber-50 dark:bg-amber-900/20' }, + { label: t('newsletter.status_sent'), value: sentCount, icon: CheckCircle2, color: 'text-emerald-600 dark:text-emerald-400', bg: 'bg-emerald-50 dark:bg-emerald-900/20' }, + { label: t('newsletter.total_newsletters'), value: newsletters.length, icon: Newspaper, color: 'text-teal-600 dark:text-teal-400', bg: 'bg-teal-50 dark:bg-teal-900/20' }, + ].map((stat, i) => ( + + + +
+
+

{stat.label}

+

+ +

+
+ +
+
+
+
+ ))} +
+ + {/* Search and filters */} +
+
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+ + + {!isStudentOrParent && ( + + )} +
+ + {/* Newsletter list */} + {isLoading ? ( +
+ {[1, 2, 3, 4, 5, 6].map((i) => ( + + +
+
+
+ + + ))} +
+ ) : filteredNewsletters.length === 0 ? ( + + + +

{t('newsletter.no_newsletters')}

+

{t('newsletter.no_newsletters_desc')}

+ {!isStudentOrParent && ( + + )} +
+
+ ) : ( +
+ + {filteredNewsletters.map((newsletter, i) => { + const StatusIcon = STATUS_ICONS[newsletter.status] || Edit3; + return ( + + + {newsletter.bannerImageUrl && ( +
+ { (e.target as HTMLImageElement).style.display = 'none'; }} + /> +
+ )} + {!newsletter.bannerImageUrl && ( +
+
+
+ + + {t(`newsletter.status_${newsletter.status}`)} + +
+
+ )} + + {newsletter.bannerImageUrl && ( + + + {t(`newsletter.status_${newsletter.status}`)} + + )} +

{newsletter.title}

+ {newsletter.summary && ( +

{newsletter.summary}

+ )} +
+ {newsletter.author.firstName} {newsletter.author.lastName} + · + {formatDate(newsletter.createdAt)} +
+ {newsletter.status === 'sent' && newsletter.totalRecipients > 0 && ( +
+
+ {t('newsletter.open_rate')} + + {newsletter.totalRecipients > 0 + ? Math.round((newsletter.openCount / newsletter.totalRecipients) * 100) + : 0}% + +
+ 0 + ? (newsletter.openCount / newsletter.totalRecipients) * 100 + : 0} + className="h-1.5" + /> +
+ )} +
+
+ + {t(`newsletter.template_${newsletter.templateType}`)} + + + {t(`newsletter.category_${newsletter.category}`)} + +
+ {!isStudentOrParent && ( + + + + + + setViewNewsletter(newsletter)}> + + {t('action.view')} + + {(isAdmin || newsletter.authorId === currentUser?.id) && newsletter.status === 'draft' && ( + openEditEditor(newsletter)}> + + {t('action.edit')} + + )} + {(isAdmin || newsletter.authorId === currentUser?.id) && ( + handleDuplicate(newsletter.id)}> + + {t('newsletter.duplicate')} + + )} + {(isAdmin || newsletter.authorId === currentUser?.id) && newsletter.status === 'draft' && ( + { + setSendNewsletterId(newsletter.id); + setSendAudience('all'); + setSendScheduledAt(''); + setSendDialogOpen(true); + }}> + + {t('newsletter.send')} + + )} + {isAdmin && newsletter.status === 'sent' && ( + handleArchive(newsletter.id)}> + + {t('newsletter.archive')} + + )} + {(isAdmin || newsletter.authorId === currentUser?.id) && ( + handleDelete(newsletter.id)} + > + + {t('action.delete')} + + )} + + + )} + {isStudentOrParent && ( + + )} +
+
+ + + ); + })} + +
+ )} +
+ ); + + // ─── Render: Editor ───────────────────────────────────────────── + + const renderEditor = () => ( +
+ {/* Header */} +
+
+ +

+ {editingNewsletter ? t('newsletter.edit_newsletter') : t('newsletter.create_new')} +

+
+
+ + {isPreviewMode && ( + + )} + +
+
+ +
+ {/* Main editor area */} +
+ {/* Title */} +
+ + setEditorTitle(e.target.value)} + className="text-lg font-semibold" + /> +
+ + {/* Subject line */} +
+ + setEditorSubject(e.target.value)} + /> +
+ + {/* Banner URL */} +
+ +
+ setEditorBannerUrl(e.target.value)} + /> + {editorBannerUrl && ( + + )} +
+ {editorBannerUrl && ( +
+ Banner { (e.target as HTMLImageElement).style.display = 'none'; }} + /> +
+ )} +
+ + {/* Summary */} +
+ +