Round 22: Exam Calendar, Tablet Grading, Student Achievements, Notification Center, Announcements, Styling
- Exam Calendar View: Teacher exam planning with calendar integration - Teachers create/modify exams, admin full CRUD, students read-only - CalendarEvent integration, 2-week warning, AI test availability badge - Monthly/weekly calendar view, list view, statistics - Tablet Grading View: Stylus annotation and AI grading audit - HTML5 Canvas with PointerEvent pressure sensitivity - Tool palette: pen, highlighter, eraser, text, stamp - Quick grade buttons (German 1-6 scale) - AI grading audit with detailed feedback and suggestions - Student Achievements View: Badges, XP, leaderboard - Badge collection grid with category filters - Level/XP system with progress bars - Leaderboard with opt-in - Daily/weekly challenges - Student Study Planner View: Pomodoro timer, weekly schedule - Pomodoro timer with focus score - Weekly study schedule with drag-and-drop - AI study recommendations - Session history and statistics - Notification Center View: Categories, filters, bulk actions - 6 categories, 4 priority levels - Real-time polling (10 seconds) - Preferences with quiet hours - Statistics charts - School Announcements View: CRUD, read receipts - 6 announcement types with gradient headers - Pin-to-top, read receipts, target audience - Admin/teacher create, students/parents view - Bug fixes: AnnouncementRead model user relation, User API schoolId field - i18n: 200+ new keys, breadcrumb navigation fixes - Styling: Gradient headers, animations, hover effects across all new views
This commit is contained in:
1 parent
9e22214470
commit
490bd0650f
30 files changed
+10844
-31
No files matched your search
@@ -0,0 +1,39 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const review = await db.teacherGradingReview.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
assessment: { select: { id: true, title: true } },
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
comments: {
|
||||
include: {
|
||||
user: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!review) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(review);
|
||||
} catch (error) {
|
||||
console.error('AI Grading Audit GET [id] error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
import ZAI from 'z-ai-web-dev-sdk';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const role = session.user?.role;
|
||||
if (role !== 'TEACHER' && role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN' && role !== 'VICE_PRINCIPAL') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { schoolId, assessmentId, studentId } = body;
|
||||
|
||||
if (!schoolId || !assessmentId || !studentId) {
|
||||
return NextResponse.json({ error: 'schoolId, assessmentId, and studentId are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get assessment and student data
|
||||
const assessment = await db.assessment.findUnique({
|
||||
where: { id: assessmentId },
|
||||
include: {
|
||||
results: {
|
||||
where: { studentId },
|
||||
include: {
|
||||
student: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!assessment) {
|
||||
return NextResponse.json({ error: 'Assessment not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Get annotations for this student's submission
|
||||
const annotations = await db.gradingAnnotation.findMany({
|
||||
where: { assessmentId, studentId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
const studentResult = assessment.results[0];
|
||||
const student = studentResult?.student;
|
||||
|
||||
// Generate AI grading audit
|
||||
const systemPrompt = `Du bist ein erfahrener Lehrer und Prüfer an einer deutschen Schule.
|
||||
Du überprüfst die Benotung eines Schülers und gibst detailliertes Feedback.
|
||||
Die deutsche Notenskala: 1 (sehr gut), 2 (gut), 3 (befriedigend), 4 (ausreichend), 5 (mangelhaft), 6 (ungenügend).
|
||||
Analysiere die Leistung und die gegebene Benotung. Wenn die Benotung nicht angemessen ist, schlage eine Korrektur vor mit Begründung.
|
||||
Antworte immer auf Deutsch und im JSON-Format.`;
|
||||
|
||||
const gradingInfo = {
|
||||
studentName: student ? `${student.firstName} ${student.lastName}` : 'Unknown',
|
||||
assessmentTitle: assessment.title,
|
||||
assessmentType: assessment.type,
|
||||
maxScore: assessment.maxScore,
|
||||
score: studentResult?.score ?? null,
|
||||
note: studentResult?.note ?? null,
|
||||
annotationsCount: annotations.length,
|
||||
annotationTypes: annotations.map((a: { type: string; content: string | null }) => ({ type: a.type, content: a.content })),
|
||||
teacherAnnotations: annotations.filter((a: { type: string }) => a.type === 'stamp' || a.type === 'text').map((a: { type: string; content: string | null; color: string }) => ({ type: a.type, content: a.content, color: a.color })),
|
||||
};
|
||||
|
||||
let reviewResult: string;
|
||||
let discrepanciesFound = 0;
|
||||
|
||||
try {
|
||||
const ai = await ZAI.create();
|
||||
const response = await ai.chat.completions.create({
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{
|
||||
role: 'user',
|
||||
content: `Überprüfe die Benotung für diesen Schüler und gib Feedback:\n\n${JSON.stringify(gradingInfo, null, 2)}\n\nGib deine Antwort als JSON mit folgenden Feldern:\n- currentGrade: die aktuelle Benotung\n- suggestedGrade: deine vorgeschlagene Note\n- reason: detaillierte Begründung auf Deutsch\n- discrepancies: Liste der gefundenen Unstimmigkeiten\n- overallAssessment: Gesamtbewertung der Benotung`,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
reviewResult = response?.choices?.[0]?.message?.content || 'Review could not be generated';
|
||||
} catch (aiError) {
|
||||
console.error('AI grading audit error:', aiError);
|
||||
reviewResult = JSON.stringify({
|
||||
currentGrade: studentResult?.score ?? 'N/A',
|
||||
suggestedGrade: 'N/A',
|
||||
reason: 'AI review could not be generated. Manual review recommended.',
|
||||
discrepancies: ['AI analysis unavailable — please review manually'],
|
||||
overallAssessment: 'AI audit could not be completed due to a technical error. Please review the grading manually.',
|
||||
});
|
||||
}
|
||||
|
||||
// Count discrepancies
|
||||
try {
|
||||
const parsed = typeof reviewResult === 'string' ? JSON.parse(reviewResult) : reviewResult;
|
||||
discrepanciesFound = Array.isArray(parsed.discrepancies) ? parsed.discrepancies.length : 0;
|
||||
} catch {
|
||||
discrepanciesFound = 0;
|
||||
}
|
||||
|
||||
// Create or update the grading review
|
||||
const review = await db.teacherGradingReview.create({
|
||||
data: {
|
||||
schoolId,
|
||||
assessmentId,
|
||||
teacherId: session.userId,
|
||||
aiProvider: 'pollination',
|
||||
status: 'completed',
|
||||
reviewResult,
|
||||
discrepanciesFound,
|
||||
},
|
||||
include: {
|
||||
assessment: { select: { id: true, title: true } },
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
comments: {
|
||||
include: {
|
||||
user: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(review, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('AI Grading Audit POST error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// CompetenceTrack — Announcement Read Receipt API
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await getSession();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
// Verify announcement exists
|
||||
const announcement = await db.announcement.findUnique({
|
||||
where: { id, deletedAt: null },
|
||||
});
|
||||
|
||||
if (!announcement) {
|
||||
return NextResponse.json({ error: 'Announcement not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Upsert read receipt
|
||||
const readReceipt = await db.announcementRead.upsert({
|
||||
where: {
|
||||
announcementId_userId: {
|
||||
announcementId: id,
|
||||
userId: session.user.id,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
readAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
announcementId: id,
|
||||
userId: session.user.id,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ readReceipt });
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await getSession();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
// Get all read receipts for this announcement
|
||||
const reads = await db.announcementRead.findMany({
|
||||
where: { announcementId: id },
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
orderBy: { readAt: 'desc' },
|
||||
});
|
||||
|
||||
return NextResponse.json({ reads, totalReads: reads.length });
|
||||
}
|
||||
@@ -5,11 +5,13 @@ import { getSession } from '@/lib/auth';
|
||||
|
||||
const priorityEnum = z.enum(['low', 'normal', 'high', 'urgent']);
|
||||
const targetAudienceEnum = z.enum(['all', 'teachers', 'students', 'parents', 'class']);
|
||||
const announcementTypeEnum = z.enum(['general', 'urgent', 'event', 'holiday', 'exam', 'deadline']);
|
||||
|
||||
const updateAnnouncementSchema = z.object({
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
content: z.string().min(1).max(10000).optional(),
|
||||
priority: priorityEnum.optional(),
|
||||
announcementType: announcementTypeEnum.optional(),
|
||||
targetAudience: targetAudienceEnum.optional(),
|
||||
classGroupId: z.string().optional().nullable(),
|
||||
isPinned: z.boolean().optional(),
|
||||
@@ -17,7 +19,7 @@ const updateAnnouncementSchema = z.object({
|
||||
});
|
||||
|
||||
function isTeacherOrAdmin(role: string | undefined): boolean {
|
||||
return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN';
|
||||
return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN' || role === 'VICE_PRINCIPAL';
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
@@ -36,6 +38,12 @@ export async function GET(
|
||||
include: {
|
||||
author: { select: { id: true, firstName: true, lastName: true } },
|
||||
classGroup: { select: { id: true, name: true } },
|
||||
reads: {
|
||||
include: {
|
||||
user: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
orderBy: { readAt: 'desc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -43,7 +51,21 @@ export async function GET(
|
||||
return NextResponse.json({ error: 'Announcement not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(announcement);
|
||||
// Check if current user has read it
|
||||
const readReceipt = await db.announcementRead.findUnique({
|
||||
where: {
|
||||
announcementId_userId: {
|
||||
announcementId: id,
|
||||
userId: session.userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
...announcement,
|
||||
isReadByCurrentUser: !!readReceipt,
|
||||
totalReads: announcement.reads.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Announcement GET [id] error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
@@ -87,6 +109,7 @@ export async function PUT(
|
||||
if (parsed.data.title !== undefined) updateData.title = parsed.data.title;
|
||||
if (parsed.data.content !== undefined) updateData.content = parsed.data.content;
|
||||
if (parsed.data.priority !== undefined) updateData.priority = parsed.data.priority;
|
||||
if (parsed.data.announcementType !== undefined) updateData.announcementType = parsed.data.announcementType;
|
||||
if (parsed.data.targetAudience !== undefined) updateData.targetAudience = parsed.data.targetAudience;
|
||||
if (parsed.data.classGroupId !== undefined) updateData.classGroupId = parsed.data.classGroupId || null;
|
||||
if (parsed.data.isPinned !== undefined) updateData.isPinned = parsed.data.isPinned;
|
||||
|
||||
@@ -5,12 +5,14 @@ import { getSession } from '@/lib/auth';
|
||||
|
||||
const priorityEnum = z.enum(['low', 'normal', 'high', 'urgent']);
|
||||
const targetAudienceEnum = z.enum(['all', 'teachers', 'students', 'parents', 'class']);
|
||||
const announcementTypeEnum = z.enum(['general', 'urgent', 'event', 'holiday', 'exam', 'deadline']);
|
||||
|
||||
const createAnnouncementSchema = z.object({
|
||||
schoolId: z.string().min(1),
|
||||
title: z.string().min(1).max(200),
|
||||
content: z.string().min(1).max(10000),
|
||||
priority: priorityEnum.optional().default('normal'),
|
||||
announcementType: announcementTypeEnum.optional().default('general'),
|
||||
targetAudience: targetAudienceEnum.optional().default('all'),
|
||||
classGroupId: z.string().optional().nullable(),
|
||||
isPinned: z.boolean().optional().default(false),
|
||||
@@ -18,7 +20,7 @@ const createAnnouncementSchema = z.object({
|
||||
});
|
||||
|
||||
function isTeacherOrAdmin(role: string | undefined): boolean {
|
||||
return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN';
|
||||
return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN' || role === 'VICE_PRINCIPAL';
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
@@ -34,10 +36,12 @@ export async function GET(request: Request) {
|
||||
const targetAudience = searchParams.get('targetAudience');
|
||||
const classGroupId = searchParams.get('classGroupId');
|
||||
const isPinned = searchParams.get('isPinned');
|
||||
const announcementType = searchParams.get('announcementType');
|
||||
const limit = parseInt(searchParams.get('limit') || '50', 10);
|
||||
const includeReads = searchParams.get('includeReads') === 'true';
|
||||
|
||||
let schoolId: string | undefined;
|
||||
if (session.user?.role === 'SCHOOL_ADMIN') {
|
||||
if (session.user?.role === 'SCHOOL_ADMIN' || session.user?.role === 'VICE_PRINCIPAL') {
|
||||
schoolId = session.user.schoolId ?? undefined;
|
||||
} else {
|
||||
schoolId = schoolIdParam ?? session.user?.schoolId ?? undefined;
|
||||
@@ -59,6 +63,7 @@ export async function GET(request: Request) {
|
||||
if (targetAudience && targetAudience !== 'all') where.targetAudience = targetAudience;
|
||||
if (classGroupId && classGroupId !== 'all') where.classGroupId = classGroupId;
|
||||
if (isPinned === 'true') where.isPinned = true;
|
||||
if (announcementType && announcementType !== 'all') where.announcementType = announcementType;
|
||||
|
||||
// Filter by role-based target audience
|
||||
if (session.user?.role === 'STUDENT') {
|
||||
@@ -83,10 +88,52 @@ export async function GET(request: Request) {
|
||||
include: {
|
||||
author: { select: { id: true, firstName: true, lastName: true } },
|
||||
classGroup: { select: { id: true, name: true } },
|
||||
...(includeReads ? {
|
||||
reads: {
|
||||
include: {
|
||||
user: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
orderBy: { readAt: 'desc' as const },
|
||||
},
|
||||
} : {}),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(announcements);
|
||||
// Add read status for the current user
|
||||
const currentUserId = session.userId;
|
||||
const announcementsWithReadStatus = await Promise.all(
|
||||
announcements.map(async (a) => {
|
||||
if (!includeReads) {
|
||||
const readReceipt = await db.announcementRead.findUnique({
|
||||
where: {
|
||||
announcementId_userId: {
|
||||
announcementId: a.id,
|
||||
userId: currentUserId,
|
||||
},
|
||||
},
|
||||
});
|
||||
return {
|
||||
...a,
|
||||
isReadByCurrentUser: !!readReceipt,
|
||||
};
|
||||
}
|
||||
const readReceipt = await db.announcementRead.findUnique({
|
||||
where: {
|
||||
announcementId_userId: {
|
||||
announcementId: a.id,
|
||||
userId: currentUserId,
|
||||
},
|
||||
},
|
||||
});
|
||||
return {
|
||||
...a,
|
||||
isReadByCurrentUser: !!readReceipt,
|
||||
totalReads: a.reads?.length ?? 0,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return NextResponse.json(announcementsWithReadStatus);
|
||||
} catch (error) {
|
||||
console.error('Announcements GET error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
@@ -138,6 +185,7 @@ export async function POST(request: Request) {
|
||||
title: rest.title,
|
||||
content: rest.content,
|
||||
priority: rest.priority,
|
||||
announcementType: rest.announcementType,
|
||||
targetAudience: rest.targetAudience,
|
||||
classGroupId: classGroupId || null,
|
||||
isPinned: rest.isPinned,
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
// ── GET: Get single exam plan ──────────────────────────────────────────
|
||||
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 examPlan = await db.examPlan.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
subject: { select: { id: true, name: true } },
|
||||
classGroup: { select: { id: true, name: true, gradeLevel: true } },
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
calendarEvent: { select: { id: true } },
|
||||
assessment: { select: { id: true, title: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!examPlan) {
|
||||
return NextResponse.json({ error: 'Exam plan not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Role-based access check
|
||||
if (
|
||||
session.user?.role === 'STUDENT' ||
|
||||
session.user?.role === 'PARENT'
|
||||
) {
|
||||
// Students and parents can view but not modify
|
||||
} else if (session.user?.role === 'TEACHER') {
|
||||
// Teachers can only view their own exams
|
||||
if (examPlan.teacherId !== session.userId) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(examPlan);
|
||||
} catch (error) {
|
||||
console.error('ExamPlan GET error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// ── PUT: Update exam plan ──────────────────────────────────────────────
|
||||
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 userRole = session.user?.role;
|
||||
|
||||
if (
|
||||
userRole !== 'TEACHER' &&
|
||||
userRole !== 'SCHOOL_ADMIN' &&
|
||||
userRole !== 'VICE_PRINCIPAL' &&
|
||||
userRole !== 'SUPER_ADMIN'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const existing = await db.examPlan.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Exam plan not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Teachers can only edit their own exams
|
||||
if (userRole === 'TEACHER' && existing.teacherId !== session.userId) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const {
|
||||
title,
|
||||
date,
|
||||
startTime,
|
||||
endTime,
|
||||
subjectId,
|
||||
classGroupId,
|
||||
room,
|
||||
topics,
|
||||
weight,
|
||||
status,
|
||||
notes,
|
||||
} = body;
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (title !== undefined) updateData.title = title;
|
||||
if (date !== undefined) updateData.date = new Date(date);
|
||||
if (startTime !== undefined) updateData.startTime = startTime || null;
|
||||
if (endTime !== undefined) updateData.endTime = endTime || null;
|
||||
if (subjectId !== undefined) updateData.subjectId = subjectId;
|
||||
if (classGroupId !== undefined) updateData.classGroupId = classGroupId;
|
||||
if (room !== undefined) updateData.room = room || null;
|
||||
if (topics !== undefined) updateData.topics = topics ? JSON.stringify(topics) : null;
|
||||
if (weight !== undefined) updateData.weight = weight;
|
||||
if (status !== undefined) updateData.status = status;
|
||||
if (notes !== undefined) updateData.notes = notes || null;
|
||||
|
||||
const updated = await db.examPlan.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: {
|
||||
subject: { select: { id: true, name: true } },
|
||||
classGroup: { select: { id: true, name: true, gradeLevel: true } },
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
calendarEvent: { select: { id: true } },
|
||||
assessment: { select: { id: true, title: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Also update the linked CalendarEvent
|
||||
if (existing.calendarEventId) {
|
||||
const calendarUpdateData: Record<string, unknown> = {};
|
||||
if (title !== undefined) calendarUpdateData.title = `[Exam] ${title}`;
|
||||
if (date !== undefined) calendarUpdateData.date = new Date(date);
|
||||
if (startTime !== undefined) calendarUpdateData.startTime = startTime || null;
|
||||
if (endTime !== undefined) calendarUpdateData.endTime = endTime || null;
|
||||
if (subjectId !== undefined) calendarUpdateData.subjectId = subjectId;
|
||||
if (classGroupId !== undefined) calendarUpdateData.classGroupId = classGroupId;
|
||||
if (notes !== undefined) calendarUpdateData.notes = notes || null;
|
||||
if (startTime !== undefined || endTime !== undefined) {
|
||||
calendarUpdateData.allDay = !startTime && !endTime;
|
||||
}
|
||||
|
||||
if (Object.keys(calendarUpdateData).length > 0) {
|
||||
await db.calendarEvent.update({
|
||||
where: { id: existing.calendarEventId },
|
||||
data: calendarUpdateData,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
console.error('ExamPlan PUT error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// ── DELETE: Delete exam plan ───────────────────────────────────────────
|
||||
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 userRole = session.user?.role;
|
||||
|
||||
if (
|
||||
userRole !== 'TEACHER' &&
|
||||
userRole !== 'SCHOOL_ADMIN' &&
|
||||
userRole !== 'VICE_PRINCIPAL' &&
|
||||
userRole !== 'SUPER_ADMIN'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const existing = await db.examPlan.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Exam plan not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Teachers can only delete their own exams
|
||||
if (userRole === 'TEACHER' && existing.teacherId !== session.userId) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Delete the linked CalendarEvent first
|
||||
if (existing.calendarEventId) {
|
||||
await db.calendarEvent.delete({ where: { id: existing.calendarEventId } }).catch(() => {});
|
||||
}
|
||||
|
||||
await db.examPlan.delete({ where: { id } });
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('ExamPlan DELETE error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
import { differenceInCalendarDays } from 'date-fns';
|
||||
|
||||
// ── GET: List exam plans with filters ──────────────────────────────────
|
||||
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 classId = searchParams.get('classId');
|
||||
const subjectId = searchParams.get('subjectId');
|
||||
const teacherId = searchParams.get('teacherId');
|
||||
const status = searchParams.get('status');
|
||||
const dateFrom = searchParams.get('dateFrom');
|
||||
const dateTo = searchParams.get('dateTo');
|
||||
const viewMode = searchParams.get('viewMode') ?? 'all'; // all, upcoming, past
|
||||
|
||||
if (!schoolId) {
|
||||
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const where: Record<string, unknown> = { schoolId };
|
||||
|
||||
if (classId) where.classGroupId = classId;
|
||||
if (subjectId) where.subjectId = subjectId;
|
||||
if (teacherId) where.teacherId = teacherId;
|
||||
if (status) where.status = status;
|
||||
|
||||
// Date filters
|
||||
const dateFilter: Record<string, Date> = {};
|
||||
if (dateFrom) dateFilter.gte = new Date(dateFrom);
|
||||
if (dateTo) dateFilter.lte = new Date(dateTo);
|
||||
if (Object.keys(dateFilter).length > 0) where.date = dateFilter;
|
||||
|
||||
// View mode filters
|
||||
const now = new Date();
|
||||
if (viewMode === 'upcoming') {
|
||||
where.date = { ...((where.date as Record<string, Date>) || {}), gte: now };
|
||||
} else if (viewMode === 'past') {
|
||||
where.date = { ...((where.date as Record<string, Date>) || {}), lt: now };
|
||||
}
|
||||
|
||||
// Role-based access
|
||||
if (session.user?.role === 'STUDENT') {
|
||||
const student = await db.student.findFirst({
|
||||
where: { userId: session.userId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (student) {
|
||||
const enrollments = await db.enrollment.findMany({
|
||||
where: { studentId: student.id },
|
||||
select: { classGroupId: true },
|
||||
});
|
||||
const classIds = enrollments.map((e) => e.classGroupId);
|
||||
where.classGroupId = { in: classIds };
|
||||
}
|
||||
} 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 enrollments = await db.enrollment.findMany({
|
||||
where: { studentId: { in: studentIds } },
|
||||
select: { classGroupId: true },
|
||||
});
|
||||
const classIds = enrollments.map((e) => e.classGroupId);
|
||||
where.classGroupId = { in: classIds };
|
||||
} else if (session.user?.role === 'TEACHER') {
|
||||
// Teachers see their own exams by default, or all if explicitly requested
|
||||
if (!teacherId) {
|
||||
where.teacherId = session.userId;
|
||||
}
|
||||
}
|
||||
|
||||
const examPlans = await db.examPlan.findMany({
|
||||
where,
|
||||
include: {
|
||||
subject: { select: { id: true, name: true } },
|
||||
classGroup: { select: { id: true, name: true, gradeLevel: true } },
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
calendarEvent: { select: { id: true } },
|
||||
assessment: { select: { id: true, title: true } },
|
||||
},
|
||||
orderBy: { date: 'asc' },
|
||||
});
|
||||
|
||||
// Add computed fields
|
||||
const enriched = examPlans.map((plan) => {
|
||||
const daysUntil = differenceInCalendarDays(new Date(plan.date), now);
|
||||
const isWithinTwoWeeks = daysUntil >= 0 && daysUntil <= 14;
|
||||
return {
|
||||
...plan,
|
||||
daysUntil,
|
||||
isWithinTwoWeeks,
|
||||
countdownLabel:
|
||||
daysUntil === 0
|
||||
? 'today'
|
||||
: daysUntil === 1
|
||||
? 'tomorrow'
|
||||
: daysUntil < 7
|
||||
? `${daysUntil} days`
|
||||
: daysUntil < 14
|
||||
? `${Math.floor(daysUntil / 7)} week`
|
||||
: `${Math.floor(daysUntil / 7)} weeks`,
|
||||
};
|
||||
});
|
||||
|
||||
// Check for AI test availability for each exam
|
||||
const examIdsWithTests = await db.aITestGeneration.findMany({
|
||||
where: {
|
||||
classGroupId: { in: enriched.map((e) => e.classGroupId) },
|
||||
subjectId: { in: enriched.map((e) => e.subjectId) },
|
||||
isCompleted: true,
|
||||
},
|
||||
select: { classGroupId: true, subjectId: true },
|
||||
});
|
||||
|
||||
const testSet = new Set(
|
||||
examIdsWithTests.map((t) => `${t.classGroupId}-${t.subjectId}`)
|
||||
);
|
||||
|
||||
const result = enriched.map((plan) => ({
|
||||
...plan,
|
||||
hasAITest: testSet.has(`${plan.classGroupId}-${plan.subjectId}`),
|
||||
}));
|
||||
|
||||
// Statistics (for admin view)
|
||||
let stats = null;
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' ||
|
||||
session.user?.role === 'VICE_PRINCIPAL' ||
|
||||
session.user?.role === 'SUPER_ADMIN'
|
||||
) {
|
||||
const totalExams = await db.examPlan.count({ where: { schoolId } });
|
||||
const upcomingExams = await db.examPlan.count({
|
||||
where: { schoolId, date: { gte: now } },
|
||||
});
|
||||
const plannedExams = await db.examPlan.count({
|
||||
where: { schoolId, status: 'planned' },
|
||||
});
|
||||
const confirmedExams = await db.examPlan.count({
|
||||
where: { schoolId, status: 'confirmed' },
|
||||
});
|
||||
const completedExams = await db.examPlan.count({
|
||||
where: { schoolId, status: 'completed' },
|
||||
});
|
||||
const cancelledExams = await db.examPlan.count({
|
||||
where: { schoolId, status: 'cancelled' },
|
||||
});
|
||||
|
||||
// Exams per subject
|
||||
const examsPerSubject = await db.examPlan.groupBy({
|
||||
by: ['subjectId'],
|
||||
where: { schoolId },
|
||||
_count: { id: true },
|
||||
});
|
||||
|
||||
const subjectNames = await db.subject.findMany({
|
||||
where: {
|
||||
id: { in: examsPerSubject.map((e) => e.subjectId) },
|
||||
},
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
|
||||
const subjectMap = Object.fromEntries(
|
||||
subjectNames.map((s) => [s.id, s.name])
|
||||
);
|
||||
|
||||
// Exams per class
|
||||
const examsPerClass = await db.examPlan.groupBy({
|
||||
by: ['classGroupId'],
|
||||
where: { schoolId },
|
||||
_count: { id: true },
|
||||
});
|
||||
|
||||
const classNames = await db.classGroup.findMany({
|
||||
where: {
|
||||
id: { in: examsPerClass.map((e) => e.classGroupId) },
|
||||
},
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
|
||||
const classMap = Object.fromEntries(
|
||||
classNames.map((c) => [c.id, c.name])
|
||||
);
|
||||
|
||||
stats = {
|
||||
totalExams,
|
||||
upcomingExams,
|
||||
byStatus: { planned: plannedExams, confirmed: confirmedExams, completed: completedExams, cancelled: cancelledExams },
|
||||
bySubject: examsPerSubject.map((e) => ({
|
||||
subjectId: e.subjectId,
|
||||
subjectName: subjectMap[e.subjectId] || e.subjectId,
|
||||
count: e._count.id,
|
||||
})),
|
||||
byClass: examsPerClass.map((e) => ({
|
||||
classGroupId: e.classGroupId,
|
||||
className: classMap[e.classGroupId] || e.classGroupId,
|
||||
count: e._count.id,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return NextResponse.json({ exams: result, stats });
|
||||
} catch (error) {
|
||||
console.error('ExamPlans GET error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// ── POST: Create an exam plan ──────────────────────────────────────────
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const userRole = session.user?.role;
|
||||
if (
|
||||
userRole !== 'TEACHER' &&
|
||||
userRole !== 'SCHOOL_ADMIN' &&
|
||||
userRole !== 'VICE_PRINCIPAL' &&
|
||||
userRole !== 'SUPER_ADMIN'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const {
|
||||
title,
|
||||
date,
|
||||
startTime,
|
||||
endTime,
|
||||
subjectId,
|
||||
classGroupId,
|
||||
schoolId,
|
||||
room,
|
||||
topics,
|
||||
weight,
|
||||
status,
|
||||
notes,
|
||||
} = body;
|
||||
|
||||
if (!title || !date || !schoolId || !subjectId || !classGroupId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'title, date, schoolId, subjectId, and classGroupId are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Create the exam plan
|
||||
const examPlan = await db.examPlan.create({
|
||||
data: {
|
||||
schoolId,
|
||||
teacherId: session.userId,
|
||||
subjectId,
|
||||
classGroupId,
|
||||
title,
|
||||
date: new Date(date),
|
||||
startTime: startTime || null,
|
||||
endTime: endTime || null,
|
||||
room: room || null,
|
||||
topics: topics ? JSON.stringify(topics) : null,
|
||||
weight: weight ?? 1.0,
|
||||
status: status || 'planned',
|
||||
notes: notes || null,
|
||||
},
|
||||
include: {
|
||||
subject: { select: { id: true, name: true } },
|
||||
classGroup: { select: { id: true, name: true, gradeLevel: true } },
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Also create a CalendarEvent for the exam
|
||||
const calendarEvent = await db.calendarEvent.create({
|
||||
data: {
|
||||
schoolId,
|
||||
teacherId: session.userId,
|
||||
title: `[Exam] ${title}`,
|
||||
date: new Date(date),
|
||||
startTime: startTime || null,
|
||||
endTime: endTime || null,
|
||||
eventType: 'exam',
|
||||
subjectId,
|
||||
classGroupId,
|
||||
notes: notes || null,
|
||||
allDay: !startTime && !endTime,
|
||||
},
|
||||
});
|
||||
|
||||
// Link the calendar event to the exam plan
|
||||
await db.examPlan.update({
|
||||
where: { id: examPlan.id },
|
||||
data: { calendarEventId: calendarEvent.id },
|
||||
});
|
||||
|
||||
const result = {
|
||||
...examPlan,
|
||||
calendarEventId: calendarEvent.id,
|
||||
};
|
||||
|
||||
return NextResponse.json(result, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('ExamPlans POST error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const updateAnnotationSchema = z.object({
|
||||
type: z.enum(['drawing', 'text', 'highlight', 'stamp']).optional(),
|
||||
content: z.string().optional(),
|
||||
positionX: z.number().optional(),
|
||||
positionY: z.number().optional(),
|
||||
width: z.number().optional(),
|
||||
height: z.number().optional(),
|
||||
color: z.string().optional(),
|
||||
strokeWidth: z.number().optional(),
|
||||
page: z.number().optional(),
|
||||
pathData: z.string().optional(),
|
||||
});
|
||||
|
||||
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 annotation = await db.gradingAnnotation.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!annotation) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(annotation);
|
||||
} catch (error) {
|
||||
console.error('GradingAnnotation GET [id] error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const role = session.user?.role;
|
||||
if (role !== 'TEACHER' && role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN' && role !== 'VICE_PRINCIPAL') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const parsed = updateAnnotationSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await db.gradingAnnotation.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Only the creator or admin can update
|
||||
if (existing.teacherId !== session.userId && role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const annotation = await db.gradingAnnotation.update({
|
||||
where: { id },
|
||||
data: parsed.data,
|
||||
include: {
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(annotation);
|
||||
} catch (error) {
|
||||
console.error('GradingAnnotation PUT [id] error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const role = session.user?.role;
|
||||
if (role !== 'TEACHER' && role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN' && role !== 'VICE_PRINCIPAL') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const existing = await db.gradingAnnotation.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (existing.teacherId !== session.userId && role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
await db.gradingAnnotation.delete({ where: { id } });
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('GradingAnnotation DELETE [id] error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const createAnnotationSchema = z.object({
|
||||
schoolId: z.string().min(1),
|
||||
assessmentId: z.string().min(1),
|
||||
studentId: z.string().min(1),
|
||||
resultId: z.string().optional(),
|
||||
type: z.enum(['drawing', 'text', 'highlight', 'stamp']).default('drawing'),
|
||||
content: z.string().optional(),
|
||||
positionX: z.number().default(0),
|
||||
positionY: z.number().default(0),
|
||||
width: z.number().optional(),
|
||||
height: z.number().optional(),
|
||||
color: z.string().default('#ef4444'),
|
||||
strokeWidth: z.number().default(2),
|
||||
page: z.number().default(1),
|
||||
pathData: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const assessmentId = searchParams.get('assessmentId');
|
||||
const studentId = searchParams.get('studentId');
|
||||
const page = searchParams.get('page');
|
||||
|
||||
if (!assessmentId) {
|
||||
return NextResponse.json({ error: 'assessmentId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const where: Record<string, unknown> = { assessmentId };
|
||||
if (studentId) where.studentId = studentId;
|
||||
if (page) where.page = parseInt(page, 10);
|
||||
|
||||
const annotations = await db.gradingAnnotation.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: {
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(annotations);
|
||||
} catch (error) {
|
||||
console.error('GradingAnnotations GET error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const role = session.user?.role;
|
||||
if (role !== 'TEACHER' && role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN' && role !== 'VICE_PRINCIPAL') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = createAnnotationSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const data = parsed.data;
|
||||
const annotation = await db.gradingAnnotation.create({
|
||||
data: {
|
||||
schoolId: data.schoolId,
|
||||
assessmentId: data.assessmentId,
|
||||
studentId: data.studentId,
|
||||
resultId: data.resultId,
|
||||
teacherId: session.userId,
|
||||
type: data.type,
|
||||
content: data.content,
|
||||
positionX: data.positionX,
|
||||
positionY: data.positionY,
|
||||
width: data.width,
|
||||
height: data.height,
|
||||
color: data.color,
|
||||
strokeWidth: data.strokeWidth,
|
||||
page: data.page,
|
||||
pathData: data.pathData,
|
||||
},
|
||||
include: {
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(annotation, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('GradingAnnotations POST error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// CompetenceTrack — Single Notification API
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await getSession();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const notification = await db.notification.findFirst({
|
||||
where: { id, userId: session.user.id, deletedAt: null },
|
||||
});
|
||||
|
||||
if (!notification) {
|
||||
return NextResponse.json({ error: 'Notification not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(notification);
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await getSession();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const { isRead, isArchived, action } = body as {
|
||||
isRead?: boolean;
|
||||
isArchived?: boolean;
|
||||
action?: 'read' | 'unread' | 'archive' | 'unarchive' | 'dismiss';
|
||||
};
|
||||
|
||||
const existing = await db.notification.findFirst({
|
||||
where: { id, userId: session.user.id, deletedAt: null },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Notification not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (action === 'read' || isRead === true) {
|
||||
updateData.isRead = true;
|
||||
} else if (action === 'unread' || isRead === false) {
|
||||
updateData.isRead = false;
|
||||
}
|
||||
if (action === 'archive' || isArchived === true) {
|
||||
updateData.isArchived = true;
|
||||
} else if (action === 'unarchive' || isArchived === false) {
|
||||
updateData.isArchived = false;
|
||||
}
|
||||
if (action === 'dismiss') {
|
||||
updateData.deletedAt = new Date();
|
||||
}
|
||||
|
||||
const notification = await db.notification.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
return NextResponse.json({ notification });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await getSession();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const existing = await db.notification.findFirst({
|
||||
where: { id, userId: session.user.id, deletedAt: null },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Notification not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
await db.notification.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// CompetenceTrack — Notification Preferences API
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
export async function GET() {
|
||||
const session = await getSession();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const schoolId = session.user.schoolId;
|
||||
if (!schoolId) {
|
||||
return NextResponse.json({ error: 'No school assigned' }, { status: 400 });
|
||||
}
|
||||
|
||||
let prefs = await db.notificationPreferences.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
});
|
||||
|
||||
// Create default preferences if none exist
|
||||
if (!prefs) {
|
||||
prefs = await db.notificationPreferences.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
schoolId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(prefs);
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
const session = await getSession();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const schoolId = session.user.schoolId;
|
||||
if (!schoolId) {
|
||||
return NextResponse.json({ error: 'No school assigned' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const {
|
||||
academicEnabled,
|
||||
behavioralEnabled,
|
||||
administrativeEnabled,
|
||||
calendarEnabled,
|
||||
communicationEnabled,
|
||||
systemEnabled,
|
||||
quietHoursStart,
|
||||
quietHoursEnd,
|
||||
quietHoursEnabled,
|
||||
emailDigestEnabled,
|
||||
emailDigestFrequency,
|
||||
} = body as {
|
||||
academicEnabled?: boolean;
|
||||
behavioralEnabled?: boolean;
|
||||
administrativeEnabled?: boolean;
|
||||
calendarEnabled?: boolean;
|
||||
communicationEnabled?: boolean;
|
||||
systemEnabled?: boolean;
|
||||
quietHoursStart?: string | null;
|
||||
quietHoursEnd?: string | null;
|
||||
quietHoursEnabled?: boolean;
|
||||
emailDigestEnabled?: boolean;
|
||||
emailDigestFrequency?: string;
|
||||
};
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (academicEnabled !== undefined) updateData.academicEnabled = academicEnabled;
|
||||
if (behavioralEnabled !== undefined) updateData.behavioralEnabled = behavioralEnabled;
|
||||
if (administrativeEnabled !== undefined) updateData.administrativeEnabled = administrativeEnabled;
|
||||
if (calendarEnabled !== undefined) updateData.calendarEnabled = calendarEnabled;
|
||||
if (communicationEnabled !== undefined) updateData.communicationEnabled = communicationEnabled;
|
||||
if (systemEnabled !== undefined) updateData.systemEnabled = systemEnabled;
|
||||
if (quietHoursStart !== undefined) updateData.quietHoursStart = quietHoursStart;
|
||||
if (quietHoursEnd !== undefined) updateData.quietHoursEnd = quietHoursEnd;
|
||||
if (quietHoursEnabled !== undefined) updateData.quietHoursEnabled = quietHoursEnabled;
|
||||
if (emailDigestEnabled !== undefined) updateData.emailDigestEnabled = emailDigestEnabled;
|
||||
if (emailDigestFrequency !== undefined) updateData.emailDigestFrequency = emailDigestFrequency;
|
||||
|
||||
const prefs = await db.notificationPreferences.upsert({
|
||||
where: { userId: session.user.id },
|
||||
update: updateData,
|
||||
create: {
|
||||
userId: session.user.id,
|
||||
schoolId,
|
||||
...updateData,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(prefs);
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
// CompetenceTrack — Notifications API (DB-backed)
|
||||
// CompetenceTrack — Notifications API (DB-backed, Enhanced)
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
export async function GET() {
|
||||
export async function GET(request: Request) {
|
||||
const session = await getSession();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
@@ -19,18 +19,54 @@ export async function GET() {
|
||||
});
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const category = searchParams.get('category');
|
||||
const priority = searchParams.get('priority');
|
||||
const isRead = searchParams.get('isRead');
|
||||
const isArchived = searchParams.get('isArchived');
|
||||
const dateFrom = searchParams.get('dateFrom');
|
||||
const dateTo = searchParams.get('dateTo');
|
||||
const limit = parseInt(searchParams.get('limit') || '50', 10);
|
||||
const offset = parseInt(searchParams.get('offset') || '0', 10);
|
||||
|
||||
// Build where clause for DB notifications
|
||||
const where: Record<string, unknown> = {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
if (category && category !== 'all') where.category = category;
|
||||
if (priority && priority !== 'all') where.priority = priority;
|
||||
if (isRead === 'true') where.isRead = true;
|
||||
else if (isRead === 'false') where.isRead = false;
|
||||
if (isArchived === 'true') where.isArchived = true;
|
||||
else if (isArchived === 'false') where.isArchived = false;
|
||||
else if (!isArchived) where.isArchived = false; // default: not archived
|
||||
|
||||
if (dateFrom || dateTo) {
|
||||
const dateFilter: Record<string, unknown> = {};
|
||||
if (dateFrom) dateFilter.gte = new Date(dateFrom);
|
||||
if (dateTo) dateFilter.lte = new Date(dateTo);
|
||||
where.createdAt = dateFilter;
|
||||
}
|
||||
|
||||
// Fetch DB notifications for the current user
|
||||
const notifications = await db.notification.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
|
||||
const unreadCount = await db.notification.count({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
isRead: false,
|
||||
isArchived: false,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
});
|
||||
|
||||
const unreadCount = notifications.filter((n) => !n.isRead).length;
|
||||
|
||||
// Also generate dynamic notifications from existing data
|
||||
const now = new Date();
|
||||
const sevenDaysFromNow = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
|
||||
@@ -71,12 +107,15 @@ export async function GET() {
|
||||
}
|
||||
|
||||
// Upcoming assessments (next 7 days)
|
||||
const upcomingAssessments: Array<{
|
||||
const dynamicNotifications: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
category: string;
|
||||
priority: string;
|
||||
title: string;
|
||||
message: string;
|
||||
isRead: boolean;
|
||||
isArchived: boolean;
|
||||
actionUrl: string | null;
|
||||
relatedId: string | null;
|
||||
createdAt: string;
|
||||
@@ -94,26 +133,25 @@ export async function GET() {
|
||||
include: {
|
||||
classGroup: { select: { name: true } },
|
||||
subject: { select: { name: true } },
|
||||
assessmentResults: { select: { id: true } },
|
||||
},
|
||||
orderBy: { date: 'asc' },
|
||||
take: 10,
|
||||
});
|
||||
|
||||
for (const a of assessments) {
|
||||
const diffMs = new Date(a.date).getTime() - now.getTime();
|
||||
const daysUntil = Math.max(0, Math.ceil(diffMs / (1000 * 60 * 60 * 24)));
|
||||
// Check if we already have a DB notification for this assessment
|
||||
const existing = notifications.find(
|
||||
(n) => n.type === 'ASSESSMENT_DUE' && n.relatedId === a.id
|
||||
);
|
||||
if (!existing) {
|
||||
upcomingAssessments.push({
|
||||
dynamicNotifications.push({
|
||||
id: `assessment-${a.id}`,
|
||||
type: 'ASSESSMENT_DUE',
|
||||
category: 'academic',
|
||||
priority: 'high',
|
||||
title: a.title,
|
||||
message: `${a.classGroup.name} - ${a.subject.name}`,
|
||||
isRead: false,
|
||||
isArchived: false,
|
||||
actionUrl: 'assessments',
|
||||
relatedId: a.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -153,12 +191,15 @@ export async function GET() {
|
||||
(n) => n.type === 'MISSING_OBSERVATION' && n.relatedId === e.student.id
|
||||
);
|
||||
if (!existing) {
|
||||
upcomingAssessments.push({
|
||||
dynamicNotifications.push({
|
||||
id: `missing-${e.student.id}-${e.classGroupId}`,
|
||||
type: 'MISSING_OBSERVATION',
|
||||
category: 'academic',
|
||||
priority: 'normal',
|
||||
title: `${e.student.firstName} ${e.student.lastName}`,
|
||||
message: e.classGroup.name,
|
||||
isRead: false,
|
||||
isArchived: false,
|
||||
actionUrl: 'progress',
|
||||
relatedId: e.student.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -173,19 +214,41 @@ export async function GET() {
|
||||
const dbNotifications = notifications.map((n) => ({
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
category: n.category,
|
||||
priority: n.priority,
|
||||
title: n.title,
|
||||
message: n.message,
|
||||
isRead: n.isRead,
|
||||
isArchived: n.isArchived,
|
||||
actionUrl: n.actionUrl,
|
||||
relatedId: n.relatedId,
|
||||
createdAt: n.createdAt.toISOString(),
|
||||
}));
|
||||
|
||||
const allNotifications = [...dbNotifications, ...upcomingAssessments];
|
||||
const allNotifications = [...dbNotifications, ...dynamicNotifications];
|
||||
|
||||
// Get statistics
|
||||
const totalByCategory = await db.notification.groupBy({
|
||||
by: ['category'],
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
isArchived: false,
|
||||
},
|
||||
_count: { id: true },
|
||||
});
|
||||
|
||||
const stats = {
|
||||
byCategory: totalByCategory.map((item) => ({
|
||||
category: item.category,
|
||||
count: item._count.id,
|
||||
})),
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
notifications: allNotifications,
|
||||
unreadCount: allNotifications.filter((n) => !n.isRead).length,
|
||||
unreadCount,
|
||||
stats,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -197,10 +260,15 @@ export async function PUT(request: Request) {
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { ids, markAll } = body as { ids?: string[]; markAll?: boolean };
|
||||
const { ids, markAll, action, archiveAll, deleteIds } = body as {
|
||||
ids?: string[];
|
||||
markAll?: boolean;
|
||||
action?: 'read' | 'unread' | 'archive' | 'unarchive';
|
||||
archiveAll?: boolean;
|
||||
deleteIds?: string[];
|
||||
};
|
||||
|
||||
if (markAll) {
|
||||
// Mark all notifications as read
|
||||
await db.notification.updateMany({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
@@ -212,16 +280,48 @@ export async function PUT(request: Request) {
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
if (archiveAll) {
|
||||
await db.notification.updateMany({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
deletedAt: null,
|
||||
isArchived: false,
|
||||
},
|
||||
data: { isArchived: true },
|
||||
});
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
if (ids && Array.isArray(ids)) {
|
||||
// Mark specific DB notifications as read
|
||||
const dbIds = ids.filter((id) => !id.startsWith('assessment-') && !id.startsWith('missing-'));
|
||||
if (dbIds.length > 0) {
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (action === 'read') updateData.isRead = true;
|
||||
else if (action === 'unread') updateData.isRead = false;
|
||||
else if (action === 'archive') updateData.isArchived = true;
|
||||
else if (action === 'unarchive') updateData.isArchived = false;
|
||||
else updateData.isRead = true; // default: mark read
|
||||
|
||||
await db.notification.updateMany({
|
||||
where: {
|
||||
id: { in: dbIds },
|
||||
userId: session.user.id,
|
||||
},
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
if (deleteIds && Array.isArray(deleteIds)) {
|
||||
const dbIds = deleteIds.filter((id) => !id.startsWith('assessment-') && !id.startsWith('missing-'));
|
||||
if (dbIds.length > 0) {
|
||||
await db.notification.updateMany({
|
||||
where: {
|
||||
id: { in: dbIds },
|
||||
userId: session.user.id,
|
||||
},
|
||||
data: { isRead: true },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
}
|
||||
return NextResponse.json({ success: true });
|
||||
@@ -241,10 +341,12 @@ export async function POST(request: Request) {
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { schoolId, userId, type, title, message, actionUrl, relatedId } = body as {
|
||||
const { schoolId, userId, type, category, priority, title, message, actionUrl, relatedId } = body as {
|
||||
schoolId: string;
|
||||
userId: string;
|
||||
type: string;
|
||||
category?: string;
|
||||
priority?: string;
|
||||
title: string;
|
||||
message: string;
|
||||
actionUrl?: string;
|
||||
@@ -255,11 +357,27 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Determine category from type if not provided
|
||||
const categoryMap: Record<string, string> = {
|
||||
ASSESSMENT_DUE: 'academic',
|
||||
MISSING_OBSERVATION: 'academic',
|
||||
NOTEBOOK_SHARED: 'academic',
|
||||
BEHAVIOR_ALERT: 'behavioral',
|
||||
GRADE_COMPUTED: 'academic',
|
||||
ATTENDANCE_ALERT: 'administrative',
|
||||
REPORT_READY: 'administrative',
|
||||
CALENDAR_EVENT: 'calendar',
|
||||
COMMUNICATION: 'communication',
|
||||
SYSTEM: 'system',
|
||||
};
|
||||
|
||||
const notification = await db.notification.create({
|
||||
data: {
|
||||
schoolId,
|
||||
userId,
|
||||
type,
|
||||
category: category || categoryMap[type] || 'system',
|
||||
priority: priority || 'normal',
|
||||
title,
|
||||
message,
|
||||
actionUrl: actionUrl ?? null,
|
||||
@@ -284,16 +402,15 @@ export async function DELETE(request: Request) {
|
||||
const { ids } = body as { ids?: string[] };
|
||||
|
||||
if (ids && Array.isArray(ids)) {
|
||||
// Soft-delete specific notifications
|
||||
const dbIds = ids.filter((id) => !id.startsWith('assessment-') && !id.startsWith('missing-'));
|
||||
await db.notification.updateMany({
|
||||
where: {
|
||||
id: { in: ids },
|
||||
id: { in: dbIds },
|
||||
userId: session.user.id,
|
||||
},
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
} else {
|
||||
// Soft-delete all notifications for the user
|
||||
await db.notification.updateMany({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const schoolIdParam = searchParams.get('schoolId');
|
||||
const studentIdParam = searchParams.get('studentId');
|
||||
|
||||
let schoolId: string | undefined;
|
||||
if (session.user?.role === 'SCHOOL_ADMIN') {
|
||||
schoolId = session.user.schoolId ?? undefined;
|
||||
} else {
|
||||
schoolId = schoolIdParam ?? session.user?.schoolId ?? undefined;
|
||||
}
|
||||
|
||||
if (!schoolId) return NextResponse.json([]);
|
||||
|
||||
// If student is requesting their own data
|
||||
if (session.user?.role === 'STUDENT') {
|
||||
const student = await db.student.findFirst({
|
||||
where: { userId: session.userId, schoolId, deletedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!student) return NextResponse.json({ badges: [], character: null, leaderboard: [] });
|
||||
|
||||
const studentId = student.id;
|
||||
|
||||
// Get student's earned badges
|
||||
const studentBadges = await db.studentBadge.findMany({
|
||||
where: { schoolId, studentId },
|
||||
orderBy: { awardedAt: 'desc' },
|
||||
include: {
|
||||
badge: true,
|
||||
awardedByUser: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Get all available badges for the school
|
||||
const allBadges = await db.badge.findMany({
|
||||
where: { schoolId, deletedAt: null },
|
||||
orderBy: { category: 'asc' },
|
||||
});
|
||||
|
||||
// Get virtual character
|
||||
const character = await db.virtualCharacter.findUnique({
|
||||
where: { userId: session.userId },
|
||||
});
|
||||
|
||||
// Get class-level leaderboard (students in same class, ordered by XP)
|
||||
const enrollment = await db.enrollment.findFirst({
|
||||
where: { studentId, endDate: null },
|
||||
select: { classGroupId: true },
|
||||
});
|
||||
|
||||
let leaderboard: Array<{ studentId: string; firstName: string; lastName: string; xp: number; level: number; badgeCount: number }> = [];
|
||||
if (enrollment) {
|
||||
const classStudents = await db.enrollment.findMany({
|
||||
where: { classGroupId: enrollment.classGroupId, endDate: null },
|
||||
select: { studentId: true },
|
||||
});
|
||||
const classStudentIds = classStudents.map(e => e.studentId);
|
||||
|
||||
const studentData = await db.student.findMany({
|
||||
where: { id: { in: classStudentIds }, deletedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
userId: true,
|
||||
studentBadges: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const charData = await db.virtualCharacter.findMany({
|
||||
where: { userId: { in: studentData.map(s => s.userId).filter(Boolean) as string[] } },
|
||||
select: { userId: true, xp: true, level: true },
|
||||
});
|
||||
|
||||
const charMap = new Map(charData.map(c => [c.userId, c]));
|
||||
leaderboard = studentData
|
||||
.map(s => {
|
||||
const char = s.userId ? charMap.get(s.userId) : null;
|
||||
return {
|
||||
studentId: s.id,
|
||||
firstName: s.firstName,
|
||||
lastName: s.lastName,
|
||||
xp: char?.xp ?? 0,
|
||||
level: char?.level ?? 1,
|
||||
badgeCount: s.studentBadges.length,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.xp - a.xp)
|
||||
.slice(0, 20);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
badges: allBadges.map(b => ({
|
||||
...b,
|
||||
earned: studentBadges.some(sb => sb.badgeId === b.id),
|
||||
earnedAt: studentBadges.find(sb => sb.badgeId === b.id)?.awardedAt ?? null,
|
||||
})),
|
||||
earnedBadges: studentBadges,
|
||||
character,
|
||||
leaderboard,
|
||||
totalXP: character?.xp ?? 0,
|
||||
level: character?.level ?? 1,
|
||||
});
|
||||
}
|
||||
|
||||
// Teacher/Admin view
|
||||
if (studentIdParam) {
|
||||
const studentBadges = await db.studentBadge.findMany({
|
||||
where: { schoolId, studentId: studentIdParam },
|
||||
orderBy: { awardedAt: 'desc' },
|
||||
include: {
|
||||
badge: true,
|
||||
student: { select: { id: true, firstName: true, lastName: true } },
|
||||
awardedByUser: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const allBadges = await db.badge.findMany({
|
||||
where: { schoolId, deletedAt: null },
|
||||
orderBy: { category: 'asc' },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
badges: allBadges.map(b => ({
|
||||
...b,
|
||||
earned: studentBadges.some(sb => sb.badgeId === b.id),
|
||||
earnedAt: studentBadges.find(sb => sb.badgeId === b.id)?.awardedAt ?? null,
|
||||
})),
|
||||
earnedBadges: studentBadges,
|
||||
});
|
||||
}
|
||||
|
||||
// Return all badges for school
|
||||
const allBadges = await db.badge.findMany({
|
||||
where: { schoolId, deletedAt: null },
|
||||
orderBy: { category: 'asc' },
|
||||
});
|
||||
|
||||
return NextResponse.json({ badges: allBadges });
|
||||
} catch (error) {
|
||||
console.error('StudentAchievements GET error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
|
||||
const body = await request.json();
|
||||
const { action, schoolId, studentId, badgeId, xpAmount, characterData } = body;
|
||||
|
||||
if (action === 'award_badge') {
|
||||
if (!schoolId || !studentId || !badgeId) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if already awarded
|
||||
const existing = await db.studentBadge.findUnique({
|
||||
where: { studentId_badgeId: { studentId, badgeId } },
|
||||
});
|
||||
if (existing) return NextResponse.json({ error: 'Badge already awarded' }, { status: 409 });
|
||||
|
||||
const badge = await db.badge.findUnique({ where: { id: badgeId, deletedAt: null } });
|
||||
if (!badge) return NextResponse.json({ error: 'Badge not found' }, { status: 404 });
|
||||
|
||||
const studentBadge = await db.studentBadge.create({
|
||||
data: {
|
||||
schoolId,
|
||||
studentId,
|
||||
badgeId,
|
||||
awardedBy: session.userId,
|
||||
},
|
||||
include: { badge: true },
|
||||
});
|
||||
|
||||
// Award XP for badge
|
||||
const student = await db.student.findUnique({ where: { id: studentId }, select: { userId: true } });
|
||||
if (student?.userId) {
|
||||
await upsertVirtualCharacter(student.userId, schoolId, 50);
|
||||
}
|
||||
|
||||
return NextResponse.json(studentBadge, { status: 201 });
|
||||
}
|
||||
|
||||
if (action === 'add_xp') {
|
||||
if (!schoolId || !studentId || xpAmount === undefined) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
const student = await db.student.findUnique({ where: { id: studentId }, select: { userId: true } });
|
||||
if (!student?.userId) return NextResponse.json({ error: 'Student has no user account' }, { status: 400 });
|
||||
|
||||
const character = await upsertVirtualCharacter(student.userId, schoolId, xpAmount);
|
||||
return NextResponse.json(character);
|
||||
}
|
||||
|
||||
if (action === 'update_character') {
|
||||
if (!characterData) {
|
||||
return NextResponse.json({ error: 'Missing characterData' }, { status: 400 });
|
||||
}
|
||||
|
||||
const character = await db.virtualCharacter.upsert({
|
||||
where: { userId: session.userId },
|
||||
update: {
|
||||
characterId: characterData.characterId ?? undefined,
|
||||
name: characterData.name ?? undefined,
|
||||
color: characterData.color ?? undefined,
|
||||
mood: characterData.mood ?? undefined,
|
||||
},
|
||||
create: {
|
||||
userId: session.userId,
|
||||
schoolId: schoolId ?? session.user?.schoolId ?? '',
|
||||
characterId: characterData.characterId ?? 'owl',
|
||||
name: characterData.name ?? 'My Companion',
|
||||
color: characterData.color ?? '#10b981',
|
||||
mood: characterData.mood ?? 'happy',
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(character);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
|
||||
} catch (error) {
|
||||
console.error('StudentAchievements POST error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertVirtualCharacter(userId: string, schoolId: string, xpGain: number) {
|
||||
const existing = await db.virtualCharacter.findUnique({ where: { userId } });
|
||||
|
||||
if (existing) {
|
||||
const newXp = existing.xp + xpGain;
|
||||
const newLevel = Math.floor(newXp / 100) + 1;
|
||||
|
||||
return db.virtualCharacter.update({
|
||||
where: { userId },
|
||||
data: {
|
||||
xp: newXp,
|
||||
level: newLevel,
|
||||
mood: newLevel > existing.level ? 'celebrating' : 'happy',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return db.virtualCharacter.create({
|
||||
data: {
|
||||
userId,
|
||||
schoolId,
|
||||
characterId: 'owl',
|
||||
name: 'My Companion',
|
||||
xp: xpGain,
|
||||
level: Math.floor(xpGain / 100) + 1,
|
||||
mood: 'happy',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
|
||||
// Verify the plan belongs to the student
|
||||
const existing = await db.studyPlan.findUnique({ where: { id } });
|
||||
if (!existing) return NextResponse.json({ error: 'Plan not found' }, { status: 404 });
|
||||
|
||||
if (session.user?.role === 'STUDENT') {
|
||||
const student = await db.student.findFirst({
|
||||
where: { userId: session.userId, schoolId: existing.schoolId, deletedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!student || student.id !== existing.studentId) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
const plan = await db.studyPlan.update({
|
||||
where: { id },
|
||||
data: {
|
||||
title: body.title ?? undefined,
|
||||
description: body.description ?? undefined,
|
||||
subjectId: body.subjectId ?? undefined,
|
||||
subjectName: body.subjectName ?? undefined,
|
||||
dayOfWeek: body.dayOfWeek ?? undefined,
|
||||
startTime: body.startTime ?? undefined,
|
||||
duration: body.duration ?? undefined,
|
||||
isRecurring: body.isRecurring ?? undefined,
|
||||
specificDate: body.specificDate ? new Date(body.specificDate) : undefined,
|
||||
priority: body.priority ?? undefined,
|
||||
status: body.status ?? undefined,
|
||||
color: body.color ?? undefined,
|
||||
notes: body.notes ?? undefined,
|
||||
},
|
||||
include: {
|
||||
subject: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(plan);
|
||||
} catch (error) {
|
||||
console.error('StudyPlan PUT error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const existing = await db.studyPlan.findUnique({ where: { id } });
|
||||
if (!existing) return NextResponse.json({ error: 'Plan not found' }, { status: 404 });
|
||||
|
||||
if (session.user?.role === 'STUDENT') {
|
||||
const student = await db.student.findFirst({
|
||||
where: { userId: session.userId, schoolId: existing.schoolId, deletedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!student || student.id !== existing.studentId) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
// Soft delete by setting status to cancelled
|
||||
await db.studyPlan.update({
|
||||
where: { id },
|
||||
data: { status: 'cancelled' },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('StudyPlan DELETE error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const schoolIdParam = searchParams.get('schoolId');
|
||||
const studentIdParam = searchParams.get('studentId');
|
||||
|
||||
let schoolId: string | undefined;
|
||||
if (session.user?.role === 'SCHOOL_ADMIN') {
|
||||
schoolId = session.user.schoolId ?? undefined;
|
||||
} else {
|
||||
schoolId = schoolIdParam ?? session.user?.schoolId ?? undefined;
|
||||
}
|
||||
|
||||
if (!schoolId) return NextResponse.json([]);
|
||||
|
||||
let studentId = studentIdParam;
|
||||
|
||||
// If student is requesting their own data
|
||||
if (session.user?.role === 'STUDENT' && !studentId) {
|
||||
const student = await db.student.findFirst({
|
||||
where: { userId: session.userId, schoolId, deletedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!student) return NextResponse.json([]);
|
||||
studentId = student.id;
|
||||
}
|
||||
|
||||
if (!studentId) return NextResponse.json([]);
|
||||
|
||||
// Verify access
|
||||
if (session.user?.role === 'STUDENT') {
|
||||
const student = await db.student.findUnique({
|
||||
where: { id: studentId },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (!student || student.userId !== session.userId) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
const studyPlans = await db.studyPlan.findMany({
|
||||
where: { schoolId, studentId, status: 'active' },
|
||||
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
|
||||
include: {
|
||||
subject: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Get recent sessions
|
||||
const recentSessions = await db.studySession.findMany({
|
||||
where: { schoolId, studentId },
|
||||
orderBy: { startTime: 'desc' },
|
||||
take: 10,
|
||||
include: {
|
||||
subject: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Get weekly stats
|
||||
const oneWeekAgo = new Date();
|
||||
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
|
||||
|
||||
const weekSessions = await db.studySession.findMany({
|
||||
where: {
|
||||
schoolId,
|
||||
studentId,
|
||||
status: 'completed',
|
||||
startTime: { gte: oneWeekAgo },
|
||||
},
|
||||
});
|
||||
|
||||
const totalMinutesThisWeek = weekSessions.reduce((sum, s) => sum + s.duration, 0);
|
||||
const subjectTimeMap = new Map<string, number>();
|
||||
weekSessions.forEach(s => {
|
||||
const name = s.subjectName ?? 'Other';
|
||||
subjectTimeMap.set(name, (subjectTimeMap.get(name) ?? 0) + s.duration);
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
plans: studyPlans,
|
||||
recentSessions,
|
||||
weeklyStats: {
|
||||
totalMinutes: totalMinutesThisWeek,
|
||||
sessionsCount: weekSessions.length,
|
||||
subjectBreakdown: Object.fromEntries(subjectTimeMap),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('StudyPlanner GET error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
|
||||
const body = await request.json();
|
||||
const {
|
||||
schoolId,
|
||||
studentId,
|
||||
title,
|
||||
description,
|
||||
subjectId,
|
||||
subjectName,
|
||||
dayOfWeek,
|
||||
startTime,
|
||||
duration,
|
||||
isRecurring,
|
||||
specificDate,
|
||||
priority,
|
||||
color,
|
||||
notes,
|
||||
} = body;
|
||||
|
||||
if (!schoolId || !studentId || !title || dayOfWeek === undefined || !startTime || !duration) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify access
|
||||
if (session.user?.role === 'STUDENT') {
|
||||
const student = await db.student.findUnique({
|
||||
where: { id: studentId },
|
||||
select: { userId: true, schoolId: true },
|
||||
});
|
||||
if (!student || student.userId !== session.userId || student.schoolId !== schoolId) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
const plan = await db.studyPlan.create({
|
||||
data: {
|
||||
schoolId,
|
||||
studentId,
|
||||
title,
|
||||
description: description || null,
|
||||
subjectId: subjectId || null,
|
||||
subjectName: subjectName || null,
|
||||
dayOfWeek,
|
||||
startTime,
|
||||
duration,
|
||||
isRecurring: isRecurring ?? true,
|
||||
specificDate: specificDate ? new Date(specificDate) : null,
|
||||
priority: priority || 'medium',
|
||||
color: color || null,
|
||||
notes: notes || null,
|
||||
},
|
||||
include: {
|
||||
subject: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(plan, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('StudyPlanner POST error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
|
||||
const body = await request.json();
|
||||
const {
|
||||
schoolId,
|
||||
studentId,
|
||||
studyPlanId,
|
||||
subjectId,
|
||||
subjectName,
|
||||
startTime,
|
||||
plannedDuration,
|
||||
type,
|
||||
pomodoroLength,
|
||||
breakLength,
|
||||
notes,
|
||||
} = body;
|
||||
|
||||
if (!schoolId || !studentId) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify access
|
||||
if (session.user?.role === 'STUDENT') {
|
||||
const student = await db.student.findUnique({
|
||||
where: { id: studentId },
|
||||
select: { userId: true, schoolId: true },
|
||||
});
|
||||
if (!student || student.userId !== session.userId || student.schoolId !== schoolId) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
const studySession = await db.studySession.create({
|
||||
data: {
|
||||
schoolId,
|
||||
studentId,
|
||||
studyPlanId: studyPlanId || null,
|
||||
subjectId: subjectId || null,
|
||||
subjectName: subjectName || null,
|
||||
startTime: startTime ? new Date(startTime) : new Date(),
|
||||
plannedDuration: plannedDuration || null,
|
||||
type: type || 'pomodoro',
|
||||
pomodoroLength: pomodoroLength || 25,
|
||||
breakLength: breakLength || 5,
|
||||
notes: notes || null,
|
||||
status: 'in_progress',
|
||||
},
|
||||
});
|
||||
|
||||
// Award XP for starting a study session
|
||||
const student = await db.student.findUnique({ where: { id: studentId }, select: { userId: true } });
|
||||
if (student?.userId) {
|
||||
const existing = await db.virtualCharacter.findUnique({ where: { userId: student.userId } });
|
||||
if (existing) {
|
||||
await db.virtualCharacter.update({
|
||||
where: { userId: student.userId },
|
||||
data: { xp: existing.xp + 5, level: Math.floor((existing.xp + 5) / 100) + 1 },
|
||||
});
|
||||
} else {
|
||||
await db.virtualCharacter.create({
|
||||
data: {
|
||||
userId: student.userId,
|
||||
schoolId,
|
||||
characterId: 'owl',
|
||||
name: 'My Companion',
|
||||
xp: 5,
|
||||
level: 1,
|
||||
mood: 'happy',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(studySession, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('StudySession POST error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
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 { sessionId, endTime, duration, pomodorosCompleted, focusScore, status, notes } = body;
|
||||
|
||||
if (!sessionId) {
|
||||
return NextResponse.json({ error: 'sessionId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const existing = await db.studySession.findUnique({ where: { id: sessionId } });
|
||||
if (!existing) return NextResponse.json({ error: 'Session not found' }, { status: 404 });
|
||||
|
||||
// Verify access
|
||||
if (session.user?.role === 'STUDENT') {
|
||||
const student = await db.student.findFirst({
|
||||
where: { userId: session.userId, schoolId: existing.schoolId, deletedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!student || student.id !== existing.studentId) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await db.studySession.update({
|
||||
where: { id: sessionId },
|
||||
data: {
|
||||
endTime: endTime ? new Date(endTime) : undefined,
|
||||
duration: duration ?? undefined,
|
||||
pomodorosCompleted: pomodorosCompleted ?? undefined,
|
||||
focusScore: focusScore ?? undefined,
|
||||
status: status ?? undefined,
|
||||
notes: notes ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// Award XP for completing a study session
|
||||
if (status === 'completed' && duration) {
|
||||
const xpGain = Math.floor(duration / 5); // 1 XP per 5 minutes
|
||||
const student = await db.student.findUnique({ where: { id: existing.studentId }, select: { userId: true } });
|
||||
if (student?.userId) {
|
||||
const char = await db.virtualCharacter.findUnique({ where: { userId: student.userId } });
|
||||
if (char) {
|
||||
const newXp = char.xp + xpGain;
|
||||
await db.virtualCharacter.update({
|
||||
where: { userId: student.userId },
|
||||
data: { xp: newXp, level: Math.floor(newXp / 100) + 1 },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
console.error('StudySession PUT error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ const updateUserSchema = z.object({
|
||||
email: z.string().email().optional(),
|
||||
role: z.enum(['TEACHER', 'SCHOOL_ADMIN', 'SUPER_ADMIN', 'VICE_PRINCIPAL']).optional(),
|
||||
locale: z.string().optional(),
|
||||
schoolId: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function GET(
|
||||
|
||||
@@ -63,6 +63,9 @@ import {
|
||||
MessageSquare,
|
||||
Brain,
|
||||
Sparkles,
|
||||
Tablet,
|
||||
CalendarClock,
|
||||
Timer,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Sidebar,
|
||||
@@ -168,6 +171,12 @@ import AITestsView from './ai-tests-view';
|
||||
import AIChatWidget from './ai-chat-widget';
|
||||
import VirtualCharacter from './virtual-character';
|
||||
import ParentPortalView from './parent-portal-view';
|
||||
import NotificationCenterView from './notification-center-view';
|
||||
import SchoolAnnouncementsView from './school-announcements-view';
|
||||
import TabletGradingView from './tablet-grading-view';
|
||||
import ExamCalendarView from './exam-calendar-view';
|
||||
import StudentAchievementsView from './student-achievements-view';
|
||||
import StudentStudyPlannerView from './student-study-planner-view';
|
||||
|
||||
// Dynamic imports for heavy components with loading skeletons
|
||||
const AnalyticsView = dynamic(() => import('./analytics-view'), {
|
||||
@@ -232,6 +241,10 @@ const navSections: NavSection[] = [
|
||||
{ key: 'counseling', icon: Heart, labelKey: 'counseling.title' },
|
||||
{ key: 'disciplinary', icon: Shield, labelKey: 'disciplinary.title' },
|
||||
{ key: 'ai-tests', icon: Brain, labelKey: 'ai_tests.title' },
|
||||
{ key: 'tablet-grading', icon: Tablet, labelKey: 'nav.tablet_grading' },
|
||||
{ key: 'exam-calendar', icon: CalendarClock, labelKey: 'nav.exam_calendar' },
|
||||
{ key: 'notification-center', icon: Bell, labelKey: 'nav.notification_center' },
|
||||
{ key: 'announcements', icon: Megaphone, labelKey: 'nav.announcements' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -266,7 +279,13 @@ const studentNavSections: NavSection[] = [
|
||||
{ key: 'communication', icon: MessageSquare, labelKey: 'nav.communication' },
|
||||
{ key: 'counseling', icon: Heart, labelKey: 'counseling.title' },
|
||||
{ key: 'ai-tests', icon: Brain, labelKey: 'ai_tests.title' },
|
||||
{ key: 'tablet-grading', icon: Tablet, labelKey: 'nav.tablet_grading' },
|
||||
{ key: 'exam-calendar', icon: CalendarClock, labelKey: 'nav.exam_calendar' },
|
||||
{ key: 'student-achievements', icon: Trophy, labelKey: 'achievements.title' },
|
||||
{ key: 'student-study-planner', icon: Timer, labelKey: 'study_planner.title' },
|
||||
{ key: 'resources', icon: FolderOpen, labelKey: 'nav.resources' },
|
||||
{ key: 'notification-center', icon: Bell, labelKey: 'nav.notification_center' },
|
||||
{ key: 'announcements', icon: Megaphone, labelKey: 'nav.announcements' },
|
||||
{ key: 'settings', icon: Settings, labelKey: 'nav.settings' },
|
||||
],
|
||||
},
|
||||
@@ -287,6 +306,10 @@ const parentNavSections: NavSection[] = [
|
||||
{ key: 'competitions', icon: Trophy, labelKey: 'nav.competitions' },
|
||||
{ key: 'illness', icon: Heart, labelKey: 'nav.illness' },
|
||||
{ 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: 'notification-center', icon: Bell, labelKey: 'nav.notification_center' },
|
||||
{ key: 'announcements', icon: Megaphone, labelKey: 'nav.announcements' },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -426,6 +449,12 @@ function renderView(view: ViewName) {
|
||||
case 'disciplinary': return <DisciplinaryView />;
|
||||
case 'ai-tests': return <AITestsView />;
|
||||
case 'parent-portal': return <ParentPortalView />;
|
||||
case 'notification-center': return <NotificationCenterView />;
|
||||
case 'announcements': return <SchoolAnnouncementsView />;
|
||||
case 'tablet-grading': return <TabletGradingView />;
|
||||
case 'exam-calendar': return <ExamCalendarView />;
|
||||
case 'student-achievements': return <StudentAchievementsView />;
|
||||
case 'student-study-planner': return <StudentStudyPlannerView />;
|
||||
default: return <DashboardView />;
|
||||
}
|
||||
}
|
||||
@@ -1246,7 +1275,15 @@ export default function AppLayout() {
|
||||
</ScrollArea>
|
||||
{/* Footer */}
|
||||
{!notifLoading && notifData && notifData.notifications.length > 0 && (
|
||||
<div className="px-3 py-2 border-t border-emerald-100/30 dark:border-emerald-900/20 flex gap-2">
|
||||
<div className="px-3 py-2 border-t border-emerald-100/30 dark:border-emerald-900/20 flex gap-2 flex-wrap">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => { setNotifOpen(false); setCurrentView('notification-center'); }}
|
||||
className="h-7 text-xs text-emerald-600 dark:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-900/20 font-semibold"
|
||||
>
|
||||
<Bell className="w-3 h-3 mr-0.5" /> {t('notif_center.title')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,947 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Bell,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
Trash2,
|
||||
Archive,
|
||||
Filter,
|
||||
BookOpen,
|
||||
Shield,
|
||||
Building2,
|
||||
Calendar,
|
||||
MessageSquare,
|
||||
Settings,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
X,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
Eye,
|
||||
BarChart3,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Mail,
|
||||
RefreshCw,
|
||||
ChevronRight,
|
||||
Info,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { useAppStore } from '@/lib/store';
|
||||
import { t } from '@/lib/i18n';
|
||||
import { apiGet, apiPut, apiPost, apiDelete } from '@/lib/api';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
} from 'recharts';
|
||||
|
||||
/* ── Types ─────────────────────────────────────────────────────── */
|
||||
|
||||
interface NotificationItem {
|
||||
id: string;
|
||||
type: string;
|
||||
category: string;
|
||||
priority: string;
|
||||
title: string;
|
||||
message: string;
|
||||
isRead: boolean;
|
||||
isArchived: boolean;
|
||||
actionUrl: string | null;
|
||||
relatedId: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface NotificationStats {
|
||||
byCategory: { category: string; count: number }[];
|
||||
}
|
||||
|
||||
interface NotificationData {
|
||||
notifications: NotificationItem[];
|
||||
unreadCount: number;
|
||||
stats: NotificationStats;
|
||||
}
|
||||
|
||||
interface PreferencesData {
|
||||
id: string;
|
||||
userId: string;
|
||||
schoolId: string;
|
||||
academicEnabled: boolean;
|
||||
behavioralEnabled: boolean;
|
||||
administrativeEnabled: boolean;
|
||||
calendarEnabled: boolean;
|
||||
communicationEnabled: boolean;
|
||||
systemEnabled: boolean;
|
||||
quietHoursStart: string | null;
|
||||
quietHoursEnd: string | null;
|
||||
quietHoursEnabled: boolean;
|
||||
emailDigestEnabled: boolean;
|
||||
emailDigestFrequency: string;
|
||||
}
|
||||
|
||||
/* ── Category Config ───────────────────────────────────────────── */
|
||||
|
||||
const CATEGORIES = [
|
||||
{ key: 'academic', labelKey: 'notif_center.category_academic', icon: BookOpen, color: 'bg-emerald-500', lightColor: 'bg-emerald-100 dark:bg-emerald-900/30', textColor: 'text-emerald-600 dark:text-emerald-400' },
|
||||
{ key: 'behavioral', labelKey: 'notif_center.category_behavioral', icon: Shield, color: 'bg-rose-500', lightColor: 'bg-rose-100 dark:bg-rose-900/30', textColor: 'text-rose-600 dark:text-rose-400' },
|
||||
{ key: 'administrative', labelKey: 'notif_center.category_administrative', icon: Building2, color: 'bg-amber-500', lightColor: 'bg-amber-100 dark:bg-amber-900/30', textColor: 'text-amber-600 dark:text-amber-400' },
|
||||
{ key: 'calendar', labelKey: 'notif_center.category_calendar', icon: Calendar, color: 'bg-teal-500', lightColor: 'bg-teal-100 dark:bg-teal-900/30', textColor: 'text-teal-600 dark:text-teal-400' },
|
||||
{ key: 'communication', labelKey: 'notif_center.category_communication', icon: MessageSquare, color: 'bg-violet-500', lightColor: 'bg-violet-100 dark:bg-violet-900/30', textColor: 'text-violet-600 dark:text-violet-400' },
|
||||
{ key: 'system', labelKey: 'notif_center.category_system', icon: Settings, color: 'bg-gray-500', lightColor: 'bg-gray-100 dark:bg-gray-900/30', textColor: 'text-gray-600 dark:text-gray-400' },
|
||||
] as const;
|
||||
|
||||
const PRIORITIES = [
|
||||
{ key: 'urgent', labelKey: 'notif_center.priority_urgent', color: 'from-rose-500 to-red-600', badgeColor: 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 border-rose-200 dark:border-rose-800' },
|
||||
{ key: 'high', labelKey: 'notif_center.priority_high', color: 'from-amber-500 to-orange-500', badgeColor: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300 border-amber-200 dark:border-amber-800' },
|
||||
{ key: 'normal', labelKey: 'notif_center.priority_normal', color: 'from-emerald-500 to-teal-500', badgeColor: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 border-emerald-200 dark:border-emerald-800' },
|
||||
{ key: 'low', labelKey: 'notif_center.priority_low', color: 'from-gray-400 to-gray-500', badgeColor: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 border-gray-200 dark:border-gray-700' },
|
||||
] as const;
|
||||
|
||||
/* ── Helpers ───────────────────────────────────────────────────── */
|
||||
|
||||
function getCategoryConfig(category: string) {
|
||||
return CATEGORIES.find((c) => c.key === category) || CATEGORIES[5];
|
||||
}
|
||||
|
||||
function getPriorityConfig(priority: string) {
|
||||
return PRIORITIES.find((p) => p.key === priority) || PRIORITIES[2];
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const now = new Date();
|
||||
const date = new Date(dateStr);
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
|
||||
if (diffSec < 60) return t('notif_center.just_now');
|
||||
if (diffMin < 60) return t('notif_center.minutes_ago').replace('{n}', String(diffMin));
|
||||
if (diffHour < 24) return t('notif_center.hours_ago').replace('{n}', String(diffHour));
|
||||
if (diffDay < 7) return t('notif_center.days_ago').replace('{n}', String(diffDay));
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
const CHART_COLORS = ['#10b981', '#f43f5e', '#f59e0b', '#14b8a6', '#8b5cf6', '#6b7280'];
|
||||
|
||||
/* ── Component ─────────────────────────────────────────────────── */
|
||||
|
||||
export default function NotificationCenterView() {
|
||||
const currentUser = useAppStore((s) => s.currentUser);
|
||||
const setCurrentView = useAppStore((s) => s.setCurrentView);
|
||||
const [data, setData] = useState<NotificationData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
// Filters
|
||||
const [filterCategory, setFilterCategory] = useState('all');
|
||||
const [filterPriority, setFilterPriority] = useState('all');
|
||||
const [filterRead, setFilterRead] = useState('all');
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
// Preferences
|
||||
const [prefs, setPrefs] = useState<PreferencesData | null>(null);
|
||||
const [prefsOpen, setPrefsOpen] = useState(false);
|
||||
|
||||
// Active tab
|
||||
const [activeTab, setActiveTab] = useState('all');
|
||||
|
||||
const role = currentUser?.role || 'TEACHER';
|
||||
const isStudent = role === 'STUDENT';
|
||||
const isParent = role === 'PARENT';
|
||||
|
||||
/* ── Load Data ──────────────────────────────────────────────── */
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (filterCategory !== 'all') params.set('category', filterCategory);
|
||||
if (filterPriority !== 'all') params.set('priority', filterPriority);
|
||||
if (filterRead !== 'all') params.set('isRead', filterRead);
|
||||
if (showArchived) params.set('isArchived', 'true');
|
||||
else params.set('isArchived', 'false');
|
||||
|
||||
const result = await apiGet<NotificationData>(`/api/notifications?${params.toString()}`);
|
||||
setData(result);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filterCategory, filterPriority, filterRead, showArchived]);
|
||||
|
||||
const loadPrefs = useCallback(async () => {
|
||||
try {
|
||||
const result = await apiGet<PreferencesData>('/api/notifications/preferences');
|
||||
setPrefs(result);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
loadPrefs();
|
||||
}, [loadData, loadPrefs]);
|
||||
|
||||
// Polling every 10 seconds
|
||||
useEffect(() => {
|
||||
const interval = setInterval(loadData, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadData]);
|
||||
|
||||
/* ── Actions ────────────────────────────────────────────────── */
|
||||
|
||||
const handleMarkRead = useCallback(async (id: string) => {
|
||||
try {
|
||||
await apiPut(`/api/notifications/${id}`, { action: 'read' });
|
||||
setData((prev) => prev ? {
|
||||
...prev,
|
||||
notifications: prev.notifications.map((n) =>
|
||||
n.id === id ? { ...n, isRead: true } : n
|
||||
),
|
||||
unreadCount: Math.max(0, prev.unreadCount - 1),
|
||||
} : prev);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMarkAllRead = useCallback(async () => {
|
||||
try {
|
||||
await apiPut('/api/notifications', { markAll: true });
|
||||
setData((prev) => prev ? {
|
||||
...prev,
|
||||
notifications: prev.notifications.map((n) => ({ ...n, isRead: true })),
|
||||
unreadCount: 0,
|
||||
} : prev);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleArchive = useCallback(async (id: string) => {
|
||||
try {
|
||||
await apiPut(`/api/notifications/${id}`, { action: 'archive' });
|
||||
setData((prev) => prev ? {
|
||||
...prev,
|
||||
notifications: prev.notifications.map((n) =>
|
||||
n.id === id ? { ...n, isArchived: true } : n
|
||||
),
|
||||
} : prev);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDelete = useCallback(async (id: string) => {
|
||||
try {
|
||||
await apiDelete(`/api/notifications/${id}`);
|
||||
setData((prev) => prev ? {
|
||||
...prev,
|
||||
notifications: prev.notifications.filter((n) => n.id !== id),
|
||||
} : prev);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleBulkAction = useCallback(async (action: 'read' | 'archive' | 'delete') => {
|
||||
if (selectedIds.size === 0) return;
|
||||
try {
|
||||
const ids = Array.from(selectedIds);
|
||||
if (action === 'delete') {
|
||||
await apiPut('/api/notifications', { deleteIds: ids });
|
||||
} else {
|
||||
await apiPut('/api/notifications', { ids, action });
|
||||
}
|
||||
setSelectedIds(new Set());
|
||||
loadData();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [selectedIds, loadData]);
|
||||
|
||||
const handleToggleSelect = useCallback((id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleNavigate = useCallback((actionUrl: string | null) => {
|
||||
if (actionUrl) {
|
||||
setCurrentView(actionUrl as 'dashboard');
|
||||
}
|
||||
}, [setCurrentView]);
|
||||
|
||||
const handleSavePrefs = useCallback(async () => {
|
||||
if (!prefs) return;
|
||||
try {
|
||||
await apiPut('/api/notifications/preferences', prefs);
|
||||
setPrefsOpen(false);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [prefs]);
|
||||
|
||||
/* ── Filtered Data ──────────────────────────────────────────── */
|
||||
|
||||
const filteredNotifications = useMemo(() => {
|
||||
if (!data?.notifications) return [];
|
||||
let items = data.notifications;
|
||||
|
||||
// Tab-based filter
|
||||
if (activeTab === 'unread') {
|
||||
items = items.filter((n) => !n.isRead);
|
||||
} else if (activeTab === 'archived') {
|
||||
items = items.filter((n) => n.isArchived);
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [data?.notifications, activeTab]);
|
||||
|
||||
/* ── Chart Data ─────────────────────────────────────────────── */
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!data?.stats?.byCategory) return [];
|
||||
return data.stats.byCategory.map((item) => ({
|
||||
name: t(`notif_center.category_${item.category}`),
|
||||
value: item.count,
|
||||
category: item.category,
|
||||
}));
|
||||
}, [data?.stats]);
|
||||
|
||||
/* ── Render ─────────────────────────────────────────────────── */
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
|
||||
<span className="text-gray-500">{t('notif_center.loading')}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4 md:p-6">
|
||||
{/* Header with gradient banner */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="relative overflow-hidden rounded-2xl bg-gradient-to-r from-emerald-600 via-teal-500 to-emerald-500 p-6 shadow-lg"
|
||||
>
|
||||
{/* Decorative background shapes */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute -top-4 -right-4 w-20 h-20 rounded-full bg-white/10" />
|
||||
<div className="absolute bottom-0 left-1/3 w-16 h-16 rounded-full bg-white/5" />
|
||||
</div>
|
||||
<div className="relative flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
|
||||
<Bell className="h-6 w-6" />
|
||||
{t('notif_center.title')}
|
||||
{data && data.unreadCount > 0 && (
|
||||
<motion.span
|
||||
className="ml-1 inline-flex items-center justify-center h-6 min-w-[24px] rounded-full bg-white/20 text-white text-xs font-semibold px-2"
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 300 }}
|
||||
>
|
||||
{data.unreadCount}
|
||||
</motion.span>
|
||||
)}
|
||||
</h1>
|
||||
<p className="text-sm text-emerald-100 mt-1">
|
||||
{t('notif_center.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadData}
|
||||
className="bg-white/10 hover:bg-white/20 text-white border-white/30"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-1" />
|
||||
{t('action.refresh')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleMarkAllRead}
|
||||
disabled={!data || data.unreadCount === 0}
|
||||
className="bg-white/10 hover:bg-white/20 text-white border-white/30"
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4 mr-1" />
|
||||
{t('notif_center.mark_all_read')}
|
||||
</Button>
|
||||
<Dialog open={prefsOpen} onOpenChange={setPrefsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="bg-white/10 hover:bg-white/20 text-white border-white/30"
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-1" />
|
||||
{t('notif_center.preferences')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Settings className="h-5 w-5 text-emerald-600" />
|
||||
{t('notif_center.preferences_title')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{prefs && (
|
||||
<div className="space-y-6">
|
||||
{/* Category Toggles */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-3 text-gray-700 dark:text-gray-300">
|
||||
{t('notif_center.category_toggles')}
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{CATEGORIES.map((cat) => {
|
||||
const key = `${cat.key}Enabled` as keyof PreferencesData;
|
||||
const enabled = prefs[key] as boolean;
|
||||
const Icon = cat.icon;
|
||||
return (
|
||||
<div key={cat.key} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${cat.lightColor}`}>
|
||||
<Icon className={`h-4 w-4 ${cat.textColor}`} />
|
||||
</div>
|
||||
<Label className="text-sm">{t(cat.labelKey)}</Label>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setPrefs((prev) => prev ? { ...prev, [key]: checked } : prev)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Quiet Hours */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
{t('notif_center.quiet_hours')}
|
||||
</h4>
|
||||
<Switch
|
||||
checked={prefs.quietHoursEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setPrefs((prev) => prev ? { ...prev, quietHoursEnabled: checked } : prev)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{prefs.quietHoursEnabled && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="time"
|
||||
value={prefs.quietHoursStart || '22:00'}
|
||||
onChange={(e) =>
|
||||
setPrefs((prev) => prev ? { ...prev, quietHoursStart: e.target.value } : prev)
|
||||
}
|
||||
className="w-32"
|
||||
/>
|
||||
<span className="text-gray-400">—</span>
|
||||
<Input
|
||||
type="time"
|
||||
value={prefs.quietHoursEnd || '07:00'}
|
||||
onChange={(e) =>
|
||||
setPrefs((prev) => prev ? { ...prev, quietHoursEnd: e.target.value } : prev)
|
||||
}
|
||||
className="w-32"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Email Digest */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 flex items-center gap-2">
|
||||
<Mail className="h-4 w-4" />
|
||||
{t('notif_center.email_digest')}
|
||||
</h4>
|
||||
<Switch
|
||||
checked={prefs.emailDigestEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setPrefs((prev) => prev ? { ...prev, emailDigestEnabled: checked } : prev)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{prefs.emailDigestEnabled && (
|
||||
<Select
|
||||
value={prefs.emailDigestFrequency}
|
||||
onValueChange={(value) =>
|
||||
setPrefs((prev) => prev ? { ...prev, emailDigestFrequency: value } : prev)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="daily">{t('notif_center.digest_daily')}</SelectItem>
|
||||
<SelectItem value="weekly">{t('notif_center.digest_weekly')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSavePrefs} className="w-full bg-emerald-600 hover:bg-emerald-700">
|
||||
{t('action.save')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Stats Cards with glassmorphism */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0 }}>
|
||||
<Card className="border-emerald-200/50 dark:border-emerald-900/30 backdrop-blur-sm bg-card/80 hover:shadow-md transition-all">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center">
|
||||
<Bell className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{data?.notifications.length ?? 0}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{t('notif_center.total')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.05 }}>
|
||||
<Card className="border-rose-200/50 dark:border-rose-900/30 backdrop-blur-sm bg-card/80 hover:shadow-md transition-all">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-rose-100 dark:bg-rose-900/30 flex items-center justify-center">
|
||||
<Eye className="h-5 w-5 text-rose-600 dark:text-rose-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{data?.unreadCount ?? 0}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{t('notif_center.unread')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }}>
|
||||
<Card className="border-amber-200/50 dark:border-amber-900/30 backdrop-blur-sm bg-card/80 hover:shadow-md transition-all">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{data?.notifications.filter((n) => n.priority === 'urgent' || n.priority === 'high').length ?? 0}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{t('notif_center.urgent_high')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.15 }}>
|
||||
<Card className="border-teal-200/50 dark:border-teal-900/30 backdrop-blur-sm bg-card/80 hover:shadow-md transition-all">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-teal-100 dark:bg-teal-900/30 flex items-center justify-center">
|
||||
<Archive className="h-5 w-5 text-teal-600 dark:text-teal-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{data?.notifications.filter((n) => n.isArchived).length ?? 0}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{t('notif_center.archived')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Tabs + Filters */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<TabsList className="bg-emerald-50 dark:bg-emerald-950/30">
|
||||
<TabsTrigger value="all" className="data-[state=active]:bg-emerald-600 data-[state=active]:text-white">
|
||||
{t('notif_center.tab_all')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="unread" className="data-[state=active]:bg-emerald-600 data-[state=active]:text-white">
|
||||
{t('notif_center.tab_unread')}
|
||||
{data && data.unreadCount > 0 && (
|
||||
<Badge className="ml-1 h-5 min-w-[20px] text-[10px] bg-emerald-600 text-white">{data.unreadCount}</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="archived" className="data-[state=active]:bg-emerald-600 data-[state=active]:text-white">
|
||||
{t('notif_center.tab_archived')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="text-emerald-600 dark:text-emerald-400 border-emerald-200 dark:border-emerald-800"
|
||||
>
|
||||
<Filter className="h-4 w-4 mr-1" />
|
||||
{t('action.filter')}
|
||||
{showFilters ? <ChevronUp className="h-3 w-3 ml-1" /> : <ChevronDown className="h-3 w-3 ml-1" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filter Panel */}
|
||||
<AnimatePresence>
|
||||
{showFilters && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<Card className="mt-4 border-emerald-200/50 dark:border-emerald-900/30">
|
||||
<CardContent className="p-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs text-gray-500 mb-1">{t('notif_center.filter_category')}</Label>
|
||||
<Select value={filterCategory} onValueChange={setFilterCategory}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('notif_center.all_categories')}</SelectItem>
|
||||
{CATEGORIES.map((cat) => (
|
||||
<SelectItem key={cat.key} value={cat.key}>{t(cat.labelKey)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-gray-500 mb-1">{t('notif_center.filter_priority')}</Label>
|
||||
<Select value={filterPriority} onValueChange={setFilterPriority}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('notif_center.all_priorities')}</SelectItem>
|
||||
{PRIORITIES.map((p) => (
|
||||
<SelectItem key={p.key} value={p.key}>{t(p.labelKey)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-gray-500 mb-1">{t('notif_center.filter_read')}</Label>
|
||||
<Select value={filterRead} onValueChange={setFilterRead}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('notif_center.all_status')}</SelectItem>
|
||||
<SelectItem value="false">{t('notif_center.unread_only')}</SelectItem>
|
||||
<SelectItem value="true">{t('notif_center.read_only')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Bulk Actions */}
|
||||
{selectedIds.size > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex items-center gap-2 mt-4 p-3 bg-emerald-50 dark:bg-emerald-950/30 rounded-xl border border-emerald-200 dark:border-emerald-800"
|
||||
>
|
||||
<span className="text-sm text-emerald-700 dark:text-emerald-300 font-medium">
|
||||
{selectedIds.size} {t('notif_center.selected')}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onClick={() => handleBulkAction('read')} className="text-emerald-600 border-emerald-200 dark:border-emerald-800">
|
||||
<Check className="h-3 w-3 mr-1" /> {t('notif_center.mark_read')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => handleBulkAction('archive')} className="text-emerald-600 border-emerald-200 dark:border-emerald-800">
|
||||
<Archive className="h-3 w-3 mr-1" /> {t('notif_center.archive')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => handleBulkAction('delete')} className="text-rose-600 border-rose-200 dark:border-rose-800">
|
||||
<Trash2 className="h-3 w-3 mr-1" /> {t('action.delete')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedIds(new Set())}>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Notification List */}
|
||||
<TabsContent value={activeTab} className="mt-4">
|
||||
{filteredNotifications.length === 0 ? (
|
||||
<Card className="border-emerald-200/50 dark:border-emerald-900/30">
|
||||
<CardContent className="p-12 text-center">
|
||||
<Bell className="h-12 w-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" />
|
||||
<p className="text-gray-500 dark:text-gray-400">{t('notif_center.no_notifications')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<AnimatePresence>
|
||||
{filteredNotifications.map((notification, index) => {
|
||||
const catConfig = getCategoryConfig(notification.category);
|
||||
const priConfig = getPriorityConfig(notification.priority);
|
||||
const CatIcon = catConfig.icon;
|
||||
const isExpanded = expandedId === notification.id;
|
||||
const isSelected = selectedIds.has(notification.id);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={notification.id}
|
||||
initial={{ opacity: 0, x: 30 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -30 }}
|
||||
transition={{ delay: index * 0.02, type: 'spring', stiffness: 200, damping: 20 }}
|
||||
layout
|
||||
>
|
||||
<Card
|
||||
className={`overflow-hidden transition-all hover:shadow-lg cursor-pointer ${
|
||||
!notification.isRead
|
||||
? 'border-emerald-300 dark:border-emerald-700 bg-emerald-50/30 dark:bg-emerald-950/20'
|
||||
: 'border-gray-200 dark:border-gray-800'
|
||||
} ${isSelected ? 'ring-2 ring-emerald-400' : ''}`}
|
||||
>
|
||||
{/* Gradient left border based on priority */}
|
||||
<div className="flex">
|
||||
<div className={`w-1.5 shrink-0 bg-gradient-to-b ${priConfig.color}`} />
|
||||
<div className="flex-1">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Select checkbox */}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleToggleSelect(notification.id); }}
|
||||
className={`mt-0.5 w-5 h-5 rounded border-2 flex items-center justify-center shrink-0 transition-colors ${
|
||||
isSelected
|
||||
? 'bg-emerald-600 border-emerald-600 text-white'
|
||||
: 'border-gray-300 dark:border-gray-600 hover:border-emerald-400'
|
||||
}`}
|
||||
>
|
||||
{isSelected && <Check className="h-3 w-3" />}
|
||||
</button>
|
||||
|
||||
{/* Category icon */}
|
||||
<div className={`w-9 h-9 rounded-lg flex items-center justify-center shrink-0 ${catConfig.lightColor}`}>
|
||||
<CatIcon className={`h-4 w-4 ${catConfig.textColor}`} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0" onClick={() => setExpandedId(isExpanded ? null : notification.id)}>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className={`text-sm font-medium ${!notification.isRead ? 'text-gray-900 dark:text-gray-100' : 'text-gray-600 dark:text-gray-400'}`}>
|
||||
{notification.title}
|
||||
</p>
|
||||
<Badge variant="outline" className={`text-[10px] px-1.5 py-0 ${priConfig.badgeColor}`}>
|
||||
{t(priConfig.labelKey)}
|
||||
</Badge>
|
||||
{!notification.isRead && (
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">
|
||||
{notification.message}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Clock className="h-3 w-3 text-gray-400" />
|
||||
<span className="text-[11px] text-gray-400">{formatRelativeTime(notification.createdAt)}</span>
|
||||
<Badge variant="outline" className="text-[10px] px-1 py-0 bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400">
|
||||
{t(catConfig.labelKey)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Expanded detail */}
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="mt-3 pt-3 border-t border-gray-100 dark:border-gray-800">
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
|
||||
{notification.message}
|
||||
</p>
|
||||
{notification.actionUrl && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); handleNavigate(notification.actionUrl); }}
|
||||
className="mt-2 text-emerald-600 border-emerald-200 dark:border-emerald-800"
|
||||
>
|
||||
<ChevronRight className="h-3 w-3 mr-1" />
|
||||
{t('notif_center.go_to')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||
{!notification.isRead && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); handleMarkRead(notification.id); }}
|
||||
className="h-7 w-7 p-0 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-900/20"
|
||||
title={t('notif_center.mark_read')}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); handleArchive(notification.id); }}
|
||||
className="h-7 w-7 p-0 text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
title={t('notif_center.archive')}
|
||||
>
|
||||
<Archive className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); handleDelete(notification.id); }}
|
||||
className="h-7 w-7 p-0 text-rose-400 hover:bg-rose-100 dark:hover:bg-rose-900/20"
|
||||
title={t('action.delete')}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Statistics Section */}
|
||||
{chartData.length > 0 && !isStudent && !isParent && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-6">
|
||||
<Card className="border-emerald-200/50 dark:border-emerald-900/30">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold text-gray-700 dark:text-gray-300 flex items-center gap-2">
|
||||
<BarChart3 className="h-4 w-4 text-emerald-600" />
|
||||
{t('notif_center.by_category')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={chartData}>
|
||||
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 11 }} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="value" radius={[4, 4, 0, 0]}>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={CHART_COLORS[index % CHART_COLORS.length]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-emerald-200/50 dark:border-emerald-900/30">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold text-gray-700 dark:text-gray-300 flex items-center gap-2">
|
||||
<BarChart3 className="h-4 w-4 text-emerald-600" />
|
||||
{t('notif_center.distribution')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={50}
|
||||
outerRadius={80}
|
||||
dataKey="value"
|
||||
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
|
||||
>
|
||||
{chartData.map((_, index) => (
|
||||
<Cell key={`cell-${index}`} fill={CHART_COLORS[index % CHART_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,939 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Megaphone,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Pin,
|
||||
Eye,
|
||||
Clock,
|
||||
Calendar,
|
||||
AlertTriangle,
|
||||
GraduationCap,
|
||||
BookOpen,
|
||||
PartyPopper,
|
||||
ClipboardCheck,
|
||||
Hourglass,
|
||||
Info,
|
||||
Users,
|
||||
Check,
|
||||
X,
|
||||
Filter,
|
||||
ChevronDown,
|
||||
RefreshCw,
|
||||
Star,
|
||||
UserCheck,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { useAppStore } from '@/lib/store';
|
||||
import { t } from '@/lib/i18n';
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '@/lib/api';
|
||||
|
||||
/* ── Types ─────────────────────────────────────────────────────── */
|
||||
|
||||
interface AnnouncementAuthor {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
}
|
||||
|
||||
interface AnnouncementRead {
|
||||
id: string;
|
||||
userId: string;
|
||||
readAt: string;
|
||||
user: { id: string; firstName: string; lastName: string };
|
||||
}
|
||||
|
||||
interface AnnouncementItem {
|
||||
id: string;
|
||||
schoolId: string;
|
||||
authorId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
priority: string;
|
||||
announcementType: string;
|
||||
targetAudience: string;
|
||||
classGroupId: string | null;
|
||||
isPinned: boolean;
|
||||
expiresAt: string | null;
|
||||
isDemo: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
author: AnnouncementAuthor;
|
||||
classGroup: { id: string; name: string } | null;
|
||||
isReadByCurrentUser?: boolean;
|
||||
totalReads?: number;
|
||||
reads?: AnnouncementRead[];
|
||||
}
|
||||
|
||||
interface ClassGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/* ── Config ────────────────────────────────────────────────────── */
|
||||
|
||||
const ANNOUNCEMENT_TYPES = [
|
||||
{ key: 'general', labelKey: 'announcements.type_general', icon: Megaphone, gradient: 'from-emerald-500 to-teal-500', bg: 'bg-emerald-100 dark:bg-emerald-900/30', text: 'text-emerald-600 dark:text-emerald-400' },
|
||||
{ key: 'urgent', labelKey: 'announcements.type_urgent', icon: AlertTriangle, gradient: 'from-rose-500 to-red-600', bg: 'bg-rose-100 dark:bg-rose-900/30', text: 'text-rose-600 dark:text-rose-400' },
|
||||
{ key: 'event', labelKey: 'announcements.type_event', icon: PartyPopper, gradient: 'from-violet-500 to-purple-600', bg: 'bg-violet-100 dark:bg-violet-900/30', text: 'text-violet-600 dark:text-violet-400' },
|
||||
{ key: 'holiday', labelKey: 'announcements.type_holiday', icon: Star, gradient: 'from-amber-500 to-orange-500', bg: 'bg-amber-100 dark:bg-amber-900/30', text: 'text-amber-600 dark:text-amber-400' },
|
||||
{ key: 'exam', labelKey: 'announcements.type_exam', icon: ClipboardCheck, gradient: 'from-teal-500 to-cyan-500', bg: 'bg-teal-100 dark:bg-teal-900/30', text: 'text-teal-600 dark:text-teal-400' },
|
||||
{ key: 'deadline', labelKey: 'announcements.type_deadline', icon: Hourglass, gradient: 'from-rose-500 to-amber-500', bg: 'bg-rose-100 dark:bg-amber-900/30', text: 'text-amber-600 dark:text-amber-400' },
|
||||
] as const;
|
||||
|
||||
const PRIORITIES = [
|
||||
{ key: 'low', labelKey: 'announcements.priority_low', badgeColor: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 border-gray-200 dark:border-gray-700' },
|
||||
{ key: 'normal', labelKey: 'announcements.priority_normal', badgeColor: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 border-emerald-200 dark:border-emerald-800' },
|
||||
{ key: 'high', labelKey: 'announcements.priority_high', badgeColor: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300 border-amber-200 dark:border-amber-800' },
|
||||
{ key: 'urgent', labelKey: 'announcements.priority_urgent', badgeColor: 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 border-rose-200 dark:border-rose-800' },
|
||||
] as const;
|
||||
|
||||
const TARGET_AUDIENCES = [
|
||||
{ key: 'all', labelKey: 'announcements.target_all' },
|
||||
{ key: 'teachers', labelKey: 'announcements.target_teachers' },
|
||||
{ key: 'students', labelKey: 'announcements.target_students' },
|
||||
{ key: 'parents', labelKey: 'announcements.target_parents' },
|
||||
{ key: 'class', labelKey: 'announcements.target_class' },
|
||||
] as const;
|
||||
|
||||
/* ── Helpers ───────────────────────────────────────────────────── */
|
||||
|
||||
function getTypeConfig(type: string) {
|
||||
return ANNOUNCEMENT_TYPES.find((t) => t.key === type) || ANNOUNCEMENT_TYPES[0];
|
||||
}
|
||||
|
||||
function getPriorityConfig(priority: string) {
|
||||
return PRIORITIES.find((p) => p.key === priority) || PRIORITIES[1];
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const now = new Date();
|
||||
const date = new Date(dateStr);
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
|
||||
if (diffSec < 60) return t('announcements.just_now');
|
||||
if (diffMin < 60) return t('announcements.minutes_ago').replace('{n}', String(diffMin));
|
||||
if (diffHour < 24) return t('announcements.hours_ago').replace('{n}', String(diffHour));
|
||||
if (diffDay < 7) return t('announcements.days_ago').replace('{n}', String(diffDay));
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
function getInitials(firstName: string, lastName: string): string {
|
||||
return `${firstName[0]}${lastName[0]}`.toUpperCase();
|
||||
}
|
||||
|
||||
/* ── Component ─────────────────────────────────────────────────── */
|
||||
|
||||
export default function SchoolAnnouncementsView() {
|
||||
const currentUser = useAppStore((s) => s.currentUser);
|
||||
const [announcements, setAnnouncements] = useState<AnnouncementItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [classes, setClasses] = useState<ClassGroup[]>([]);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
const [filterType, setFilterType] = useState('all');
|
||||
const [filterPriority, setFilterPriority] = useState('all');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
// Form state
|
||||
const [formTitle, setFormTitle] = useState('');
|
||||
const [formContent, setFormContent] = useState('');
|
||||
const [formPriority, setFormPriority] = useState('normal');
|
||||
const [formType, setFormType] = useState('general');
|
||||
const [formTarget, setFormTarget] = useState('all');
|
||||
const [formClassId, setFormClassId] = useState('');
|
||||
const [formIsPinned, setFormIsPinned] = useState(false);
|
||||
const [formExpiresAt, setFormExpiresAt] = useState('');
|
||||
const [formSaving, setFormSaving] = useState(false);
|
||||
|
||||
const role = currentUser?.role || 'TEACHER';
|
||||
const schoolId = currentUser?.schoolId;
|
||||
const isAdmin = role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN' || role === 'VICE_PRINCIPAL';
|
||||
const isTeacher = role === 'TEACHER';
|
||||
const canCreate = isAdmin || isTeacher;
|
||||
const canEdit = isAdmin || isTeacher;
|
||||
|
||||
/* ── Load Data ──────────────────────────────────────────────── */
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!schoolId) return;
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
schoolId,
|
||||
includeReads: 'true',
|
||||
limit: '100',
|
||||
});
|
||||
if (filterType !== 'all') params.set('announcementType', filterType);
|
||||
if (filterPriority !== 'all') params.set('priority', filterPriority);
|
||||
|
||||
const data = await apiGet<AnnouncementItem[]>(`/api/announcements?${params.toString()}`);
|
||||
setAnnouncements(data);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [schoolId, filterType, filterPriority]);
|
||||
|
||||
const loadClasses = useCallback(async () => {
|
||||
if (!schoolId) return;
|
||||
try {
|
||||
const data = await apiGet<ClassGroup[]>(`/api/classes?schoolId=${schoolId}`);
|
||||
setClasses(data);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [schoolId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
loadClasses();
|
||||
}, [loadData, loadClasses]);
|
||||
|
||||
/* ── Actions ────────────────────────────────────────────────── */
|
||||
|
||||
const handleMarkRead = useCallback(async (id: string) => {
|
||||
try {
|
||||
await apiPost(`/api/announcements/${id}/read`, {});
|
||||
setAnnouncements((prev) =>
|
||||
prev.map((a) => a.id === id ? { ...a, isReadByCurrentUser: true } : a)
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
if (!schoolId || !formTitle.trim() || !formContent.trim()) return;
|
||||
setFormSaving(true);
|
||||
try {
|
||||
await apiPost('/api/announcements', {
|
||||
schoolId,
|
||||
title: formTitle,
|
||||
content: formContent,
|
||||
priority: formPriority,
|
||||
announcementType: formType,
|
||||
targetAudience: formTarget,
|
||||
classGroupId: formTarget === 'class' ? formClassId : null,
|
||||
isPinned: formIsPinned,
|
||||
expiresAt: formExpiresAt || null,
|
||||
});
|
||||
setCreateOpen(false);
|
||||
resetForm();
|
||||
loadData();
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setFormSaving(false);
|
||||
}
|
||||
}, [schoolId, formTitle, formContent, formPriority, formType, formTarget, formClassId, formIsPinned, formExpiresAt, loadData]);
|
||||
|
||||
const handleEdit = useCallback(async () => {
|
||||
if (!editId || !formTitle.trim() || !formContent.trim()) return;
|
||||
setFormSaving(true);
|
||||
try {
|
||||
await apiPut(`/api/announcements/${editId}`, {
|
||||
title: formTitle,
|
||||
content: formContent,
|
||||
priority: formPriority,
|
||||
announcementType: formType,
|
||||
targetAudience: formTarget,
|
||||
classGroupId: formTarget === 'class' ? formClassId : null,
|
||||
isPinned: formIsPinned,
|
||||
expiresAt: formExpiresAt || null,
|
||||
});
|
||||
setEditId(null);
|
||||
resetForm();
|
||||
loadData();
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setFormSaving(false);
|
||||
}
|
||||
}, [editId, formTitle, formContent, formPriority, formType, formTarget, formClassId, formIsPinned, formExpiresAt, loadData]);
|
||||
|
||||
const handleDelete = useCallback(async (id: string) => {
|
||||
try {
|
||||
await apiDelete(`/api/announcements/${id}`);
|
||||
setAnnouncements((prev) => prev.filter((a) => a.id !== id));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTogglePin = useCallback(async (id: string, isPinned: boolean) => {
|
||||
try {
|
||||
await apiPut(`/api/announcements/${id}`, { isPinned: !isPinned });
|
||||
setAnnouncements((prev) =>
|
||||
prev.map((a) => a.id === id ? { ...a, isPinned: !isPinned } : a)
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
const openEdit = useCallback((announcement: AnnouncementItem) => {
|
||||
setEditId(announcement.id);
|
||||
setFormTitle(announcement.title);
|
||||
setFormContent(announcement.content);
|
||||
setFormPriority(announcement.priority);
|
||||
setFormType(announcement.announcementType);
|
||||
setFormTarget(announcement.targetAudience);
|
||||
setFormClassId(announcement.classGroupId || '');
|
||||
setFormIsPinned(announcement.isPinned);
|
||||
setFormExpiresAt(announcement.expiresAt ? new Date(announcement.expiresAt).toISOString().split('T')[0] : '');
|
||||
}, []);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setFormTitle('');
|
||||
setFormContent('');
|
||||
setFormPriority('normal');
|
||||
setFormType('general');
|
||||
setFormTarget('all');
|
||||
setFormClassId('');
|
||||
setFormIsPinned(false);
|
||||
setFormExpiresAt('');
|
||||
}, []);
|
||||
|
||||
/* ── Sorted Announcements ───────────────────────────────────── */
|
||||
|
||||
const sortedAnnouncements = useMemo(() => {
|
||||
return [...announcements].sort((a, b) => {
|
||||
if (a.isPinned && !b.isPinned) return -1;
|
||||
if (!a.isPinned && b.isPinned) return 1;
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
});
|
||||
}, [announcements]);
|
||||
|
||||
/* ── Render ─────────────────────────────────────────────────── */
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
|
||||
<span className="text-gray-500">{t('announcements.loading')}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4 md:p-6">
|
||||
{/* Header with gradient banner */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="relative overflow-hidden rounded-2xl bg-gradient-to-r from-emerald-600 via-teal-500 to-emerald-500 p-6 shadow-lg"
|
||||
>
|
||||
{/* Decorative background shapes */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute -top-4 -right-4 w-20 h-20 rounded-full bg-white/10" />
|
||||
<div className="absolute bottom-0 left-1/3 w-16 h-16 rounded-full bg-white/5" />
|
||||
</div>
|
||||
<div className="relative flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
|
||||
<Megaphone className="h-6 w-6" />
|
||||
{t('announcements.title')}
|
||||
</h1>
|
||||
<p className="text-sm text-emerald-100 mt-1">
|
||||
{t('announcements.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadData}
|
||||
className="bg-white/10 hover:bg-white/20 text-white border-white/30"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-1" />
|
||||
{t('action.refresh')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="bg-white/10 hover:bg-white/20 text-white border-white/30"
|
||||
>
|
||||
<Filter className="h-4 w-4 mr-1" />
|
||||
{t('action.filter')}
|
||||
{showFilters ? <ChevronDown className="h-3 w-3 ml-1" /> : <ChevronDown className="h-3 w-3 ml-1" />}
|
||||
</Button>
|
||||
{canCreate && (
|
||||
<Dialog open={createOpen} onOpenChange={(open) => { setCreateOpen(open); if (!open) resetForm(); }}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="bg-white/20 hover:bg-white/30 text-white backdrop-blur-sm border border-white/30 shadow-md">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t('announcements.create')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Megaphone className="h-5 w-5 text-emerald-600" />
|
||||
{t('announcements.create_title')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<AnnouncementForm
|
||||
title={formTitle} setTitle={setFormTitle}
|
||||
content={formContent} setContent={setFormContent}
|
||||
priority={formPriority} setPriority={setFormPriority}
|
||||
type={formType} setType={setFormType}
|
||||
target={formTarget} setTarget={setFormTarget}
|
||||
classId={formClassId} setClassId={setFormClassId}
|
||||
isPinned={formIsPinned} setIsPinned={setFormIsPinned}
|
||||
expiresAt={formExpiresAt} setExpiresAt={setFormExpiresAt}
|
||||
classes={classes}
|
||||
onSave={handleCreate}
|
||||
saving={formSaving}
|
||||
saveLabel={t('action.create')}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Filter Panel */}
|
||||
<AnimatePresence>
|
||||
{showFilters && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<Card className="border-emerald-200/50 dark:border-emerald-900/30">
|
||||
<CardContent className="p-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs text-gray-500 mb-1">{t('announcements.filter_type')}</Label>
|
||||
<Select value={filterType} onValueChange={setFilterType}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('announcements.all_types')}</SelectItem>
|
||||
{ANNOUNCEMENT_TYPES.map((type) => (
|
||||
<SelectItem key={type.key} value={type.key}>{t(type.labelKey)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-gray-500 mb-1">{t('announcements.filter_priority')}</Label>
|
||||
<Select value={filterPriority} onValueChange={setFilterPriority}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('announcements.all_priorities')}</SelectItem>
|
||||
{PRIORITIES.map((p) => (
|
||||
<SelectItem key={p.key} value={p.key}>{t(p.labelKey)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<Dialog open={!!editId} onOpenChange={(open) => { if (!open) { setEditId(null); resetForm(); } }}>
|
||||
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Pencil className="h-5 w-5 text-emerald-600" />
|
||||
{t('announcements.edit_title')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<AnnouncementForm
|
||||
title={formTitle} setTitle={setFormTitle}
|
||||
content={formContent} setContent={setFormContent}
|
||||
priority={formPriority} setPriority={setFormPriority}
|
||||
type={formType} setType={setFormType}
|
||||
target={formTarget} setTarget={setFormTarget}
|
||||
classId={formClassId} setClassId={setFormClassId}
|
||||
isPinned={formIsPinned} setIsPinned={setFormIsPinned}
|
||||
expiresAt={formExpiresAt} setExpiresAt={setFormExpiresAt}
|
||||
classes={classes}
|
||||
onSave={handleEdit}
|
||||
saving={formSaving}
|
||||
saveLabel={t('action.save')}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0 }}>
|
||||
<Card className="border-emerald-200/50 dark:border-emerald-900/30">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center">
|
||||
<Megaphone className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">{announcements.length}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{t('announcements.total')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.05 }}>
|
||||
<Card className="border-rose-200/50 dark:border-rose-900/30">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-rose-100 dark:bg-rose-900/30 flex items-center justify-center">
|
||||
<Pin className="h-5 w-5 text-rose-600 dark:text-rose-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{announcements.filter((a) => a.isPinned).length}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{t('announcements.pinned')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }}>
|
||||
<Card className="border-amber-200/50 dark:border-amber-900/30">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{announcements.filter((a) => a.priority === 'urgent' || a.priority === 'high').length}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{t('announcements.urgent_high')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.15 }}>
|
||||
<Card className="border-teal-200/50 dark:border-teal-900/30">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-teal-100 dark:bg-teal-900/30 flex items-center justify-center">
|
||||
<Eye className="h-5 w-5 text-teal-600 dark:text-teal-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{announcements.filter((a) => !a.isReadByCurrentUser).length}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{t('announcements.unread')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Announcements List */}
|
||||
{sortedAnnouncements.length === 0 ? (
|
||||
<Card className="border-emerald-200/50 dark:border-emerald-900/30">
|
||||
<CardContent className="p-12 text-center">
|
||||
<Megaphone className="h-12 w-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" />
|
||||
<p className="text-gray-500 dark:text-gray-400">{t('announcements.no_announcements')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<AnimatePresence>
|
||||
{sortedAnnouncements.map((announcement, index) => {
|
||||
const typeConfig = getTypeConfig(announcement.announcementType);
|
||||
const priConfig = getPriorityConfig(announcement.priority);
|
||||
const TypeIcon = typeConfig.icon;
|
||||
const isExpanded = expandedId === announcement.id;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={announcement.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ delay: index * 0.03 }}
|
||||
layout
|
||||
>
|
||||
<Card className={`overflow-hidden transition-all hover:shadow-lg backdrop-blur-sm bg-card/80 ${
|
||||
announcement.isPinned
|
||||
? 'border-emerald-300 dark:border-emerald-700 ring-1 ring-emerald-200/50 dark:ring-emerald-800/50'
|
||||
: !announcement.isReadByCurrentUser
|
||||
? 'border-amber-200 dark:border-amber-800 bg-amber-50/20 dark:bg-amber-950/10'
|
||||
: 'border-gray-200 dark:border-gray-800'
|
||||
}`}>
|
||||
{/* Image header area with gradient */}
|
||||
<div className={`h-2 bg-gradient-to-r ${typeConfig.gradient}`} />
|
||||
|
||||
<CardContent className="p-4 sm:p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Type icon */}
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center shrink-0 ${typeConfig.bg}`}>
|
||||
<TypeIcon className={`h-6 w-6 ${typeConfig.text}`} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100">
|
||||
{announcement.title}
|
||||
</h3>
|
||||
{announcement.isPinned && (
|
||||
<motion.div
|
||||
animate={{ rotate: [0, -10, 10, -10, 0] }}
|
||||
transition={{ duration: 2, repeat: Infinity, repeatDelay: 3 }}
|
||||
>
|
||||
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 border-emerald-200 dark:border-emerald-800 text-[10px] shadow-sm">
|
||||
<Pin className="h-3 w-3 mr-0.5" />
|
||||
{t('announcements.pinned')}
|
||||
</Badge>
|
||||
</motion.div>
|
||||
)}
|
||||
<Badge variant="outline" className={`text-[10px] px-1.5 py-0 ${priConfig.badgeColor}`}>
|
||||
{t(priConfig.labelKey)}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400">
|
||||
{t(typeConfig.labelKey)}
|
||||
</Badge>
|
||||
{!announcement.isReadByCurrentUser && (
|
||||
<motion.span
|
||||
className="w-2 h-2 rounded-full bg-emerald-500"
|
||||
animate={{ opacity: [1, 0.3, 1], scale: [1, 1.2, 1] }}
|
||||
transition={{ duration: 1.5, repeat: Infinity }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Author & time */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Avatar className="h-5 w-5">
|
||||
<AvatarFallback className="text-[8px] bg-emerald-100 dark:bg-emerald-900 text-emerald-700 dark:text-emerald-300">
|
||||
{getInitials(announcement.author.firstName, announcement.author.lastName)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{announcement.author.firstName} {announcement.author.lastName}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">·</span>
|
||||
<Clock className="h-3 w-3 text-gray-400" />
|
||||
<span className="text-xs text-gray-400">{formatRelativeTime(announcement.createdAt)}</span>
|
||||
{announcement.targetAudience !== 'all' && (
|
||||
<>
|
||||
<span className="text-xs text-gray-400">·</span>
|
||||
<Users className="h-3 w-3 text-gray-400" />
|
||||
<span className="text-xs text-gray-400">
|
||||
{t(`announcements.target_${announcement.targetAudience}`)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{announcement.classGroup && (
|
||||
<span className="text-xs text-gray-400">
|
||||
({announcement.classGroup.name})
|
||||
</span>
|
||||
)}
|
||||
{announcement.expiresAt && (
|
||||
<>
|
||||
<span className="text-xs text-gray-400">·</span>
|
||||
<Calendar className="h-3 w-3 text-gray-400" />
|
||||
<motion.span
|
||||
className="text-xs text-gray-400 flex items-center gap-1"
|
||||
animate={new Date(announcement.expiresAt) < new Date(Date.now() + 7 * 86400000) ? { opacity: [1, 0.5, 1] } : {}}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
>
|
||||
{t('announcements.expires')} {new Date(announcement.expiresAt).toLocaleDateString()}
|
||||
</motion.span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content (truncated unless expanded) */}
|
||||
<p className={`text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap ${
|
||||
!isExpanded ? 'line-clamp-3' : ''
|
||||
}`}>
|
||||
{announcement.content}
|
||||
</p>
|
||||
|
||||
{announcement.content.length > 200 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setExpandedId(isExpanded ? null : announcement.id)}
|
||||
className="mt-1 text-emerald-600 dark:text-emerald-400 h-7 text-xs"
|
||||
>
|
||||
{isExpanded ? t('announcements.show_less') : t('announcements.show_more')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Read receipts */}
|
||||
{announcement.totalReads !== undefined && announcement.totalReads > 0 && (
|
||||
<div className="flex items-center gap-1 mt-2">
|
||||
<UserCheck className="h-3 w-3 text-emerald-500" />
|
||||
<span className="text-[11px] text-emerald-600 dark:text-emerald-400">
|
||||
{announcement.totalReads} {t('announcements.have_read')}
|
||||
</span>
|
||||
{announcement.reads && announcement.reads.length > 0 && (
|
||||
<div className="flex -space-x-1 ml-1">
|
||||
{announcement.reads.slice(0, 5).map((r) => (
|
||||
<Avatar key={r.id} className="h-4 w-4 border border-white dark:border-gray-800">
|
||||
<AvatarFallback className="text-[6px] bg-emerald-100 dark:bg-emerald-900 text-emerald-700 dark:text-emerald-300">
|
||||
{getInitials(r.user.firstName, r.user.lastName)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
{announcement.reads.length > 5 && (
|
||||
<span className="text-[10px] text-gray-400 ml-1">+{announcement.reads.length - 5}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 mt-3 flex-wrap">
|
||||
{!announcement.isReadByCurrentUser && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleMarkRead(announcement.id)}
|
||||
className="h-7 text-xs text-emerald-600 border-emerald-200 dark:border-emerald-800"
|
||||
>
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
{t('announcements.mark_read')}
|
||||
</Button>
|
||||
)}
|
||||
{canEdit && (isAdmin || announcement.authorId === currentUser?.id) && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleTogglePin(announcement.id, announcement.isPinned)}
|
||||
className="h-7 text-xs text-emerald-600 dark:text-emerald-400"
|
||||
>
|
||||
<Pin className="h-3 w-3 mr-1" />
|
||||
{announcement.isPinned ? t('announcements.unpin') : t('announcements.pin')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openEdit(announcement)}
|
||||
className="h-7 text-xs text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
<Pencil className="h-3 w-3 mr-1" />
|
||||
{t('action.edit')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(announcement.id)}
|
||||
className="h-7 text-xs text-rose-600 dark:text-rose-400"
|
||||
>
|
||||
<Trash2 className="h-3 w-3 mr-1" />
|
||||
{t('action.delete')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Announcement Form Sub-component ───────────────────────────── */
|
||||
|
||||
function AnnouncementForm({
|
||||
title, setTitle,
|
||||
content, setContent,
|
||||
priority, setPriority,
|
||||
type, setType,
|
||||
target, setTarget,
|
||||
classId, setClassId,
|
||||
isPinned, setIsPinned,
|
||||
expiresAt, setExpiresAt,
|
||||
classes,
|
||||
onSave,
|
||||
saving,
|
||||
saveLabel,
|
||||
}: {
|
||||
title: string;
|
||||
setTitle: (v: string) => void;
|
||||
content: string;
|
||||
setContent: (v: string) => void;
|
||||
priority: string;
|
||||
setPriority: (v: string) => void;
|
||||
type: string;
|
||||
setType: (v: string) => void;
|
||||
target: string;
|
||||
setTarget: (v: string) => void;
|
||||
classId: string;
|
||||
setClassId: (v: string) => void;
|
||||
isPinned: boolean;
|
||||
setIsPinned: (v: boolean) => void;
|
||||
expiresAt: string;
|
||||
setExpiresAt: (v: string) => void;
|
||||
classes: ClassGroup[];
|
||||
onSave: () => void;
|
||||
saving: boolean;
|
||||
saveLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-sm font-medium">{t('announcements.field_title')}</Label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={t('announcements.field_title_placeholder')}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-sm font-medium">{t('announcements.field_content')}</Label>
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder={t('announcements.field_content_placeholder')}
|
||||
className="mt-1 min-h-[120px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-sm font-medium">{t('announcements.field_type')}</Label>
|
||||
<Select value={type} onValueChange={setType}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ANNOUNCEMENT_TYPES.map((at) => (
|
||||
<SelectItem key={at.key} value={at.key}>{t(at.labelKey)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-sm font-medium">{t('announcements.field_priority')}</Label>
|
||||
<Select value={priority} onValueChange={setPriority}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PRIORITIES.map((p) => (
|
||||
<SelectItem key={p.key} value={p.key}>{t(p.labelKey)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-sm font-medium">{t('announcements.field_target')}</Label>
|
||||
<Select value={target} onValueChange={setTarget}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TARGET_AUDIENCES.map((a) => (
|
||||
<SelectItem key={a.key} value={a.key}>{t(a.labelKey)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{target === 'class' && (
|
||||
<div>
|
||||
<Label className="text-sm font-medium">{t('announcements.field_class')}</Label>
|
||||
<Select value={classId} onValueChange={setClassId}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{classes.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={isPinned} onCheckedChange={setIsPinned} />
|
||||
<Label className="text-sm">{t('announcements.pin_to_top')}</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-sm font-medium">{t('announcements.field_expiry')}</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={expiresAt}
|
||||
onChange={(e) => setExpiresAt(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={onSave}
|
||||
disabled={saving || !title.trim() || !content.trim()}
|
||||
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
>
|
||||
{saving ? (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
) : (
|
||||
saveLabel
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Target,
|
||||
@@ -51,6 +51,7 @@ import { useAppStore } from '@/lib/store';
|
||||
import { t } from '@/lib/i18n';
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '@/lib/api';
|
||||
import { toast } from 'sonner';
|
||||
import { Radar, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, ResponsiveContainer, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip } from 'recharts';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -737,6 +738,240 @@ export default function StudentPortalView() {
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* ── Quick Action Cards ──────────────────────────────────────────── */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: t('student_portal.quick_practice'), icon: BookOpen, color: 'from-emerald-400 to-teal-500', view: 'subjects' as const },
|
||||
{ label: t('student_portal.quick_homework'), icon: ClipboardCheck, color: 'from-amber-400 to-orange-500', view: 'homework' as const },
|
||||
{ label: t('student_portal.quick_achievements'), icon: Trophy, color: 'from-violet-400 to-purple-500', view: 'student-achievements' as const },
|
||||
{ label: t('student_portal.quick_study'), icon: Clock, color: 'from-sky-400 to-cyan-500', view: 'student-study-planner' as const },
|
||||
].map((action, idx) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<motion.div
|
||||
key={idx}
|
||||
whileHover={{ scale: 1.03, y: -3 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
>
|
||||
<Card
|
||||
className="border-0 shadow-sm rounded-xl overflow-hidden cursor-pointer hover:shadow-md transition-all duration-300"
|
||||
onClick={() => {
|
||||
const store = useAppStore.getState();
|
||||
store.setCurrentView(action.view);
|
||||
}}
|
||||
>
|
||||
<CardContent className="p-4 flex flex-col items-center text-center gap-2">
|
||||
<div className={`flex items-center justify-center w-10 h-10 rounded-xl bg-gradient-to-br ${action.color} text-white shadow-md`}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<p className="text-xs font-semibold text-gray-900 dark:text-gray-100">{action.label}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</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">
|
||||
<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">
|
||||
<Target className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
{t('student_portal.competency_radar')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-2">
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RadarChart data={competencies.map(c => ({
|
||||
subject: c.subject.length > 8 ? c.subject.substring(0, 8) + '…' : c.subject,
|
||||
progress: c.progress,
|
||||
level: c.level * 25,
|
||||
}))}>
|
||||
<PolarGrid stroke="currentColor" className="text-gray-200 dark:text-gray-700" />
|
||||
<PolarAngleAxis dataKey="subject" className="text-[10px] fill-gray-500 dark:fill-gray-400" />
|
||||
<PolarRadiusAxis angle={30} domain={[0, 100]} className="text-[8px]" />
|
||||
<Radar name={t('student_portal.progress_bar')} dataKey="progress" stroke="#10b981" fill="#10b981" fillOpacity={0.2} strokeWidth={2} />
|
||||
<Radar name={t('student_portal.mastery_level')} dataKey="level" stroke="#8b5cf6" fill="#8b5cf6" fillOpacity={0.1} strokeWidth={2} />
|
||||
<RechartsTooltip />
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Grade Trend Chart */}
|
||||
<Card className="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">
|
||||
<TrendingUp className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
{t('student_portal.grade_trend')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-2">
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={[
|
||||
{ month: 'Sep', math: 65, german: 72, english: 58 },
|
||||
{ month: 'Oct', math: 70, german: 75, english: 62 },
|
||||
{ month: 'Nov', math: 68, german: 78, english: 65 },
|
||||
{ month: 'Dec', math: 75, german: 80, english: 70 },
|
||||
{ month: 'Jan', math: 78, german: 82, english: 73 },
|
||||
{ month: 'Feb', math: 82, german: 85, english: 76 },
|
||||
]}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="text-gray-200 dark:text-gray-700" />
|
||||
<XAxis dataKey="month" className="text-[10px] fill-gray-500 dark:fill-gray-400" />
|
||||
<YAxis domain={[0, 100]} className="text-[10px] fill-gray-500 dark:fill-gray-400" />
|
||||
<RechartsTooltip />
|
||||
<Line type="monotone" dataKey="math" stroke="#10b981" strokeWidth={2} dot={{ r: 3 }} name={t('student.notebook_math')} />
|
||||
<Line type="monotone" dataKey="german" stroke="#f59e0b" strokeWidth={2} dot={{ r: 3 }} name={t('student.notebook_german')} />
|
||||
<Line type="monotone" dataKey="english" stroke="#8b5cf6" strokeWidth={2} dot={{ r: 3 }} name={t('student.notebook_english')} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* ── Weekly Streak Heatmap ───────────────────────────────────────── */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card className="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">
|
||||
<Flame className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
{t('student_portal.weekly_heatmap')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-2">
|
||||
{Array.from({ length: 28 }).map((_, i) => {
|
||||
const daysAgo = 27 - i;
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - daysAgo);
|
||||
const dayName = date.toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', { weekday: 'narrow' });
|
||||
const intensity = Math.random();
|
||||
const isToday = daysAgo === 0;
|
||||
const bgColor = intensity > 0.7
|
||||
? 'bg-emerald-500 dark:bg-emerald-600'
|
||||
: intensity > 0.4
|
||||
? 'bg-emerald-300 dark:bg-emerald-700'
|
||||
: intensity > 0.15
|
||||
? 'bg-emerald-100 dark:bg-emerald-900'
|
||||
: 'bg-gray-100 dark:bg-gray-800';
|
||||
return (
|
||||
<div key={i} className="flex flex-col items-center gap-1 shrink-0">
|
||||
<span className="text-[8px] text-gray-400 dark:text-gray-500">{i % 7 === 0 ? dayName : ''}</span>
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: i * 0.02, type: 'spring', stiffness: 200 }}
|
||||
className={`w-6 h-6 rounded-md ${bgColor} ${isToday ? 'ring-2 ring-emerald-400 dark:ring-emerald-500' : ''} transition-colors`}
|
||||
title={date.toLocaleDateString()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className="text-[10px] text-gray-400 dark:text-gray-500">{t('student_portal.less')}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 rounded-sm bg-gray-100 dark:bg-gray-800" />
|
||||
<div className="w-3 h-3 rounded-sm bg-emerald-100 dark:bg-emerald-900" />
|
||||
<div className="w-3 h-3 rounded-sm bg-emerald-300 dark:bg-emerald-700" />
|
||||
<div className="w-3 h-3 rounded-sm bg-emerald-500 dark:bg-emerald-600" />
|
||||
</div>
|
||||
<span className="text-[10px] text-gray-400 dark:text-gray-500">{t('student_portal.more')}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* ── Daily Challenge Widget ──────────────────────────────────────── */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card className="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">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
{t('student_portal.daily_challenge')}
|
||||
<Badge className="text-[10px] bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
|
||||
+50 XP
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<CircularProgress value={60} size={56} strokeWidth={4} colorClass="stroke-amber-400">
|
||||
<span className="text-xs font-bold text-gray-900 dark:text-gray-100">60%</span>
|
||||
</CircularProgress>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">{t('student_portal.challenge_title')}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{t('student_portal.challenge_description')}</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Button size="sm" className="h-7 text-xs bg-gradient-to-r from-amber-500 to-orange-500 text-white" onClick={() => {
|
||||
const store = useAppStore.getState();
|
||||
store.setCurrentView('subjects');
|
||||
}}>
|
||||
{t('student_portal.start_challenge')}
|
||||
<ChevronRight className="h-3 w-3 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* ── Learning Time Tracker ───────────────────────────────────────── */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card className="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">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
{t('student_portal.learning_time')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="grid grid-cols-3 gap-3 mb-4">
|
||||
<div className="text-center p-2 rounded-xl bg-gray-50 dark:bg-gray-800/50">
|
||||
<p className="text-lg font-bold text-gray-900 dark:text-gray-100">3.5h</p>
|
||||
<p className="text-[10px] text-gray-500 dark:text-gray-400">{t('student_portal.today')}</p>
|
||||
</div>
|
||||
<div className="text-center p-2 rounded-xl bg-gray-50 dark:bg-gray-800/50">
|
||||
<p className="text-lg font-bold text-gray-900 dark:text-gray-100">12.5h</p>
|
||||
<p className="text-[10px] text-gray-500 dark:text-gray-400">{t('student_portal.this_week')}</p>
|
||||
</div>
|
||||
<div className="text-center p-2 rounded-xl bg-gray-50 dark:bg-gray-800/50">
|
||||
<p className="text-lg font-bold text-gray-900 dark:text-gray-100">45h</p>
|
||||
<p className="text-[10px] text-gray-500 dark:text-gray-400">{t('student_portal.this_month')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">{t('student_portal.weekly_goal')}</span>
|
||||
<div className="flex-1">
|
||||
<Progress value={62} className="h-2" />
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-gray-700 dark:text-gray-300">12.5/20h</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* ── Main Tabs ──────────────────────────────────────────────────── */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -2702,6 +2702,43 @@ export function deleteNotifications(ids?: string[]): Promise<{ success: boolean
|
||||
return apiDelete<{ success: boolean }>(`/api/notifications${ids ? `?ids=${ids.join(',')}` : ''}`);
|
||||
}
|
||||
|
||||
/* ── Notification Preferences ──────────────────────────────────── */
|
||||
|
||||
export interface NotificationPreferencesData {
|
||||
id: string;
|
||||
userId: string;
|
||||
schoolId: string;
|
||||
academicEnabled: boolean;
|
||||
behavioralEnabled: boolean;
|
||||
administrativeEnabled: boolean;
|
||||
calendarEnabled: boolean;
|
||||
communicationEnabled: boolean;
|
||||
systemEnabled: boolean;
|
||||
quietHoursStart: string | null;
|
||||
quietHoursEnd: string | null;
|
||||
quietHoursEnabled: boolean;
|
||||
emailDigestEnabled: boolean;
|
||||
emailDigestFrequency: string;
|
||||
}
|
||||
|
||||
export function fetchNotificationPreferences(): Promise<NotificationPreferencesData> {
|
||||
return apiGet<NotificationPreferencesData>('/api/notifications/preferences');
|
||||
}
|
||||
|
||||
export function updateNotificationPreferences(data: Partial<NotificationPreferencesData>): Promise<NotificationPreferencesData> {
|
||||
return apiPut<NotificationPreferencesData>('/api/notifications/preferences', data);
|
||||
}
|
||||
|
||||
/* ── Announcement Read Receipts ────────────────────────────────── */
|
||||
|
||||
export function markAnnouncementRead(announcementId: string): Promise<{ readReceipt: { id: string; announcementId: string; userId: string; readAt: string } }> {
|
||||
return apiPost('/api/announcements/' + announcementId + '/read', {});
|
||||
}
|
||||
|
||||
export function getAnnouncementReads(announcementId: string): Promise<{ reads: Array<{ id: string; userId: string; readAt: string; user: { id: string; firstName: string; lastName: string } }>; totalReads: number }> {
|
||||
return apiGet('/api/announcements/' + announcementId + '/read');
|
||||
}
|
||||
|
||||
/* ── Batch Delete & Reorder ─────────────────────────────────────────── */
|
||||
|
||||
export async function deleteBatchProgressEntries(ids: string[]): Promise<void> {
|
||||
|
||||
+772
@@ -4904,6 +4904,392 @@ const de: TranslationDict = {
|
||||
'portal.study_progress': 'Lernfortschritt',
|
||||
'portal.focus_areas': 'Schwerpunkte',
|
||||
'portal.motivation_message': 'Weiter so! Du bist auf dem richtigen Weg.',
|
||||
|
||||
// Exam Calendar
|
||||
'nav.exam_calendar': 'Prüfungsplan',
|
||||
'exam_calendar.title': 'Prüfungsplan',
|
||||
'exam_calendar.subtitle': 'Planen und verwalten Sie Prüfungen und Klassenarbeiten',
|
||||
'exam_calendar.create_exam': 'Prüfung erstellen',
|
||||
'exam_calendar.create_exam_desc': 'Planen Sie eine neue Prüfung oder Klassenarbeit',
|
||||
'exam_calendar.edit_exam': 'Prüfung bearbeiten',
|
||||
'exam_calendar.edit_exam_desc': 'Bearbeiten Sie die Prüfungsdetails',
|
||||
'exam_calendar.exam_title': 'Titel',
|
||||
'exam_calendar.exam_title_placeholder': 'z.B. Klassenarbeit 3 - Bruchrechnung',
|
||||
'exam_calendar.exam_date': 'Datum',
|
||||
'exam_calendar.start_time': 'Beginn',
|
||||
'exam_calendar.end_time': 'Ende',
|
||||
'exam_calendar.subject': 'Fach',
|
||||
'exam_calendar.select_subject': 'Fach wählen',
|
||||
'exam_calendar.class': 'Klasse',
|
||||
'exam_calendar.select_class': 'Klasse wählen',
|
||||
'exam_calendar.room': 'Raum',
|
||||
'exam_calendar.room_placeholder': 'z.B. Raum 204',
|
||||
'exam_calendar.topics': 'Themen',
|
||||
'exam_calendar.topics_placeholder': 'Durch Komma getrennt, z.B. Brüche, Dezimalzahlen',
|
||||
'exam_calendar.weight': 'Gewichtung',
|
||||
'exam_calendar.status': 'Status',
|
||||
'exam_calendar.status_planned': 'Geplant',
|
||||
'exam_calendar.status_confirmed': 'Bestätigt',
|
||||
'exam_calendar.status_completed': 'Abgeschlossen',
|
||||
'exam_calendar.status_cancelled': 'Abgesagt',
|
||||
'exam_calendar.notes': 'Notizen',
|
||||
'exam_calendar.notes_placeholder': 'Zusätzliche Hinweise für die Prüfung...',
|
||||
'exam_calendar.time': 'Uhrzeit',
|
||||
'exam_calendar.all_day': 'Ganztägig',
|
||||
'exam_calendar.teacher': 'Lehrkraft',
|
||||
'exam_calendar.total_exams': 'Prüfungen gesamt',
|
||||
'exam_calendar.upcoming_exams': 'Bevorstehende Prüfungen',
|
||||
'exam_calendar.confirmed_exams': 'Bestätigte Prüfungen',
|
||||
'exam_calendar.two_week_warning': 'In 2 Wochen',
|
||||
'exam_calendar.student_upcoming': 'Bevorstehende Prüfungen',
|
||||
'exam_calendar.ai_tests_available': 'KI-Tests verfügbar',
|
||||
'exam_calendar.two_week_banner_title': 'Prüfungen in den nächsten 2 Wochen',
|
||||
'exam_calendar.ai_test_available': 'KI-Übungstest verfügbar',
|
||||
'exam_calendar.search_placeholder': 'Prüfungen suchen...',
|
||||
'exam_calendar.filter_class': 'Klasse',
|
||||
'exam_calendar.filter_subject': 'Fach',
|
||||
'exam_calendar.filter_status': 'Status',
|
||||
'exam_calendar.all_classes': 'Alle Klassen',
|
||||
'exam_calendar.all_subjects': 'Alle Fächer',
|
||||
'exam_calendar.all_statuses': 'Alle Status',
|
||||
'exam_calendar.view_calendar': 'Kalender',
|
||||
'exam_calendar.view_list': 'Liste',
|
||||
'exam_calendar.more': 'weitere',
|
||||
'exam_calendar.no_exams': 'Keine Prüfungen gefunden',
|
||||
'exam_calendar.exams_by_subject': 'Prüfungen nach Fach',
|
||||
'exam_calendar.exams_by_class': 'Prüfungen nach Klasse',
|
||||
'exam_calendar.error_fetch': 'Fehler beim Laden der Prüfungen',
|
||||
'exam_calendar.error_required_fields': 'Bitte füllen Sie alle Pflichtfelder aus',
|
||||
'exam_calendar.error_create': 'Fehler beim Erstellen der Prüfung',
|
||||
'exam_calendar.error_update': 'Fehler beim Aktualisieren der Prüfung',
|
||||
'exam_calendar.error_delete': 'Fehler beim Löschen der Prüfung',
|
||||
'exam_calendar.exam_created': 'Prüfung erfolgreich erstellt',
|
||||
'exam_calendar.exam_updated': 'Prüfung erfolgreich aktualisiert',
|
||||
'exam_calendar.exam_deleted': 'Prüfung erfolgreich gelöscht',
|
||||
'exam_calendar.creating': 'Wird erstellt...',
|
||||
'exam_calendar.saving': 'Wird gespeichert...',
|
||||
'exam_calendar.delete_confirm_title': 'Prüfung löschen',
|
||||
'exam_calendar.delete_confirm_desc': 'Möchten Sie die Prüfung "{title}" wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.',
|
||||
|
||||
// Tablet Grading
|
||||
'nav.tablet_grading': 'Tablet-Benotung',
|
||||
'tablet_grading.loading': 'Benotungsansicht wird geladen...',
|
||||
'tablet_grading.select_class': 'Klasse auswählen',
|
||||
'tablet_grading.select_assessment': 'Leistungüberprüfung auswählen',
|
||||
'tablet_grading.search_student': 'Schüler suchen...',
|
||||
'tablet_grading.all': 'Alle',
|
||||
'tablet_grading.submitted': 'Abgegeben',
|
||||
'tablet_grading.graded': 'Benotet',
|
||||
'tablet_grading.pending': 'Ausstehend',
|
||||
'tablet_grading.no_students': 'Keine Schüler gefunden',
|
||||
'tablet_grading.students': 'Schüler',
|
||||
'tablet_grading.select_student_hint': 'Wählen Sie einen Schüler aus, um mit der Benotung zu beginnen',
|
||||
'tablet_grading.quick_grade': 'Schnellbenotung (Deutsche Notenskala)',
|
||||
'tablet_grading.score': 'Punktzahl',
|
||||
'tablet_grading.teacher_notes': 'Lehrernotizen',
|
||||
'tablet_grading.notes_placeholder': 'Notizen zur Arbeit dieses Schülers...',
|
||||
'tablet_grading.ai_suggestion': 'KI-Vorschlag',
|
||||
'tablet_grading.suggested_grade': 'Vorgeschlagene Note',
|
||||
'tablet_grading.current_grade': 'Aktuelle Note',
|
||||
'tablet_grading.accept': 'Akzeptieren',
|
||||
'tablet_grading.reject': 'Ablehnen',
|
||||
'tablet_grading.suggestion_accepted': 'Vorschlag akzeptiert',
|
||||
'tablet_grading.suggestion_rejected': 'Vorschlag abgelehnt',
|
||||
'tablet_grading.save_grade': 'Note speichern',
|
||||
'tablet_grading.saved': 'Anmerkungen gespeichert',
|
||||
'tablet_grading.save_error': 'Fehler beim Speichern der Anmerkungen',
|
||||
'tablet_grading.grade_saved': 'Note gespeichert',
|
||||
'tablet_grading.grade_error': 'Fehler beim Speichern der Note',
|
||||
'tablet_grading.ai_audit': 'KI-Prüfung',
|
||||
'tablet_grading.ai_results': 'KI-Ergebnisse',
|
||||
'tablet_grading.ai_review_complete': 'KI-Prüfung abgeschlossen',
|
||||
'tablet_grading.ai_review_error': 'Fehler bei der KI-Prüfung',
|
||||
'tablet_grading.ai_audit_title': 'KI-Benotungsprüfung',
|
||||
'tablet_grading.analyzing': 'Analysiere...',
|
||||
'tablet_grading.trigger_audit': 'KI-Prüfung starten',
|
||||
'tablet_grading.latest_review': 'Neueste Prüfung',
|
||||
'tablet_grading.reasoning': 'Begründung',
|
||||
'tablet_grading.discrepancies_found': 'Gefundene Unstimmigkeiten',
|
||||
'tablet_grading.discrepancy': 'Unstimmigkeit',
|
||||
'tablet_grading.consistent': 'Konsistent',
|
||||
'tablet_grading.discrepancies': 'Unstimmigkeiten',
|
||||
'tablet_grading.review_history': 'Prüfungsverlauf',
|
||||
'tablet_grading.suggested': 'Vorgeschlagen',
|
||||
'tablet_grading.no_reviews': 'Noch keine KI-Prüfungen. Starten Sie eine Prüfung.',
|
||||
'tablet_grading.student_view': 'Schüleransicht — Nur Lesen',
|
||||
'tablet_grading.admin_view': 'Admin-Ansicht',
|
||||
'tablet_grading.enter_text': 'Text eingeben...',
|
||||
|
||||
// Notification Center
|
||||
'nav.notification_center': 'Benachrichtigungen',
|
||||
'nav.notification-center': 'Benachrichtigungen',
|
||||
'nav.announcements': 'Ankündigungen',
|
||||
'nav.exam-calendar': 'Prüfungsplan',
|
||||
'nav.tablet-grading': 'Tablet-Benotung',
|
||||
'nav.student-achievements': 'Erfolge',
|
||||
'nav.student-study-planner': 'Lernplaner',
|
||||
'notif_center.title': 'Benachrichtigungscenter',
|
||||
'notif_center.subtitle': 'Alle Benachrichtigungen und Updates an einem Ort',
|
||||
'notif_center.loading': 'Laden...',
|
||||
'notif_center.mark_all_read': 'Alle als gelesen markieren',
|
||||
'notif_center.preferences': 'Einstellungen',
|
||||
'notif_center.preferences_title': 'Benachrichtigungseinstellungen',
|
||||
'notif_center.category_toggles': 'Kategorien aktivieren/deaktivieren',
|
||||
'notif_center.quiet_hours': 'Ruhezeiten',
|
||||
'notif_center.email_digest': 'E-Mail-Zusammenfassung',
|
||||
'notif_center.digest_daily': 'Täglich',
|
||||
'notif_center.digest_weekly': 'Wöchentlich',
|
||||
'notif_center.category_academic': 'Akademisch',
|
||||
'notif_center.category_behavioral': 'Verhalten',
|
||||
'notif_center.category_administrative': 'Verwaltung',
|
||||
'notif_center.category_calendar': 'Kalender',
|
||||
'notif_center.category_communication': 'Kommunikation',
|
||||
'notif_center.category_system': 'System',
|
||||
'notif_center.priority_urgent': 'Dringend',
|
||||
'notif_center.priority_high': 'Hoch',
|
||||
'notif_center.priority_normal': 'Normal',
|
||||
'notif_center.priority_low': 'Niedrig',
|
||||
'notif_center.just_now': 'Gerade eben',
|
||||
'notif_center.minutes_ago': 'vor {n} Minuten',
|
||||
'notif_center.hours_ago': 'vor {n} Stunden',
|
||||
'notif_center.days_ago': 'vor {n} Tagen',
|
||||
'notif_center.total': 'Gesamt',
|
||||
'notif_center.unread': 'Ungelesen',
|
||||
'notif_center.urgent_high': 'Dringend/Hoch',
|
||||
'notif_center.archived': 'Archiviert',
|
||||
'notif_center.tab_all': 'Alle',
|
||||
'notif_center.tab_unread': 'Ungelesen',
|
||||
'notif_center.tab_archived': 'Archiviert',
|
||||
'notif_center.filter_category': 'Kategorie',
|
||||
'notif_center.filter_priority': 'Priorität',
|
||||
'notif_center.filter_read': 'Status',
|
||||
'notif_center.all_categories': 'Alle Kategorien',
|
||||
'notif_center.all_priorities': 'Alle Prioritäten',
|
||||
'notif_center.all_status': 'Alle Status',
|
||||
'notif_center.unread_only': 'Nur ungelesen',
|
||||
'notif_center.read_only': 'Nur gelesen',
|
||||
'notif_center.no_notifications': 'Keine Benachrichtigungen vorhanden',
|
||||
'notif_center.mark_read': 'Als gelesen markieren',
|
||||
'notif_center.archive': 'Archivieren',
|
||||
'notif_center.selected': 'ausgewählt',
|
||||
'notif_center.go_to': 'Gehe zu',
|
||||
'notif_center.by_category': 'Nach Kategorie',
|
||||
'notif_center.distribution': 'Verteilung',
|
||||
|
||||
// Announcements
|
||||
'announcements.title': 'Schulankündigungen',
|
||||
'announcements.subtitle': 'Offizielle Ankündigungen und Informationen der Schule',
|
||||
'announcements.loading': 'Laden...',
|
||||
'announcements.create': 'Ankündigung erstellen',
|
||||
'announcements.create_title': 'Neue Ankündigung',
|
||||
'announcements.edit_title': 'Ankündigung bearbeiten',
|
||||
'announcements.total': 'Gesamt',
|
||||
'announcements.pinned': 'Angeheftet',
|
||||
'announcements.urgent_high': 'Dringend/Hoch',
|
||||
'announcements.unread': 'Ungelesen',
|
||||
'announcements.no_announcements': 'Keine Ankündigungen vorhanden',
|
||||
'announcements.mark_read': 'Als gelesen markieren',
|
||||
'announcements.pin': 'Anheften',
|
||||
'announcements.unpin': 'Loslösen',
|
||||
'announcements.show_more': 'Mehr anzeigen',
|
||||
'announcements.show_less': 'Weniger anzeigen',
|
||||
'announcements.have_read': 'gelesen',
|
||||
'announcements.expires': 'Läuft ab am',
|
||||
'announcements.just_now': 'Gerade eben',
|
||||
'announcements.minutes_ago': 'vor {n} Minuten',
|
||||
'announcements.hours_ago': 'vor {n} Stunden',
|
||||
'announcements.days_ago': 'vor {n} Tagen',
|
||||
'announcements.type_general': 'Allgemein',
|
||||
'announcements.type_urgent': 'Dringend',
|
||||
'announcements.type_event': 'Veranstaltung',
|
||||
'announcements.type_holiday': 'Feiertag',
|
||||
'announcements.type_exam': 'Prüfung',
|
||||
'announcements.type_deadline': 'Frist',
|
||||
'announcements.priority_low': 'Niedrig',
|
||||
'announcements.priority_normal': 'Normal',
|
||||
'announcements.priority_high': 'Hoch',
|
||||
'announcements.priority_urgent': 'Dringend',
|
||||
'announcements.target_all': 'Alle',
|
||||
'announcements.target_teachers': 'Lehrkräfte',
|
||||
'announcements.target_students': 'Schüler',
|
||||
'announcements.target_parents': 'Eltern',
|
||||
'announcements.target_class': 'Klasse',
|
||||
'announcements.filter_type': 'Typ',
|
||||
'announcements.filter_priority': 'Priorität',
|
||||
'announcements.all_types': 'Alle Typen',
|
||||
'announcements.all_priorities': 'Alle Prioritäten',
|
||||
'announcements.field_title': 'Titel',
|
||||
'announcements.field_title_placeholder': 'Titel der Ankündigung',
|
||||
'announcements.field_content': 'Inhalt',
|
||||
'announcements.field_content_placeholder': 'Inhalt der Ankündigung...',
|
||||
'announcements.field_type': 'Typ',
|
||||
'announcements.field_priority': 'Priorität',
|
||||
'announcements.field_target': 'Zielgruppe',
|
||||
'announcements.field_class': 'Klasse',
|
||||
'announcements.field_expiry': 'Ablaufdatum',
|
||||
'announcements.pin_to_top': 'Oben anheften',
|
||||
|
||||
// Student Portal Enhancements (DE)
|
||||
'student_portal.quick_practice': 'Ueben starten',
|
||||
'student_portal.quick_homework': 'Hausaufgaben',
|
||||
'student_portal.quick_achievements': 'Erfolge',
|
||||
'student_portal.quick_study': 'Lernplaner',
|
||||
'student_portal.competency_radar': 'Kompetenz-Radar',
|
||||
'student_portal.grade_trend': 'Notenverlauf',
|
||||
'student_portal.weekly_heatmap': 'Woechentliche Aktivitaet',
|
||||
'student_portal.less': 'Weniger',
|
||||
'student_portal.more': 'Mehr',
|
||||
'student_portal.daily_challenge': 'Tagesherausforderung',
|
||||
'student_portal.challenge_title': 'Lose 5 Uebungen',
|
||||
'student_portal.challenge_description': 'Schliesse heute 5 Uebungen ab, um XP zu verdienen!',
|
||||
'student_portal.start_challenge': 'Herausforderung starten',
|
||||
'student_portal.learning_time': 'Lernzeit',
|
||||
'student_portal.today': 'Heute',
|
||||
'student_portal.this_week': 'Diese Woche',
|
||||
'student_portal.this_month': 'Dieser Monat',
|
||||
'student_portal.weekly_goal': 'Wochenziel',
|
||||
|
||||
// Achievements (DE)
|
||||
'achievements.title': 'Erfolge',
|
||||
'achievements.subtitle': 'Sammle Abzeichen und steigere dein Level!',
|
||||
'achievements.badges': 'Abzeichen',
|
||||
'achievements.leaderboard': 'Rangliste',
|
||||
'achievements.challenges': 'Herausforderungen',
|
||||
'achievements.badges_earned': 'Abzeichen verdient',
|
||||
'achievements.badges_locked': 'Abzeichen gesperrt',
|
||||
'achievements.total_xp': 'Gesamt-XP',
|
||||
'achievements.current_level': 'Aktuelle Stufe',
|
||||
'achievements.xp_progress': 'XP-Fortschritt',
|
||||
'achievements.next_level': 'Naechste Stufe',
|
||||
'achievements.category_progress': 'Kategorie-Fortschritt',
|
||||
'achievements.earned_badges': 'Verdiente Abzeichen',
|
||||
'achievements.locked_badges': 'Gesperrte Abzeichen',
|
||||
'achievements.class_leaderboard': 'Klassenrangliste',
|
||||
'achievements.you': 'Du',
|
||||
'achievements.share': 'Teilen',
|
||||
'achievements.share_with_parents': 'Mit Eltern teilen',
|
||||
'achievements.share_description': 'Teile diesen Erfolg mit deinen Eltern',
|
||||
'achievements.share_success': 'Erfolg geteilt!',
|
||||
'achievements.daily_challenge': 'Tagesherausforderung',
|
||||
'achievements.daily_challenge_1': 'Lose 3 Uebungen',
|
||||
'achievements.daily_challenge_1_desc': 'Schliesse 3 Uebungen in einem Fach ab',
|
||||
'achievements.daily_challenge_2': 'Lerne 15 Minuten',
|
||||
'achievements.daily_challenge_2_desc': 'Verbringe 15 Minuten mit dem Lernplaner',
|
||||
'achievements.daily_challenge_3': 'Frage einen Klassenkameraden',
|
||||
'achievements.daily_challenge_3_desc': 'Hilf einem Mitschueler bei einer Frage',
|
||||
'achievements.weekly_challenge': 'Wochenaufgabe',
|
||||
'achievements.weekly_challenge_title': 'Lerne 5 Stunden',
|
||||
'achievements.weekly_challenge_desc': 'Verbringe diese Woche mindestens 5 Stunden beim Lernen',
|
||||
'achievements.completed': 'Abgeschlossen',
|
||||
'achievements.level': 'Stufe',
|
||||
|
||||
// Study Planner (DE)
|
||||
'study_planner.title': 'Lernplaner',
|
||||
'study_planner.subtitle': 'Plane deine Lernzeit effektiv!',
|
||||
'study_planner.weekly_minutes': 'Minuten diese Woche',
|
||||
'study_planner.sessions_this_week': 'Sitzungen diese Woche',
|
||||
'study_planner.active_plans': 'Aktive Plaene',
|
||||
'study_planner.streak': 'Strich',
|
||||
'study_planner.days': 'Tage',
|
||||
'study_planner.subject_allocation': 'Fachverteilung',
|
||||
'study_planner.weekly_schedule': 'Wochenplan',
|
||||
'study_planner.timer': 'Timer',
|
||||
'study_planner.ai_plan': 'KI-Plan',
|
||||
'study_planner.pomodoro_timer': 'Pomodoro-Timer',
|
||||
'study_planner.pomodoros': 'Pomodoros',
|
||||
'study_planner.focus': 'Fokus',
|
||||
'study_planner.break': 'Pause',
|
||||
'study_planner.start': 'Starten',
|
||||
'study_planner.pause': 'Pause',
|
||||
'study_planner.subject': 'Fach',
|
||||
'study_planner.select_subject': 'Fach waehlen',
|
||||
'study_planner.pomodoro_complete': 'Pomodoro abgeschlossen! ({count} gesamt)',
|
||||
'study_planner.break_over': 'Pause vorbei! Weiter gehts!',
|
||||
'study_planner.exam_checklist': 'Pruefungsvorbereitung',
|
||||
'study_planner.checklist_review_notes': 'Notizen wiederholen',
|
||||
'study_planner.checklist_practice_problems': 'Uebungsaufgaben loesen',
|
||||
'study_planner.checklist_create_summary': 'Zusammenfassung erstellen',
|
||||
'study_planner.checklist_teach_someone': 'Jemandem erklaeren',
|
||||
'study_planner.checklist_take_breaks': 'Pausen machen',
|
||||
'study_planner.ai_recommendation': 'KI-Lernempfehlung',
|
||||
'study_planner.ai_recommendation_desc': 'Basierend auf deinen Faechern und Schwachbereichen',
|
||||
'study_planner.weak_areas': 'Schwachbereiche',
|
||||
'study_planner.add_plan': 'Plan hinzufuegen',
|
||||
'study_planner.edit_plan': 'Plan bearbeiten',
|
||||
'study_planner.edit_plan_desc': 'Aendere deinen Lernplan',
|
||||
'study_planner.add_plan_desc': 'Erstelle einen neuen Lernplan',
|
||||
'study_planner.plan_title': 'Titel',
|
||||
'study_planner.plan_title_placeholder': 'z.B. Mathe ueben',
|
||||
'study_planner.plan_description': 'Beschreibung',
|
||||
'study_planner.plan_description_placeholder': 'z.B. Algebra Uebungen',
|
||||
'study_planner.day': 'Tag',
|
||||
'study_planner.start_time': 'Startzeit',
|
||||
'study_planner.duration': 'Dauer',
|
||||
'study_planner.priority': 'Prioritaet',
|
||||
'study_planner.low': 'Niedrig',
|
||||
'study_planner.medium': 'Mittel',
|
||||
'study_planner.high': 'Hoch',
|
||||
'study_planner.plan_created': 'Plan erstellt!',
|
||||
'study_planner.plan_updated': 'Plan aktualisiert!',
|
||||
'study_planner.plan_deleted': 'Plan geloescht!',
|
||||
'study_planner.title_required': 'Titel ist erforderlich',
|
||||
'study_planner.quick_access': 'Schnellzugriff',
|
||||
'study_planner.notebooks': 'Hefte',
|
||||
'study_planner.subjects': 'Faecher',
|
||||
'study_planner.homework': 'Hausaufgaben',
|
||||
'study_planner.resources': 'Ressourcen',
|
||||
'study_planner.monday': 'Montag',
|
||||
'study_planner.tuesday': 'Dienstag',
|
||||
'study_planner.wednesday': 'Mittwoch',
|
||||
'study_planner.thursday': 'Donnerstag',
|
||||
'study_planner.friday': 'Freitag',
|
||||
'study_planner.review': 'Wiederholung',
|
||||
'study_planner.vocabulary': 'Vokabeln',
|
||||
'study_planner.practice': 'Uebung',
|
||||
'study_planner.writing': 'Schreiben',
|
||||
'study_planner.add': 'Hinzufuegen',
|
||||
'study_planner.history': 'Verlauf',
|
||||
'study_planner.session_history': 'Sitzungsverlauf',
|
||||
'study_planner.no_sessions': 'Noch keine Sitzungen',
|
||||
'study_planner.general_study': 'Allgemeines Lernen',
|
||||
'study_planner.free_study': 'Freies Lernen',
|
||||
'study_planner.completed': 'Abgeschlossen',
|
||||
'study_planner.no_plans': 'Keine Plaene fuer diesen Tag',
|
||||
'study_planner.sessions': 'Sitzungen',
|
||||
'study_planner.rate_focus': 'Fokus bewerten',
|
||||
'study_planner.rate_focus_desc': 'Bewerte deinen Fokus fuer diese {duration} min Sitzung',
|
||||
'study_planner.focus_excellent': 'Ausgezeichneter Fokus',
|
||||
'study_planner.focus_good': 'Guter Fokus',
|
||||
'study_planner.focus_ok': 'Mittelmassiger Fokus',
|
||||
'study_planner.focus_low': 'Niedriger Fokus',
|
||||
'study_planner.session_notes': 'Sitzungsnotizen',
|
||||
'study_planner.session_notes_placeholder': 'Was hast du gelernt?',
|
||||
'study_planner.save_session': 'Sitzung speichern',
|
||||
'study_planner.session_saved': 'Sitzung gespeichert!',
|
||||
'study_planner.suggested_plan': 'Vorgeschlagener Plan',
|
||||
'study_planner.all_ready': 'Bereit fuer die Pruefung!',
|
||||
'study_planner.all_ready_desc': 'Alle Vorbereitungsaufgaben erledigt. Viel Erfolg!',
|
||||
'achievements.all_categories': 'Alle Kategorien',
|
||||
'achievements.badge_detail': 'Abzeichen-Details',
|
||||
'achievements.badge_locked': 'Abzeichen gesperrt',
|
||||
'achievements.badge_detail_desc': 'Details zu diesem Abzeichen',
|
||||
'achievements.earned_on': 'Verdient am',
|
||||
'achievements.progress': 'Fortschritt',
|
||||
'achievements.auto_badge': 'Automatisch',
|
||||
'achievements.manual_badge': 'Manuell',
|
||||
'achievements.recently_earned': 'Kuerzlich verdient',
|
||||
'achievements.show_on_leaderboard': 'Sichtbar in Rangliste',
|
||||
'achievements.leaderboard_opt_out': 'Du hast die Rangliste verlassen',
|
||||
'achievements.join_leaderboard': 'Rangliste beitreten',
|
||||
'achievements.leaderboard_joined': 'Du bist jetzt in der Rangliste sichtbar!',
|
||||
'achievements.leaderboard_left': 'Du bist jetzt in der Rangliste unsichtbar',
|
||||
'achievements.streaks': 'Lernserien',
|
||||
'achievements.current_streak': 'Aktuelle Serie',
|
||||
'achievements.longest_streak': 'Laengste Serie',
|
||||
'achievements.total_badges': 'Abzeichen gesamt',
|
||||
'achievements.to_next_level': 'bis zur naechsten Stufe',
|
||||
'achievements.time_remaining': 'Verbleibende Zeit',
|
||||
'achievements.days': 'Tage',
|
||||
};
|
||||
|
||||
const en: TranslationDict = {
|
||||
@@ -9806,6 +10192,392 @@ const en: TranslationDict = {
|
||||
'portal.study_progress': 'Study Progress',
|
||||
'portal.focus_areas': 'Focus Areas',
|
||||
'portal.motivation_message': 'Keep going! You are on the right track.',
|
||||
|
||||
// Exam Calendar
|
||||
'nav.exam_calendar': 'Exam Calendar',
|
||||
'exam_calendar.title': 'Exam Calendar',
|
||||
'exam_calendar.subtitle': 'Plan and manage exams and assessments',
|
||||
'exam_calendar.create_exam': 'Create Exam',
|
||||
'exam_calendar.create_exam_desc': 'Plan a new exam or assessment',
|
||||
'exam_calendar.edit_exam': 'Edit Exam',
|
||||
'exam_calendar.edit_exam_desc': 'Edit the exam details',
|
||||
'exam_calendar.exam_title': 'Title',
|
||||
'exam_calendar.exam_title_placeholder': 'e.g. Test 3 - Fractions',
|
||||
'exam_calendar.exam_date': 'Date',
|
||||
'exam_calendar.start_time': 'Start Time',
|
||||
'exam_calendar.end_time': 'End Time',
|
||||
'exam_calendar.subject': 'Subject',
|
||||
'exam_calendar.select_subject': 'Select subject',
|
||||
'exam_calendar.class': 'Class',
|
||||
'exam_calendar.select_class': 'Select class',
|
||||
'exam_calendar.room': 'Room',
|
||||
'exam_calendar.room_placeholder': 'e.g. Room 204',
|
||||
'exam_calendar.topics': 'Topics',
|
||||
'exam_calendar.topics_placeholder': 'Comma separated, e.g. Fractions, Decimals',
|
||||
'exam_calendar.weight': 'Weight',
|
||||
'exam_calendar.status': 'Status',
|
||||
'exam_calendar.status_planned': 'Planned',
|
||||
'exam_calendar.status_confirmed': 'Confirmed',
|
||||
'exam_calendar.status_completed': 'Completed',
|
||||
'exam_calendar.status_cancelled': 'Cancelled',
|
||||
'exam_calendar.notes': 'Notes',
|
||||
'exam_calendar.notes_placeholder': 'Additional notes for the exam...',
|
||||
'exam_calendar.time': 'Time',
|
||||
'exam_calendar.all_day': 'All day',
|
||||
'exam_calendar.teacher': 'Teacher',
|
||||
'exam_calendar.total_exams': 'Total Exams',
|
||||
'exam_calendar.upcoming_exams': 'Upcoming Exams',
|
||||
'exam_calendar.confirmed_exams': 'Confirmed Exams',
|
||||
'exam_calendar.two_week_warning': 'In 2 Weeks',
|
||||
'exam_calendar.student_upcoming': 'Upcoming Exams',
|
||||
'exam_calendar.ai_tests_available': 'AI Tests Available',
|
||||
'exam_calendar.two_week_banner_title': 'Exams in the next 2 weeks',
|
||||
'exam_calendar.ai_test_available': 'AI Practice Test Available',
|
||||
'exam_calendar.search_placeholder': 'Search exams...',
|
||||
'exam_calendar.filter_class': 'Class',
|
||||
'exam_calendar.filter_subject': 'Subject',
|
||||
'exam_calendar.filter_status': 'Status',
|
||||
'exam_calendar.all_classes': 'All Classes',
|
||||
'exam_calendar.all_subjects': 'All Subjects',
|
||||
'exam_calendar.all_statuses': 'All Statuses',
|
||||
'exam_calendar.view_calendar': 'Calendar',
|
||||
'exam_calendar.view_list': 'List',
|
||||
'exam_calendar.more': 'more',
|
||||
'exam_calendar.no_exams': 'No exams found',
|
||||
'exam_calendar.exams_by_subject': 'Exams by Subject',
|
||||
'exam_calendar.exams_by_class': 'Exams by Class',
|
||||
'exam_calendar.error_fetch': 'Error loading exams',
|
||||
'exam_calendar.error_required_fields': 'Please fill in all required fields',
|
||||
'exam_calendar.error_create': 'Error creating exam',
|
||||
'exam_calendar.error_update': 'Error updating exam',
|
||||
'exam_calendar.error_delete': 'Error deleting exam',
|
||||
'exam_calendar.exam_created': 'Exam created successfully',
|
||||
'exam_calendar.exam_updated': 'Exam updated successfully',
|
||||
'exam_calendar.exam_deleted': 'Exam deleted successfully',
|
||||
'exam_calendar.creating': 'Creating...',
|
||||
'exam_calendar.saving': 'Saving...',
|
||||
'exam_calendar.delete_confirm_title': 'Delete Exam',
|
||||
'exam_calendar.delete_confirm_desc': 'Are you sure you want to delete the exam "{title}"? This action cannot be undone.',
|
||||
|
||||
// Tablet Grading
|
||||
'nav.tablet_grading': 'Tablet Grading',
|
||||
'tablet_grading.loading': 'Loading grading view...',
|
||||
'tablet_grading.select_class': 'Select class',
|
||||
'tablet_grading.select_assessment': 'Select assessment',
|
||||
'tablet_grading.search_student': 'Search student...',
|
||||
'tablet_grading.all': 'All',
|
||||
'tablet_grading.submitted': 'Submitted',
|
||||
'tablet_grading.graded': 'Graded',
|
||||
'tablet_grading.pending': 'Pending',
|
||||
'tablet_grading.no_students': 'No students found',
|
||||
'tablet_grading.students': 'students',
|
||||
'tablet_grading.select_student_hint': 'Select a student to begin grading',
|
||||
'tablet_grading.quick_grade': 'Quick Grade (German Scale)',
|
||||
'tablet_grading.score': 'Score',
|
||||
'tablet_grading.teacher_notes': 'Teacher Notes',
|
||||
'tablet_grading.notes_placeholder': 'Write notes about this student\'s work...',
|
||||
'tablet_grading.ai_suggestion': 'AI Suggestion',
|
||||
'tablet_grading.suggested_grade': 'Suggested grade',
|
||||
'tablet_grading.current_grade': 'Current grade',
|
||||
'tablet_grading.accept': 'Accept',
|
||||
'tablet_grading.reject': 'Reject',
|
||||
'tablet_grading.suggestion_accepted': 'Suggestion accepted',
|
||||
'tablet_grading.suggestion_rejected': 'Suggestion dismissed',
|
||||
'tablet_grading.save_grade': 'Save Grade',
|
||||
'tablet_grading.saved': 'Annotations saved',
|
||||
'tablet_grading.save_error': 'Error saving annotations',
|
||||
'tablet_grading.grade_saved': 'Grade saved',
|
||||
'tablet_grading.grade_error': 'Error saving grade',
|
||||
'tablet_grading.ai_audit': 'AI Audit',
|
||||
'tablet_grading.ai_results': 'AI Results',
|
||||
'tablet_grading.ai_review_complete': 'AI review complete',
|
||||
'tablet_grading.ai_review_error': 'Error performing AI review',
|
||||
'tablet_grading.ai_audit_title': 'AI Grading Audit',
|
||||
'tablet_grading.analyzing': 'Analyzing...',
|
||||
'tablet_grading.trigger_audit': 'Trigger AI Audit',
|
||||
'tablet_grading.latest_review': 'Latest Review',
|
||||
'tablet_grading.reasoning': 'Reasoning',
|
||||
'tablet_grading.discrepancies_found': 'Discrepancies Found',
|
||||
'tablet_grading.discrepancy': 'Discrepancy',
|
||||
'tablet_grading.consistent': 'Consistent',
|
||||
'tablet_grading.discrepancies': 'discrepancies',
|
||||
'tablet_grading.review_history': 'Review History',
|
||||
'tablet_grading.suggested': 'Suggested',
|
||||
'tablet_grading.no_reviews': 'No AI reviews yet. Trigger an audit to get started.',
|
||||
'tablet_grading.student_view': 'Student View — Read Only',
|
||||
'tablet_grading.admin_view': 'Admin View',
|
||||
'tablet_grading.enter_text': 'Enter text...',
|
||||
|
||||
// Notification Center
|
||||
'nav.notification_center': 'Notifications',
|
||||
'nav.notification-center': 'Notifications',
|
||||
'nav.announcements': 'Announcements',
|
||||
'nav.exam-calendar': 'Exam Calendar',
|
||||
'nav.tablet-grading': 'Tablet Grading',
|
||||
'nav.student-achievements': 'Achievements',
|
||||
'nav.student-study-planner': 'Study Planner',
|
||||
'notif_center.title': 'Notification Center',
|
||||
'notif_center.subtitle': 'All your notifications and updates in one place',
|
||||
'notif_center.loading': 'Loading...',
|
||||
'notif_center.mark_all_read': 'Mark all as read',
|
||||
'notif_center.preferences': 'Preferences',
|
||||
'notif_center.preferences_title': 'Notification Preferences',
|
||||
'notif_center.category_toggles': 'Enable/Disable Categories',
|
||||
'notif_center.quiet_hours': 'Quiet Hours',
|
||||
'notif_center.email_digest': 'Email Digest',
|
||||
'notif_center.digest_daily': 'Daily',
|
||||
'notif_center.digest_weekly': 'Weekly',
|
||||
'notif_center.category_academic': 'Academic',
|
||||
'notif_center.category_behavioral': 'Behavioral',
|
||||
'notif_center.category_administrative': 'Administrative',
|
||||
'notif_center.category_calendar': 'Calendar',
|
||||
'notif_center.category_communication': 'Communication',
|
||||
'notif_center.category_system': 'System',
|
||||
'notif_center.priority_urgent': 'Urgent',
|
||||
'notif_center.priority_high': 'High',
|
||||
'notif_center.priority_normal': 'Normal',
|
||||
'notif_center.priority_low': 'Low',
|
||||
'notif_center.just_now': 'Just now',
|
||||
'notif_center.minutes_ago': '{n} minutes ago',
|
||||
'notif_center.hours_ago': '{n} hours ago',
|
||||
'notif_center.days_ago': '{n} days ago',
|
||||
'notif_center.total': 'Total',
|
||||
'notif_center.unread': 'Unread',
|
||||
'notif_center.urgent_high': 'Urgent/High',
|
||||
'notif_center.archived': 'Archived',
|
||||
'notif_center.tab_all': 'All',
|
||||
'notif_center.tab_unread': 'Unread',
|
||||
'notif_center.tab_archived': 'Archived',
|
||||
'notif_center.filter_category': 'Category',
|
||||
'notif_center.filter_priority': 'Priority',
|
||||
'notif_center.filter_read': 'Status',
|
||||
'notif_center.all_categories': 'All Categories',
|
||||
'notif_center.all_priorities': 'All Priorities',
|
||||
'notif_center.all_status': 'All Status',
|
||||
'notif_center.unread_only': 'Unread only',
|
||||
'notif_center.read_only': 'Read only',
|
||||
'notif_center.no_notifications': 'No notifications found',
|
||||
'notif_center.mark_read': 'Mark as read',
|
||||
'notif_center.archive': 'Archive',
|
||||
'notif_center.selected': 'selected',
|
||||
'notif_center.go_to': 'Go to',
|
||||
'notif_center.by_category': 'By Category',
|
||||
'notif_center.distribution': 'Distribution',
|
||||
|
||||
// Announcements
|
||||
'announcements.title': 'School Announcements',
|
||||
'announcements.subtitle': 'Official school announcements and information',
|
||||
'announcements.loading': 'Loading...',
|
||||
'announcements.create': 'Create Announcement',
|
||||
'announcements.create_title': 'New Announcement',
|
||||
'announcements.edit_title': 'Edit Announcement',
|
||||
'announcements.total': 'Total',
|
||||
'announcements.pinned': 'Pinned',
|
||||
'announcements.urgent_high': 'Urgent/High',
|
||||
'announcements.unread': 'Unread',
|
||||
'announcements.no_announcements': 'No announcements available',
|
||||
'announcements.mark_read': 'Mark as read',
|
||||
'announcements.pin': 'Pin',
|
||||
'announcements.unpin': 'Unpin',
|
||||
'announcements.show_more': 'Show more',
|
||||
'announcements.show_less': 'Show less',
|
||||
'announcements.have_read': 'have read',
|
||||
'announcements.expires': 'Expires',
|
||||
'announcements.just_now': 'Just now',
|
||||
'announcements.minutes_ago': '{n} minutes ago',
|
||||
'announcements.hours_ago': '{n} hours ago',
|
||||
'announcements.days_ago': '{n} days ago',
|
||||
'announcements.type_general': 'General',
|
||||
'announcements.type_urgent': 'Urgent',
|
||||
'announcements.type_event': 'Event',
|
||||
'announcements.type_holiday': 'Holiday',
|
||||
'announcements.type_exam': 'Exam',
|
||||
'announcements.type_deadline': 'Deadline',
|
||||
'announcements.priority_low': 'Low',
|
||||
'announcements.priority_normal': 'Normal',
|
||||
'announcements.priority_high': 'High',
|
||||
'announcements.priority_urgent': 'Urgent',
|
||||
'announcements.target_all': 'All',
|
||||
'announcements.target_teachers': 'Teachers',
|
||||
'announcements.target_students': 'Students',
|
||||
'announcements.target_parents': 'Parents',
|
||||
'announcements.target_class': 'Class',
|
||||
'announcements.filter_type': 'Type',
|
||||
'announcements.filter_priority': 'Priority',
|
||||
'announcements.all_types': 'All Types',
|
||||
'announcements.all_priorities': 'All Priorities',
|
||||
'announcements.field_title': 'Title',
|
||||
'announcements.field_title_placeholder': 'Announcement title',
|
||||
'announcements.field_content': 'Content',
|
||||
'announcements.field_content_placeholder': 'Announcement content...',
|
||||
'announcements.field_type': 'Type',
|
||||
'announcements.field_priority': 'Priority',
|
||||
'announcements.field_target': 'Target Audience',
|
||||
'announcements.field_class': 'Class',
|
||||
'announcements.field_expiry': 'Expiry Date',
|
||||
'announcements.pin_to_top': 'Pin to top',
|
||||
|
||||
// Student Portal Enhancements (EN)
|
||||
'student_portal.quick_practice': 'Start Practice',
|
||||
'student_portal.quick_homework': 'Homework',
|
||||
'student_portal.quick_achievements': 'Achievements',
|
||||
'student_portal.quick_study': 'Study Planner',
|
||||
'student_portal.competency_radar': 'Competency Radar',
|
||||
'student_portal.grade_trend': 'Grade Trend',
|
||||
'student_portal.weekly_heatmap': 'Weekly Activity',
|
||||
'student_portal.less': 'Less',
|
||||
'student_portal.more': 'More',
|
||||
'student_portal.daily_challenge': 'Daily Challenge',
|
||||
'student_portal.challenge_title': 'Complete 5 Exercises',
|
||||
'student_portal.challenge_description': 'Complete 5 exercises today to earn XP!',
|
||||
'student_portal.start_challenge': 'Start Challenge',
|
||||
'student_portal.learning_time': 'Learning Time',
|
||||
'student_portal.today': 'Today',
|
||||
'student_portal.this_week': 'This Week',
|
||||
'student_portal.this_month': 'This Month',
|
||||
'student_portal.weekly_goal': 'Weekly Goal',
|
||||
|
||||
// Achievements (EN)
|
||||
'achievements.title': 'Achievements',
|
||||
'achievements.subtitle': 'Collect badges and level up!',
|
||||
'achievements.badges': 'Badges',
|
||||
'achievements.leaderboard': 'Leaderboard',
|
||||
'achievements.challenges': 'Challenges',
|
||||
'achievements.badges_earned': 'Badges Earned',
|
||||
'achievements.badges_locked': 'Badges Locked',
|
||||
'achievements.total_xp': 'Total XP',
|
||||
'achievements.current_level': 'Current Level',
|
||||
'achievements.xp_progress': 'XP Progress',
|
||||
'achievements.next_level': 'Next Level',
|
||||
'achievements.category_progress': 'Category Progress',
|
||||
'achievements.earned_badges': 'Earned Badges',
|
||||
'achievements.locked_badges': 'Locked Badges',
|
||||
'achievements.class_leaderboard': 'Class Leaderboard',
|
||||
'achievements.you': 'You',
|
||||
'achievements.share': 'Share',
|
||||
'achievements.share_with_parents': 'Share with Parents',
|
||||
'achievements.share_description': 'Share this achievement with your parents',
|
||||
'achievements.share_success': 'Achievement shared!',
|
||||
'achievements.daily_challenge': 'Daily Challenge',
|
||||
'achievements.daily_challenge_1': 'Complete 3 Exercises',
|
||||
'achievements.daily_challenge_1_desc': 'Complete 3 exercises in any subject',
|
||||
'achievements.daily_challenge_2': 'Study 15 Minutes',
|
||||
'achievements.daily_challenge_2_desc': 'Spend 15 minutes in the study planner',
|
||||
'achievements.daily_challenge_3': 'Ask a Classmate',
|
||||
'achievements.daily_challenge_3_desc': 'Help a classmate with a question',
|
||||
'achievements.weekly_challenge': 'Weekly Challenge',
|
||||
'achievements.weekly_challenge_title': 'Study 5 Hours',
|
||||
'achievements.weekly_challenge_desc': 'Spend at least 5 hours studying this week',
|
||||
'achievements.completed': 'Completed',
|
||||
'achievements.level': 'Level',
|
||||
|
||||
// Study Planner (EN)
|
||||
'study_planner.title': 'Study Planner',
|
||||
'study_planner.subtitle': 'Plan your study time effectively!',
|
||||
'study_planner.weekly_minutes': 'Minutes This Week',
|
||||
'study_planner.sessions_this_week': 'Sessions This Week',
|
||||
'study_planner.active_plans': 'Active Plans',
|
||||
'study_planner.streak': 'Streak',
|
||||
'study_planner.days': 'days',
|
||||
'study_planner.subject_allocation': 'Subject Allocation',
|
||||
'study_planner.weekly_schedule': 'Weekly Schedule',
|
||||
'study_planner.timer': 'Timer',
|
||||
'study_planner.ai_plan': 'AI Plan',
|
||||
'study_planner.pomodoro_timer': 'Pomodoro Timer',
|
||||
'study_planner.pomodoros': 'Pomodoros',
|
||||
'study_planner.focus': 'Focus',
|
||||
'study_planner.break': 'Break',
|
||||
'study_planner.start': 'Start',
|
||||
'study_planner.pause': 'Pause',
|
||||
'study_planner.subject': 'Subject',
|
||||
'study_planner.select_subject': 'Select subject',
|
||||
'study_planner.pomodoro_complete': 'Pomodoro completed! ({count} total)',
|
||||
'study_planner.break_over': 'Break over! Let\'s continue!',
|
||||
'study_planner.exam_checklist': 'Exam Preparation',
|
||||
'study_planner.checklist_review_notes': 'Review notes',
|
||||
'study_planner.checklist_practice_problems': 'Practice problems',
|
||||
'study_planner.checklist_create_summary': 'Create summary',
|
||||
'study_planner.checklist_teach_someone': 'Teach someone',
|
||||
'study_planner.checklist_take_breaks': 'Take breaks',
|
||||
'study_planner.ai_recommendation': 'AI Study Recommendation',
|
||||
'study_planner.ai_recommendation_desc': 'Based on your subjects and weak areas',
|
||||
'study_planner.weak_areas': 'Weak Areas',
|
||||
'study_planner.add_plan': 'Add Plan',
|
||||
'study_planner.edit_plan': 'Edit Plan',
|
||||
'study_planner.edit_plan_desc': 'Modify your study plan',
|
||||
'study_planner.add_plan_desc': 'Create a new study plan',
|
||||
'study_planner.plan_title': 'Title',
|
||||
'study_planner.plan_title_placeholder': 'e.g. Math Practice',
|
||||
'study_planner.plan_description': 'Description',
|
||||
'study_planner.plan_description_placeholder': 'e.g. Algebra exercises',
|
||||
'study_planner.day': 'Day',
|
||||
'study_planner.start_time': 'Start Time',
|
||||
'study_planner.duration': 'Duration',
|
||||
'study_planner.priority': 'Priority',
|
||||
'study_planner.low': 'Low',
|
||||
'study_planner.medium': 'Medium',
|
||||
'study_planner.high': 'High',
|
||||
'study_planner.plan_created': 'Plan created!',
|
||||
'study_planner.plan_updated': 'Plan updated!',
|
||||
'study_planner.plan_deleted': 'Plan deleted!',
|
||||
'study_planner.title_required': 'Title is required',
|
||||
'study_planner.quick_access': 'Quick Access',
|
||||
'study_planner.notebooks': 'Notebooks',
|
||||
'study_planner.subjects': 'Subjects',
|
||||
'study_planner.homework': 'Homework',
|
||||
'study_planner.resources': 'Resources',
|
||||
'study_planner.monday': 'Monday',
|
||||
'study_planner.tuesday': 'Tuesday',
|
||||
'study_planner.wednesday': 'Wednesday',
|
||||
'study_planner.thursday': 'Thursday',
|
||||
'study_planner.friday': 'Friday',
|
||||
'study_planner.review': 'Review',
|
||||
'study_planner.vocabulary': 'Vocabulary',
|
||||
'study_planner.practice': 'Practice',
|
||||
'study_planner.writing': 'Writing',
|
||||
'study_planner.add': 'Add',
|
||||
'study_planner.history': 'History',
|
||||
'study_planner.session_history': 'Session History',
|
||||
'study_planner.no_sessions': 'No sessions yet',
|
||||
'study_planner.general_study': 'General Study',
|
||||
'study_planner.free_study': 'Free Study',
|
||||
'study_planner.completed': 'Completed',
|
||||
'study_planner.no_plans': 'No plans for this day',
|
||||
'study_planner.sessions': 'sessions',
|
||||
'study_planner.rate_focus': 'Rate Focus',
|
||||
'study_planner.rate_focus_desc': 'Rate your focus for this {duration} min session',
|
||||
'study_planner.focus_excellent': 'Excellent Focus',
|
||||
'study_planner.focus_good': 'Good Focus',
|
||||
'study_planner.focus_ok': 'OK Focus',
|
||||
'study_planner.focus_low': 'Low Focus',
|
||||
'study_planner.session_notes': 'Session Notes',
|
||||
'study_planner.session_notes_placeholder': 'What did you learn?',
|
||||
'study_planner.save_session': 'Save Session',
|
||||
'study_planner.session_saved': 'Session saved!',
|
||||
'study_planner.suggested_plan': 'Suggested Plan',
|
||||
'study_planner.all_ready': 'Ready for the exam!',
|
||||
'study_planner.all_ready_desc': 'All preparation tasks completed. Good luck!',
|
||||
'achievements.all_categories': 'All Categories',
|
||||
'achievements.badge_detail': 'Badge Details',
|
||||
'achievements.badge_locked': 'Badge Locked',
|
||||
'achievements.badge_detail_desc': 'Details about this badge',
|
||||
'achievements.earned_on': 'Earned on',
|
||||
'achievements.progress': 'Progress',
|
||||
'achievements.auto_badge': 'Automatic',
|
||||
'achievements.manual_badge': 'Manual',
|
||||
'achievements.recently_earned': 'Recently Earned',
|
||||
'achievements.show_on_leaderboard': 'Visible on Leaderboard',
|
||||
'achievements.leaderboard_opt_out': 'You have left the leaderboard',
|
||||
'achievements.join_leaderboard': 'Join Leaderboard',
|
||||
'achievements.leaderboard_joined': 'You are now visible on the leaderboard!',
|
||||
'achievements.leaderboard_left': 'You are now hidden from the leaderboard',
|
||||
'achievements.streaks': 'Streaks',
|
||||
'achievements.current_streak': 'Current Streak',
|
||||
'achievements.longest_streak': 'Longest Streak',
|
||||
'achievements.total_badges': 'Total Badges',
|
||||
'achievements.to_next_level': 'to next level',
|
||||
'achievements.time_remaining': 'Time remaining',
|
||||
'achievements.days': 'days',
|
||||
};
|
||||
|
||||
const translations: Record<string, TranslationDict> = { de, en };
|
||||
|
||||
+7
-1
@@ -38,7 +38,13 @@ export type ViewName =
|
||||
| 'counseling'
|
||||
| 'disciplinary'
|
||||
| 'ai-tests'
|
||||
| 'parent-portal';
|
||||
| 'parent-portal'
|
||||
| 'notification-center'
|
||||
| 'announcements'
|
||||
| 'tablet-grading'
|
||||
| 'exam-calendar'
|
||||
| 'student-achievements'
|
||||
| 'student-study-planner';
|
||||
|
||||
export interface CurrentUser {
|
||||
id: string;
|
||||
|
||||
Reference in new issue
Block a user