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
This commit is contained in:
1 parent
a159ed7fe6
commit
3d0d518bb9
57 files changed
+8370
-48
No files matched your search
@@ -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<string, unknown> = {};
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> = { 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 });
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> = {};
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> = { 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 });
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> = { 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<string, unknown> = {};
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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<string, number> = {};
|
||||
profiles.forEach((p) => {
|
||||
const cluster = p.careerCluster || 'unassigned';
|
||||
clusterCounts[cluster] = (clusterCounts[cluster] || 0) + 1;
|
||||
});
|
||||
|
||||
// Education path distribution
|
||||
const pathCounts: Record<string, number> = {};
|
||||
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 });
|
||||
}
|
||||
}
|
||||
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
@@ -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<string, unknown> = {};
|
||||
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 } },
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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<string, { sent: number; opens: number; clicks: number; recipients: number }> = {};
|
||||
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<number, { total: number; opens: number }> = {};
|
||||
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<string, { count: number; openRate: number; clickRate: number }> = {};
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
@@ -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<string, unknown> = {};
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> = {};
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> = { 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 });
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> = {};
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> = { 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 });
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> = {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
@@ -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 */
|
||||
|
||||
@@ -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 <SchoolLibraryView />;
|
||||
case 'report-cards': return <ReportCardView />;
|
||||
case 'student-wellness': return <StudentWellnessView />;
|
||||
case 'student-career': return <StudentCareerView />;
|
||||
case 'data-import-export': return <DataImportExportView />;
|
||||
case 'school-events': return <SchoolEventsView />;
|
||||
case 'substitute-teacher': return <SubstituteTeacherView />;
|
||||
case 'school-newsletter': return <SchoolNewsletterView />;
|
||||
default: return <DashboardView />;
|
||||
}
|
||||
}
|
||||
|
||||
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
File diff suppressed because it is too large.
Load diff
Regular → Executable
File mode changed.
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Settings as SettingsIcon,
|
||||
School,
|
||||
@@ -105,6 +105,7 @@ import { Separator } from '@/components/ui/separator';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { useAppStore } from '@/lib/store';
|
||||
import { t } from '@/lib/i18n';
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '@/lib/api';
|
||||
@@ -2576,20 +2577,54 @@ export default function SettingsView() {
|
||||
animate="visible"
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Header */}
|
||||
{/* Header — Gradient Banner with School Branding */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-md shadow-emerald-300/30">
|
||||
<SettingsIcon className="w-5 h-5" />
|
||||
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-emerald-500 via-teal-500 to-cyan-500 dark:from-emerald-700 dark:via-teal-700 dark:to-cyan-700 p-6 sm:p-8 text-white shadow-xl shadow-emerald-200/30 dark:shadow-emerald-900/30">
|
||||
{/* Decorative elements */}
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-white/5 rounded-full -translate-y-1/2 translate-x-1/3" />
|
||||
<div className="absolute bottom-0 left-0 w-48 h-48 bg-white/5 rounded-full translate-y-1/2 -translate-x-1/4" />
|
||||
<div className="absolute top-4 right-4 sm:top-6 sm:right-6">
|
||||
<motion.div
|
||||
animate={{ rotate: [0, 10, -10, 0] }}
|
||||
transition={{ duration: 4, repeat: Infinity, ease: 'easeInOut' }}
|
||||
>
|
||||
<SettingsIcon className="h-8 w-8 text-white/30" />
|
||||
</motion.div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">{t('settings.title')}</h2>
|
||||
<p className="text-emerald-600/60 dark:text-emerald-400/40 mt-0.5">{t('app.subtitle')}</p>
|
||||
|
||||
<div className="relative z-10 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center justify-center w-14 h-14 rounded-2xl bg-white/20 backdrop-blur-sm ring-2 ring-white/20 shadow-lg">
|
||||
<SettingsIcon className="w-7 h-7" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl sm:text-3xl font-bold">{t('settings.title')}</h2>
|
||||
<p className="text-emerald-100/80 mt-0.5 text-sm sm:text-base">{selectedSchool?.name || t('app.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* User Profile Avatar */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white/15 backdrop-blur-sm border border-white/20">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-white/25 text-white text-xs font-bold">
|
||||
{currentUser?.firstName?.[0] || ''}{currentUser?.lastName?.[0] || ''}
|
||||
</div>
|
||||
<div className="hidden sm:block">
|
||||
<p className="text-xs font-medium text-white">{currentUser?.firstName} {currentUser?.lastName}</p>
|
||||
<p className="text-[10px] text-white/70">{currentUser?.role === 'SUPER_ADMIN' ? 'Super Admin' : currentUser?.role === 'SCHOOL_ADMIN' ? 'School Admin' : 'Admin'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-white/15 backdrop-blur-sm border border-white/20 text-sm font-medium">
|
||||
<CalendarDays className="h-3.5 w-3.5" />
|
||||
<span className="font-semibold">
|
||||
{new Date().toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Quick stats banner */}
|
||||
{/* Quick stats banner — with hover lift and gradient borders */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3">
|
||||
{[
|
||||
@@ -2598,8 +2633,14 @@ export default function SettingsView() {
|
||||
{ icon: BookOpen, label: t('polish.total_subjects'), value: subjects.length, color: 'from-amber-50 to-amber-100/50 dark:from-amber-900/20 dark:to-amber-800/10', text: 'text-amber-700 dark:text-amber-300', iconBg: 'bg-gradient-to-br from-amber-400 to-amber-500', border: 'border-amber-200/40 dark:border-amber-900/30', tab: 'subjects' },
|
||||
{ icon: Calendar, label: t('polish.total_years'), value: schoolYears.length, color: 'from-teal-50 to-teal-100/50 dark:from-teal-900/20 dark:to-teal-800/10', text: 'text-teal-700 dark:text-teal-300', iconBg: 'bg-gradient-to-br from-teal-400 to-emerald-500', border: 'border-teal-200/40 dark:border-teal-900/30', tab: 'years' },
|
||||
{ icon: Activity, label: t('settings.tab_audit'), value: auditEntries.length, color: 'from-slate-50 to-slate-100/50 dark:from-slate-800/40 dark:to-slate-700/10', text: 'text-slate-700 dark:text-slate-300', iconBg: 'bg-gradient-to-br from-slate-400 to-slate-500', border: 'border-slate-200/40 dark:border-slate-700/30', tab: 'audit' },
|
||||
].map((stat) => (
|
||||
<div key={stat.label} className={`p-3 rounded-xl bg-gradient-to-br ${stat.color} border ${stat.border} flex items-center gap-2.5 cursor-pointer hover:shadow-md transition-shadow`} onClick={() => setActiveTab(stat.tab)}>
|
||||
].map((stat, idx) => (
|
||||
<motion.div
|
||||
key={stat.label}
|
||||
whileHover={{ y: -2, boxShadow: '0 8px 24px -6px rgba(16, 185, 129, 0.18)' }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className={`p-3 rounded-xl bg-gradient-to-br ${stat.color} border ${stat.border} flex items-center gap-2.5 cursor-pointer transition-shadow`}
|
||||
onClick={() => setActiveTab(stat.tab)}
|
||||
>
|
||||
<div className={`flex items-center justify-center w-9 h-9 rounded-lg ${stat.iconBg} text-white shadow-sm shrink-0`}>
|
||||
<stat.icon className="h-4 w-4" />
|
||||
</div>
|
||||
@@ -2607,15 +2648,15 @@ export default function SettingsView() {
|
||||
<p className="text-[10px] uppercase tracking-wide font-semibold text-gray-500 dark:text-gray-400 truncate">{stat.label}</p>
|
||||
<p className={`text-xl font-bold ${stat.text}`}>{stat.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Tabs */}
|
||||
{/* Tabs — with smooth framer-motion transitions */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
||||
<TabsList className="bg-emerald-50 dark:bg-emerald-900/20 rounded-xl flex flex-wrap gap-1 h-auto p-1">
|
||||
<TabsList className="bg-emerald-50/80 dark:bg-emerald-900/20 backdrop-blur-sm rounded-xl flex flex-wrap gap-1 h-auto p-1 ring-1 ring-emerald-200/30 dark:ring-emerald-800/20">
|
||||
<TabsTrigger value="school" className="rounded-lg data-[state=active]:bg-emerald-500 data-[state=active]:text-white">
|
||||
<School className="h-4 w-4 mr-1.5" />
|
||||
<span className="hidden sm:inline">{t('settings.tab_school')}</span>
|
||||
@@ -2688,7 +2729,15 @@ export default function SettingsView() {
|
||||
|
||||
{/* ── School Info Tab ─────────────────────────────────── */}
|
||||
<TabsContent value="school">
|
||||
<Card className="card-hover-lift border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key="school"
|
||||
initial={{ opacity: 0, x: 12 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -12 }}
|
||||
transition={{ duration: 0.25, ease: 'easeOut' }}
|
||||
>
|
||||
<Card className="glassmorphism-card border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden">
|
||||
<CardHeader className="bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
|
||||
@@ -2778,11 +2827,21 @@ export default function SettingsView() {
|
||||
<NotificationSoundSetting />
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── School Years Tab ────────────────────────────────── */}
|
||||
<TabsContent value="years">
|
||||
<Card className="card-hover-lift border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key="years"
|
||||
initial={{ opacity: 0, x: 12 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -12 }}
|
||||
transition={{ duration: 0.25, ease: 'easeOut' }}
|
||||
>
|
||||
<Card className="glassmorphism-card border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden">
|
||||
<CardHeader className="bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
|
||||
@@ -2890,6 +2949,8 @@ export default function SettingsView() {
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Subjects Tab ────────────────────────────────────── */}
|
||||
@@ -3752,9 +3813,17 @@ export default function SettingsView() {
|
||||
|
||||
{/* ── Data Management Tab ─────────────────────────────── */}
|
||||
<TabsContent value="data">
|
||||
<div className="space-y-6">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key="data"
|
||||
initial={{ opacity: 0, x: 12 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -12 }}
|
||||
transition={{ duration: 0.25, ease: 'easeOut' }}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Data Import */}
|
||||
<Card className="card-hover-lift border-0 shadow-sm rounded-xl border-l-3 border-l-amber-500 overflow-hidden">
|
||||
<Card className="glassmorphism-card border-0 shadow-sm rounded-xl border-l-3 border-l-amber-500 overflow-hidden">
|
||||
<CardHeader className="bg-gradient-to-r from-amber-50/50 to-transparent dark:from-amber-900/10 dark:to-transparent">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400">
|
||||
@@ -3885,7 +3954,7 @@ export default function SettingsView() {
|
||||
</Card>
|
||||
|
||||
{/* Enhanced Data Export */}
|
||||
<Card className="card-hover-lift border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden">
|
||||
<Card className="glassmorphism-card border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden">
|
||||
<CardHeader className="bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
|
||||
@@ -3988,7 +4057,7 @@ export default function SettingsView() {
|
||||
</Card>
|
||||
|
||||
{/* GDPR Data Erasure — Danger Zone */}
|
||||
<div className="danger-zone">
|
||||
<div className="danger-zone animate-pulse-glow">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-rose-100 dark:bg-rose-900/30 text-rose-600 dark:text-rose-400">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
@@ -4047,7 +4116,8 @@ export default function SettingsView() {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Demo Accounts Tab ───────────────────────────────── */}
|
||||
@@ -4806,7 +4876,15 @@ export default function SettingsView() {
|
||||
|
||||
{/* ── Branding Tab ───────────────────────────────────────────── */}
|
||||
<TabsContent value="branding">
|
||||
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key="branding"
|
||||
initial={{ opacity: 0, x: 12 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -12 }}
|
||||
transition={{ duration: 0.25, ease: 'easeOut' }}
|
||||
>
|
||||
<Card className="glassmorphism-card border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden">
|
||||
<CardHeader className="bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
|
||||
@@ -4817,7 +4895,7 @@ export default function SettingsView() {
|
||||
<CardDescription>{t('branding.colors')} · {t('branding.typography')} · {t('branding.contact')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Logo */}
|
||||
{/* Logo Preview Section */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-teal-700 dark:text-teal-400">{t('branding.logo_url')}</Label>
|
||||
<Input
|
||||
@@ -4826,15 +4904,28 @@ export default function SettingsView() {
|
||||
placeholder={t('branding.logo_placeholder')}
|
||||
className="border-teal-200 dark:border-teal-900/30 rounded-xl"
|
||||
/>
|
||||
{brandingForm.logoUrl && (
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
{brandingForm.logoUrl ? (
|
||||
<div className="mt-2 flex items-center gap-3 p-3 rounded-xl bg-gradient-to-r from-teal-50/50 to-emerald-50/30 dark:from-teal-900/10 dark:to-emerald-900/5 border border-teal-200/30 dark:border-teal-900/20">
|
||||
<img src={brandingForm.logoUrl} alt="Logo preview" className="w-12 h-12 rounded-xl object-contain border border-teal-200 dark:border-teal-900/30" onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">{t('branding.preview')}</span>
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-teal-700 dark:text-teal-300">{t('branding.preview')}</p>
|
||||
<p className="text-[10px] text-gray-500 dark:text-gray-400">{selectedSchool?.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 flex items-center gap-3 p-3 rounded-xl bg-gradient-to-r from-teal-50/50 to-emerald-50/30 dark:from-teal-900/10 dark:to-emerald-900/5 border border-teal-200/30 dark:border-teal-900/20">
|
||||
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-gradient-to-br from-teal-400 to-emerald-500 text-white shadow-sm">
|
||||
<School className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-teal-700 dark:text-teal-300">{selectedSchool?.name || 'School'}</p>
|
||||
<p className="text-[10px] text-gray-500 dark:text-gray-400">{t('branding.logo_placeholder')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Colors */}
|
||||
{/* Colors — with visual color picker */}
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm font-semibold text-teal-700 dark:text-teal-400">{t('branding.colors')}</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
@@ -4887,6 +4978,15 @@ export default function SettingsView() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Color preview strip */}
|
||||
<div className="flex items-center gap-2 p-3 rounded-xl bg-gray-50/50 dark:bg-gray-800/50 border border-gray-200/30 dark:border-gray-700/20">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 shrink-0">Preview:</span>
|
||||
<div className="flex items-center gap-1.5 flex-1">
|
||||
<div className="h-8 flex-1 rounded-lg" style={{ backgroundColor: brandingForm.primaryColor }} />
|
||||
<div className="h-8 flex-1 rounded-lg" style={{ backgroundColor: brandingForm.secondaryColor }} />
|
||||
<div className="h-8 flex-1 rounded-lg" style={{ backgroundColor: brandingForm.accentColor }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Typography */}
|
||||
@@ -5038,6 +5138,8 @@ export default function SettingsView() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Email Tab ───────────────────────────────────────────────── */}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -594,7 +594,7 @@ export default function StudentPortalView() {
|
||||
>
|
||||
{/* ── Hero Section ──────────────────────────────────────────────── */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-emerald-500 via-teal-500 to-cyan-500 dark:from-emerald-700 dark:via-teal-700 dark:to-cyan-700 p-6 sm:p-8 text-white shadow-xl shadow-emerald-200/30 dark:shadow-emerald-900/30">
|
||||
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-emerald-500 via-teal-500 to-cyan-500 dark:from-emerald-700 dark:via-teal-700 dark:to-cyan-700 p-6 sm:p-8 text-white shadow-xl shadow-emerald-200/30 dark:shadow-emerald-900/30 animate-pulse-glow">
|
||||
{/* Decorative elements */}
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-white/5 rounded-full -translate-y-1/2 translate-x-1/3" />
|
||||
<div className="absolute bottom-0 left-0 w-48 h-48 bg-white/5 rounded-full translate-y-1/2 -translate-x-1/4" />
|
||||
@@ -636,7 +636,7 @@ export default function StudentPortalView() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats Bar inside hero */}
|
||||
{/* Quick Stats Bar inside hero — with gradient borders */}
|
||||
<div className="relative z-10 mt-6 grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: t('student_portal.points_earned'), value: totalPoints, icon: Zap, color: 'bg-white/15' },
|
||||
@@ -647,7 +647,7 @@ export default function StudentPortalView() {
|
||||
<motion.div
|
||||
key={stat.label}
|
||||
whileHover={{ scale: 1.04, backgroundColor: 'rgba(255,255,255,0.25)' }}
|
||||
className={`flex items-center gap-3 ${stat.color} backdrop-blur-sm rounded-xl px-3 py-2.5 border border-white/10 transition-colors cursor-default`}
|
||||
className={`flex items-center gap-3 ${stat.color} backdrop-blur-sm rounded-xl px-3 py-2.5 border border-white/10 transition-colors cursor-default gradient-border-card`}
|
||||
>
|
||||
<stat.icon className="h-5 w-5 text-white/80 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
@@ -664,7 +664,7 @@ export default function StudentPortalView() {
|
||||
|
||||
{/* ── Level Up Progress Bar ──────────────────────────────────────── */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card className="border-0 shadow-sm rounded-xl overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05]">
|
||||
<Card className="glassmorphism-card border-0 shadow-sm rounded-xl overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -696,7 +696,7 @@ export default function StudentPortalView() {
|
||||
|
||||
{/* ── Streak Counter ──────────────────────────────────────────────── */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card className="border-0 shadow-sm rounded-xl overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05]">
|
||||
<Card className="glassmorphism-card border-0 shadow-sm rounded-xl overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
@@ -738,7 +738,7 @@ export default function StudentPortalView() {
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* ── Quick Action Cards ──────────────────────────────────────────── */}
|
||||
{/* ── Quick Action Cards — with hover lift effects ──────────────────── */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{[
|
||||
@@ -751,11 +751,12 @@ export default function StudentPortalView() {
|
||||
return (
|
||||
<motion.div
|
||||
key={idx}
|
||||
whileHover={{ scale: 1.03, y: -3 }}
|
||||
whileHover={{ scale: 1.03, y: -4 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
className="card-hover-lift"
|
||||
>
|
||||
<Card
|
||||
className="border-0 shadow-sm rounded-xl overflow-hidden cursor-pointer hover:shadow-md transition-all duration-300"
|
||||
className="glassmorphism-card border-0 shadow-sm rounded-xl overflow-hidden cursor-pointer hover:shadow-lg transition-all duration-300"
|
||||
onClick={() => {
|
||||
const store = useAppStore.getState();
|
||||
store.setCurrentView(action.view);
|
||||
@@ -774,11 +775,139 @@ export default function StudentPortalView() {
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* ── Today's Schedule Mini-Timeline & Motivational Quote ─────────── */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Today's Schedule Mini-Timeline */}
|
||||
<Card className="glassmorphism-card border-0 shadow-sm rounded-xl overflow-hidden border-l-3 border-l-emerald-500">
|
||||
<CardHeader className="pb-2 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
|
||||
<CardTitle className="text-sm font-bold flex items-center gap-2">
|
||||
<div className="flex items-center justify-center w-7 h-7 rounded-lg bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-sm">
|
||||
<CalendarDays className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
{t('student_portal.my_schedule')}
|
||||
<div className="h-0.5 w-12 rounded-full bg-gradient-to-r from-emerald-400 to-transparent" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
{schedule.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{schedule.slice(0, 4).map((period, idx) => {
|
||||
const isCurrent = currentPeriodIndex === idx;
|
||||
const isPast = currentPeriodIndex !== -1 && idx < currentPeriodIndex;
|
||||
return (
|
||||
<motion.div
|
||||
key={idx}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: idx * 0.05, duration: 0.3 }}
|
||||
className={`flex items-center gap-3 p-2.5 rounded-lg transition-all ${
|
||||
isCurrent
|
||||
? 'bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200/50 dark:border-emerald-800/30'
|
||||
: isPast
|
||||
? 'opacity-50'
|
||||
: 'bg-gray-50/50 dark:bg-gray-800/30'
|
||||
}`}
|
||||
>
|
||||
<div className={`flex items-center justify-center w-8 h-8 rounded-lg text-xs font-bold shrink-0 ${
|
||||
isCurrent
|
||||
? 'bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-sm'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400'
|
||||
}`}>
|
||||
{period.period}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">{period.subject}</p>
|
||||
{isCurrent && (
|
||||
<span className="status-dot animate-pulse-glow" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-500 dark:text-gray-400">
|
||||
{period.startTime} - {period.endTime}
|
||||
{period.room ? ` · ${period.room}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
{isCurrent && (
|
||||
<Badge className="text-[10px] bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-emerald-800">
|
||||
Now
|
||||
</Badge>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-4">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{t('student_portal.no_schedule')}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Motivational Quote Section with fade animation */}
|
||||
<Card className="glassmorphism-card border-0 shadow-sm rounded-xl overflow-hidden border-l-3 border-l-violet-500">
|
||||
<CardHeader className="pb-2 bg-gradient-to-r from-violet-50/50 to-transparent dark:from-violet-900/10 dark:to-transparent">
|
||||
<CardTitle className="text-sm font-bold flex items-center gap-2">
|
||||
<div className="flex items-center justify-center w-7 h-7 rounded-lg bg-gradient-to-br from-violet-400 to-purple-500 text-white shadow-sm">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
{t('student_portal.daily_inspiration')}
|
||||
<div className="h-0.5 w-12 rounded-full bg-gradient-to-r from-violet-400 to-transparent" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease: 'easeOut' }}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="p-4 rounded-xl bg-gradient-to-br from-violet-50/50 to-purple-50/30 dark:from-violet-900/10 dark:to-purple-900/5 border border-violet-100/40 dark:border-violet-900/20">
|
||||
<p className="text-sm font-medium text-gray-800 dark:text-gray-200 italic leading-relaxed">
|
||||
“{locale === 'de'
|
||||
? 'Bildung ist nicht das Befuellen eines Fasses, sondern das Entzuenden einer Flamme.'
|
||||
: 'Education is not the filling of a pail, but the lighting of a fire.'
|
||||
}”
|
||||
</p>
|
||||
<p className="text-xs text-violet-600 dark:text-violet-400 mt-2 font-semibold">
|
||||
— {locale === 'de' ? 'Heraklit' : 'William Butler Yeats'}
|
||||
</p>
|
||||
</div>
|
||||
{/* My Progress section with animated progress bars */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">{t('student_portal.my_competencies')}</p>
|
||||
{competencies.slice(0, 4).map((comp, idx) => {
|
||||
const colors = masteryColors[comp.level] || masteryColors[1];
|
||||
return (
|
||||
<div key={idx} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-700 dark:text-gray-300 font-medium truncate">{comp.subject}</span>
|
||||
<span className="text-gray-500 dark:text-gray-400 font-semibold">{comp.progress}%</span>
|
||||
</div>
|
||||
<div className="relative h-2 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
className={`absolute inset-y-0 left-0 rounded-full bg-gradient-to-r ${colors.gradient} progress-animated`}
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${comp.progress}%` }}
|
||||
transition={{ duration: 0.8, ease: 'easeOut', delay: 0.2 + idx * 0.1 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* ── Charts Section: Radar + Grade Trend ─────────────────────────── */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Competency Radar Chart */}
|
||||
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
|
||||
<Card className="glassmorphism-card border-0 shadow-sm rounded-xl overflow-hidden">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-bold flex items-center gap-2">
|
||||
<div className="flex items-center justify-center w-7 h-7 rounded-lg bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-sm">
|
||||
@@ -808,7 +937,7 @@ export default function StudentPortalView() {
|
||||
</Card>
|
||||
|
||||
{/* Grade Trend Chart */}
|
||||
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
|
||||
<Card className="glassmorphism-card border-0 shadow-sm rounded-xl overflow-hidden">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-bold flex items-center gap-2">
|
||||
<div className="flex items-center justify-center w-7 h-7 rounded-lg bg-gradient-to-br from-violet-400 to-purple-500 text-white shadow-sm">
|
||||
@@ -845,7 +974,7 @@ export default function StudentPortalView() {
|
||||
|
||||
{/* ── Weekly Streak Heatmap ───────────────────────────────────────── */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
|
||||
<Card className="glassmorphism-card border-0 shadow-sm rounded-xl overflow-hidden">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-bold flex items-center gap-2">
|
||||
<div className="flex items-center justify-center w-7 h-7 rounded-lg bg-gradient-to-br from-orange-400 to-red-500 text-white shadow-sm">
|
||||
@@ -900,7 +1029,7 @@ export default function StudentPortalView() {
|
||||
|
||||
{/* ── Daily Challenge Widget ──────────────────────────────────────── */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card className="border-0 shadow-sm rounded-xl overflow-hidden border-l-3 border-l-amber-500">
|
||||
<Card className="glassmorphism-card border-0 shadow-sm rounded-xl overflow-hidden border-l-3 border-l-amber-500">
|
||||
<CardHeader className="pb-2 bg-gradient-to-r from-amber-50/50 to-transparent dark:from-amber-900/10 dark:to-transparent">
|
||||
<CardTitle className="text-sm font-bold flex items-center gap-2">
|
||||
<div className="flex items-center justify-center w-7 h-7 rounded-lg bg-gradient-to-br from-amber-400 to-amber-500 text-white shadow-sm">
|
||||
@@ -937,7 +1066,7 @@ export default function StudentPortalView() {
|
||||
|
||||
{/* ── Learning Time Tracker ───────────────────────────────────────── */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
|
||||
<Card className="glassmorphism-card border-0 shadow-sm rounded-xl overflow-hidden">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-bold flex items-center gap-2">
|
||||
<div className="flex items-center justify-center w-7 h-7 rounded-lg bg-gradient-to-br from-sky-400 to-cyan-500 text-white shadow-sm">
|
||||
@@ -975,7 +1104,7 @@ export default function StudentPortalView() {
|
||||
{/* ── Main Tabs ──────────────────────────────────────────────────── */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
|
||||
<TabsList className="grid w-full grid-cols-3 sm:grid-cols-6 h-auto gap-1 bg-gray-100/50 dark:bg-gray-800/50 p-1 rounded-xl">
|
||||
<TabsList className="grid w-full grid-cols-3 sm:grid-cols-6 h-auto gap-1 bg-gray-100/50 dark:bg-gray-800/50 backdrop-blur-sm p-1 rounded-xl ring-1 ring-gray-200/30 dark:ring-gray-700/20">
|
||||
<TabsTrigger value="competencies" className="text-xs sm:text-sm gap-1.5 min-h-[40px] data-[state=active]:bg-white dark:data-[state=active]:bg-gray-900 data-[state=active]:shadow-sm rounded-lg">
|
||||
<BookOpen className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">{t('student_portal.my_competencies')}</span>
|
||||
|
||||
Regular → Executable
File mode changed.
File diff suppressed because it is too large.
Load diff
+676
@@ -5982,6 +5982,344 @@ const de: TranslationDict = {
|
||||
'se.type_concert': 'Konzert',
|
||||
'se.type_graduation': 'Abschlussfeier',
|
||||
'se.type_fair': 'Schulfest',
|
||||
// Substitute Teacher Management
|
||||
'nav.substitute-teacher': 'Vertretungsplan',
|
||||
'sub.title': 'Vertretungsplanung',
|
||||
'sub.subtitle': 'Vertretungslehrer, Abwesenheiten & Einsatzplanung verwalten',
|
||||
'sub.pool': 'Vertretungspool',
|
||||
'sub.absences': 'Abwesenheiten',
|
||||
'sub.schedule': 'Vertretungsplan',
|
||||
'sub.statistics': 'Statistiken',
|
||||
'sub.add_substitute': 'Vertretungslehrer hinzufügen',
|
||||
'sub.edit_substitute': 'Vertretungslehrer bearbeiten',
|
||||
'sub.delete_substitute': 'Vertretungslehrer entfernen',
|
||||
'sub.first_name': 'Vorname',
|
||||
'sub.last_name': 'Nachname',
|
||||
'sub.email': 'E-Mail',
|
||||
'sub.phone': 'Telefon',
|
||||
'sub.qualifications': 'Qualifikationen',
|
||||
'sub.subjects': 'Fächer',
|
||||
'sub.grade_levels': 'Jahrgangsstufen',
|
||||
'sub.availability': 'Verfügbarkeit',
|
||||
'sub.max_days': 'Max. Tage/Woche',
|
||||
'sub.rating': 'Bewertung',
|
||||
'sub.total_assignments': 'Gesamteinsätze',
|
||||
'sub.notes': 'Notizen',
|
||||
'sub.active': 'Aktiv',
|
||||
'sub.inactive': 'Inaktiv',
|
||||
'sub.search_substitute': 'Vertretungslehrer suchen...',
|
||||
'sub.filter_active': 'Nur aktive',
|
||||
'sub.filter_all': 'Alle',
|
||||
'sub.no_substitutes': 'Keine Vertretungslehrer gefunden',
|
||||
'sub.add_from_teachers': 'Aus Lehrerpool übernehmen',
|
||||
'sub.report_absence': 'Abwesenheit melden',
|
||||
'sub.edit_absence': 'Abwesenheit bearbeiten',
|
||||
'sub.delete_absence': 'Abwesenheit löschen',
|
||||
'sub.teacher': 'Lehrkraft',
|
||||
'sub.absence_type': 'Abwesenheitsart',
|
||||
'sub.type_sick': 'Krankheit',
|
||||
'sub.type_personal': 'Persönlich',
|
||||
'sub.type_training': 'Fortbildung',
|
||||
'sub.type_other': 'Sonstiges',
|
||||
'sub.start_date': 'Beginn',
|
||||
'sub.end_date': 'Ende',
|
||||
'sub.reason': 'Grund',
|
||||
'sub.status': 'Status',
|
||||
'sub.status_reported': 'Gemeldet',
|
||||
'sub.status_covered': 'Abgedeckt',
|
||||
'sub.status_cancelled': 'Storniert',
|
||||
'sub.bulk_absence': 'Mehrtägige Abwesenheit',
|
||||
'sub.no_absences': 'Keine Abwesenheiten gefunden',
|
||||
'sub.create_assignment': 'Vertretung zuweisen',
|
||||
'sub.edit_assignment': 'Vertretungseinsatz bearbeiten',
|
||||
'sub.substitute': 'Vertretungslehrer',
|
||||
'sub.class': 'Klasse',
|
||||
'sub.subject': 'Fach',
|
||||
'sub.date': 'Datum',
|
||||
'sub.period': 'Stunde',
|
||||
'sub.room': 'Raum',
|
||||
'sub.assignment_status': 'Einsatzstatus',
|
||||
'sub.status_pending': 'Ausstehend',
|
||||
'sub.status_confirmed': 'Bestätigt',
|
||||
'sub.status_completed': 'Abgeschlossen',
|
||||
'sub.assignment_cancelled': 'Storniert',
|
||||
'sub.no_assignments': 'Keine Vertretungseinsätze gefunden',
|
||||
'sub.weekly_view': 'Wochenansicht',
|
||||
'sub.print_plan': 'Plan drucken',
|
||||
'sub.notify_teachers': 'Lehrkräfte benachrichtigen',
|
||||
'sub.suggest_substitute': 'Vertretung vorschlagen',
|
||||
'sub.auto_assign': 'Automatisch zuweisen',
|
||||
'sub.coverage_rate': 'Abdeckungsrate',
|
||||
'sub.absence_frequency': 'Abwesenheitshäufigkeit',
|
||||
'sub.substitute_utilization': 'Vertretungsauslastung',
|
||||
'sub.cost_tracking': 'Kostenverfolgung',
|
||||
'sub.monthly_trends': 'Monatliche Trends',
|
||||
'sub.absences_per_month': 'Abwesenheiten pro Monat',
|
||||
'sub.assignments_per_status': 'Einsätze nach Status',
|
||||
'sub.top_absent_teachers': 'Häufigste Abwesenheiten',
|
||||
'sub.total_absences': 'Gesamt Abwesenheiten',
|
||||
'sub.total_substitutes': 'Gesamt Vertretungen',
|
||||
'sub.total_assignments_count': 'Gesamteinsätze',
|
||||
'sub.avg_rating': 'Durchschnittsbewertung',
|
||||
'sub.this_week': 'Diese Woche',
|
||||
'sub.uncovered': 'Nicht abgedeckt',
|
||||
'sub.cost_per_day': 'Kosten pro Tag',
|
||||
'sub.total_cost': 'Gesamtkosten',
|
||||
'sub.monday': 'Montag',
|
||||
'sub.tuesday': 'Dienstag',
|
||||
'sub.wednesday': 'Mittwoch',
|
||||
'sub.thursday': 'Donnerstag',
|
||||
'sub.friday': 'Freitag',
|
||||
'sub.saturday': 'Samstag',
|
||||
'sub.confirm_delete_substitute': 'Möchten Sie diesen Vertretungslehrer wirklich entfernen?',
|
||||
'sub.confirm_delete_absence': 'Möchten Sie diese Abwesenheit wirklich löschen?',
|
||||
'sub.confirm_cancel_assignment': 'Möchten Sie diesen Vertretungseinsatz wirklich stornieren?',
|
||||
'sub.save_success': 'Erfolgreich gespeichert',
|
||||
'sub.delete_success': 'Erfolgreich gelöscht',
|
||||
'sub.error_save': 'Fehler beim Speichern',
|
||||
'sub.error_delete': 'Fehler beim Löschen',
|
||||
'sub.error_load': 'Fehler beim Laden',
|
||||
'sub.no_teacher_selected': 'Bitte Lehrkraft auswählen',
|
||||
'sub.quick_add': 'Schnell hinzufügen',
|
||||
'sub.per_day': 'pro Tag',
|
||||
'sub.rate_substitute': 'Vertretung bewerten',
|
||||
'sub.daily_rate': 'Tagessatz',
|
||||
'sub.hours': 'Stunden',
|
||||
'sub.lessons': 'Unterrichtsstunden',
|
||||
|
||||
// Career Guidance
|
||||
'nav.student-career': 'Berufsorientierung',
|
||||
'career.title': 'Berufsorientierung',
|
||||
'career.subtitle': 'Berufsweg erkunden und planen',
|
||||
'career.exploration': 'Berufserkundung',
|
||||
'career.quiz': 'Interessenstest',
|
||||
'career.profiles': 'Berufsprofile',
|
||||
'career.clusters': 'Berufsfelder',
|
||||
'career.portfolio': 'Karriereportfolio',
|
||||
'career.planning': 'Berufsplanung',
|
||||
'career.appointments': 'Beratungsgespräche',
|
||||
'career.goals': 'Berufsziele',
|
||||
'career.statistics': 'Statistiken',
|
||||
'career.cluster_technology': 'Technologie',
|
||||
'career.cluster_health': 'Gesundheit',
|
||||
'career.cluster_business': 'Wirtschaft',
|
||||
'career.cluster_arts': 'Kunst & Kultur',
|
||||
'career.cluster_science': 'Naturwissenschaften',
|
||||
'career.cluster_social': 'Soziales',
|
||||
'career.path_hauptschule': 'Hauptschule',
|
||||
'career.path_realschule': 'Realschule',
|
||||
'career.path_gymnasium': 'Gymnasium',
|
||||
'career.path_berufsschule': 'Berufsschule',
|
||||
'career.interest_creativity': 'Kreativität',
|
||||
'career.interest_analytical': 'Analytisches Denken',
|
||||
'career.interest_social_skill': 'Soziale Kompetenz',
|
||||
'career.interest_technical': 'Technisches Verständnis',
|
||||
'career.interest_leadership': 'Führung',
|
||||
'career.interest_nature': 'Natur & Umwelt',
|
||||
'career.interest_communication': 'Kommunikation',
|
||||
'career.interest_organization': 'Organisation',
|
||||
'career.quiz_title': 'Berufsinteressentest',
|
||||
'career.quiz_subtitle': 'Entdecke deine Interessen und Stärken',
|
||||
'career.quiz_question': 'Wie sehr interessierst du dich für...',
|
||||
'career.quiz_start': 'Test starten',
|
||||
'career.quiz_retake': 'Test wiederholen',
|
||||
'career.quiz_result': 'Dein Ergebnis',
|
||||
'career.quiz_result_desc': 'Basierend auf deinen Antworten',
|
||||
'career.quiz_not_taken': 'Du hast den Interessenstest noch nicht gemacht',
|
||||
'career.quiz_not_taken_desc': 'Starte den Test, um deine Berufsinteressen zu entdecken',
|
||||
'career.profile_title': 'Dein Berufsprofil',
|
||||
'career.profile_subtitle': 'Interessen, Stärken und Berufsweg',
|
||||
'career.interests': 'Interessen',
|
||||
'career.strengths': 'Stärken',
|
||||
'career.desired_career': 'Wunschberuf',
|
||||
'career.education_path': 'Bildungsweg',
|
||||
'career.career_cluster': 'Beruffeld',
|
||||
'career.work_experience': 'Arbeitserfahrung',
|
||||
'career.volunteer_experience': 'Ehrenamtliche Tätigkeit',
|
||||
'career.certifications': 'Zertifikate & Qualifikationen',
|
||||
'career.documents': 'Dokumente',
|
||||
'career.add_work_exp': 'Arbeitserfahrung hinzufügen',
|
||||
'career.add_volunteer': 'Ehrenamt hinzufügen',
|
||||
'career.add_certification': 'Zertifikat hinzufügen',
|
||||
'career.add_document': 'Dokument hinzufügen',
|
||||
'career.company': 'Unternehmen',
|
||||
'career.role': 'Rolle/Position',
|
||||
'career.start_date': 'Beginn',
|
||||
'career.end_date': 'Ende',
|
||||
'career.organization': 'Organisation',
|
||||
'career.name': 'Name',
|
||||
'career.issuer': 'Aussteller',
|
||||
'career.date': 'Datum',
|
||||
'career.type': 'Typ',
|
||||
'career.appointment_title': 'Beratungsgespräch',
|
||||
'career.appointment_subtitle': 'Termin mit Berufsberater/in',
|
||||
'career.schedule_appointment': 'Gespräch vereinbaren',
|
||||
'career.counselor': 'Berater/in',
|
||||
'career.duration': 'Dauer',
|
||||
'career.appointment_type_guidance': 'Berufsberatung',
|
||||
'career.appointment_type_career': 'Karriereberatung',
|
||||
'career.appointment_type_academic': 'Akademische Beratung',
|
||||
'career.appointment_type_college': 'Studienberatung',
|
||||
'career.upcoming_appointments': 'Anstehende Gespräche',
|
||||
'career.past_appointments': 'Vergangene Gespräche',
|
||||
'career.no_appointments': 'Keine Gespräche vorhanden',
|
||||
'career.no_appointments_desc': 'Vereinbare dein erstes Beratungsgespräch',
|
||||
'career.action_items': 'Aktionspunkte',
|
||||
'career.appointment_notes': 'Gesprächsnotizen',
|
||||
'career.complete_appointment': 'Gespräch abschließen',
|
||||
'career.cancel_appointment': 'Gespräch absagen',
|
||||
'career.goal_title': 'Berufsziele',
|
||||
'career.goal_subtitle': 'Setze und verfolge deine Ziele',
|
||||
'career.add_goal': 'Ziel hinzufügen',
|
||||
'career.goal_short_term': 'Kurzfristiges Ziel',
|
||||
'career.goal_long_term': 'Langfristiges Ziel',
|
||||
'career.goal_academic': 'Akademisches Ziel',
|
||||
'career.goal_professional': 'Berufliches Ziel',
|
||||
'career.target_date': 'Zieldatum',
|
||||
'career.progress': 'Fortschritt',
|
||||
'career.milestones': 'Meilensteine',
|
||||
'career.add_milestone': 'Meilenstein hinzufügen',
|
||||
'career.milestone_completed': 'Abgeschlossen',
|
||||
'career.milestone_pending': 'Ausstehend',
|
||||
'career.status_active': 'Aktiv',
|
||||
'career.status_completed': 'Abgeschlossen',
|
||||
'career.status_abandoned': 'Aufgegeben',
|
||||
'career.scholarship_finder': 'Stipendiensuche',
|
||||
'career.application_tracker': 'Bewerbungstracker',
|
||||
'career.important_dates': 'Wichtige Termine',
|
||||
'career.no_goals': 'Keine Ziele vorhanden',
|
||||
'career.no_goals_desc': 'Erstelle dein erstes Berufsziel',
|
||||
'career.career_profile_card': 'Beruf: {name}',
|
||||
'career.salary_range': 'Gehaltsspanne',
|
||||
'career.requirements': 'Voraussetzungen',
|
||||
'career.subject_recommendations': 'Fachempfehlungen',
|
||||
'career.skills_needed': 'Erforderliche Fähigkeiten',
|
||||
'career.save_profile': 'Profil speichern',
|
||||
'career.edit_profile': 'Profil bearbeiten',
|
||||
'career.create_profile': 'Profil erstellen',
|
||||
'career.no_profile': 'Kein Berufsprofil vorhanden',
|
||||
'career.no_profile_desc': 'Erstelle dein Berufsprofil, um deinen Weg zu planen',
|
||||
'career.school_overview': 'Schulübersicht',
|
||||
'career.students_with_profiles': 'Schüler mit Profil',
|
||||
'career.total_goals': 'Gesamtziele',
|
||||
'career.avg_progress': 'Durchschn. Fortschritt',
|
||||
'career.appointments_this_month': 'Gespräche diesen Monat',
|
||||
'career.cluster_distribution': 'Beruffeld-Verteilung',
|
||||
'career.path_distribution': 'Bildungsweg-Verteilung',
|
||||
'career.minutes': 'Minuten',
|
||||
'career.no_career_cluster': 'Nicht zugewiesen',
|
||||
|
||||
// ─── School Newsletter ────────────────────────────────────────────
|
||||
'nav.school-newsletter': 'Schulnewsletter',
|
||||
'newsletter.title': 'Schulnewsletter',
|
||||
'newsletter.subtitle': 'Erstellen, verwalten und versenden Sie Schulnewsletter an Ihre Schulgemeinschaft.',
|
||||
'newsletter.subtitle_readonly': 'Newsletter und Mitteilungen der Schule.',
|
||||
'newsletter.tab_newsletters': 'Newsletter',
|
||||
'newsletter.tab_analytics': 'Analyse',
|
||||
'newsletter.create_new': 'Neuer Newsletter',
|
||||
'newsletter.create_first': 'Ersten Newsletter erstellen',
|
||||
'newsletter.search_placeholder': 'Newsletter durchsuchen...',
|
||||
'newsletter.filter_status': 'Status',
|
||||
'newsletter.filter_category': 'Kategorie',
|
||||
'newsletter.filter_all': 'Alle',
|
||||
'newsletter.no_newsletters': 'Keine Newsletter vorhanden',
|
||||
'newsletter.no_newsletters_desc': 'Erstellen Sie Ihren ersten Schulnewsletter.',
|
||||
'newsletter.duplicate': 'Duplizieren',
|
||||
'newsletter.archive': 'Archivieren',
|
||||
'newsletter.send': 'Versenden',
|
||||
'newsletter.total_newsletters': 'Gesamt',
|
||||
'newsletter.status_draft': 'Entwurf',
|
||||
'newsletter.status_scheduled': 'Geplant',
|
||||
'newsletter.status_sent': 'Gesendet',
|
||||
'newsletter.status_archived': 'Archiviert',
|
||||
'newsletter.open_rate': 'Öffnungsrate',
|
||||
'newsletter.click_rate': 'Klickrate',
|
||||
'newsletter.bounce_rate': 'Bouncerate',
|
||||
'newsletter.template_monthly': 'Monatlicher Update',
|
||||
'newsletter.template_event': 'Veranstaltungs-Rückblick',
|
||||
'newsletter.template_parent': 'Elternnewsletter',
|
||||
'newsletter.template_emergency': 'Notfall-Mitteilung',
|
||||
'newsletter.template_monthly_title': 'Monatlicher Schul-Update',
|
||||
'newsletter.template_event_title': 'Veranstaltungs-Rückblick',
|
||||
'newsletter.template_parent_title': 'Elternnewsletter',
|
||||
'newsletter.template_emergency_title': 'Wichtige Mitteilung',
|
||||
'newsletter.category_general': 'Allgemein',
|
||||
'newsletter.category_academic': 'Akademisch',
|
||||
'newsletter.category_sports': 'Sport',
|
||||
'newsletter.category_arts': 'Kunst',
|
||||
'newsletter.category_events': 'Veranstaltungen',
|
||||
'newsletter.category_community': 'Gemeinschaft',
|
||||
'newsletter.audience_all': 'Alle',
|
||||
'newsletter.audience_teachers': 'Lehrkräfte',
|
||||
'newsletter.audience_parents': 'Eltern',
|
||||
'newsletter.audience_classes': 'Bestimmte Klassen',
|
||||
'newsletter.audience_roles': 'Bestimmte Rollen',
|
||||
'newsletter.edit_newsletter': 'Newsletter bearbeiten',
|
||||
'newsletter.title_label': 'Titel',
|
||||
'newsletter.title_placeholder': 'Newsletter-Titel eingeben...',
|
||||
'newsletter.subject_label': 'Betreffzeile',
|
||||
'newsletter.subject_placeholder': 'E-Mail-Betreff eingeben...',
|
||||
'newsletter.banner_url_label': 'Banner-Bild-URL',
|
||||
'newsletter.banner_url_placeholder': 'https://beispiel.de/banner.jpg',
|
||||
'newsletter.summary_label': 'Zusammenfassung',
|
||||
'newsletter.summary_placeholder': 'Kurze Zusammenfassung des Newsletters...',
|
||||
'newsletter.content_label': 'Inhalt',
|
||||
'newsletter.heading': 'Überschrift',
|
||||
'newsletter.heading2': 'Überschrift 2',
|
||||
'newsletter.heading3': 'Überschrift 3',
|
||||
'newsletter.paragraph': 'Absatz',
|
||||
'newsletter.enter_url': 'URL eingeben:',
|
||||
'newsletter.enter_image_url': 'Bild-URL eingeben:',
|
||||
'newsletter.edit_mode': 'Bearbeiten',
|
||||
'newsletter.preview_mode': 'Vorschau',
|
||||
'newsletter.desktop': 'Desktop',
|
||||
'newsletter.mobile': 'Mobil',
|
||||
'newsletter.save_draft': 'Als Entwurf speichern',
|
||||
'newsletter.untitled': 'Unbenannter Newsletter',
|
||||
'newsletter.template_section': 'Vorlage',
|
||||
'newsletter.template_type': 'Vorlagentyp',
|
||||
'newsletter.category_label': 'Kategorie',
|
||||
'newsletter.audience_section': 'Zielgruppe',
|
||||
'newsletter.tags_section': 'Schlagwörter',
|
||||
'newsletter.tags_placeholder': 'Schlagwort1, Schlagwort2, ...',
|
||||
'newsletter.tags_hint': 'Durch Komma getrennt',
|
||||
'newsletter.quick_templates': 'Schnellvorlagen',
|
||||
'newsletter.send_newsletter': 'Newsletter versenden',
|
||||
'newsletter.send_description': 'Wählen Sie die Zielgruppe und den Versandzeitpunkt.',
|
||||
'newsletter.target_audience': 'Zielgruppe',
|
||||
'newsletter.schedule_send': 'Versand planen (optional)',
|
||||
'newsletter.schedule_hint': 'Leer lassen für sofortigen Versand.',
|
||||
'newsletter.schedule': 'Planen',
|
||||
'newsletter.send_now': 'Jetzt versenden',
|
||||
'newsletter.section_header': 'Kopfzeile',
|
||||
'newsletter.section_highlights': 'Highlights',
|
||||
'newsletter.section_upcoming': 'Kommende Termine',
|
||||
'newsletter.section_announcements': 'Mitteilungen',
|
||||
'newsletter.section_details': 'Details',
|
||||
'newsletter.section_registration': 'Anmeldung',
|
||||
'newsletter.section_important': 'Wichtig',
|
||||
'newsletter.section_dates': 'Wichtige Termine',
|
||||
'newsletter.section_actions': 'Maßnahmen',
|
||||
'newsletter.template_placeholder': 'Inhalt hier eingeben...',
|
||||
'newsletter.template_monthly_header': 'Hier ist Ihr monatlicher Schul-Update mit den wichtigsten Neuigkeiten und Terminen.',
|
||||
'newsletter.template_event_header': 'Lesen Sie alles über unsere bevorstehende Veranstaltung.',
|
||||
'newsletter.template_parent_header': 'Liebe Eltern, hier sind die wichtigsten Informationen für Sie.',
|
||||
'newsletter.template_emergency_header': 'WICHTIG: Bitte lesen Sie diese Mitteilung aufmerksam durch.',
|
||||
'newsletter.no_analytics': 'Noch keine Analysedaten verfügbar.',
|
||||
'newsletter.no_data': 'Keine Daten verfügbar',
|
||||
'newsletter.total_sent': 'Gesendet',
|
||||
'newsletter.opens': 'Öffnungen',
|
||||
'newsletter.clicks': 'Klicks',
|
||||
'newsletter.bounces': 'Bounces',
|
||||
'newsletter.recipients': 'Empfänger',
|
||||
'newsletter.subscribers': 'Abonnenten',
|
||||
'newsletter.best_send_time': 'Beste Sendezeit',
|
||||
'newsletter.best_send_time_desc': 'Die höchste Öffnungsrate wird um {0} Uhr erreicht ({1} Öffnungsrate).',
|
||||
'newsletter.engagement_trend': 'Engagement-Trend',
|
||||
'newsletter.template_performance': 'Vorlagen-Performance',
|
||||
'newsletter.engagement_distribution': 'Engagement-Verteilung',
|
||||
'newsletter.recent_performance': 'Aktuelle Performance',
|
||||
'newsletter.subscriber_growth': 'Abonnenten-Wachstum',
|
||||
};
|
||||
|
||||
const en: TranslationDict = {
|
||||
@@ -11962,6 +12300,344 @@ const en: TranslationDict = {
|
||||
'se.type_concert': 'Concert',
|
||||
'se.type_graduation': 'Graduation',
|
||||
'se.type_fair': 'School Fair',
|
||||
// Substitute Teacher Management
|
||||
'nav.substitute-teacher': 'Substitute Management',
|
||||
'sub.title': 'Substitute Management',
|
||||
'sub.subtitle': 'Manage substitute teachers, absences & lesson coverage',
|
||||
'sub.pool': 'Substitute Pool',
|
||||
'sub.absences': 'Absences',
|
||||
'sub.schedule': 'Schedule',
|
||||
'sub.statistics': 'Statistics',
|
||||
'sub.add_substitute': 'Add Substitute',
|
||||
'sub.edit_substitute': 'Edit Substitute',
|
||||
'sub.delete_substitute': 'Remove Substitute',
|
||||
'sub.first_name': 'First Name',
|
||||
'sub.last_name': 'Last Name',
|
||||
'sub.email': 'Email',
|
||||
'sub.phone': 'Phone',
|
||||
'sub.qualifications': 'Qualifications',
|
||||
'sub.subjects': 'Subjects',
|
||||
'sub.grade_levels': 'Grade Levels',
|
||||
'sub.availability': 'Availability',
|
||||
'sub.max_days': 'Max Days/Week',
|
||||
'sub.rating': 'Rating',
|
||||
'sub.total_assignments': 'Total Assignments',
|
||||
'sub.notes': 'Notes',
|
||||
'sub.active': 'Active',
|
||||
'sub.inactive': 'Inactive',
|
||||
'sub.search_substitute': 'Search substitutes...',
|
||||
'sub.filter_active': 'Active only',
|
||||
'sub.filter_all': 'All',
|
||||
'sub.no_substitutes': 'No substitutes found',
|
||||
'sub.add_from_teachers': 'Add from teacher pool',
|
||||
'sub.report_absence': 'Report Absence',
|
||||
'sub.edit_absence': 'Edit Absence',
|
||||
'sub.delete_absence': 'Delete Absence',
|
||||
'sub.teacher': 'Teacher',
|
||||
'sub.absence_type': 'Absence Type',
|
||||
'sub.type_sick': 'Sick',
|
||||
'sub.type_personal': 'Personal',
|
||||
'sub.type_training': 'Training',
|
||||
'sub.type_other': 'Other',
|
||||
'sub.start_date': 'Start Date',
|
||||
'sub.end_date': 'End Date',
|
||||
'sub.reason': 'Reason',
|
||||
'sub.status': 'Status',
|
||||
'sub.status_reported': 'Reported',
|
||||
'sub.status_covered': 'Covered',
|
||||
'sub.status_cancelled': 'Cancelled',
|
||||
'sub.bulk_absence': 'Multi-day absence',
|
||||
'sub.no_absences': 'No absences found',
|
||||
'sub.create_assignment': 'Assign Substitute',
|
||||
'sub.edit_assignment': 'Edit Assignment',
|
||||
'sub.substitute': 'Substitute',
|
||||
'sub.class': 'Class',
|
||||
'sub.subject': 'Subject',
|
||||
'sub.date': 'Date',
|
||||
'sub.period': 'Period',
|
||||
'sub.room': 'Room',
|
||||
'sub.assignment_status': 'Assignment Status',
|
||||
'sub.status_pending': 'Pending',
|
||||
'sub.status_confirmed': 'Confirmed',
|
||||
'sub.status_completed': 'Completed',
|
||||
'sub.assignment_cancelled': 'Cancelled',
|
||||
'sub.no_assignments': 'No assignments found',
|
||||
'sub.weekly_view': 'Weekly View',
|
||||
'sub.print_plan': 'Print Plan',
|
||||
'sub.notify_teachers': 'Notify Teachers',
|
||||
'sub.suggest_substitute': 'Suggest Substitute',
|
||||
'sub.auto_assign': 'Auto-assign',
|
||||
'sub.coverage_rate': 'Coverage Rate',
|
||||
'sub.absence_frequency': 'Absence Frequency',
|
||||
'sub.substitute_utilization': 'Substitute Utilization',
|
||||
'sub.cost_tracking': 'Cost Tracking',
|
||||
'sub.monthly_trends': 'Monthly Trends',
|
||||
'sub.absences_per_month': 'Absences per Month',
|
||||
'sub.assignments_per_status': 'Assignments by Status',
|
||||
'sub.top_absent_teachers': 'Most Absent Teachers',
|
||||
'sub.total_absences': 'Total Absences',
|
||||
'sub.total_substitutes': 'Total Substitutes',
|
||||
'sub.total_assignments_count': 'Total Assignments',
|
||||
'sub.avg_rating': 'Average Rating',
|
||||
'sub.this_week': 'This Week',
|
||||
'sub.uncovered': 'Uncovered',
|
||||
'sub.cost_per_day': 'Cost per Day',
|
||||
'sub.total_cost': 'Total Cost',
|
||||
'sub.monday': 'Monday',
|
||||
'sub.tuesday': 'Tuesday',
|
||||
'sub.wednesday': 'Wednesday',
|
||||
'sub.thursday': 'Thursday',
|
||||
'sub.friday': 'Friday',
|
||||
'sub.saturday': 'Saturday',
|
||||
'sub.confirm_delete_substitute': 'Are you sure you want to remove this substitute?',
|
||||
'sub.confirm_delete_absence': 'Are you sure you want to delete this absence?',
|
||||
'sub.confirm_cancel_assignment': 'Are you sure you want to cancel this assignment?',
|
||||
'sub.save_success': 'Saved successfully',
|
||||
'sub.delete_success': 'Deleted successfully',
|
||||
'sub.error_save': 'Error saving',
|
||||
'sub.error_delete': 'Error deleting',
|
||||
'sub.error_load': 'Error loading',
|
||||
'sub.no_teacher_selected': 'Please select a teacher',
|
||||
'sub.quick_add': 'Quick Add',
|
||||
'sub.per_day': 'per day',
|
||||
'sub.rate_substitute': 'Rate Substitute',
|
||||
'sub.daily_rate': 'Daily Rate',
|
||||
'sub.hours': 'Hours',
|
||||
'sub.lessons': 'Lessons',
|
||||
|
||||
// Career Guidance
|
||||
'nav.student-career': 'Career Guidance',
|
||||
'career.title': 'Career Guidance',
|
||||
'career.subtitle': 'Explore and plan your career path',
|
||||
'career.exploration': 'Career Exploration',
|
||||
'career.quiz': 'Interest Quiz',
|
||||
'career.profiles': 'Career Profiles',
|
||||
'career.clusters': 'Career Clusters',
|
||||
'career.portfolio': 'Career Portfolio',
|
||||
'career.planning': 'Career Planning',
|
||||
'career.appointments': 'Guidance Appointments',
|
||||
'career.goals': 'Career Goals',
|
||||
'career.statistics': 'Statistics',
|
||||
'career.cluster_technology': 'Technology',
|
||||
'career.cluster_health': 'Health',
|
||||
'career.cluster_business': 'Business',
|
||||
'career.cluster_arts': 'Arts & Culture',
|
||||
'career.cluster_science': 'Science',
|
||||
'career.cluster_social': 'Social',
|
||||
'career.path_hauptschule': 'Hauptschule',
|
||||
'career.path_realschule': 'Realschule',
|
||||
'career.path_gymnasium': 'Gymnasium',
|
||||
'career.path_berufsschule': 'Vocational School',
|
||||
'career.interest_creativity': 'Creativity',
|
||||
'career.interest_analytical': 'Analytical Thinking',
|
||||
'career.interest_social_skill': 'Social Skills',
|
||||
'career.interest_technical': 'Technical Aptitude',
|
||||
'career.interest_leadership': 'Leadership',
|
||||
'career.interest_nature': 'Nature & Environment',
|
||||
'career.interest_communication': 'Communication',
|
||||
'career.interest_organization': 'Organization',
|
||||
'career.quiz_title': 'Career Interest Quiz',
|
||||
'career.quiz_subtitle': 'Discover your interests and strengths',
|
||||
'career.quiz_question': 'How interested are you in...',
|
||||
'career.quiz_start': 'Start Quiz',
|
||||
'career.quiz_retake': 'Retake Quiz',
|
||||
'career.quiz_result': 'Your Result',
|
||||
'career.quiz_result_desc': 'Based on your answers',
|
||||
'career.quiz_not_taken': 'You haven\'t taken the interest quiz yet',
|
||||
'career.quiz_not_taken_desc': 'Start the quiz to discover your career interests',
|
||||
'career.profile_title': 'Your Career Profile',
|
||||
'career.profile_subtitle': 'Interests, strengths, and career path',
|
||||
'career.interests': 'Interests',
|
||||
'career.strengths': 'Strengths',
|
||||
'career.desired_career': 'Desired Career',
|
||||
'career.education_path': 'Education Path',
|
||||
'career.career_cluster': 'Career Cluster',
|
||||
'career.work_experience': 'Work Experience',
|
||||
'career.volunteer_experience': 'Volunteer Experience',
|
||||
'career.certifications': 'Certifications & Qualifications',
|
||||
'career.documents': 'Documents',
|
||||
'career.add_work_exp': 'Add Work Experience',
|
||||
'career.add_volunteer': 'Add Volunteer Experience',
|
||||
'career.add_certification': 'Add Certification',
|
||||
'career.add_document': 'Add Document',
|
||||
'career.company': 'Company',
|
||||
'career.role': 'Role/Position',
|
||||
'career.start_date': 'Start Date',
|
||||
'career.end_date': 'End Date',
|
||||
'career.organization': 'Organization',
|
||||
'career.name': 'Name',
|
||||
'career.issuer': 'Issuer',
|
||||
'career.date': 'Date',
|
||||
'career.type': 'Type',
|
||||
'career.appointment_title': 'Guidance Appointment',
|
||||
'career.appointment_subtitle': 'Schedule a meeting with a career counselor',
|
||||
'career.schedule_appointment': 'Schedule Appointment',
|
||||
'career.counselor': 'Counselor',
|
||||
'career.duration': 'Duration',
|
||||
'career.appointment_type_guidance': 'Guidance',
|
||||
'career.appointment_type_career': 'Career Counseling',
|
||||
'career.appointment_type_academic': 'Academic Advisory',
|
||||
'career.appointment_type_college': 'College Advisory',
|
||||
'career.upcoming_appointments': 'Upcoming Appointments',
|
||||
'career.past_appointments': 'Past Appointments',
|
||||
'career.no_appointments': 'No appointments yet',
|
||||
'career.no_appointments_desc': 'Schedule your first guidance appointment',
|
||||
'career.action_items': 'Action Items',
|
||||
'career.appointment_notes': 'Appointment Notes',
|
||||
'career.complete_appointment': 'Complete Appointment',
|
||||
'career.cancel_appointment': 'Cancel Appointment',
|
||||
'career.goal_title': 'Career Goals',
|
||||
'career.goal_subtitle': 'Set and track your goals',
|
||||
'career.add_goal': 'Add Goal',
|
||||
'career.goal_short_term': 'Short-term Goal',
|
||||
'career.goal_long_term': 'Long-term Goal',
|
||||
'career.goal_academic': 'Academic Goal',
|
||||
'career.goal_professional': 'Professional Goal',
|
||||
'career.target_date': 'Target Date',
|
||||
'career.progress': 'Progress',
|
||||
'career.milestones': 'Milestones',
|
||||
'career.add_milestone': 'Add Milestone',
|
||||
'career.milestone_completed': 'Completed',
|
||||
'career.milestone_pending': 'Pending',
|
||||
'career.status_active': 'Active',
|
||||
'career.status_completed': 'Completed',
|
||||
'career.status_abandoned': 'Abandoned',
|
||||
'career.scholarship_finder': 'Scholarship Finder',
|
||||
'career.application_tracker': 'Application Tracker',
|
||||
'career.important_dates': 'Important Dates',
|
||||
'career.no_goals': 'No goals yet',
|
||||
'career.no_goals_desc': 'Create your first career goal',
|
||||
'career.career_profile_card': 'Career: {name}',
|
||||
'career.salary_range': 'Salary Range',
|
||||
'career.requirements': 'Requirements',
|
||||
'career.subject_recommendations': 'Subject Recommendations',
|
||||
'career.skills_needed': 'Skills Needed',
|
||||
'career.save_profile': 'Save Profile',
|
||||
'career.edit_profile': 'Edit Profile',
|
||||
'career.create_profile': 'Create Profile',
|
||||
'career.no_profile': 'No career profile yet',
|
||||
'career.no_profile_desc': 'Create your career profile to plan your path',
|
||||
'career.school_overview': 'School Overview',
|
||||
'career.students_with_profiles': 'Students with Profiles',
|
||||
'career.total_goals': 'Total Goals',
|
||||
'career.avg_progress': 'Avg Progress',
|
||||
'career.appointments_this_month': 'Appointments This Month',
|
||||
'career.cluster_distribution': 'Cluster Distribution',
|
||||
'career.path_distribution': 'Education Path Distribution',
|
||||
'career.minutes': 'minutes',
|
||||
'career.no_career_cluster': 'Unassigned',
|
||||
|
||||
// ─── School Newsletter ────────────────────────────────────────────
|
||||
'nav.school-newsletter': 'School Newsletter',
|
||||
'newsletter.title': 'School Newsletter',
|
||||
'newsletter.subtitle': 'Create, manage, and distribute school newsletters to your community.',
|
||||
'newsletter.subtitle_readonly': 'School newsletters and communications.',
|
||||
'newsletter.tab_newsletters': 'Newsletters',
|
||||
'newsletter.tab_analytics': 'Analytics',
|
||||
'newsletter.create_new': 'New Newsletter',
|
||||
'newsletter.create_first': 'Create Your First Newsletter',
|
||||
'newsletter.search_placeholder': 'Search newsletters...',
|
||||
'newsletter.filter_status': 'Status',
|
||||
'newsletter.filter_category': 'Category',
|
||||
'newsletter.filter_all': 'All',
|
||||
'newsletter.no_newsletters': 'No newsletters yet',
|
||||
'newsletter.no_newsletters_desc': 'Create your first school newsletter.',
|
||||
'newsletter.duplicate': 'Duplicate',
|
||||
'newsletter.archive': 'Archive',
|
||||
'newsletter.send': 'Send',
|
||||
'newsletter.total_newsletters': 'Total',
|
||||
'newsletter.status_draft': 'Draft',
|
||||
'newsletter.status_scheduled': 'Scheduled',
|
||||
'newsletter.status_sent': 'Sent',
|
||||
'newsletter.status_archived': 'Archived',
|
||||
'newsletter.open_rate': 'Open Rate',
|
||||
'newsletter.click_rate': 'Click Rate',
|
||||
'newsletter.bounce_rate': 'Bounce Rate',
|
||||
'newsletter.template_monthly': 'Monthly Update',
|
||||
'newsletter.template_event': 'Event Recap',
|
||||
'newsletter.template_parent': 'Parent Newsletter',
|
||||
'newsletter.template_emergency': 'Emergency Alert',
|
||||
'newsletter.template_monthly_title': 'Monthly School Update',
|
||||
'newsletter.template_event_title': 'Event Recap',
|
||||
'newsletter.template_parent_title': 'Parent Newsletter',
|
||||
'newsletter.template_emergency_title': 'Important Notice',
|
||||
'newsletter.category_general': 'General',
|
||||
'newsletter.category_academic': 'Academic',
|
||||
'newsletter.category_sports': 'Sports',
|
||||
'newsletter.category_arts': 'Arts',
|
||||
'newsletter.category_events': 'Events',
|
||||
'newsletter.category_community': 'Community',
|
||||
'newsletter.audience_all': 'Everyone',
|
||||
'newsletter.audience_teachers': 'Teachers',
|
||||
'newsletter.audience_parents': 'Parents',
|
||||
'newsletter.audience_classes': 'Specific Classes',
|
||||
'newsletter.audience_roles': 'Specific Roles',
|
||||
'newsletter.edit_newsletter': 'Edit Newsletter',
|
||||
'newsletter.title_label': 'Title',
|
||||
'newsletter.title_placeholder': 'Enter newsletter title...',
|
||||
'newsletter.subject_label': 'Subject Line',
|
||||
'newsletter.subject_placeholder': 'Enter email subject...',
|
||||
'newsletter.banner_url_label': 'Banner Image URL',
|
||||
'newsletter.banner_url_placeholder': 'https://example.com/banner.jpg',
|
||||
'newsletter.summary_label': 'Summary',
|
||||
'newsletter.summary_placeholder': 'Brief summary of the newsletter...',
|
||||
'newsletter.content_label': 'Content',
|
||||
'newsletter.heading': 'Heading',
|
||||
'newsletter.heading2': 'Heading 2',
|
||||
'newsletter.heading3': 'Heading 3',
|
||||
'newsletter.paragraph': 'Paragraph',
|
||||
'newsletter.enter_url': 'Enter URL:',
|
||||
'newsletter.enter_image_url': 'Enter image URL:',
|
||||
'newsletter.edit_mode': 'Edit',
|
||||
'newsletter.preview_mode': 'Preview',
|
||||
'newsletter.desktop': 'Desktop',
|
||||
'newsletter.mobile': 'Mobile',
|
||||
'newsletter.save_draft': 'Save Draft',
|
||||
'newsletter.untitled': 'Untitled Newsletter',
|
||||
'newsletter.template_section': 'Template',
|
||||
'newsletter.template_type': 'Template Type',
|
||||
'newsletter.category_label': 'Category',
|
||||
'newsletter.audience_section': 'Audience',
|
||||
'newsletter.tags_section': 'Tags',
|
||||
'newsletter.tags_placeholder': 'tag1, tag2, ...',
|
||||
'newsletter.tags_hint': 'Separate with commas',
|
||||
'newsletter.quick_templates': 'Quick Templates',
|
||||
'newsletter.send_newsletter': 'Send Newsletter',
|
||||
'newsletter.send_description': 'Choose the target audience and sending schedule.',
|
||||
'newsletter.target_audience': 'Target Audience',
|
||||
'newsletter.schedule_send': 'Schedule Send (optional)',
|
||||
'newsletter.schedule_hint': 'Leave empty to send immediately.',
|
||||
'newsletter.schedule': 'Schedule',
|
||||
'newsletter.send_now': 'Send Now',
|
||||
'newsletter.section_header': 'Header',
|
||||
'newsletter.section_highlights': 'Highlights',
|
||||
'newsletter.section_upcoming': 'Upcoming Events',
|
||||
'newsletter.section_announcements': 'Announcements',
|
||||
'newsletter.section_details': 'Details',
|
||||
'newsletter.section_registration': 'Registration',
|
||||
'newsletter.section_important': 'Important',
|
||||
'newsletter.section_dates': 'Key Dates',
|
||||
'newsletter.section_actions': 'Actions',
|
||||
'newsletter.template_placeholder': 'Enter content here...',
|
||||
'newsletter.template_monthly_header': 'Here is your monthly school update with the latest news and upcoming events.',
|
||||
'newsletter.template_event_header': 'Read all about our upcoming event.',
|
||||
'newsletter.template_parent_header': 'Dear parents, here is the most important information for you.',
|
||||
'newsletter.template_emergency_header': 'IMPORTANT: Please read this notice carefully.',
|
||||
'newsletter.no_analytics': 'No analytics data available yet.',
|
||||
'newsletter.no_data': 'No data available',
|
||||
'newsletter.total_sent': 'Total Sent',
|
||||
'newsletter.opens': 'Opens',
|
||||
'newsletter.clicks': 'Clicks',
|
||||
'newsletter.bounces': 'Bounces',
|
||||
'newsletter.recipients': 'Recipients',
|
||||
'newsletter.subscribers': 'Subscribers',
|
||||
'newsletter.best_send_time': 'Best Sending Time',
|
||||
'newsletter.best_send_time_desc': 'The highest open rate is achieved at {0}:00 ({1} open rate).',
|
||||
'newsletter.engagement_trend': 'Engagement Trend',
|
||||
'newsletter.template_performance': 'Template Performance',
|
||||
'newsletter.engagement_distribution': 'Engagement Distribution',
|
||||
'newsletter.recent_performance': 'Recent Performance',
|
||||
'newsletter.subscriber_growth': 'Subscriber Growth',
|
||||
};
|
||||
|
||||
const translations: Record<string, TranslationDict> = { de, en };
|
||||
|
||||
+4
-1
@@ -50,7 +50,10 @@ export type ViewName =
|
||||
| 'report-cards'
|
||||
| 'data-import-export'
|
||||
| 'student-wellness'
|
||||
| 'school-events';
|
||||
| 'school-events'
|
||||
| 'substitute-teacher'
|
||||
| 'student-career'
|
||||
| 'school-newsletter';
|
||||
|
||||
export interface CurrentUser {
|
||||
id: string;
|
||||
|
||||
Reference in new issue
Block a user