Round 24: Data Import/Export, School Events, Student Wellness, Styling
- Data Import/Export View: CSV/JSON import with drag-and-drop, column mapping, data preview, validation, export with filters, database statistics - School Event Management View: Calendar/list views, event CRUD with recurring support, registration, feedback, budget tracking - Student Wellness View: Daily check-in (mood, sleep, stress, activity), wellness score gauge, health records, trend charts - 4 new Prisma models: DataImportJob, DataExportJob, WellnessCheckin, WellnessScore - Enhanced SchoolEvent model with budget, registration, recurring, and status fields - Added EventFeedback model - Enhanced auth-view styling with glassmorphism card and animated floating shapes - Enhanced dashboard-view with gradient header, animated stat counters, welcome section - Added global CSS utility classes and keyframe animations - 14+ new API route files - 300+ i18n keys added - All views verified working in browser
This commit is contained in:
1 parent
7ea8dc48c8
commit
a159ed7fe6
22 files changed
+8310
-114
No files matched your search
+127
-1
@@ -104,6 +104,10 @@ model School {
|
||||
bookCheckouts BookCheckout[]
|
||||
bookReservations BookReservation[]
|
||||
seatingCharts SeatingChart[]
|
||||
dataImportJobs DataImportJob[]
|
||||
dataExportJobs DataExportJob[]
|
||||
wellnessCheckins WellnessCheckin[]
|
||||
wellnessScores WellnessScore[]
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -149,6 +153,7 @@ model User {
|
||||
resources Resource[]
|
||||
organizedSchoolEvents SchoolEvent[]
|
||||
eventRegistrations EventRegistration[]
|
||||
eventFeedbacks EventFeedback[]
|
||||
awardedBadges StudentBadge[]
|
||||
newsletters Newsletter[]
|
||||
competitionsCreated Competition[]
|
||||
@@ -187,6 +192,8 @@ model User {
|
||||
seatingCharts SeatingChart[]
|
||||
bookCheckouts BookCheckout[]
|
||||
bookReservations BookReservation[]
|
||||
dataImportJobs DataImportJob[]
|
||||
dataExportJobs DataExportJob[]
|
||||
}
|
||||
|
||||
model SchoolYear {
|
||||
@@ -305,6 +312,8 @@ model Student {
|
||||
gradingAnnotations GradingAnnotation[]
|
||||
bookCheckouts BookCheckout[]
|
||||
bookReservations BookReservation[]
|
||||
wellnessCheckins WellnessCheckin[]
|
||||
wellnessScores WellnessScore[]
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
@@ -1622,7 +1631,7 @@ model SchoolEvent {
|
||||
schoolId String
|
||||
title String
|
||||
description String?
|
||||
eventType String // "assembly", "field_trip", "sports_day", "concert", "fair", "parent_meeting", "graduation", "holiday"
|
||||
eventType String // "assembly", "field_trip", "sports_day", "concert", "fair", "parent_meeting", "graduation", "holiday", "cultural", "academic", "social"
|
||||
startDate DateTime
|
||||
endDate DateTime?
|
||||
location String?
|
||||
@@ -1637,11 +1646,20 @@ model SchoolEvent {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
budget Float?
|
||||
registrationDeadline DateTime?
|
||||
capacity Int?
|
||||
isRecurring Boolean @default(false)
|
||||
recurrenceRule String? // JSON: { type: "weekly", interval: 1, days: ["mon","wed"] }
|
||||
bannerImageUrl String?
|
||||
status String @default("draft") // draft, published, cancelled, completed
|
||||
feedbackForm String? // JSON: feedback questions
|
||||
|
||||
school School @relation(fields: [schoolId], references: [id])
|
||||
organizer User? @relation(fields: [organizerId], references: [id])
|
||||
classGroup ClassGroup? @relation(fields: [classGroupId], references: [id])
|
||||
registrations EventRegistration[]
|
||||
feedbacks EventFeedback[]
|
||||
|
||||
@@index([schoolId, startDate])
|
||||
@@map("school_events")
|
||||
@@ -1663,6 +1681,21 @@ model EventRegistration {
|
||||
@@map("event_registrations")
|
||||
}
|
||||
|
||||
model EventFeedback {
|
||||
id String @id @default(cuid())
|
||||
eventId String
|
||||
userId String
|
||||
rating Int // 1-5
|
||||
comment String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
event SchoolEvent @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@unique([eventId, userId])
|
||||
@@map("event_feedbacks")
|
||||
}
|
||||
|
||||
// ─── Student Transportation ──────────────────────────────────────
|
||||
|
||||
model StudentTransport {
|
||||
@@ -2803,3 +2836,96 @@ model SeatingChart {
|
||||
@@index([teacherId])
|
||||
@@map("seating_charts")
|
||||
}
|
||||
|
||||
model DataImportJob {
|
||||
id String @id @default(cuid())
|
||||
schoolId String
|
||||
userId String
|
||||
type String // STUDENT, TEACHER, GRADE, ATTENDANCE, COMPETENCY
|
||||
fileName String
|
||||
fileSize Int
|
||||
status String @default("pending") // pending, processing, completed, failed
|
||||
totalRows Int @default(0)
|
||||
successRows Int @default(0)
|
||||
errorRows Int @default(0)
|
||||
errors String? // JSON string of error details
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
school School @relation(fields: [schoolId], references: [id])
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@index([schoolId])
|
||||
@@index([userId])
|
||||
@@map("data_import_jobs")
|
||||
}
|
||||
|
||||
model DataExportJob {
|
||||
id String @id @default(cuid())
|
||||
schoolId String
|
||||
userId String
|
||||
type String // STUDENT, GRADE, ATTENDANCE, COMPETENCY, REPORT
|
||||
format String // CSV, JSON
|
||||
filters String? // JSON string of filter parameters
|
||||
fileName String?
|
||||
fileSize Int @default(0)
|
||||
status String @default("pending") // pending, processing, completed, failed
|
||||
fileData String? // Base64 encoded file data
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
school School @relation(fields: [schoolId], references: [id])
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@index([schoolId])
|
||||
@@index([userId])
|
||||
@@map("data_export_jobs")
|
||||
}
|
||||
|
||||
model WellnessCheckin {
|
||||
id String @id @default(cuid())
|
||||
schoolId String
|
||||
studentId String
|
||||
date DateTime
|
||||
mood Int // 1-5
|
||||
sleepHours Float?
|
||||
sleepQuality Int? // 1-5
|
||||
stressLevel Int? // 1-10
|
||||
activityType String?
|
||||
activityMinutes Int?
|
||||
mealsCount Int?
|
||||
waterGlasses Int?
|
||||
gratitudeEntry String?
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
school School @relation(fields: [schoolId], references: [id])
|
||||
student Student @relation(fields: [studentId], references: [id])
|
||||
|
||||
@@index([schoolId])
|
||||
@@index([studentId])
|
||||
@@index([date])
|
||||
@@map("wellness_checkins")
|
||||
}
|
||||
|
||||
model WellnessScore {
|
||||
id String @id @default(cuid())
|
||||
schoolId String
|
||||
studentId String
|
||||
date DateTime
|
||||
overallScore Float // 0-100
|
||||
physicalScore Float? // 0-100
|
||||
mentalScore Float? // 0-100
|
||||
socialScore Float? // 0-100
|
||||
academicScore Float? // 0-100
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
school School @relation(fields: [schoolId], references: [id])
|
||||
student Student @relation(fields: [studentId], references: [id])
|
||||
|
||||
@@index([schoolId])
|
||||
@@index([studentId])
|
||||
@@index([date])
|
||||
@@map("wellness_scores")
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
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 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'VICE_PRINCIPAL'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const schoolId = searchParams.get('schoolId') || session.user?.schoolId;
|
||||
|
||||
if (!schoolId) {
|
||||
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get database statistics
|
||||
const [
|
||||
studentCount,
|
||||
teacherCount,
|
||||
gradeCount,
|
||||
attendanceCount,
|
||||
competencyCount,
|
||||
classCount,
|
||||
subjectCount,
|
||||
assessmentCount,
|
||||
reportCount,
|
||||
orphanedStudents,
|
||||
orphanedEnrollments,
|
||||
] = await Promise.all([
|
||||
db.student.count({ where: { schoolId, deletedAt: null } }),
|
||||
db.user.count({ where: { schoolId, deletedAt: null, role: 'TEACHER' } }),
|
||||
db.assessmentResult.count({
|
||||
where: { assessment: { classGroup: { schoolId } } },
|
||||
}),
|
||||
db.attendanceRecord.count({
|
||||
where: { student: { schoolId, deletedAt: null } },
|
||||
}),
|
||||
db.learningProgressEntry.count({
|
||||
where: { student: { schoolId, deletedAt: null } },
|
||||
}),
|
||||
db.classGroup.count({ where: { schoolId } }),
|
||||
db.subject.count({ where: { schoolId } }),
|
||||
db.assessment.count({ where: { classGroup: { schoolId } } }),
|
||||
db.report.count({ where: { schoolId } }),
|
||||
// Orphaned students: students without enrollments
|
||||
db.student.count({
|
||||
where: {
|
||||
schoolId,
|
||||
deletedAt: null,
|
||||
enrollments: { none: {} },
|
||||
},
|
||||
}),
|
||||
// Orphaned enrollments: enrollments pointing to deleted students
|
||||
db.enrollment.count({
|
||||
where: {
|
||||
student: { deletedAt: { not: null } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
statistics: {
|
||||
students: studentCount,
|
||||
teachers: teacherCount,
|
||||
grades: gradeCount,
|
||||
attendance: attendanceCount,
|
||||
competencies: competencyCount,
|
||||
classes: classCount,
|
||||
subjects: subjectCount,
|
||||
assessments: assessmentCount,
|
||||
reports: reportCount,
|
||||
total: studentCount + teacherCount + gradeCount + attendanceCount + competencyCount,
|
||||
},
|
||||
cleanup: {
|
||||
orphanedStudents,
|
||||
orphanedEnrollments,
|
||||
totalOrphans: orphanedStudents + orphanedEnrollments,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Data Cleanup GET error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { operation, schoolId: bodySchoolId } = body as {
|
||||
operation: string;
|
||||
schoolId?: string;
|
||||
};
|
||||
|
||||
const schoolId = bodySchoolId || session.user?.schoolId;
|
||||
if (!schoolId) {
|
||||
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
let result: Record<string, unknown> = {};
|
||||
|
||||
if (operation === 'remove_orphans') {
|
||||
// Remove orphaned enrollments (enrollments pointing to deleted students)
|
||||
const deletedEnrollments = await db.enrollment.deleteMany({
|
||||
where: {
|
||||
student: { deletedAt: { not: null } },
|
||||
},
|
||||
});
|
||||
|
||||
// Remove orphaned attendance records
|
||||
const deletedAttendance = await db.attendanceRecord.deleteMany({
|
||||
where: {
|
||||
student: { deletedAt: { not: null } },
|
||||
},
|
||||
});
|
||||
|
||||
// Remove orphaned learning progress entries
|
||||
const deletedProgress = await db.learningProgressEntry.deleteMany({
|
||||
where: {
|
||||
student: { deletedAt: { not: null } },
|
||||
},
|
||||
});
|
||||
|
||||
result = {
|
||||
deletedEnrollments: deletedEnrollments.count,
|
||||
deletedAttendance: deletedAttendance.count,
|
||||
deletedProgress: deletedProgress.count,
|
||||
totalDeleted: deletedEnrollments.count + deletedAttendance.count + deletedProgress.count,
|
||||
};
|
||||
} else if (operation === 'bulk_delete') {
|
||||
const { type, ids } = body as { type: string; ids: string[] };
|
||||
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
return NextResponse.json({ error: 'ids array is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
let deleted = 0;
|
||||
|
||||
if (type === 'STUDENT') {
|
||||
const res = await db.student.updateMany({
|
||||
where: { id: { in: ids }, schoolId },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
deleted = res.count;
|
||||
} else if (type === 'ASSESSMENT') {
|
||||
const res = await db.assessment.deleteMany({
|
||||
where: { id: { in: ids }, classGroup: { schoolId } },
|
||||
});
|
||||
deleted = res.count;
|
||||
} else if (type === 'REPORT') {
|
||||
const res = await db.report.deleteMany({
|
||||
where: { id: { in: ids }, schoolId },
|
||||
});
|
||||
deleted = res.count;
|
||||
}
|
||||
|
||||
result = { type, deleted };
|
||||
} else if (operation === 'backup') {
|
||||
// Create a simple backup by counting all records
|
||||
const tables = {
|
||||
schools: await db.school.count(),
|
||||
users: await db.user.count(),
|
||||
students: await db.student.count({ where: { deletedAt: null } }),
|
||||
classes: await db.classGroup.count(),
|
||||
subjects: await db.subject.count(),
|
||||
assessments: await db.assessment.count(),
|
||||
reports: await db.report.count(),
|
||||
};
|
||||
|
||||
result = {
|
||||
backup: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
tables,
|
||||
message: 'Backup metadata recorded. Full database backup requires server-side access.',
|
||||
};
|
||||
} else {
|
||||
return NextResponse.json({ error: 'Unknown operation' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Create audit log
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId,
|
||||
action: operation.toUpperCase(),
|
||||
entityType: 'DataCleanup',
|
||||
entityId: null,
|
||||
metadata: JSON.stringify(result),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, result });
|
||||
} catch (error) {
|
||||
console.error('Data Cleanup POST error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
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 job = await db.dataExportJob.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!job) {
|
||||
return NextResponse.json({ error: 'Export job not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check access
|
||||
if (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'VICE_PRINCIPAL' &&
|
||||
job.userId !== session.userId
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
return NextResponse.json(job);
|
||||
} catch (error) {
|
||||
console.error('Data Export Job GET error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'VICE_PRINCIPAL'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const job = await db.dataExportJob.findUnique({ where: { id } });
|
||||
|
||||
if (!job) {
|
||||
return NextResponse.json({ error: 'Export job not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
await db.dataExportJob.delete({ where: { id } });
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Data Export Job DELETE error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
function escapeCsvField(field: string | number | null | undefined): string {
|
||||
if (field === null || field === undefined) return '';
|
||||
const str = String(field);
|
||||
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
function toCsvRow(fields: (string | number | null | undefined)[]): string {
|
||||
return fields.map(escapeCsvField).join(',');
|
||||
}
|
||||
|
||||
function objectsToCsv(data: Record<string, unknown>[]): string {
|
||||
if (data.length === 0) return '';
|
||||
const headers = Object.keys(data[0]);
|
||||
const headerRow = toCsvRow(headers);
|
||||
const dataRows = data.map((item) =>
|
||||
toCsvRow(headers.map((h) => item[h] as string | number | null | undefined))
|
||||
);
|
||||
return [headerRow, ...dataRows].join('\n');
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
if (!schoolId) {
|
||||
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const where: Record<string, unknown> = { schoolId };
|
||||
if (session.user?.role === 'TEACHER') {
|
||||
where.userId = session.userId;
|
||||
}
|
||||
|
||||
const jobs = await db.dataExportJob.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
});
|
||||
|
||||
return NextResponse.json(jobs);
|
||||
} catch (error) {
|
||||
console.error('Data Export GET error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'VICE_PRINCIPAL' &&
|
||||
session.user?.role !== 'TEACHER'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { type, format, filters } = body as {
|
||||
type: string;
|
||||
format: string;
|
||||
filters?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const schoolId = (filters?.schoolId as string) || session.user?.schoolId;
|
||||
if (!schoolId) {
|
||||
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Create export job
|
||||
const exportJob = await db.dataExportJob.create({
|
||||
data: {
|
||||
schoolId,
|
||||
userId: session.userId,
|
||||
type: type || 'STUDENT',
|
||||
format: format || 'CSV',
|
||||
filters: filters ? JSON.stringify(filters) : null,
|
||||
status: 'processing',
|
||||
},
|
||||
});
|
||||
|
||||
let exportData: Record<string, unknown>[] = [];
|
||||
let fileName = '';
|
||||
|
||||
try {
|
||||
if (type === 'STUDENT') {
|
||||
const students = await db.student.findMany({
|
||||
where: {
|
||||
schoolId,
|
||||
deletedAt: null,
|
||||
...(filters?.classId ? { enrollments: { some: { classGroupId: filters.classId as string } } } : {}),
|
||||
},
|
||||
include: {
|
||||
enrollments: { include: { classGroup: { select: { name: true } } } },
|
||||
},
|
||||
take: 10000,
|
||||
});
|
||||
exportData = students.map((s) => ({
|
||||
id: s.id,
|
||||
firstName: s.firstName,
|
||||
lastName: s.lastName,
|
||||
externalId: s.externalId || '',
|
||||
dateOfBirth: s.dateOfBirth ? s.dateOfBirth.toISOString().split('T')[0] : '',
|
||||
class: s.enrollments[0]?.classGroup?.name || '',
|
||||
}));
|
||||
fileName = `students_export_${new Date().toISOString().split('T')[0]}`;
|
||||
} else if (type === 'GRADE') {
|
||||
const results = await db.assessmentResult.findMany({
|
||||
where: {
|
||||
assessment: {
|
||||
classGroup: { schoolId },
|
||||
...(filters?.classId ? { classGroupId: filters.classId as string } : {}),
|
||||
...(filters?.subjectId ? { subjectId: filters.subjectId as string } : {}),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
student: { select: { firstName: true, lastName: true } },
|
||||
assessment: { select: { title: true, type: true } },
|
||||
},
|
||||
take: 10000,
|
||||
});
|
||||
exportData = results.map((r) => ({
|
||||
studentName: `${r.student.firstName} ${r.student.lastName}`,
|
||||
assessmentTitle: r.assessment.title,
|
||||
assessmentType: r.assessment.type,
|
||||
score: r.score ?? '',
|
||||
note: r.note || '',
|
||||
}));
|
||||
fileName = `grades_export_${new Date().toISOString().split('T')[0]}`;
|
||||
} else if (type === 'ATTENDANCE') {
|
||||
const records = await db.attendanceRecord.findMany({
|
||||
where: {
|
||||
student: { schoolId, deletedAt: null },
|
||||
...(filters?.classId ? { student: { enrollments: { some: { classGroupId: filters.classId as string } } } } : {}),
|
||||
},
|
||||
include: {
|
||||
student: { select: { firstName: true, lastName: true } },
|
||||
},
|
||||
take: 10000,
|
||||
});
|
||||
exportData = records.map((r) => ({
|
||||
studentName: `${r.student.firstName} ${r.student.lastName}`,
|
||||
status: r.status,
|
||||
date: r.date ? new Date(r.date).toISOString().split('T')[0] : '',
|
||||
}));
|
||||
fileName = `attendance_export_${new Date().toISOString().split('T')[0]}`;
|
||||
} else if (type === 'COMPETENCY') {
|
||||
const entries = await db.learningProgressEntry.findMany({
|
||||
where: {
|
||||
student: { schoolId, deletedAt: null },
|
||||
...(filters?.classId ? { student: { enrollments: { some: { classGroupId: filters.classId as string } } } } : {}),
|
||||
},
|
||||
include: {
|
||||
student: { select: { firstName: true, lastName: true } },
|
||||
competency: { select: { code: true, title: true } },
|
||||
},
|
||||
take: 10000,
|
||||
});
|
||||
exportData = entries.map((e) => ({
|
||||
studentName: `${e.student.firstName} ${e.student.lastName}`,
|
||||
competencyCode: e.competency.code,
|
||||
competencyTitle: e.competency.title,
|
||||
level: e.level,
|
||||
note: e.note || '',
|
||||
}));
|
||||
fileName = `competencies_export_${new Date().toISOString().split('T')[0]}`;
|
||||
} else if (type === 'REPORT') {
|
||||
const reports = await db.report.findMany({
|
||||
where: {
|
||||
schoolId,
|
||||
...(filters?.classId ? { classGroupId: filters.classId as string } : {}),
|
||||
},
|
||||
include: {
|
||||
student: { select: { firstName: true, lastName: true } },
|
||||
},
|
||||
take: 10000,
|
||||
});
|
||||
exportData = reports.map((r) => ({
|
||||
studentName: `${r.student.firstName} ${r.student.lastName}`,
|
||||
title: r.title || '',
|
||||
status: r.status,
|
||||
createdAt: r.createdAt.toISOString().split('T')[0],
|
||||
}));
|
||||
fileName = `reports_export_${new Date().toISOString().split('T')[0]}`;
|
||||
}
|
||||
|
||||
// Generate file content
|
||||
let fileContent: string;
|
||||
const ext = format === 'JSON' ? 'json' : 'csv';
|
||||
|
||||
if (format === 'JSON') {
|
||||
fileContent = JSON.stringify(exportData, null, 2);
|
||||
} else {
|
||||
fileContent = objectsToCsv(exportData);
|
||||
}
|
||||
|
||||
fileName = `${fileName}.${ext}`;
|
||||
|
||||
// Update export job as completed
|
||||
await db.dataExportJob.update({
|
||||
where: { id: exportJob.id },
|
||||
data: {
|
||||
status: 'completed',
|
||||
fileName,
|
||||
fileSize: Buffer.byteLength(fileContent),
|
||||
fileData: Buffer.from(fileContent).toString('base64'),
|
||||
},
|
||||
});
|
||||
|
||||
// Create audit log
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId,
|
||||
action: 'EXPORT',
|
||||
entityType: 'DataExportJob',
|
||||
entityId: exportJob.id,
|
||||
metadata: JSON.stringify({ type, format, recordCount: exportData.length, fileName }),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
id: exportJob.id,
|
||||
type,
|
||||
format,
|
||||
fileName,
|
||||
fileSize: Buffer.byteLength(fileContent),
|
||||
recordCount: exportData.length,
|
||||
status: 'completed',
|
||||
fileData: Buffer.from(fileContent).toString('base64'),
|
||||
}, { status: 201 });
|
||||
} catch (processingError) {
|
||||
await db.dataExportJob.update({
|
||||
where: { id: exportJob.id },
|
||||
data: { status: 'failed' },
|
||||
});
|
||||
throw processingError;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Data Export POST error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -3,19 +3,6 @@ import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
import { withRateLimit } from '@/lib/rate-limit';
|
||||
|
||||
function escapeCsvField(field: string | number | null | undefined): string {
|
||||
if (field === null || field === undefined) return '';
|
||||
const str = String(field);
|
||||
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
function toCsvRow(fields: (string | number | null | undefined)[]): string {
|
||||
return fields.map(escapeCsvField).join(',');
|
||||
}
|
||||
|
||||
interface ImportRow {
|
||||
[key: string]: string;
|
||||
}
|
||||
@@ -60,6 +47,21 @@ function parseCsv(text: string): { headers: string[]; rows: ImportRow[] } {
|
||||
return { headers, rows };
|
||||
}
|
||||
|
||||
function parseJson(text: string): { headers: string[]; rows: ImportRow[] } {
|
||||
const data = JSON.parse(text);
|
||||
const arr = Array.isArray(data) ? data : data.records || data.data || [];
|
||||
if (arr.length === 0) return { headers: [], rows: [] };
|
||||
const headers = Object.keys(arr[0]);
|
||||
const rows = arr.map((item: Record<string, unknown>) => {
|
||||
const row: ImportRow = {};
|
||||
headers.forEach((h) => {
|
||||
row[h] = item[h] !== undefined && item[h] !== null ? String(item[h]) : '';
|
||||
});
|
||||
return row;
|
||||
});
|
||||
return { headers, rows };
|
||||
}
|
||||
|
||||
export const POST = withRateLimit(async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
@@ -70,6 +72,7 @@ export const POST = withRateLimit(async function POST(request: Request) {
|
||||
if (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'VICE_PRINCIPAL' &&
|
||||
session.user?.role !== 'TEACHER'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
@@ -77,8 +80,9 @@ export const POST = withRateLimit(async function POST(request: Request) {
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
const type = formData.get('type') as string || 'students';
|
||||
const schoolId = formData.get('schoolId') as string || session.user?.schoolId;
|
||||
const type = (formData.get('type') as string) || 'STUDENT';
|
||||
const schoolId = (formData.get('schoolId') as string) || session.user?.schoolId;
|
||||
const columnMapping = formData.get('columnMapping') as string | null;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
|
||||
@@ -88,22 +92,84 @@ export const POST = withRateLimit(async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Create import job record
|
||||
const importJob = await db.dataImportJob.create({
|
||||
data: {
|
||||
schoolId,
|
||||
userId: session.userId,
|
||||
type,
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
status: 'processing',
|
||||
},
|
||||
});
|
||||
|
||||
const text = await file.text();
|
||||
const { headers, rows } = parseCsv(text);
|
||||
let parsed: { headers: string[]; rows: ImportRow[] };
|
||||
|
||||
// Parse based on file type
|
||||
if (file.name.endsWith('.json')) {
|
||||
try {
|
||||
parsed = parseJson(text);
|
||||
} catch {
|
||||
await db.dataImportJob.update({
|
||||
where: { id: importJob.id },
|
||||
data: { status: 'failed', errors: JSON.stringify([{ error: 'Invalid JSON format' }]) },
|
||||
});
|
||||
return NextResponse.json({ error: 'Invalid JSON format' }, { status: 400 });
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
parsed = parseCsv(text);
|
||||
} catch {
|
||||
await db.dataImportJob.update({
|
||||
where: { id: importJob.id },
|
||||
data: { status: 'failed', errors: JSON.stringify([{ error: 'Invalid CSV format' }]) },
|
||||
});
|
||||
return NextResponse.json({ error: 'Invalid CSV format' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const { headers, rows } = parsed;
|
||||
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json({ error: 'No data rows found in CSV' }, { status: 400 });
|
||||
await db.dataImportJob.update({
|
||||
where: { id: importJob.id },
|
||||
data: { status: 'failed', totalRows: 0, errors: JSON.stringify([{ error: 'No data rows found' }]) },
|
||||
});
|
||||
return NextResponse.json({ error: 'No data rows found in file' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Apply column mapping if provided
|
||||
let mappedRows = rows;
|
||||
if (columnMapping) {
|
||||
try {
|
||||
const mapping: Record<string, string> = JSON.parse(columnMapping);
|
||||
mappedRows = rows.map((row) => {
|
||||
const mapped: ImportRow = {};
|
||||
for (const [csvCol, dbField] of Object.entries(mapping)) {
|
||||
if (dbField && row[csvCol] !== undefined) {
|
||||
mapped[dbField] = row[csvCol];
|
||||
}
|
||||
}
|
||||
return mapped;
|
||||
});
|
||||
} catch {
|
||||
// If mapping fails, use original rows
|
||||
}
|
||||
}
|
||||
|
||||
const result: ImportResult = { created: 0, skipped: 0, errors: [] };
|
||||
|
||||
if (type === 'students') {
|
||||
for (const row of rows) {
|
||||
// Import based on type
|
||||
if (type === 'STUDENT') {
|
||||
for (const row of mappedRows) {
|
||||
try {
|
||||
const firstName = row['First Name'] || row['firstName'] || row['Vorname'] || '';
|
||||
const lastName = row['Last Name'] || row['lastName'] || row['Nachname'] || '';
|
||||
const externalId = row['External ID'] || row['externalId'] || row['ID'] || '';
|
||||
const dateOfBirth = row['Date of Birth'] || row['dateOfBirth'] || row['Geburtsdatum'] || '';
|
||||
const firstName = row['firstName'] || row['First Name'] || row['Vorname'] || '';
|
||||
const lastName = row['lastName'] || row['Last Name'] || row['Nachname'] || '';
|
||||
const externalId = row['externalId'] || row['External ID'] || row['ID'] || '';
|
||||
const dateOfBirth = row['dateOfBirth'] || row['Date of Birth'] || row['Geburtsdatum'] || '';
|
||||
const email = row['email'] || row['Email'] || '';
|
||||
|
||||
if (!firstName || !lastName) {
|
||||
result.skipped++;
|
||||
@@ -134,31 +200,34 @@ export const POST = withRateLimit(async function POST(request: Request) {
|
||||
result.errors.push(`Row error: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
} else if (type === 'assessments') {
|
||||
for (const row of rows) {
|
||||
} else if (type === 'TEACHER') {
|
||||
for (const row of mappedRows) {
|
||||
try {
|
||||
const title = row['Title'] || row['title'] || row['Titel'] || '';
|
||||
const classGroupId = row['Class ID'] || row['classGroupId'] || '';
|
||||
const subjectId = row['Subject ID'] || row['subjectId'] || '';
|
||||
const date = row['Date'] || row['date'] || row['Datum'] || '';
|
||||
const assessmentType = row['Type'] || row['type'] || 'WRITTEN';
|
||||
const firstName = row['firstName'] || row['First Name'] || row['Vorname'] || '';
|
||||
const lastName = row['lastName'] || row['Last Name'] || row['Nachname'] || '';
|
||||
const email = row['email'] || row['Email'] || '';
|
||||
const role = row['role'] || row['Role'] || 'TEACHER';
|
||||
|
||||
if (!title || !classGroupId) {
|
||||
if (!firstName || !lastName || !email) {
|
||||
result.skipped++;
|
||||
result.errors.push(`Row skipped: missing title or class ID`);
|
||||
result.errors.push(`Row skipped: missing required fields`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await db.assessment.create({
|
||||
const existing = await db.user.findUnique({ where: { email } });
|
||||
if (existing) {
|
||||
result.skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await db.user.create({
|
||||
data: {
|
||||
title,
|
||||
classGroupId,
|
||||
subjectId: subjectId || null,
|
||||
teacherId: session.userId,
|
||||
date: date ? new Date(date) : new Date(),
|
||||
type: assessmentType,
|
||||
maxScore: row['Max Score'] || row['maxScore'] ? parseFloat(row['Max Score'] || row['maxScore']) : null,
|
||||
weight: row['Weight'] || row['weight'] ? parseFloat(row['Weight'] || row['weight']) : 1.0,
|
||||
schoolId,
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
role,
|
||||
passwordHash: '$2a$10$default.placeholder.hash.value',
|
||||
},
|
||||
});
|
||||
result.created++;
|
||||
@@ -166,12 +235,12 @@ export const POST = withRateLimit(async function POST(request: Request) {
|
||||
result.errors.push(`Row error: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
} else if (type === 'grades') {
|
||||
for (const row of rows) {
|
||||
} else if (type === 'GRADE') {
|
||||
for (const row of mappedRows) {
|
||||
try {
|
||||
const studentId = row['Student ID'] || row['studentId'] || '';
|
||||
const assessmentId = row['Assessment ID'] || row['assessmentId'] || '';
|
||||
const score = row['Score'] || row['score'] || '';
|
||||
const studentId = row['studentId'] || row['Student ID'] || '';
|
||||
const assessmentId = row['assessmentId'] || row['Assessment ID'] || '';
|
||||
const score = row['score'] || row['Score'] || '';
|
||||
|
||||
if (!studentId || !assessmentId) {
|
||||
result.skipped++;
|
||||
@@ -184,7 +253,60 @@ export const POST = withRateLimit(async function POST(request: Request) {
|
||||
studentId,
|
||||
assessmentId,
|
||||
score: score ? parseFloat(score) : null,
|
||||
note: row['Note'] || row['note'] || null,
|
||||
note: row['note'] || row['Note'] || null,
|
||||
},
|
||||
});
|
||||
result.created++;
|
||||
} catch (err) {
|
||||
result.errors.push(`Row error: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
} else if (type === 'ATTENDANCE') {
|
||||
for (const row of mappedRows) {
|
||||
try {
|
||||
const studentId = row['studentId'] || row['Student ID'] || '';
|
||||
const date = row['date'] || row['Date'] || row['Datum'] || '';
|
||||
const status = row['status'] || row['Status'] || 'PRESENT';
|
||||
|
||||
if (!studentId || !date) {
|
||||
result.skipped++;
|
||||
result.errors.push(`Row skipped: missing student ID or date`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await db.attendanceRecord.create({
|
||||
data: {
|
||||
studentId,
|
||||
sessionId: row['sessionId'] || row['Session ID'] || '',
|
||||
status,
|
||||
date: new Date(date),
|
||||
},
|
||||
});
|
||||
result.created++;
|
||||
} catch (err) {
|
||||
result.errors.push(`Row error: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
} else if (type === 'COMPETENCY') {
|
||||
for (const row of mappedRows) {
|
||||
try {
|
||||
const studentId = row['studentId'] || row['Student ID'] || '';
|
||||
const competencyId = row['competencyId'] || row['Competency ID'] || '';
|
||||
const level = row['level'] || row['Level'] || '';
|
||||
|
||||
if (!studentId || !competencyId) {
|
||||
result.skipped++;
|
||||
result.errors.push(`Row skipped: missing student ID or competency ID`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await db.learningProgressEntry.create({
|
||||
data: {
|
||||
studentId,
|
||||
competencyId,
|
||||
teacherId: session.userId,
|
||||
level: level ? parseInt(level) : 1,
|
||||
note: row['note'] || row['Note'] || null,
|
||||
},
|
||||
});
|
||||
result.created++;
|
||||
@@ -194,18 +316,38 @@ export const POST = withRateLimit(async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Update import job
|
||||
await db.dataImportJob.update({
|
||||
where: { id: importJob.id },
|
||||
data: {
|
||||
status: 'completed',
|
||||
totalRows: rows.length,
|
||||
successRows: result.created,
|
||||
errorRows: result.skipped + result.errors.length,
|
||||
errors: result.errors.length > 0 ? JSON.stringify(result.errors.slice(0, 50)) : null,
|
||||
},
|
||||
});
|
||||
|
||||
// Create audit log
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId: session.user?.schoolId ?? null,
|
||||
action: 'IMPORT',
|
||||
entityType: 'DataImport',
|
||||
entityId: null,
|
||||
metadata: JSON.stringify({ type, created: result.created, skipped: result.skipped, errorCount: result.errors.length }),
|
||||
entityType: 'DataImportJob',
|
||||
entityId: importJob.id,
|
||||
metadata: JSON.stringify({
|
||||
type,
|
||||
created: result.created,
|
||||
skipped: result.skipped,
|
||||
errorCount: result.errors.length,
|
||||
fileName: file.name,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
id: importJob.id,
|
||||
type,
|
||||
totalRows: rows.length,
|
||||
created: result.created,
|
||||
@@ -228,30 +370,27 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const type = searchParams.get('type') || 'students';
|
||||
const schoolId = searchParams.get('schoolId') || session.user?.schoolId;
|
||||
|
||||
let csvContent = '';
|
||||
let filename = '';
|
||||
|
||||
if (type === 'students') {
|
||||
csvContent = 'First Name,Last Name,Date of Birth,External ID\nMax,Mustermann,2015-03-15,STU001\nAnna,Schmidt,2015-07-22,STU002\n';
|
||||
filename = 'sample_students.csv';
|
||||
} else if (type === 'assessments') {
|
||||
csvContent = 'Title,Class ID,Subject ID,Date,Type,Max Score,Weight\nMath Test 1,cls_001,subj_math,2025-01-15,WRITTEN,100,1.0\nReading Test,cls_001,subj_german,2025-01-20,WRITTEN,50,1.0\n';
|
||||
filename = 'sample_assessments.csv';
|
||||
} else if (type === 'grades') {
|
||||
csvContent = 'Student ID,Assessment ID,Score,Note\nstu_001,asmt_001,85,Good work\nstu_002,asmt_001,92,Excellent\n';
|
||||
filename = 'sample_grades.csv';
|
||||
if (!schoolId) {
|
||||
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
return new NextResponse(csvContent, {
|
||||
headers: {
|
||||
'Content-Type': 'text/csv; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
},
|
||||
// For non-admin users, only show their own imports
|
||||
const where: Record<string, unknown> = { schoolId };
|
||||
if (session.user?.role === 'TEACHER') {
|
||||
where.userId = session.userId;
|
||||
}
|
||||
|
||||
const jobs = await db.dataImportJob.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
});
|
||||
|
||||
return NextResponse.json(jobs);
|
||||
} catch (error) {
|
||||
console.error('Sample CSV download error:', error);
|
||||
console.error('Data Import GET error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
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 event = await db.schoolEvent.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
return NextResponse.json({ error: 'Event not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const feedbacks = await db.eventFeedback.findMany({
|
||||
where: { eventId: id },
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
// Calculate average rating
|
||||
const avgRating = feedbacks.length > 0
|
||||
? feedbacks.reduce((sum, f) => sum + f.rating, 0) / feedbacks.length
|
||||
: 0;
|
||||
|
||||
return NextResponse.json({
|
||||
feedbacks,
|
||||
stats: {
|
||||
total: feedbacks.length,
|
||||
averageRating: Math.round(avgRating * 10) / 10,
|
||||
ratingDistribution: [1, 2, 3, 4, 5].map(r => ({
|
||||
rating: r,
|
||||
count: feedbacks.filter(f => f.rating === r).length,
|
||||
})),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('EventFeedback GET error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const event = await db.schoolEvent.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
return NextResponse.json({ error: 'Event not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Only allow feedback for completed events
|
||||
if (event.status !== 'completed' && new Date() < new Date(event.startDate)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Feedback can only be submitted after the event has ended' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { userId, rating, comment } = body;
|
||||
|
||||
if (!userId || !rating) {
|
||||
return NextResponse.json({ error: 'userId and rating are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (rating < 1 || rating > 5) {
|
||||
return NextResponse.json({ error: 'Rating must be between 1 and 5' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if already submitted feedback
|
||||
const existing = await db.eventFeedback.findUnique({
|
||||
where: { eventId_userId: { eventId: id, userId } },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: 'Feedback already submitted' }, { status: 409 });
|
||||
}
|
||||
|
||||
const feedback = await db.eventFeedback.create({
|
||||
data: {
|
||||
eventId: id,
|
||||
userId,
|
||||
rating,
|
||||
comment: comment || null,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(feedback, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('EventFeedback POST error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,11 @@ export async function POST(
|
||||
return NextResponse.json({ error: 'Event does not require registration' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check registration deadline
|
||||
if (event.registrationDeadline && new Date() > new Date(event.registrationDeadline)) {
|
||||
return NextResponse.json({ error: 'Registration deadline has passed' }, { status: 400 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { userId, notes } = body;
|
||||
|
||||
@@ -49,13 +54,14 @@ export async function POST(
|
||||
return NextResponse.json({ error: 'Already registered' }, { status: 409 });
|
||||
}
|
||||
|
||||
// Check max participants
|
||||
if (event.maxParticipants) {
|
||||
// Check capacity (use capacity field if available, otherwise maxParticipants)
|
||||
const maxCap = event.capacity ?? event.maxParticipants;
|
||||
if (maxCap) {
|
||||
const count = await db.eventRegistration.count({
|
||||
where: { eventId: id, status: 'registered' },
|
||||
});
|
||||
if (count >= event.maxParticipants) {
|
||||
return NextResponse.json({ error: 'Event is full' }, { status: 400 });
|
||||
if (count >= maxCap) {
|
||||
return NextResponse.json({ error: 'Event is full', waitlist: true }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +71,11 @@ export async function POST(
|
||||
userId,
|
||||
notes: notes || null,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(registration, { status: 201 });
|
||||
@@ -103,6 +114,11 @@ export async function PUT(
|
||||
const updated = await db.eventRegistration.update({
|
||||
where: { id: registration.id },
|
||||
data: { status },
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(updated);
|
||||
@@ -111,3 +127,47 @@ export async function PUT(
|
||||
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 { searchParams } = new URL(request.url);
|
||||
const userId = searchParams.get('userId');
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'userId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const registration = await db.eventRegistration.findUnique({
|
||||
where: { eventId_userId: { eventId: id, userId } },
|
||||
});
|
||||
|
||||
if (!registration) {
|
||||
return NextResponse.json({ error: 'Registration not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Cancel registration (soft delete via status)
|
||||
const updated = await db.eventRegistration.update({
|
||||
where: { id: registration.id },
|
||||
data: { status: 'cancelled' },
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
console.error('EventRegistration DELETE error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,13 @@ export async function GET(
|
||||
},
|
||||
},
|
||||
},
|
||||
feedbacks: {
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -85,6 +92,14 @@ export async function PUT(
|
||||
requiresRegistration,
|
||||
maxParticipants,
|
||||
notes,
|
||||
budget,
|
||||
registrationDeadline,
|
||||
capacity,
|
||||
isRecurring,
|
||||
recurrenceRule,
|
||||
bannerImageUrl,
|
||||
status,
|
||||
feedbackForm,
|
||||
} = body;
|
||||
|
||||
const updated = await db.schoolEvent.update({
|
||||
@@ -103,6 +118,14 @@ export async function PUT(
|
||||
...(requiresRegistration !== undefined && { requiresRegistration }),
|
||||
...(maxParticipants !== undefined && { maxParticipants: maxParticipants || null }),
|
||||
...(notes !== undefined && { notes }),
|
||||
...(budget !== undefined && { budget: budget ?? null }),
|
||||
...(registrationDeadline !== undefined && { registrationDeadline: registrationDeadline ? new Date(registrationDeadline) : null }),
|
||||
...(capacity !== undefined && { capacity: capacity ?? null }),
|
||||
...(isRecurring !== undefined && { isRecurring }),
|
||||
...(recurrenceRule !== undefined && { recurrenceRule: recurrenceRule || null }),
|
||||
...(bannerImageUrl !== undefined && { bannerImageUrl: bannerImageUrl || null }),
|
||||
...(status !== undefined && { status }),
|
||||
...(feedbackForm !== undefined && { feedbackForm: feedbackForm || null }),
|
||||
},
|
||||
include: {
|
||||
organizer: {
|
||||
@@ -118,6 +141,13 @@ export async function PUT(
|
||||
},
|
||||
},
|
||||
},
|
||||
feedbacks: {
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -14,6 +14,11 @@ export async function GET(request: Request) {
|
||||
const eventType = searchParams.get('eventType');
|
||||
const classGroupId = searchParams.get('classGroupId');
|
||||
const upcoming = searchParams.get('upcoming');
|
||||
const search = searchParams.get('search');
|
||||
const status = searchParams.get('status');
|
||||
const startDateFrom = searchParams.get('startDateFrom');
|
||||
const startDateTo = searchParams.get('startDateTo');
|
||||
const requiresRegistration = searchParams.get('requiresRegistration');
|
||||
|
||||
if (!schoolId) {
|
||||
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
|
||||
@@ -30,11 +35,24 @@ export async function GET(request: Request) {
|
||||
|
||||
if (eventType) where.eventType = eventType;
|
||||
if (classGroupId) where.classGroupId = classGroupId;
|
||||
if (status) where.status = status;
|
||||
if (requiresRegistration === 'true') where.requiresRegistration = true;
|
||||
|
||||
if (search) {
|
||||
where.title = { contains: search };
|
||||
}
|
||||
|
||||
if (upcoming === 'true') {
|
||||
where.startDate = { gte: new Date() };
|
||||
}
|
||||
|
||||
if (startDateFrom || startDateTo) {
|
||||
const dateFilter: Record<string, Date> = {};
|
||||
if (startDateFrom) dateFilter.gte = new Date(startDateFrom);
|
||||
if (startDateTo) dateFilter.lte = new Date(startDateTo);
|
||||
where.startDate = dateFilter;
|
||||
}
|
||||
|
||||
const events = await db.schoolEvent.findMany({
|
||||
where,
|
||||
orderBy: { startDate: 'asc' },
|
||||
@@ -52,6 +70,13 @@ export async function GET(request: Request) {
|
||||
},
|
||||
},
|
||||
},
|
||||
feedbacks: {
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -86,6 +111,14 @@ export async function POST(request: Request) {
|
||||
maxParticipants,
|
||||
notes,
|
||||
isDemo,
|
||||
budget,
|
||||
registrationDeadline,
|
||||
capacity,
|
||||
isRecurring,
|
||||
recurrenceRule,
|
||||
bannerImageUrl,
|
||||
status,
|
||||
feedbackForm,
|
||||
} = body;
|
||||
|
||||
if (!schoolId || !title || !eventType || !startDate) {
|
||||
@@ -116,6 +149,14 @@ export async function POST(request: Request) {
|
||||
maxParticipants: maxParticipants || null,
|
||||
notes: notes || null,
|
||||
isDemo: isDemo ?? false,
|
||||
budget: budget ?? null,
|
||||
registrationDeadline: registrationDeadline ? new Date(registrationDeadline) : null,
|
||||
capacity: capacity ?? null,
|
||||
isRecurring: isRecurring ?? false,
|
||||
recurrenceRule: recurrenceRule || null,
|
||||
bannerImageUrl: bannerImageUrl || null,
|
||||
status: status || 'draft',
|
||||
feedbackForm: feedbackForm || null,
|
||||
},
|
||||
include: {
|
||||
organizer: {
|
||||
@@ -131,6 +172,13 @@ export async function POST(request: Request) {
|
||||
},
|
||||
},
|
||||
},
|
||||
feedbacks: {
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
// GET /api/wellness — Get wellness data for a student
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const schoolId = searchParams.get('schoolId');
|
||||
const studentId = searchParams.get('studentId');
|
||||
const role = searchParams.get('role') || 'STUDENT';
|
||||
const classGroupId = searchParams.get('classGroupId');
|
||||
const limit = parseInt(searchParams.get('limit') || '30');
|
||||
|
||||
if (!schoolId) {
|
||||
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Admin/VP: school-wide overview
|
||||
if ((role === 'SCHOOL_ADMIN' || role === 'VICE_PRINCIPAL') && !studentId) {
|
||||
const totalCheckins = await db.wellnessCheckin.count({
|
||||
where: { schoolId },
|
||||
});
|
||||
const totalStudents = await db.student.count({
|
||||
where: { schoolId, deletedAt: null },
|
||||
});
|
||||
const recentCheckins = await db.wellnessCheckin.findMany({
|
||||
where: { schoolId, date: { gte: new Date(Date.now() - 7 * 86400000) } },
|
||||
orderBy: { date: 'desc' },
|
||||
take: 50,
|
||||
include: { student: { select: { id: true, firstName: true, lastName: true } } },
|
||||
});
|
||||
const avgScores = await db.wellnessScore.aggregate({
|
||||
where: { schoolId, date: { gte: new Date(Date.now() - 30 * 86400000) } },
|
||||
_avg: { overallScore: true, physicalScore: true, mentalScore: true, socialScore: true, academicScore: true },
|
||||
});
|
||||
const lowWellnessCount = await db.wellnessScore.count({
|
||||
where: { schoolId, overallScore: { lt: 40 }, date: { gte: new Date(Date.now() - 30 * 86400000) } },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
type: 'school_overview',
|
||||
totalCheckins,
|
||||
totalStudents,
|
||||
recentCheckins,
|
||||
avgScores: {
|
||||
overall: avgScores._avg.overallScore ?? 0,
|
||||
physical: avgScores._avg.physicalScore ?? 0,
|
||||
mental: avgScores._avg.mentalScore ?? 0,
|
||||
social: avgScores._avg.socialScore ?? 0,
|
||||
academic: avgScores._avg.academicScore ?? 0,
|
||||
},
|
||||
lowWellnessAlerts: lowWellnessCount,
|
||||
});
|
||||
}
|
||||
|
||||
// Teacher: class overview
|
||||
if (role === 'TEACHER' && classGroupId && !studentId) {
|
||||
const enrollments = await db.enrollment.findMany({
|
||||
where: { classGroupId, endDate: null },
|
||||
select: { studentId: true },
|
||||
});
|
||||
const studentIds = enrollments.map(e => e.studentId);
|
||||
|
||||
const checkins = await db.wellnessCheckin.findMany({
|
||||
where: { schoolId, studentId: { in: studentIds }, date: { gte: new Date(Date.now() - 30 * 86400000) } },
|
||||
orderBy: { date: 'desc' },
|
||||
take: limit,
|
||||
include: { student: { select: { id: true, firstName: true, lastName: true } } },
|
||||
});
|
||||
const scores = await db.wellnessScore.findMany({
|
||||
where: { schoolId, studentId: { in: studentIds }, date: { gte: new Date(Date.now() - 30 * 86400000) } },
|
||||
orderBy: { date: 'desc' },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
type: 'class_overview',
|
||||
checkins,
|
||||
scores,
|
||||
studentCount: studentIds.length,
|
||||
});
|
||||
}
|
||||
|
||||
// Student or specific student view
|
||||
if (studentId) {
|
||||
const checkins = await db.wellnessCheckin.findMany({
|
||||
where: { schoolId, studentId },
|
||||
orderBy: { date: 'desc' },
|
||||
take: limit,
|
||||
});
|
||||
const scores = await db.wellnessScore.findMany({
|
||||
where: { schoolId, studentId },
|
||||
orderBy: { date: 'desc' },
|
||||
take: limit,
|
||||
});
|
||||
const latestScore = scores[0] ?? null;
|
||||
|
||||
return NextResponse.json({
|
||||
type: 'student',
|
||||
checkins,
|
||||
scores,
|
||||
latestScore,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Invalid parameters' }, { status: 400 });
|
||||
} catch (error) {
|
||||
console.error('Error fetching wellness data:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch wellness data' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/wellness — Submit daily check-in
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { schoolId, studentId, mood, sleepHours, sleepQuality, stressLevel, activityType, activityMinutes, mealsCount, waterGlasses, gratitudeEntry, notes } = body;
|
||||
|
||||
if (!schoolId || !studentId || mood === undefined) {
|
||||
return NextResponse.json({ error: 'schoolId, studentId, and mood are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (mood < 1 || mood > 5) {
|
||||
return NextResponse.json({ error: 'Mood must be between 1 and 5' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check for existing check-in today
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
const existing = await db.wellnessCheckin.findFirst({
|
||||
where: {
|
||||
schoolId,
|
||||
studentId,
|
||||
date: { gte: today, lt: tomorrow },
|
||||
},
|
||||
});
|
||||
|
||||
let checkin;
|
||||
if (existing) {
|
||||
checkin = await db.wellnessCheckin.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
mood,
|
||||
sleepHours: sleepHours ?? null,
|
||||
sleepQuality: sleepQuality ?? null,
|
||||
stressLevel: stressLevel ?? null,
|
||||
activityType: activityType ?? null,
|
||||
activityMinutes: activityMinutes ?? null,
|
||||
mealsCount: mealsCount ?? null,
|
||||
waterGlasses: waterGlasses ?? null,
|
||||
gratitudeEntry: gratitudeEntry ?? null,
|
||||
notes: notes ?? null,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
checkin = await db.wellnessCheckin.create({
|
||||
data: {
|
||||
schoolId,
|
||||
studentId,
|
||||
date: new Date(),
|
||||
mood,
|
||||
sleepHours: sleepHours ?? null,
|
||||
sleepQuality: sleepQuality ?? null,
|
||||
stressLevel: stressLevel ?? null,
|
||||
activityType: activityType ?? null,
|
||||
activityMinutes: activityMinutes ?? null,
|
||||
mealsCount: mealsCount ?? null,
|
||||
waterGlasses: waterGlasses ?? null,
|
||||
gratitudeEntry: gratitudeEntry ?? null,
|
||||
notes: notes ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate wellness score
|
||||
const recentCheckins = await db.wellnessCheckin.findMany({
|
||||
where: { schoolId, studentId, date: { gte: new Date(Date.now() - 14 * 86400000) } },
|
||||
orderBy: { date: 'desc' },
|
||||
take: 14,
|
||||
});
|
||||
|
||||
if (recentCheckins.length >= 1) {
|
||||
const avgMood = recentCheckins.reduce((s, c) => s + c.mood, 0) / recentCheckins.length;
|
||||
const sleepCheckins = recentCheckins.filter(c => c.sleepHours !== null);
|
||||
const avgSleep = sleepCheckins.length > 0 ? sleepCheckins.reduce((s, c) => s + (c.sleepHours ?? 0), 0) / sleepCheckins.length : 7;
|
||||
const stressCheckins = recentCheckins.filter(c => c.stressLevel !== null);
|
||||
const avgStress = stressCheckins.length > 0 ? stressCheckins.reduce((s, c) => s + (c.stressLevel ?? 5), 0) / stressCheckins.length : 5;
|
||||
const activityCheckins = recentCheckins.filter(c => c.activityMinutes !== null);
|
||||
const avgActivity = activityCheckins.length > 0 ? activityCheckins.reduce((s, c) => s + (c.activityMinutes ?? 0), 0) / activityCheckins.length : 0;
|
||||
|
||||
// Physical: based on sleep + activity
|
||||
const physicalScore = Math.min(100, Math.max(0,
|
||||
((avgSleep >= 7 && avgSleep <= 9 ? 40 : avgSleep >= 6 ? 25 : 10) +
|
||||
(avgActivity >= 30 ? 30 : avgActivity >= 15 ? 20 : 10) +
|
||||
(avgMood >= 3 ? 30 : avgMood >= 2 ? 15 : 5))
|
||||
));
|
||||
|
||||
// Mental: based on mood + stress
|
||||
const mentalScore = Math.min(100, Math.max(0,
|
||||
((avgMood / 5) * 50) + ((10 - avgStress) / 10) * 50
|
||||
));
|
||||
|
||||
// Social: based on mood (proxy)
|
||||
const socialScore = Math.min(100, Math.max(0, (avgMood / 5) * 80 + 10));
|
||||
|
||||
// Academic: based on mood + stress + sleep
|
||||
const academicScore = Math.min(100, Math.max(0,
|
||||
((avgMood / 5) * 35) + ((10 - avgStress) / 10) * 30 + ((avgSleep >= 7 ? 1 : avgSleep / 7) * 35)
|
||||
));
|
||||
|
||||
const overallScore = Math.round((physicalScore * 0.25 + mentalScore * 0.3 + socialScore * 0.2 + academicScore * 0.25) * 10) / 10;
|
||||
|
||||
// Upsert wellness score
|
||||
const existingScore = await db.wellnessScore.findFirst({
|
||||
where: { schoolId, studentId, date: { gte: today, lt: tomorrow } },
|
||||
});
|
||||
|
||||
if (existingScore) {
|
||||
await db.wellnessScore.update({
|
||||
where: { id: existingScore.id },
|
||||
data: {
|
||||
overallScore,
|
||||
physicalScore: Math.round(physicalScore * 10) / 10,
|
||||
mentalScore: Math.round(mentalScore * 10) / 10,
|
||||
socialScore: Math.round(socialScore * 10) / 10,
|
||||
academicScore: Math.round(academicScore * 10) / 10,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await db.wellnessScore.create({
|
||||
data: {
|
||||
schoolId,
|
||||
studentId,
|
||||
date: new Date(),
|
||||
overallScore,
|
||||
physicalScore: Math.round(physicalScore * 10) / 10,
|
||||
mentalScore: Math.round(mentalScore * 10) / 10,
|
||||
socialScore: Math.round(socialScore * 10) / 10,
|
||||
academicScore: Math.round(academicScore * 10) / 10,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ checkin, success: true });
|
||||
} catch (error) {
|
||||
console.error('Error saving wellness check-in:', error);
|
||||
return NextResponse.json({ error: 'Failed to save check-in' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
// GET /api/wellness/summary — Get wellness summary/stats
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const schoolId = searchParams.get('schoolId');
|
||||
const studentId = searchParams.get('studentId');
|
||||
const period = searchParams.get('period') || 'week'; // week or month
|
||||
|
||||
if (!schoolId || !studentId) {
|
||||
return NextResponse.json({ error: 'schoolId and studentId are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const days = period === 'month' ? 30 : 7;
|
||||
const startDate = new Date(Date.now() - days * 86400000);
|
||||
|
||||
const checkins = await db.wellnessCheckin.findMany({
|
||||
where: { schoolId, studentId, date: { gte: startDate } },
|
||||
orderBy: { date: 'asc' },
|
||||
});
|
||||
|
||||
const scores = await db.wellnessScore.findMany({
|
||||
where: { schoolId, studentId, date: { gte: startDate } },
|
||||
orderBy: { date: 'asc' },
|
||||
});
|
||||
|
||||
if (checkins.length === 0) {
|
||||
return NextResponse.json({
|
||||
type: 'summary',
|
||||
period,
|
||||
checkinCount: 0,
|
||||
avgMood: 0,
|
||||
avgSleepHours: 0,
|
||||
avgSleepQuality: 0,
|
||||
avgStressLevel: 0,
|
||||
totalActivityMinutes: 0,
|
||||
avgMealsCount: 0,
|
||||
avgWaterGlasses: 0,
|
||||
avgOverallScore: 0,
|
||||
avgPhysicalScore: 0,
|
||||
avgMentalScore: 0,
|
||||
avgSocialScore: 0,
|
||||
avgAcademicScore: 0,
|
||||
gratitudeEntries: [],
|
||||
moodDistribution: { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 },
|
||||
stressDistribution: {},
|
||||
recommendations: [],
|
||||
});
|
||||
}
|
||||
|
||||
const avgMood = checkins.reduce((s, c) => s + c.mood, 0) / checkins.length;
|
||||
const sleepCheckins = checkins.filter(c => c.sleepHours !== null);
|
||||
const avgSleepHours = sleepCheckins.length > 0 ? sleepCheckins.reduce((s, c) => s + (c.sleepHours ?? 0), 0) / sleepCheckins.length : 0;
|
||||
const sleepQualityCheckins = checkins.filter(c => c.sleepQuality !== null);
|
||||
const avgSleepQuality = sleepQualityCheckins.length > 0 ? sleepQualityCheckins.reduce((s, c) => s + (c.sleepQuality ?? 0), 0) / sleepQualityCheckins.length : 0;
|
||||
const stressCheckins = checkins.filter(c => c.stressLevel !== null);
|
||||
const avgStressLevel = stressCheckins.length > 0 ? stressCheckins.reduce((s, c) => s + (c.stressLevel ?? 0), 0) / stressCheckins.length : 0;
|
||||
const activityCheckins = checkins.filter(c => c.activityMinutes !== null);
|
||||
const totalActivityMinutes = activityCheckins.reduce((s, c) => s + (c.activityMinutes ?? 0), 0);
|
||||
const mealsCheckins = checkins.filter(c => c.mealsCount !== null);
|
||||
const avgMealsCount = mealsCheckins.length > 0 ? mealsCheckins.reduce((s, c) => s + (c.mealsCount ?? 0), 0) / mealsCheckins.length : 0;
|
||||
const waterCheckins = checkins.filter(c => c.waterGlasses !== null);
|
||||
const avgWaterGlasses = waterCheckins.length > 0 ? waterCheckins.reduce((s, c) => s + (c.waterGlasses ?? 0), 0) / waterCheckins.length : 0;
|
||||
|
||||
const avgOverallScore = scores.length > 0 ? scores.reduce((s, sc) => s + sc.overallScore, 0) / scores.length : 0;
|
||||
const avgPhysicalScore = scores.length > 0 ? scores.filter(s => s.physicalScore !== null).reduce((s, sc) => s + (sc.physicalScore ?? 0), 0) / scores.length : 0;
|
||||
const avgMentalScore = scores.length > 0 ? scores.filter(s => s.mentalScore !== null).reduce((s, sc) => s + (sc.mentalScore ?? 0), 0) / scores.length : 0;
|
||||
const avgSocialScore = scores.length > 0 ? scores.filter(s => s.socialScore !== null).reduce((s, sc) => s + (sc.socialScore ?? 0), 0) / scores.length : 0;
|
||||
const avgAcademicScore = scores.length > 0 ? scores.filter(s => s.academicScore !== null).reduce((s, sc) => s + (sc.academicScore ?? 0), 0) / scores.length : 0;
|
||||
|
||||
// Mood distribution
|
||||
const moodDistribution: Record<number, number> = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
|
||||
checkins.forEach(c => { moodDistribution[c.mood] = (moodDistribution[c.mood] || 0) + 1; });
|
||||
|
||||
// Stress distribution
|
||||
const stressDistribution: Record<number, number> = {};
|
||||
stressCheckins.forEach(c => {
|
||||
const level = c.stressLevel ?? 0;
|
||||
stressDistribution[level] = (stressDistribution[level] || 0) + 1;
|
||||
});
|
||||
|
||||
// Gratitude entries
|
||||
const gratitudeEntries = checkins.filter(c => c.gratitudeEntry).map(c => ({
|
||||
date: c.date,
|
||||
entry: c.gratitudeEntry,
|
||||
}));
|
||||
|
||||
// AI recommendations based on patterns
|
||||
const recommendations: string[] = [];
|
||||
if (avgSleepHours < 7) {
|
||||
recommendations.push('Try to get at least 7-8 hours of sleep per night for better recovery.');
|
||||
}
|
||||
if (avgStressLevel > 6) {
|
||||
recommendations.push('Consider stress-reduction techniques like deep breathing, meditation, or talking to a counselor.');
|
||||
}
|
||||
if (totalActivityMinutes < 150) {
|
||||
recommendations.push('Aim for at least 30 minutes of physical activity most days of the week.');
|
||||
}
|
||||
if (avgMood < 3) {
|
||||
recommendations.push('Your mood has been consistently low. Consider reaching out to a trusted adult or counselor.');
|
||||
}
|
||||
if (avgWaterGlasses < 6) {
|
||||
recommendations.push('Try to drink at least 6-8 glasses of water daily for better hydration.');
|
||||
}
|
||||
if (avgMealsCount < 3) {
|
||||
recommendations.push('Regular meals are important for energy and concentration. Try to eat at least 3 meals per day.');
|
||||
}
|
||||
if (avgSleepQuality < 3) {
|
||||
recommendations.push('Improve sleep quality by reducing screen time before bed and maintaining a consistent sleep schedule.');
|
||||
}
|
||||
if (recommendations.length === 0) {
|
||||
recommendations.push('Great job! Your wellness indicators are looking good. Keep up the healthy habits!');
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
type: 'summary',
|
||||
period,
|
||||
checkinCount: checkins.length,
|
||||
avgMood: Math.round(avgMood * 10) / 10,
|
||||
avgSleepHours: Math.round(avgSleepHours * 10) / 10,
|
||||
avgSleepQuality: Math.round(avgSleepQuality * 10) / 10,
|
||||
avgStressLevel: Math.round(avgStressLevel * 10) / 10,
|
||||
totalActivityMinutes,
|
||||
avgMealsCount: Math.round(avgMealsCount * 10) / 10,
|
||||
avgWaterGlasses: Math.round(avgWaterGlasses * 10) / 10,
|
||||
avgOverallScore: Math.round(avgOverallScore * 10) / 10,
|
||||
avgPhysicalScore: Math.round(avgPhysicalScore * 10) / 10,
|
||||
avgMentalScore: Math.round(avgMentalScore * 10) / 10,
|
||||
avgSocialScore: Math.round(avgSocialScore * 10) / 10,
|
||||
avgAcademicScore: Math.round(avgAcademicScore * 10) / 10,
|
||||
gratitudeEntries,
|
||||
moodDistribution,
|
||||
stressDistribution,
|
||||
recommendations,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching wellness summary:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch wellness summary' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
// GET /api/wellness/trends — Get wellness trends over time
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const schoolId = searchParams.get('schoolId');
|
||||
const studentId = searchParams.get('studentId');
|
||||
const days = parseInt(searchParams.get('days') || '30');
|
||||
const classGroupId = searchParams.get('classGroupId');
|
||||
const includeClassAvg = searchParams.get('includeClassAvg') === 'true';
|
||||
|
||||
if (!schoolId) {
|
||||
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const startDate = new Date(Date.now() - days * 86400000);
|
||||
|
||||
if (studentId) {
|
||||
// Individual student trends
|
||||
const checkins = await db.wellnessCheckin.findMany({
|
||||
where: { schoolId, studentId, date: { gte: startDate } },
|
||||
orderBy: { date: 'asc' },
|
||||
});
|
||||
|
||||
const scores = await db.wellnessScore.findMany({
|
||||
where: { schoolId, studentId, date: { gte: startDate } },
|
||||
orderBy: { date: 'asc' },
|
||||
});
|
||||
|
||||
// Build daily data points
|
||||
const trendData = [];
|
||||
for (let i = 0; i < days; i++) {
|
||||
const dayDate = new Date(startDate);
|
||||
dayDate.setDate(dayDate.getDate() + i);
|
||||
const dayStr = dayDate.toISOString().split('T')[0];
|
||||
|
||||
const dayCheckin = checkins.find(c => c.date.toISOString().split('T')[0] === dayStr);
|
||||
const dayScore = scores.find(s => s.date.toISOString().split('T')[0] === dayStr);
|
||||
|
||||
trendData.push({
|
||||
date: dayStr,
|
||||
mood: dayCheckin?.mood ?? null,
|
||||
sleepHours: dayCheckin?.sleepHours ?? null,
|
||||
sleepQuality: dayCheckin?.sleepQuality ?? null,
|
||||
stressLevel: dayCheckin?.stressLevel ?? null,
|
||||
activityMinutes: dayCheckin?.activityMinutes ?? null,
|
||||
overallScore: dayScore?.overallScore ?? null,
|
||||
physicalScore: dayScore?.physicalScore ?? null,
|
||||
mentalScore: dayScore?.mentalScore ?? null,
|
||||
socialScore: dayScore?.socialScore ?? null,
|
||||
academicScore: dayScore?.academicScore ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// Class average for comparison (anonymized)
|
||||
let classAvgData: Array<{ date: string; avgOverall: number; avgMood: number; avgStress: number }> = [];
|
||||
if (includeClassAvg && classGroupId) {
|
||||
const enrollments = await db.enrollment.findMany({
|
||||
where: { classGroupId, endDate: null },
|
||||
select: { studentId: true },
|
||||
});
|
||||
const classStudentIds = enrollments.map(e => e.studentId).filter(id => id !== studentId);
|
||||
|
||||
if (classStudentIds.length > 0) {
|
||||
const classScores = await db.wellnessScore.findMany({
|
||||
where: { schoolId, studentId: { in: classStudentIds }, date: { gte: startDate } },
|
||||
orderBy: { date: 'asc' },
|
||||
});
|
||||
const classCheckins = await db.wellnessCheckin.findMany({
|
||||
where: { schoolId, studentId: { in: classStudentIds }, date: { gte: startDate } },
|
||||
orderBy: { date: 'asc' },
|
||||
});
|
||||
|
||||
for (let i = 0; i < days; i++) {
|
||||
const dayDate = new Date(startDate);
|
||||
dayDate.setDate(dayDate.getDate() + i);
|
||||
const dayStr = dayDate.toISOString().split('T')[0];
|
||||
|
||||
const dayScores = classScores.filter(s => s.date.toISOString().split('T')[0] === dayStr);
|
||||
const dayCheckins = classCheckins.filter(c => c.date.toISOString().split('T')[0] === dayStr);
|
||||
|
||||
if (dayScores.length > 0 || dayCheckins.length > 0) {
|
||||
classAvgData.push({
|
||||
date: dayStr,
|
||||
avgOverall: dayScores.length > 0 ? Math.round(dayScores.reduce((s, sc) => s + sc.overallScore, 0) / dayScores.length * 10) / 10 : 0,
|
||||
avgMood: dayCheckins.length > 0 ? Math.round(dayCheckins.reduce((s, c) => s + c.mood, 0) / dayCheckins.length * 10) / 10 : 0,
|
||||
avgStress: dayCheckins.filter(c => c.stressLevel !== null).length > 0 ? Math.round(dayCheckins.filter(c => c.stressLevel !== null).reduce((s, c) => s + (c.stressLevel ?? 0), 0) / dayCheckins.filter(c => c.stressLevel !== null).length * 10) / 10 : 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
type: 'trends',
|
||||
studentId,
|
||||
days,
|
||||
trendData,
|
||||
classAvgData,
|
||||
});
|
||||
}
|
||||
|
||||
// School-wide trends (for admin)
|
||||
const allScores = await db.wellnessScore.findMany({
|
||||
where: { schoolId, date: { gte: startDate } },
|
||||
orderBy: { date: 'asc' },
|
||||
});
|
||||
|
||||
const schoolTrendData = [];
|
||||
for (let i = 0; i < days; i++) {
|
||||
const dayDate = new Date(startDate);
|
||||
dayDate.setDate(dayDate.getDate() + i);
|
||||
const dayStr = dayDate.toISOString().split('T')[0];
|
||||
|
||||
const dayScores = allScores.filter(s => s.date.toISOString().split('T')[0] === dayStr);
|
||||
|
||||
if (dayScores.length > 0) {
|
||||
schoolTrendData.push({
|
||||
date: dayStr,
|
||||
avgOverall: Math.round(dayScores.reduce((s, sc) => s + sc.overallScore, 0) / dayScores.length * 10) / 10,
|
||||
avgPhysical: Math.round(dayScores.filter(s => s.physicalScore !== null).reduce((s, sc) => s + (sc.physicalScore ?? 0), 0) / dayScores.length * 10) / 10,
|
||||
avgMental: Math.round(dayScores.filter(s => s.mentalScore !== null).reduce((s, sc) => s + (sc.mentalScore ?? 0), 0) / dayScores.length * 10) / 10,
|
||||
avgSocial: Math.round(dayScores.filter(s => s.socialScore !== null).reduce((s, sc) => s + (sc.socialScore ?? 0), 0) / dayScores.length * 10) / 10,
|
||||
avgAcademic: Math.round(dayScores.filter(s => s.academicScore !== null).reduce((s, sc) => s + (sc.academicScore ?? 0), 0) / dayScores.length * 10) / 10,
|
||||
studentCount: dayScores.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
type: 'school_trends',
|
||||
days,
|
||||
trendData: schoolTrendData,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching wellness trends:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch wellness trends' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -876,6 +876,168 @@
|
||||
transform: translateY(0px) scale(0.99);
|
||||
}
|
||||
|
||||
/* ─── New Utility Classes (Round 24) ──────────────────────────────── */
|
||||
|
||||
/* .animate-pulse-slow — Slower pulse animation */
|
||||
@keyframes pulse-slow {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.7; transform: scale(1.05); }
|
||||
}
|
||||
.animate-pulse-slow {
|
||||
animation: pulse-slow 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* .hover-lift — Hover lift effect */
|
||||
.hover-lift {
|
||||
transition: transform 0.2s ease-out, box-shadow 0.2s ease-out;
|
||||
}
|
||||
.hover-lift:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 12px 28px -8px oklch(0.55 0.17 155 / 0.2);
|
||||
}
|
||||
.dark .hover-lift:hover {
|
||||
box-shadow: 0 12px 28px -8px oklch(0.65 0.17 155 / 0.3);
|
||||
}
|
||||
|
||||
/* .gradient-border — Border with gradient */
|
||||
.gradient-border {
|
||||
position: relative;
|
||||
border: none;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
.gradient-border::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
margin: -1.5px;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(135deg, oklch(0.55 0.17 155), oklch(0.55 0.14 175), oklch(0.65 0.22 330));
|
||||
}
|
||||
.dark .gradient-border::before {
|
||||
background: linear-gradient(135deg, oklch(0.65 0.17 155), oklch(0.65 0.14 175), oklch(0.75 0.22 330));
|
||||
}
|
||||
|
||||
/* .pattern-dots — Dot pattern background */
|
||||
.pattern-dots {
|
||||
background-image: radial-gradient(circle, oklch(0.55 0.17 155 / 0.12) 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
.dark .pattern-dots {
|
||||
background-image: radial-gradient(circle, oklch(0.65 0.17 155 / 0.15) 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
/* .pattern-grid — Grid pattern background */
|
||||
.pattern-grid {
|
||||
background-image:
|
||||
linear-gradient(oklch(0.55 0.17 155 / 0.05) 1px, transparent 1px),
|
||||
linear-gradient(90deg, oklch(0.55 0.17 155 / 0.05) 1px, transparent 1px);
|
||||
background-size: 32px 32px;
|
||||
}
|
||||
.dark .pattern-grid {
|
||||
background-image:
|
||||
linear-gradient(oklch(0.65 0.17 155 / 0.06) 1px, transparent 1px),
|
||||
linear-gradient(90deg, oklch(0.65 0.17 155 / 0.06) 1px, transparent 1px);
|
||||
background-size: 32px 32px;
|
||||
}
|
||||
|
||||
/* @keyframes slideInUp — Slide in from bottom */
|
||||
@keyframes slideInUp {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.animate-slide-in-up {
|
||||
animation: slideInUp 0.4s ease-out;
|
||||
}
|
||||
|
||||
/* @keyframes scaleIn — Scale in from center */
|
||||
@keyframes scaleIn {
|
||||
from { opacity: 0; transform: scale(0.9); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
.animate-scale-in {
|
||||
animation: scaleIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* @keyframes fadeInScale — Fade + scale combined */
|
||||
@keyframes fadeInScale {
|
||||
from { opacity: 0; transform: scale(0.95) translateY(8px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
.animate-fade-in-scale {
|
||||
animation: fadeInScale 0.4s ease-out;
|
||||
}
|
||||
|
||||
/* Notification badge pulse */
|
||||
@keyframes badge-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 oklch(0.55 0.17 155 / 0.4); }
|
||||
50% { box-shadow: 0 0 0 6px oklch(0.55 0.17 155 / 0); }
|
||||
}
|
||||
.animate-badge-pulse {
|
||||
animation: badge-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
.dark .animate-badge-pulse {
|
||||
animation-name: badge-pulse-dark;
|
||||
}
|
||||
@keyframes badge-pulse-dark {
|
||||
0%, 100% { box-shadow: 0 0 0 0 oklch(0.65 0.17 155 / 0.5); }
|
||||
50% { box-shadow: 0 0 0 6px oklch(0.65 0.17 155 / 0); }
|
||||
}
|
||||
|
||||
/* Animated gradient header banner */
|
||||
@keyframes header-gradient-shift {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
.animate-header-gradient {
|
||||
background-size: 200% 200%;
|
||||
animation: header-gradient-shift 6s ease infinite;
|
||||
}
|
||||
|
||||
/* Timeline connector line for activity entries */
|
||||
.timeline-connector {
|
||||
position: relative;
|
||||
}
|
||||
.timeline-connector::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 15px;
|
||||
top: 36px;
|
||||
bottom: -8px;
|
||||
width: 2px;
|
||||
background: oklch(0.90 0.01 155);
|
||||
}
|
||||
.dark .timeline-connector::before {
|
||||
background: oklch(0.25 0.015 260);
|
||||
}
|
||||
.timeline-connector:last-child::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Input validation states */
|
||||
.input-valid {
|
||||
border-color: oklch(0.55 0.17 155) !important;
|
||||
box-shadow: 0 0 0 3px oklch(0.55 0.17 155 / 0.12);
|
||||
}
|
||||
.input-invalid {
|
||||
border-color: oklch(0.577 0.245 27.325) !important;
|
||||
box-shadow: 0 0 0 3px oklch(0.577 0.245 27.325 / 0.12);
|
||||
}
|
||||
|
||||
/* Social login button hover */
|
||||
.social-btn {
|
||||
transition: all 0.2s ease-out;
|
||||
}
|
||||
.social-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px oklch(0 0 0 / 0.1);
|
||||
}
|
||||
.social-btn:active {
|
||||
transform: translateY(0) scale(0.98);
|
||||
}
|
||||
|
||||
/* ─── Danger Zone Styling ────────────────────────────────────────── */
|
||||
.danger-zone {
|
||||
border: 2px dashed oklch(0.55 0.22 25 / 0.4);
|
||||
|
||||
@@ -68,6 +68,8 @@ import {
|
||||
Timer,
|
||||
LayoutGrid as LayoutGridIcon,
|
||||
BookOpen as BookOpenIconNav,
|
||||
Database,
|
||||
PartyPopper,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Sidebar,
|
||||
@@ -182,6 +184,9 @@ import StudentStudyPlannerView from './student-study-planner-view';
|
||||
import ReportCardView from './report-card-view';
|
||||
import SchoolLibraryView from './school-library-view';
|
||||
import SeatingChartView from './seating-chart-view';
|
||||
import StudentWellnessView from './student-wellness-view';
|
||||
import DataImportExportView from './data-import-export-view';
|
||||
import SchoolEventsView from './school-events-view';
|
||||
|
||||
// Dynamic imports for heavy components with loading skeletons
|
||||
const AnalyticsView = dynamic(() => import('./analytics-view'), {
|
||||
@@ -251,8 +256,11 @@ const navSections: NavSection[] = [
|
||||
{ key: 'report-cards', icon: FileText, labelKey: 'nav.report_cards' },
|
||||
{ key: 'seating-chart', icon: LayoutGridIcon, labelKey: 'nav.seating_chart' },
|
||||
{ key: 'school-library', icon: LibraryIcon, labelKey: 'nav.school_library' },
|
||||
{ key: 'student-wellness', icon: Heart, labelKey: 'nav.student-wellness' },
|
||||
{ key: 'notification-center', icon: Bell, labelKey: 'nav.notification_center' },
|
||||
{ key: 'announcements', icon: Megaphone, labelKey: 'nav.announcements' },
|
||||
{ key: 'school-events', icon: PartyPopper, labelKey: 'nav.school-events' },
|
||||
{ key: 'data-import-export', icon: Database, labelKey: 'nav.data-import-export' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -293,8 +301,10 @@ const studentNavSections: NavSection[] = [
|
||||
{ key: 'seating-chart', icon: LayoutGridIcon, labelKey: 'nav.seating_chart' },
|
||||
{ key: 'student-achievements', icon: Trophy, labelKey: 'achievements.title' },
|
||||
{ key: 'student-study-planner', icon: Timer, labelKey: 'study_planner.title' },
|
||||
{ key: 'student-wellness', icon: Heart, labelKey: 'nav.student-wellness' },
|
||||
{ key: 'resources', icon: FolderOpen, labelKey: 'nav.resources' },
|
||||
{ key: 'school-library', icon: LibraryIcon, labelKey: 'nav.school_library' },
|
||||
{ key: 'school-events', icon: PartyPopper, labelKey: 'nav.school-events' },
|
||||
{ key: 'notification-center', icon: Bell, labelKey: 'nav.notification_center' },
|
||||
{ key: 'announcements', icon: Megaphone, labelKey: 'nav.announcements' },
|
||||
{ key: 'settings', icon: Settings, labelKey: 'nav.settings' },
|
||||
@@ -321,6 +331,7 @@ const parentNavSections: NavSection[] = [
|
||||
{ key: 'student-study-planner', icon: Timer, labelKey: 'study_planner.title' },
|
||||
{ key: 'report-cards', icon: FileText, labelKey: 'nav.report_cards' },
|
||||
{ key: 'school-library', icon: LibraryIcon, labelKey: 'nav.school_library' },
|
||||
{ key: 'school-events', icon: PartyPopper, labelKey: 'nav.school-events' },
|
||||
{ key: 'notification-center', icon: Bell, labelKey: 'nav.notification_center' },
|
||||
{ key: 'announcements', icon: Megaphone, labelKey: 'nav.announcements' },
|
||||
],
|
||||
@@ -471,6 +482,9 @@ function renderView(view: ViewName) {
|
||||
case 'seating-chart': return <SeatingChartView />;
|
||||
case 'school-library': return <SchoolLibraryView />;
|
||||
case 'report-cards': return <ReportCardView />;
|
||||
case 'student-wellness': return <StudentWellnessView />;
|
||||
case 'data-import-export': return <DataImportExportView />;
|
||||
case 'school-events': return <SchoolEventsView />;
|
||||
default: return <DashboardView />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { BookOpen, Mail, Lock, User, ArrowRight, GraduationCap, Heart, Sparkles, Flower2, Eye, EyeOff, KeyRound, Info, Shield, Leaf, Users as UsersIcon, HelpCircle, BarChart3, LockKeyhole, GitBranch } from 'lucide-react';
|
||||
import { BookOpen, Mail, Lock, User, ArrowRight, GraduationCap, Heart, Sparkles, Flower2, Eye, EyeOff, KeyRound, Info, Shield, Leaf, Users as UsersIcon, HelpCircle, BarChart3, LockKeyhole, GitBranch, CheckCircle2, XCircle, Globe, Github, Chrome } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useAppStore } from '@/lib/store';
|
||||
@@ -338,8 +339,19 @@ export default function AuthView() {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [rememberMe, setRememberMe] = useState(true);
|
||||
const [showForgotInfo, setShowForgotInfo] = useState(false);
|
||||
const [showForgotModal, setShowForgotModal] = useState(false);
|
||||
const [forgotEmail, setForgotEmail] = useState('');
|
||||
const [forgotSent, setForgotSent] = useState(false);
|
||||
const setCurrentUser = useAppStore((s) => s.setCurrentUser);
|
||||
|
||||
// Input validation state
|
||||
const emailValid = email.length > 0 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
const emailTouched = email.length > 0;
|
||||
const passwordValid = password.length >= 6;
|
||||
const passwordTouched = password.length > 0;
|
||||
const firstNameValid = firstName.length > 0;
|
||||
const lastNameValid = lastName.length > 0;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
@@ -591,12 +603,32 @@ export default function AuthView() {
|
||||
<Label htmlFor="firstName" className="text-sm font-medium">{t('auth.firstName')}</Label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3.5 top-1/2 -translate-y-1/2 h-5 w-5 text-emerald-400" />
|
||||
<Input id="firstName" value={firstName} onChange={(e) => setFirstName(e.target.value)} className="pl-10 h-12 min-h-[44px] border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20 text-base bg-white/60 dark:bg-gray-900/40 backdrop-blur-sm" placeholder={t('auth.firstName')} required />
|
||||
<Input id="firstName" value={firstName} onChange={(e) => setFirstName(e.target.value)} className={`pl-10 pr-9 h-12 min-h-[44px] bg-white/60 dark:bg-gray-900/40 backdrop-blur-sm transition-all duration-200 ${
|
||||
firstName.length > 0 && firstNameValid ? 'input-valid border-emerald-400 dark:border-emerald-500'
|
||||
: firstName.length > 0 && !firstNameValid ? 'input-invalid border-red-300 dark:border-red-700'
|
||||
: 'border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20'
|
||||
}`} placeholder={t('auth.firstName')} required />
|
||||
{firstName.length > 0 && (
|
||||
<motion.div initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ type: 'spring', stiffness: 300, damping: 20 }} className="absolute right-2.5 top-1/2 -translate-y-1/2">
|
||||
{firstNameValid ? <CheckCircle2 className="h-4 w-4 text-emerald-500" /> : <XCircle className="h-4 w-4 text-red-400" />}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lastName" className="text-sm font-medium">{t('auth.lastName')}</Label>
|
||||
<Input id="lastName" value={lastName} onChange={(e) => setLastName(e.target.value)} className="h-12 min-h-[44px] border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20 text-base bg-white/60 dark:bg-gray-900/40 backdrop-blur-sm" placeholder={t('auth.lastName')} required />
|
||||
<div className="relative">
|
||||
<Input id="lastName" value={lastName} onChange={(e) => setLastName(e.target.value)} className={`pr-9 h-12 min-h-[44px] bg-white/60 dark:bg-gray-900/40 backdrop-blur-sm transition-all duration-200 ${
|
||||
lastName.length > 0 && lastNameValid ? 'input-valid border-emerald-400 dark:border-emerald-500'
|
||||
: lastName.length > 0 && !lastNameValid ? 'input-invalid border-red-300 dark:border-red-700'
|
||||
: 'border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20'
|
||||
}`} placeholder={t('auth.lastName')} required />
|
||||
{lastName.length > 0 && (
|
||||
<motion.div initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ type: 'spring', stiffness: 300, damping: 20 }} className="absolute right-2.5 top-1/2 -translate-y-1/2">
|
||||
{lastNameValid ? <CheckCircle2 className="h-4 w-4 text-emerald-500" /> : <XCircle className="h-4 w-4 text-red-400" />}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
@@ -624,7 +656,7 @@ export default function AuthView() {
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Email field (always shown) */}
|
||||
{/* Email field (always shown) with validation feedback */}
|
||||
<motion.div variants={itemVariants} className="space-y-2">
|
||||
<Label htmlFor="email" className="text-sm font-medium">{t('auth.email')}</Label>
|
||||
<div className="relative">
|
||||
@@ -634,7 +666,11 @@ export default function AuthView() {
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className={`pl-10 h-12 min-h-[44px] border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20 text-base bg-white/60 dark:bg-gray-900/40 backdrop-blur-sm transition-all duration-200 ${
|
||||
className={`pl-10 pr-10 h-12 min-h-[44px] text-base bg-white/60 dark:bg-gray-900/40 backdrop-blur-sm transition-all duration-200 ${
|
||||
emailTouched && emailValid ? 'input-valid border-emerald-400 dark:border-emerald-500 focus:border-emerald-500 focus:ring-emerald-400/20'
|
||||
: emailTouched && !emailValid ? 'input-invalid border-red-300 dark:border-red-700 focus:border-red-400 focus:ring-red-400/20'
|
||||
: 'border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20'
|
||||
} ${
|
||||
loginRole === 'student' ? 'focus:border-amber-400 focus:ring-amber-400/20'
|
||||
: loginRole === 'parent' ? 'focus:border-violet-400 focus:ring-violet-400/20'
|
||||
: ''
|
||||
@@ -642,17 +678,32 @@ export default function AuthView() {
|
||||
placeholder={loginRole === 'student' ? 'name@schule.de' : loginRole === 'parent' ? 'parent@email.de' : 'name@schule.de'}
|
||||
required
|
||||
/>
|
||||
{/* Validation icon */}
|
||||
{emailTouched && (
|
||||
<motion.div
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
||||
className="absolute right-3.5 top-1/2 -translate-y-1/2"
|
||||
>
|
||||
{emailValid ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-500" />
|
||||
) : (
|
||||
<XCircle className="h-5 w-5 text-red-400" />
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Password field */}
|
||||
{/* Password field with validation feedback */}
|
||||
<motion.div variants={itemVariants} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password" className="text-sm font-medium">{t('auth.password')}</Label>
|
||||
{mode === 'login' && (
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={() => setShowForgotInfo(!showForgotInfo)}
|
||||
onClick={() => setShowForgotModal(true)}
|
||||
className="text-xs text-emerald-600 hover:text-emerald-700 dark:text-emerald-400 dark:hover:text-emerald-300 font-medium transition-colors"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
@@ -668,15 +719,34 @@ export default function AuthView() {
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="pl-9 pr-10 h-12 min-h-[44px] border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20 bg-white/60 dark:bg-gray-900/40 backdrop-blur-sm transition-all duration-200"
|
||||
className={`pl-9 pr-12 h-12 min-h-[44px] bg-white/60 dark:bg-gray-900/40 backdrop-blur-sm transition-all duration-200 ${
|
||||
passwordTouched && passwordValid ? 'input-valid border-emerald-400 dark:border-emerald-500 focus:border-emerald-500 focus:ring-emerald-400/20'
|
||||
: passwordTouched && !passwordValid ? 'input-invalid border-red-300 dark:border-red-700 focus:border-red-400 focus:ring-red-400/20'
|
||||
: 'border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20'
|
||||
}`}
|
||||
placeholder="••••••"
|
||||
minLength={6}
|
||||
required
|
||||
/>
|
||||
{/* Validation icon */}
|
||||
{passwordTouched && (
|
||||
<motion.div
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
||||
className="absolute right-10 top-1/2 -translate-y-1/2"
|
||||
>
|
||||
{passwordValid ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-red-400" />
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-emerald-400 hover:text-emerald-600 dark:hover:text-emerald-300 transition-colors w-10 h-10 flex items-center justify-center"
|
||||
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-emerald-400 hover:text-emerald-600 dark:hover:text-emerald-300 transition-colors w-6 h-6 flex items-center justify-center"
|
||||
tabIndex={-1}
|
||||
aria-label={showPassword ? t('polish.hide_password') : t('polish.show_password')}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
@@ -686,6 +756,30 @@ export default function AuthView() {
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
{/* Password strength indicator (register mode) */}
|
||||
{mode === 'register' && passwordTouched && (
|
||||
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} transition={{ duration: 0.2 }}>
|
||||
<div className="flex gap-1 mt-1">
|
||||
{[1, 2, 3, 4].map((level) => (
|
||||
<div
|
||||
key={level}
|
||||
className={`h-1 flex-1 rounded-full transition-colors duration-300 ${
|
||||
password.length >= level * 2
|
||||
? level <= 1 ? 'bg-red-400'
|
||||
: level <= 2 ? 'bg-amber-400'
|
||||
: level <= 3 ? 'bg-emerald-400'
|
||||
: 'bg-teal-400'
|
||||
: 'bg-gray-200 dark:bg-gray-700'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] mt-1 text-gray-500 dark:text-gray-400">
|
||||
{password.length < 4 ? t('auth.password_weak') || 'Weak' : password.length < 6 ? t('auth.password_fair') || 'Fair' : password.length < 8 ? t('auth.password_good') || 'Good' : t('auth.password_strong') || 'Strong'}
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Forgot Password info */}
|
||||
<AnimatePresence>
|
||||
{showForgotInfo && (
|
||||
@@ -859,6 +953,54 @@ export default function AuthView() {
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Social login buttons (visual only) */}
|
||||
{mode === 'login' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5, duration: 0.4 }}
|
||||
className="mt-5"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex-1 h-px bg-gray-200 dark:bg-gray-700" />
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 font-medium">{t('auth.or_continue_with') || 'or continue with'}</span>
|
||||
<div className="flex-1 h-px bg-gray-200 dark:bg-gray-700" />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<motion.button
|
||||
type="button"
|
||||
whileHover={{ scale: 1.03 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
className="social-btn flex items-center justify-center gap-2 h-11 rounded-xl border border-gray-200 dark:border-gray-700 bg-white/60 dark:bg-gray-800/60 backdrop-blur-sm text-gray-700 dark:text-gray-300 font-medium text-xs hover:border-gray-300 dark:hover:border-gray-600"
|
||||
onClick={() => toast.info(t('auth.social_coming_soon') || 'Social login coming soon')}
|
||||
>
|
||||
<Chrome className="h-4 w-4 text-blue-500" />
|
||||
<span className="hidden sm:inline">Google</span>
|
||||
</motion.button>
|
||||
<motion.button
|
||||
type="button"
|
||||
whileHover={{ scale: 1.03 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
className="social-btn flex items-center justify-center gap-2 h-11 rounded-xl border border-gray-200 dark:border-gray-700 bg-white/60 dark:bg-gray-800/60 backdrop-blur-sm text-gray-700 dark:text-gray-300 font-medium text-xs hover:border-gray-300 dark:hover:border-gray-600"
|
||||
onClick={() => toast.info(t('auth.social_coming_soon') || 'Social login coming soon')}
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">GitHub</span>
|
||||
</motion.button>
|
||||
<motion.button
|
||||
type="button"
|
||||
whileHover={{ scale: 1.03 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
className="social-btn flex items-center justify-center gap-2 h-11 rounded-xl border border-gray-200 dark:border-gray-700 bg-white/60 dark:bg-gray-800/60 backdrop-blur-sm text-gray-700 dark:text-gray-300 font-medium text-xs hover:border-gray-300 dark:hover:border-gray-600"
|
||||
onClick={() => toast.info(t('auth.social_coming_soon') || 'Social login coming soon')}
|
||||
>
|
||||
<Globe className="h-4 w-4 text-emerald-500" />
|
||||
<span className="hidden sm:inline">Moodle</span>
|
||||
</motion.button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Features section below form */}
|
||||
<FeaturesSection />
|
||||
|
||||
@@ -873,6 +1015,87 @@ export default function AuthView() {
|
||||
<p className="text-xs text-emerald-600/40 dark:text-emerald-400/30">{t('app.tagline')}</p>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Forgot Password Modal */}
|
||||
<Dialog open={showForgotModal} onOpenChange={setShowForgotModal}>
|
||||
<DialogContent className="sm:max-w-md glass-card rounded-2xl border-0">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-lg">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-sm">
|
||||
<LockKeyhole className="h-4 w-4" />
|
||||
</div>
|
||||
{t('auth.forgot_password')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-gray-500 dark:text-gray-400 mt-2">
|
||||
{t('auth.forgot_password_desc') || 'Enter your email address and we will send you a link to reset your password.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
{forgotSent ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 200, damping: 20 }}
|
||||
className="flex flex-col items-center py-4"
|
||||
>
|
||||
<div className="flex items-center justify-center w-14 h-14 rounded-full bg-emerald-100 dark:bg-emerald-900/30 mb-4">
|
||||
<CheckCircle2 className="h-7 w-7 text-emerald-500" />
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-1">{t('auth.forgot_email_sent') || 'Reset link sent!'}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 text-center">{t('auth.forgot_check_inbox') || 'Check your inbox for the password reset link.'}</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3.5 top-1/2 -translate-y-1/2 h-5 w-5 text-emerald-400" />
|
||||
<Input
|
||||
value={forgotEmail}
|
||||
onChange={(e) => setForgotEmail(e.target.value)}
|
||||
className="pl-10 h-12 border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20 bg-white/60 dark:bg-gray-900/40 backdrop-blur-sm"
|
||||
placeholder="name@schule.de"
|
||||
type="email"
|
||||
/>
|
||||
</div>
|
||||
{/* Demo credentials reminder */}
|
||||
<div className="flex items-start gap-2 px-3 py-3 rounded-xl bg-emerald-50/50 dark:bg-emerald-900/10 border border-emerald-200/30 dark:border-emerald-900/20 backdrop-blur-sm">
|
||||
<HelpCircle className="h-4 w-4 text-emerald-500 shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-emerald-700 dark:text-emerald-300">
|
||||
{t('polish.demo_email')}: demo@competencetrack.org / {t('polish.demo_password')}: Demo2025!
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className="flex-row gap-2 sm:justify-end">
|
||||
{forgotSent ? (
|
||||
<Button
|
||||
onClick={() => { setShowForgotModal(false); setForgotSent(false); setForgotEmail(''); }}
|
||||
className="bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white min-h-[44px] px-5 font-semibold"
|
||||
>
|
||||
{t('auth.back_to_login') || 'Back to login'}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowForgotModal(false)}
|
||||
className="min-h-[44px] px-4"
|
||||
>
|
||||
{t('auth.cancel') || 'Cancel'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => { setForgotSent(true); toast.success(t('auth.forgot_email_sent') || 'Reset link sent!'); }}
|
||||
disabled={!forgotEmail || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(forgotEmail)}
|
||||
className="bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white min-h-[44px] px-5 font-semibold"
|
||||
>
|
||||
{t('auth.send_reset_link') || 'Send reset link'}
|
||||
<ArrowRight className="h-4 w-4 ml-1.5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1868,29 +1868,32 @@ export default function DashboardView() {
|
||||
>
|
||||
{/* ===== WELCOME HEADER ===== */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Top row: welcome + date */}
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3.5">
|
||||
{/* Animated gradient header banner */}
|
||||
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-r from-emerald-500 via-teal-500 to-emerald-600 dark:from-emerald-700 dark:via-teal-700 dark:to-emerald-800 shadow-lg shadow-emerald-300/30 dark:shadow-emerald-900/40 animate-header-gradient">
|
||||
{/* Decorative pattern overlay */}
|
||||
<div className="absolute inset-0 pattern-dots opacity-10 pointer-events-none" />
|
||||
{/* Decorative floating shapes */}
|
||||
<div className="absolute top-2 right-12 w-24 h-24 rounded-full bg-white/5 animate-float-slow pointer-events-none" />
|
||||
<div className="absolute bottom-0 right-40 w-16 h-16 rounded-full bg-white/5 animate-float pointer-events-none" />
|
||||
<div className="absolute top-4 right-72 w-10 h-10 rounded-full bg-white/5 animate-float-reverse pointer-events-none" />
|
||||
|
||||
<div className="relative p-5 sm:p-6 flex flex-col sm:flex-row items-start sm:items-center gap-4">
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 150, damping: 12 }}
|
||||
className="flex items-center justify-center w-12 h-12 rounded-2xl bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-lg shadow-emerald-300/30 ring-2 ring-emerald-200/30 dark:ring-emerald-800/20"
|
||||
className="flex items-center justify-center w-14 h-14 rounded-2xl bg-white/20 backdrop-blur-sm text-white shadow-lg shrink-0 ring-1 ring-white/20"
|
||||
>
|
||||
<Sparkles className="w-6 h-6" />
|
||||
<Sparkles className="w-7 h-7" />
|
||||
</motion.div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold bg-gradient-to-r from-emerald-600 via-teal-600 to-emerald-500 dark:from-emerald-400 dark:via-teal-400 dark:to-emerald-300 bg-clip-text text-transparent">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-2xl font-bold text-white leading-tight">
|
||||
{getTimeGreeting()}, {currentUser?.firstName}!
|
||||
</h2>
|
||||
<p className="text-emerald-600/60 dark:text-emerald-400/40 mt-0.5 text-sm">{t('dashboard.overview')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1.5">
|
||||
<div className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-gradient-to-r from-emerald-50 to-teal-50/50 dark:from-emerald-900/20 dark:to-teal-900/10 border border-emerald-200/40 dark:border-emerald-900/30 text-xs font-medium text-emerald-700 dark:text-emerald-300 shadow-sm">
|
||||
<p className="text-emerald-100/90 mt-0.5 text-sm">{t('dashboard.overview')}</p>
|
||||
<div className="flex flex-wrap items-center gap-3 mt-2">
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md bg-white/15 backdrop-blur-sm text-xs font-medium text-white/90 ring-1 ring-white/10">
|
||||
<CalendarDays className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">{t('polish.today_date')}: </span>
|
||||
<span className="font-semibold">
|
||||
{new Date().toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', {
|
||||
weekday: 'long',
|
||||
@@ -1900,24 +1903,28 @@ export default function DashboardView() {
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md bg-white/60 dark:bg-gray-800/40 border border-emerald-100/50 dark:border-emerald-900/20 text-[11px] text-gray-500 dark:text-gray-400">
|
||||
<Clock className="h-3 w-3 text-emerald-500/70" />
|
||||
<span>{t('polish.last_login')}: </span>
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300">
|
||||
{new Date().toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
})}{' '}
|
||||
{new Date().toLocaleTimeString(locale === 'de' ? 'de-DE' : 'en-GB', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md bg-white/10 backdrop-blur-sm text-xs text-white/80 ring-1 ring-white/10">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>{currentTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden sm:flex flex-col items-end gap-1.5 shrink-0">
|
||||
<Button
|
||||
onClick={() => setCurrentView('progress')}
|
||||
className="bg-white/20 hover:bg-white/30 text-white border-white/30 backdrop-blur-sm min-h-[44px] px-5 font-semibold transition-all duration-200 hover:shadow-lg"
|
||||
variant="outline"
|
||||
>
|
||||
<PenLine className="h-4 w-4 mr-2" />
|
||||
{t('action.log_entry')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Tip of the day — enhanced with gradient border */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
@@ -1939,7 +1946,6 @@ export default function DashboardView() {
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* ===== QUICK STATS SUMMARY ===== */}
|
||||
@@ -2041,7 +2047,7 @@ export default function DashboardView() {
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Card className={`card-shadow-transition border-0 shadow-sm rounded-xl overflow-hidden transition-all duration-300 ${colors.hoverShadow} ${colors.glowColor} cursor-default ring-1 ring-black/[0.03] dark:ring-white/[0.05] hover:ring-2 hover:ring-emerald-200/60 dark:hover:ring-emerald-700/40`}>
|
||||
<Card className={`card-shadow-transition border-0 shadow-sm rounded-xl overflow-hidden transition-all duration-300 ${colors.hoverShadow} ${colors.glowColor} cursor-default glass-card hover-lift`}>
|
||||
{/* Gradient top border */}
|
||||
<div className={`h-1 bg-gradient-to-r ${colors.gradient}`} />
|
||||
<CardContent className={`p-5 ${colors.bg}`}>
|
||||
@@ -2617,6 +2623,105 @@ export default function DashboardView() {
|
||||
<div className="flex-1 h-px bg-gradient-to-r from-emerald-200/60 dark:from-emerald-800/30 to-transparent" />
|
||||
</motion.div>
|
||||
|
||||
{/* ===== QUICK LINKS SECTION ===== */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
{[
|
||||
{ icon: BookOpen, label: t('nav.classes'), view: 'classes' as ViewName, color: 'from-emerald-400 to-teal-500', bg: 'bg-emerald-50 dark:bg-emerald-900/20', border: 'border-emerald-200/40 dark:border-emerald-900/30' },
|
||||
{ icon: Flower2, label: t('nav.flower'), view: 'flower' as ViewName, color: 'from-teal-400 to-emerald-500', bg: 'bg-teal-50 dark:bg-teal-900/20', border: 'border-teal-200/40 dark:border-teal-900/30' },
|
||||
{ icon: ClipboardCheck, label: t('nav.assessments'), view: 'assessments' as ViewName, color: 'from-amber-400 to-orange-500', bg: 'bg-amber-50 dark:bg-amber-900/20', border: 'border-amber-200/40 dark:border-amber-900/30' },
|
||||
{ icon: BarChart3, label: t('nav.grading'), view: 'grading' as ViewName, color: 'from-violet-400 to-purple-500', bg: 'bg-violet-50 dark:bg-violet-900/20', border: 'border-violet-200/40 dark:border-violet-900/30' },
|
||||
{ icon: FileText, label: t('nav.reports'), view: 'reports' as ViewName, color: 'from-rose-400 to-pink-500', bg: 'bg-rose-50 dark:bg-rose-900/20', border: 'border-rose-200/40 dark:border-rose-900/30' },
|
||||
{ icon: UserCheck, label: t('nav.attendance'), view: 'attendance' as ViewName, color: 'from-teal-400 to-cyan-500', bg: 'bg-teal-50 dark:bg-teal-900/20', border: 'border-teal-200/40 dark:border-teal-900/30' },
|
||||
].map((link, i) => (
|
||||
<motion.button
|
||||
key={link.label}
|
||||
onClick={() => setCurrentView(link.view)}
|
||||
whileHover={{ scale: 1.05, y: -3 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={`group flex flex-col items-center gap-2.5 p-4 rounded-xl ${link.bg} border ${link.border} hover-lift transition-shadow duration-200 hover:shadow-md`}
|
||||
>
|
||||
<div className={`flex items-center justify-center w-10 h-10 rounded-xl bg-gradient-to-br ${link.color} text-white shadow-sm transition-transform duration-200 group-hover:scale-110 group-hover:rotate-3`}>
|
||||
<link.icon className="h-5 w-5" />
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-gray-700 dark:text-gray-300 text-center leading-tight">{link.label}</span>
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* ===== TODAY'S SCHEDULE MINI-CALENDAR ===== */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card className="border-0 shadow-sm rounded-xl overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05] glass-card">
|
||||
<CardHeader className="pb-2 pt-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<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-orange-500 text-white shadow-sm">
|
||||
<CalendarDays className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
{locale === 'de' ? 'Heutiger Stundenplan' : "Today's Schedule"}
|
||||
</CardTitle>
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
||||
{new Date().toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', { weekday: 'short', day: 'numeric', month: 'short' })}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pb-4">
|
||||
<div className="space-y-1.5">
|
||||
{[
|
||||
{ time: '08:00', subject: t('student.notebook_math'), room: 'R-101', status: 'completed', color: 'from-amber-400 to-orange-500' },
|
||||
{ time: '09:00', subject: t('student.notebook_german'), room: 'R-203', status: 'completed', color: 'from-emerald-400 to-teal-500' },
|
||||
{ time: '10:00', subject: t('student.notebook_english'), room: 'R-105', status: 'current', color: 'from-violet-400 to-purple-500' },
|
||||
{ time: '11:00', subject: t('student.notebook_science'), room: 'Lab-1', status: 'upcoming', color: 'from-teal-400 to-cyan-500' },
|
||||
{ time: '12:00', subject: locale === 'de' ? 'Sport' : 'PE', room: 'Gym', status: 'upcoming', color: 'from-rose-400 to-pink-500' },
|
||||
].map((slot, i) => (
|
||||
<motion.div
|
||||
key={slot.time}
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: i * 0.05, duration: 0.25 }}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200 ${
|
||||
slot.status === 'current'
|
||||
? 'bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200/40 dark:border-emerald-800/30 shadow-sm'
|
||||
: slot.status === 'completed'
|
||||
? 'bg-gray-50/50 dark:bg-gray-800/20 border border-transparent'
|
||||
: 'bg-white/50 dark:bg-gray-900/20 border border-transparent hover:bg-gray-50 dark:hover:bg-gray-800/30'
|
||||
}`}
|
||||
>
|
||||
<span className={`text-xs font-mono font-semibold w-12 shrink-0 ${
|
||||
slot.status === 'current' ? 'text-emerald-600 dark:text-emerald-400' : 'text-gray-400 dark:text-gray-500'
|
||||
}`}>
|
||||
{slot.time}
|
||||
</span>
|
||||
<div className={`flex items-center justify-center w-7 h-7 rounded-lg bg-gradient-to-br ${slot.color} text-white shadow-sm shrink-0 ${slot.status === 'completed' ? 'opacity-60' : ''}`}>
|
||||
<BookOpen className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`text-xs font-semibold truncate ${slot.status === 'completed' ? 'text-gray-400 dark:text-gray-500' : 'text-gray-800 dark:text-gray-200'}`}>
|
||||
{slot.subject}
|
||||
</p>
|
||||
<p className="text-[10px] text-gray-400 dark:text-gray-500">{slot.room}</p>
|
||||
</div>
|
||||
{slot.status === 'current' && (
|
||||
<motion.div
|
||||
animate={{ scale: [1, 1.2, 1] }}
|
||||
transition={{ duration: 1.5, repeat: Infinity }}
|
||||
className="w-2 h-2 rounded-full bg-emerald-500 shrink-0"
|
||||
/>
|
||||
)}
|
||||
{slot.status === 'completed' && (
|
||||
<CheckCircle2 className="h-4 w-4 text-gray-300 dark:text-gray-600 shrink-0" />
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* ===== CLASS OVERVIEW MINI CARD GRID ===== */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05]">
|
||||
@@ -3036,7 +3141,7 @@ export default function DashboardView() {
|
||||
</p>
|
||||
</div>
|
||||
{!n.read && (
|
||||
<Badge className="bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300 text-xs shrink-0 shadow-sm">
|
||||
<Badge className="bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300 text-xs shrink-0 shadow-sm animate-badge-pulse">
|
||||
{locale === 'de' ? 'Neu' : 'New'}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+690
@@ -5094,6 +5094,8 @@ const de: TranslationDict = {
|
||||
'nav.tablet-grading': 'Tablet-Benotung',
|
||||
'nav.student-achievements': 'Erfolge',
|
||||
'nav.student-study-planner': 'Lernplaner',
|
||||
'nav.school-events': 'Schulveranstaltungen',
|
||||
'nav.data-import-export': 'Datenimport/-export',
|
||||
'notif_center.title': 'Benachrichtigungscenter',
|
||||
'notif_center.subtitle': 'Alle Benachrichtigungen und Updates an einem Ort',
|
||||
'notif_center.loading': 'Laden...',
|
||||
@@ -5637,6 +5639,349 @@ const de: TranslationDict = {
|
||||
'report_card.section_comments': 'Bemerkungen',
|
||||
'report_card.section_attendance': 'Anwesenheit',
|
||||
'report_card.section_signature': 'Unterschrift',
|
||||
|
||||
// Student Wellness
|
||||
'nav.student-wellness': 'Wohlbefinden',
|
||||
'wellness.title': 'Schüler-Wohlbefinden',
|
||||
'wellness.subtitle': 'Gesundheit und Wohlbefinden verfolgen',
|
||||
'wellness.dashboard': 'Dashboard',
|
||||
'wellness.checkin': 'Täglicher Check-in',
|
||||
'wellness.health_records': 'Gesundheitsakten',
|
||||
'wellness.reports': 'Berichte',
|
||||
'wellness.overall_score': 'Gesamtwert',
|
||||
'wellness.overall_score_desc': 'Ihr Wohlbefindens-Score',
|
||||
'wellness.trend': 'Trend',
|
||||
'wellness.trend_chart': 'Wohlbefindenstrend',
|
||||
'wellness.trend_chart_desc': 'Verlauf über die letzten 30 Tage',
|
||||
'wellness.average_mood': 'Durchschnittsstimmung',
|
||||
'wellness.sleep_quality': 'Schlafqualität',
|
||||
'wellness.stress_level': 'Stresslevel',
|
||||
'wellness.physical_activity': 'Körperliche Aktivität',
|
||||
'wellness.physical': 'Körperlich',
|
||||
'wellness.mental': 'Mental',
|
||||
'wellness.social': 'Sozial',
|
||||
'wellness.academic': 'Akademisch',
|
||||
'wellness.mood_excellent': 'Ausgezeichnet',
|
||||
'wellness.mood_good': 'Gut',
|
||||
'wellness.mood_okay': 'Okay',
|
||||
'wellness.mood_low': 'Niedrig',
|
||||
'wellness.mood_very_low': 'Sehr niedrig',
|
||||
'wellness.mood_label': 'Wie fühlst du dich heute?',
|
||||
'wellness.sleep_hours': 'Schlafstunden',
|
||||
'wellness.sleep_hours_label': 'Wie viele Stunden hast du geschlafen?',
|
||||
'wellness.sleep_quality_label': 'Wie war deine Schlafqualität?',
|
||||
'wellness.stress_label': 'Wie gestresst bist du?',
|
||||
'wellness.stress_low': 'Niedrig',
|
||||
'wellness.stress_moderate': 'Mäßig',
|
||||
'wellness.stress_high': 'Hoch',
|
||||
'wellness.stress_very_high': 'Sehr hoch',
|
||||
'wellness.activity_type': 'Aktivitätsart',
|
||||
'wellness.activity_duration': 'Dauer (Minuten)',
|
||||
'wellness.activity_running': 'Laufen',
|
||||
'wellness.activity_walking': 'Spazieren',
|
||||
'wellness.activity_cycling': 'Fahrradfahren',
|
||||
'wellness.activity_swimming': 'Schwimmen',
|
||||
'wellness.activity_sports': 'Sport',
|
||||
'wellness.activity_dance': 'Tanzen',
|
||||
'wellness.activity_yoga': 'Yoga',
|
||||
'wellness.activity_other': 'Sonstiges',
|
||||
'wellness.meals_count': 'Mahlzeiten pro Tag',
|
||||
'wellness.water_glasses': 'Gläser Wasser',
|
||||
'wellness.gratitude': 'Dankbarkeitseintrag',
|
||||
'wellness.gratitude_placeholder': 'Wofür bist du heute dankbar?',
|
||||
'wellness.notes': 'Notizen',
|
||||
'wellness.notes_placeholder': 'Zusätzliche Notizen...',
|
||||
'wellness.submit_checkin': 'Check-in speichern',
|
||||
'wellness.checkin_success': 'Check-in erfolgreich gespeichert!',
|
||||
'wellness.checkin_error': 'Fehler beim Speichern des Check-ins',
|
||||
'wellness.no_checkins': 'Noch keine Check-ins vorhanden',
|
||||
'wellness.no_checkins_desc': 'Starte deinen ersten Check-in, um dein Wohlbefinden zu verfolgen.',
|
||||
'wellness.health_allergies': 'Allergien',
|
||||
'wellness.health_conditions': 'Erkrankungen',
|
||||
'wellness.health_medications': 'Medikamente',
|
||||
'wellness.health_blood_type': 'Blutgruppe',
|
||||
'wellness.health_doctor': 'Arzt',
|
||||
'wellness.health_doctor_phone': 'Arzt-Telefon',
|
||||
'wellness.health_insurance': 'Versicherung',
|
||||
'wellness.health_last_checkup': 'Letzte Untersuchung',
|
||||
'wellness.health_emergency': 'Notfallkontakt',
|
||||
'wellness.health_vaccinations': 'Impfungen',
|
||||
'wellness.health_visits': 'Arztbesuche',
|
||||
'wellness.health_alert': 'Gesundheitshinweis',
|
||||
'wellness.health_no_records': 'Keine Gesundheitsakten vorhanden',
|
||||
'wellness.weekly_summary': 'Wochenübersicht',
|
||||
'wellness.monthly_summary': 'Monatsübersicht',
|
||||
'wellness.summary_period': 'Zeitraum',
|
||||
'wellness.summary_week': 'Woche',
|
||||
'wellness.summary_month': 'Monat',
|
||||
'wellness.patterns': 'Muster & Trends',
|
||||
'wellness.class_comparison': 'Klassenvergleich',
|
||||
'wellness.class_comparison_desc': 'Anonymisierter Vergleich mit Klassendurchschnitt',
|
||||
'wellness.recommendations': 'Empfehlungen',
|
||||
'wellness.recommendations_desc': 'KI-gestützte Wohlbefindenstipps',
|
||||
'wellness.export_report': 'Bericht exportieren',
|
||||
'wellness.export_success': 'Bericht erfolgreich exportiert!',
|
||||
'wellness.score_excellent': 'Ausgezeichnet',
|
||||
'wellness.score_good': 'Gut',
|
||||
'wellness.score_fair': 'Befriedigend',
|
||||
'wellness.score_poor': 'Schlecht',
|
||||
'wellness.score_critical': 'Kritisch',
|
||||
'wellness.school_overview': 'Schulübersicht',
|
||||
'wellness.class_overview': 'Klassenübersicht',
|
||||
'wellness.students_checked_in': 'Schüler eingecheckt',
|
||||
'wellness.avg_wellness_score': 'Durchschn. Wohlbefinden',
|
||||
'wellness.low_wellness_alerts': 'Niedrig-Wohlbefinden-Warnungen',
|
||||
'wellness.student_wellness': 'Schüler-Wohlbefinden',
|
||||
'wellness.view_details': 'Details anzeigen',
|
||||
'wellness.today': 'Heute',
|
||||
'wellness.this_week': 'Diese Woche',
|
||||
'wellness.this_month': 'Dieser Monat',
|
||||
'wellness.days': 'Tage',
|
||||
'wellness.hours': 'Stunden',
|
||||
'wellness.minutes': 'Minuten',
|
||||
'wellness.date': 'Datum',
|
||||
'wellness.recent_checkins': 'Letzte Check-ins',
|
||||
'wellness.wellness_radar': 'Wohlbefindensradar',
|
||||
'wellness.wellness_radar_desc': 'Überblick über alle Wohlbefindensbereiche',
|
||||
'wellness.sleep_poor': 'Schlecht',
|
||||
'wellness.sleep_fair': 'Mäßig',
|
||||
'wellness.sleep_good': 'Gut',
|
||||
'wellness.sleep_excellent': 'Ausgezeichnet',
|
||||
'wellness.no_data': 'Keine Daten verfügbar',
|
||||
'wellness.checkin_history': 'Check-in-Verlauf',
|
||||
'wellness.save': 'Speichern',
|
||||
'wellness.cancel': 'Abbrechen',
|
||||
'wellness.loading': 'Laden...',
|
||||
|
||||
// Data Import/Export
|
||||
'nav.data_import_export': 'Datenimport/-export',
|
||||
'data_import_export.title': 'Datenimport & -export',
|
||||
'data_import_export.subtitle': 'Daten importieren, exportieren und verwalten',
|
||||
'data_import_export.tab_import': 'Import',
|
||||
'data_import_export.tab_export': 'Export',
|
||||
'data_import_export.tab_history': 'Verlauf',
|
||||
'data_import_export.tab_management': 'Datenverwaltung',
|
||||
'data_import_export.upload_title': 'Datei hochladen',
|
||||
'data_import_export.upload_subtitle': 'CSV- oder JSON-Datei per Drag & Drop hochladen',
|
||||
'data_import_export.drag_drop': 'Datei hierher ziehen oder klicken',
|
||||
'data_import_export.drag_drop_active': 'Datei hier ablegen',
|
||||
'data_import_export.supported_formats': 'Unterstützte Formate: CSV, JSON',
|
||||
'data_import_export.max_file_size': 'Max. Dateigröße: 10 MB',
|
||||
'data_import_export.import_type': 'Importtyp',
|
||||
'data_import_export.type_student': 'Schüler',
|
||||
'data_import_export.type_teacher': 'Lehrer',
|
||||
'data_import_export.type_grade': 'Noten',
|
||||
'data_import_export.type_attendance': 'Anwesenheit',
|
||||
'data_import_export.type_competency': 'Kompetenzen',
|
||||
'data_import_export.column_mapping': 'Spaltenzuordnung',
|
||||
'data_import_export.column_mapping_desc': 'Ordnen Sie die CSV-Spalten den Datenbankfeldern zu',
|
||||
'data_import_export.csv_column': 'CSV-Spalte',
|
||||
'data_import_export.db_field': 'Datenbankfeld',
|
||||
'data_import_export.preview': 'Datenvorschau',
|
||||
'data_import_export.preview_desc': 'Vorschau der ersten 10 Zeilen vor dem Import',
|
||||
'data_import_export.row': 'Zeile',
|
||||
'data_import_export.start_import': 'Import starten',
|
||||
'data_import_export.importing': 'Import läuft...',
|
||||
'data_import_export.import_progress': 'Importfortschritt',
|
||||
'data_import_export.import_success': 'Import erfolgreich abgeschlossen',
|
||||
'data_import_export.import_error': 'Fehler beim Import',
|
||||
'data_import_export.success_rows': 'Erfolgreiche Zeilen',
|
||||
'data_import_export.error_rows': 'Fehlerhafte Zeilen',
|
||||
'data_import_export.total_rows': 'Gesamtzeilen',
|
||||
'data_import_export.validation_errors': 'Validierungsfehler',
|
||||
'data_import_export.no_file': 'Keine Datei ausgewählt',
|
||||
'data_import_export.invalid_format': 'Ungültiges Dateiformat',
|
||||
'data_import_export.file_too_large': 'Datei zu groß',
|
||||
'data_import_export.required_field_missing': 'Pflichtfeld fehlt',
|
||||
'data_import_export.invalid_data_type': 'Ungültiger Datentyp',
|
||||
'data_import_export.export_title': 'Daten exportieren',
|
||||
'data_import_export.export_type': 'Exporttyp',
|
||||
'data_import_export.type_report': 'Berichte',
|
||||
'data_import_export.export_format': 'Exportformat',
|
||||
'data_import_export.format_csv': 'CSV',
|
||||
'data_import_export.format_json': 'JSON',
|
||||
'data_import_export.date_range': 'Zeitraum',
|
||||
'data_import_export.date_from': 'Von',
|
||||
'data_import_export.date_to': 'Bis',
|
||||
'data_import_export.class_filter': 'Klassenfilter',
|
||||
'data_import_export.subject_filter': 'Fachfilter',
|
||||
'data_import_export.all_classes': 'Alle Klassen',
|
||||
'data_import_export.all_subjects': 'Alle Fächer',
|
||||
'data_import_export.export_templates': 'Exportvorlagen',
|
||||
'data_import_export.template_full': 'Vollständiger Export',
|
||||
'data_import_export.template_grades_only': 'Nur Noten',
|
||||
'data_import_export.template_attendance_only': 'Nur Anwesenheit',
|
||||
'data_import_export.template_competency_only': 'Nur Kompetenzen',
|
||||
'data_import_export.schedule_export': 'Geplanten Export erstellen',
|
||||
'data_import_export.schedule_daily': 'Täglich',
|
||||
'data_import_export.schedule_weekly': 'Wöchentlich',
|
||||
'data_import_export.schedule_monthly': 'Monatlich',
|
||||
'data_import_export.start_export': 'Export starten',
|
||||
'data_import_export.exporting': 'Export läuft...',
|
||||
'data_import_export.export_success': 'Export erfolgreich erstellt',
|
||||
'data_import_export.export_error': 'Fehler beim Export',
|
||||
'data_import_export.download': 'Herunterladen',
|
||||
'data_import_export.history_title': 'Exportverlauf',
|
||||
'data_import_export.import_history': 'Importverlauf',
|
||||
'data_import_export.file_name': 'Dateiname',
|
||||
'data_import_export.file_size': 'Dateigröße',
|
||||
'data_import_export.status': 'Status',
|
||||
'data_import_export.created_at': 'Erstellt',
|
||||
'data_import_export.status_pending': 'Ausstehend',
|
||||
'data_import_export.status_processing': 'In Bearbeitung',
|
||||
'data_import_export.status_completed': 'Abgeschlossen',
|
||||
'data_import_export.status_failed': 'Fehlgeschlagen',
|
||||
'data_import_export.no_history': 'Kein Verlauf vorhanden',
|
||||
'data_import_export.management_title': 'Datenverwaltung',
|
||||
'data_import_export.db_statistics': 'Datenbankstatistiken',
|
||||
'data_import_export.total_records': 'Gesamtdatensätze',
|
||||
'data_import_export.bulk_delete': 'Massenlöschung',
|
||||
'data_import_export.bulk_delete_desc': 'Mehrere Datensätze gleichzeitig löschen',
|
||||
'data_import_export.cleanup_tools': 'Bereinigungstools',
|
||||
'data_import_export.remove_orphans': 'Verwaiste Datensätze entfernen',
|
||||
'data_import_export.remove_orphans_desc': 'Datensätze ohne gültige Referenzen bereinigen',
|
||||
'data_import_export.backup': 'Backup erstellen',
|
||||
'data_import_export.backup_desc': 'Vollständiges Backup der Datenbank erstellen',
|
||||
'data_import_export.restore': 'Backup wiederherstellen',
|
||||
'data_import_export.restore_desc': 'Datenbank aus Backup wiederherstellen',
|
||||
'data_import_export.confirm_delete': 'Löschen bestätigen',
|
||||
'data_import_export.confirm_delete_msg': 'Möchten Sie die ausgewählten Datensätze wirklich löschen?',
|
||||
'data_import_export.confirm_cleanup': 'Bereinigung bestätigen',
|
||||
'data_import_export.confirm_cleanup_msg': 'Möchten Sie verwaiste Datensätze wirklich entfernen?',
|
||||
'data_import_export.operation_success': 'Vorgang erfolgreich',
|
||||
'data_import_export.operation_error': 'Fehler beim Vorgang',
|
||||
'data_import_export.no_access': 'Kein Zugriff',
|
||||
'data_import_export.no_access_desc': 'Sie haben keine Berechtigung für diese Funktion',
|
||||
'data_import_export.records': 'Datensätze',
|
||||
'data_import_export.students': 'Schüler',
|
||||
'data_import_export.teachers': 'Lehrer',
|
||||
'data_import_export.grades': 'Noten',
|
||||
'data_import_export.attendance_records': 'Anwesenheitseinträge',
|
||||
'data_import_export.competency_records': 'Kompetenzeinträge',
|
||||
'data_import_export.select_all': 'Alle auswählen',
|
||||
'data_import_export.deselect_all': 'Auswahl aufheben',
|
||||
'data_import_export.apply_template': 'Vorlage anwenden',
|
||||
'data_import_export.custom_export': 'Benutzerdefinierter Export',
|
||||
'data_import_export.export_options': 'Exportoptionen',
|
||||
'data_import_export.include_headers': 'Kopfzeilen einbeziehen',
|
||||
'data_import_export.include_metadata': 'Metadaten einbeziehen',
|
||||
'data_import_export.clear_filters': 'Filter zurücksetzen',
|
||||
|
||||
// School Events
|
||||
'nav.school_events': 'Schulveranstaltungen',
|
||||
'se.title': 'Schulveranstaltungen',
|
||||
'se.subtitle': 'Verwalten Sie Schulanlässe, Anmeldungen und Feedback',
|
||||
'se.create_event': 'Veranstaltung erstellen',
|
||||
'se.edit_event': 'Veranstaltung bearbeiten',
|
||||
'se.create_event_desc': 'Planen Sie eine neue Schulveranstaltung',
|
||||
'se.edit_event_desc': 'Bearbeiten Sie die Veranstaltungsdetails',
|
||||
'se.event_title': 'Titel',
|
||||
'se.event_title_placeholder': 'z.B. Sporttag 2025',
|
||||
'se.event_description': 'Beschreibung',
|
||||
'se.event_description_placeholder': 'Beschreiben Sie die Veranstaltung...',
|
||||
'se.event_type': 'Veranstaltungsart',
|
||||
'se.start_date': 'Beginn',
|
||||
'se.end_date': 'Ende',
|
||||
'se.location': 'Ort',
|
||||
'se.location_placeholder': 'z.B. Turnhalle, Raum 204',
|
||||
'se.budget': 'Budget',
|
||||
'se.notes': 'Notizen',
|
||||
'se.notes_placeholder': 'Zusätzliche Hinweise...',
|
||||
'se.status': 'Status',
|
||||
'se.status_draft': 'Entwurf',
|
||||
'se.status_published': 'Veröffentlicht',
|
||||
'se.status_cancelled': 'Abgesagt',
|
||||
'se.status_completed': 'Abgeschlossen',
|
||||
'se.registration_settings': 'Anmeldungseinstellungen',
|
||||
'se.requires_registration': 'Anmeldung erforderlich',
|
||||
'se.capacity': 'Kapazität',
|
||||
'se.registration_deadline': 'Anmeldeschluss',
|
||||
'se.recurring_settings': 'Wiederkehrende Veranstaltung',
|
||||
'se.is_recurring': 'Wiederkehrend',
|
||||
'se.recurrence_type': 'Wiederholungsart',
|
||||
'se.recurrence_daily': 'Täglich',
|
||||
'se.recurrence_weekly': 'Wöchentlich',
|
||||
'se.recurrence_monthly': 'Monatlich',
|
||||
'se.recurrence_custom': 'Benutzerdefiniert',
|
||||
'se.recurring': 'Wiederkehrend',
|
||||
'se.banner_image_url': 'Banner-Bild-URL',
|
||||
'se.search_placeholder': 'Veranstaltungen suchen...',
|
||||
'se.filter_type': 'Art',
|
||||
'se.filter_status': 'Status',
|
||||
'se.all_types': 'Alle Arten',
|
||||
'se.all_statuses': 'Alle Status',
|
||||
'se.date_from': 'Von',
|
||||
'se.date_to': 'Bis',
|
||||
'se.total_events': 'Veranstaltungen gesamt',
|
||||
'se.upcoming_events': 'Bevorstehende',
|
||||
'se.total_registrations': 'Anmeldungen gesamt',
|
||||
'se.total_budget': 'Gesamtbudget',
|
||||
'se.event_statistics': 'Veranstaltungsstatistiken',
|
||||
'se.events_by_type': 'Veranstaltungen nach Art',
|
||||
'se.overview': 'Übersicht',
|
||||
'se.published_events': 'Veröffentlichte Veranstaltungen',
|
||||
'se.completed_events': 'Abgeschlossene Veranstaltungen',
|
||||
'se.avg_rating': 'Durchschnittsbewertung',
|
||||
'se.loading': 'Wird geladen...',
|
||||
'se.no_events': 'Keine Veranstaltungen gefunden',
|
||||
'se.create_first_event': 'Erste Veranstaltung erstellen',
|
||||
'se.more': 'weitere',
|
||||
'se.registered': 'Angemeldet',
|
||||
'se.full': 'Voll',
|
||||
'se.deadline_in': 'Anmeldeschluss in',
|
||||
'se.days': 'Tage',
|
||||
'se.in': 'in',
|
||||
'se.register': 'Anmelden',
|
||||
'se.cancel_registration': 'Anmeldung stornieren',
|
||||
'se.registration_success': 'Erfolgreich angemeldet',
|
||||
'se.registration_cancelled': 'Anmeldung storniert',
|
||||
'se.event_full_waitlist': 'Veranstaltung ist voll',
|
||||
'se.error_register': 'Fehler bei der Anmeldung',
|
||||
'se.error_cancel_registration': 'Fehler beim Stornieren',
|
||||
'se.registrations': 'Anmeldungen',
|
||||
'se.no_registrations': 'Noch keine Anmeldungen',
|
||||
'se.capacity_used': 'Kapazität ausgenutzt',
|
||||
'se.mark_attended': 'Teilgenommen',
|
||||
'se.attended': 'Teilgenommen',
|
||||
'se.attendance_updated': 'Teilnahme aktualisiert',
|
||||
'se.feedback': 'Feedback',
|
||||
'se.no_feedback': 'Noch kein Feedback',
|
||||
'se.leave_feedback': 'Feedback geben',
|
||||
'se.feedback_desc': 'Bewerten Sie diese Veranstaltung',
|
||||
'se.submit_feedback': 'Feedback absenden',
|
||||
'se.feedback_submitted': 'Feedback abgegeben',
|
||||
'se.error_feedback': 'Fehler beim Absenden des Feedbacks',
|
||||
'se.feedback_comment_placeholder': 'Ihr Feedback zur Veranstaltung...',
|
||||
'se.rating_poor': 'Schlecht',
|
||||
'se.rating_fair': 'Befriedigend',
|
||||
'se.rating_good': 'Gut',
|
||||
'se.rating_very_good': 'Sehr gut',
|
||||
'se.rating_excellent': 'Ausgezeichnet',
|
||||
'se.reviews': 'Bewertungen',
|
||||
'se.publish': 'Veröffentlichen',
|
||||
'se.event_published': 'Veranstaltung veröffentlicht',
|
||||
'se.event_created': 'Veranstaltung erfolgreich erstellt',
|
||||
'se.event_updated': 'Veranstaltung erfolgreich aktualisiert',
|
||||
'se.event_deleted': 'Veranstaltung erfolgreich gelöscht',
|
||||
'se.saving': 'Wird gespeichert...',
|
||||
'se.error_fetch': 'Fehler beim Laden der Veranstaltungen',
|
||||
'se.error_create': 'Fehler beim Erstellen der Veranstaltung',
|
||||
'se.error_update': 'Fehler beim Aktualisieren der Veranstaltung',
|
||||
'se.error_delete': 'Fehler beim Löschen der Veranstaltung',
|
||||
'se.error_required_fields': 'Bitte füllen Sie alle Pflichtfelder aus',
|
||||
'se.delete_confirm_title': 'Veranstaltung löschen',
|
||||
'se.delete_confirm_desc': 'Möchten Sie die Veranstaltung "{title}" wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.',
|
||||
'se.type_sports': 'Sporttag',
|
||||
'se.type_cultural': 'Kulturell',
|
||||
'se.type_academic': 'Akademisch',
|
||||
'se.type_social': 'Sozial',
|
||||
'se.type_parent_meeting': 'Elternabend',
|
||||
'se.type_holiday': 'Ferien/Feiertag',
|
||||
'se.type_field_trip': 'Ausflug',
|
||||
'se.type_assembly': 'Versammlung',
|
||||
'se.type_concert': 'Konzert',
|
||||
'se.type_graduation': 'Abschlussfeier',
|
||||
'se.type_fair': 'Schulfest',
|
||||
};
|
||||
|
||||
const en: TranslationDict = {
|
||||
@@ -10729,6 +11074,8 @@ const en: TranslationDict = {
|
||||
'nav.tablet-grading': 'Tablet Grading',
|
||||
'nav.student-achievements': 'Achievements',
|
||||
'nav.student-study-planner': 'Study Planner',
|
||||
'nav.school-events': 'School Events',
|
||||
'nav.data-import-export': 'Data Import/Export',
|
||||
'notif_center.title': 'Notification Center',
|
||||
'notif_center.subtitle': 'All your notifications and updates in one place',
|
||||
'notif_center.loading': 'Loading...',
|
||||
@@ -11272,6 +11619,349 @@ const en: TranslationDict = {
|
||||
'report_card.section_comments': 'Comments',
|
||||
'report_card.section_attendance': 'Attendance',
|
||||
'report_card.section_signature': 'Signature',
|
||||
|
||||
// Student Wellness
|
||||
'nav.student-wellness': 'Wellness',
|
||||
'wellness.title': 'Student Wellness',
|
||||
'wellness.subtitle': 'Track health and wellbeing',
|
||||
'wellness.dashboard': 'Dashboard',
|
||||
'wellness.checkin': 'Daily Check-in',
|
||||
'wellness.health_records': 'Health Records',
|
||||
'wellness.reports': 'Reports',
|
||||
'wellness.overall_score': 'Overall Score',
|
||||
'wellness.overall_score_desc': 'Your wellness score',
|
||||
'wellness.trend': 'Trend',
|
||||
'wellness.trend_chart': 'Wellness Trend',
|
||||
'wellness.trend_chart_desc': 'Progress over the last 30 days',
|
||||
'wellness.average_mood': 'Average Mood',
|
||||
'wellness.sleep_quality': 'Sleep Quality',
|
||||
'wellness.stress_level': 'Stress Level',
|
||||
'wellness.physical_activity': 'Physical Activity',
|
||||
'wellness.physical': 'Physical',
|
||||
'wellness.mental': 'Mental',
|
||||
'wellness.social': 'Social',
|
||||
'wellness.academic': 'Academic',
|
||||
'wellness.mood_excellent': 'Excellent',
|
||||
'wellness.mood_good': 'Good',
|
||||
'wellness.mood_okay': 'Okay',
|
||||
'wellness.mood_low': 'Low',
|
||||
'wellness.mood_very_low': 'Very Low',
|
||||
'wellness.mood_label': 'How are you feeling today?',
|
||||
'wellness.sleep_hours': 'Sleep Hours',
|
||||
'wellness.sleep_hours_label': 'How many hours did you sleep?',
|
||||
'wellness.sleep_quality_label': 'How was your sleep quality?',
|
||||
'wellness.stress_label': 'How stressed are you?',
|
||||
'wellness.stress_low': 'Low',
|
||||
'wellness.stress_moderate': 'Moderate',
|
||||
'wellness.stress_high': 'High',
|
||||
'wellness.stress_very_high': 'Very High',
|
||||
'wellness.activity_type': 'Activity Type',
|
||||
'wellness.activity_duration': 'Duration (minutes)',
|
||||
'wellness.activity_running': 'Running',
|
||||
'wellness.activity_walking': 'Walking',
|
||||
'wellness.activity_cycling': 'Cycling',
|
||||
'wellness.activity_swimming': 'Swimming',
|
||||
'wellness.activity_sports': 'Sports',
|
||||
'wellness.activity_dance': 'Dance',
|
||||
'wellness.activity_yoga': 'Yoga',
|
||||
'wellness.activity_other': 'Other',
|
||||
'wellness.meals_count': 'Meals per Day',
|
||||
'wellness.water_glasses': 'Glasses of Water',
|
||||
'wellness.gratitude': 'Gratitude Entry',
|
||||
'wellness.gratitude_placeholder': 'What are you grateful for today?',
|
||||
'wellness.notes': 'Notes',
|
||||
'wellness.notes_placeholder': 'Additional notes...',
|
||||
'wellness.submit_checkin': 'Save Check-in',
|
||||
'wellness.checkin_success': 'Check-in saved successfully!',
|
||||
'wellness.checkin_error': 'Error saving check-in',
|
||||
'wellness.no_checkins': 'No check-ins yet',
|
||||
'wellness.no_checkins_desc': 'Start your first check-in to track your wellness.',
|
||||
'wellness.health_allergies': 'Allergies',
|
||||
'wellness.health_conditions': 'Conditions',
|
||||
'wellness.health_medications': 'Medications',
|
||||
'wellness.health_blood_type': 'Blood Type',
|
||||
'wellness.health_doctor': 'Doctor',
|
||||
'wellness.health_doctor_phone': 'Doctor Phone',
|
||||
'wellness.health_insurance': 'Insurance',
|
||||
'wellness.health_last_checkup': 'Last Checkup',
|
||||
'wellness.health_emergency': 'Emergency Contact',
|
||||
'wellness.health_vaccinations': 'Vaccinations',
|
||||
'wellness.health_visits': 'Doctor Visits',
|
||||
'wellness.health_alert': 'Health Alert',
|
||||
'wellness.health_no_records': 'No health records available',
|
||||
'wellness.weekly_summary': 'Weekly Summary',
|
||||
'wellness.monthly_summary': 'Monthly Summary',
|
||||
'wellness.summary_period': 'Period',
|
||||
'wellness.summary_week': 'Week',
|
||||
'wellness.summary_month': 'Month',
|
||||
'wellness.patterns': 'Patterns & Trends',
|
||||
'wellness.class_comparison': 'Class Comparison',
|
||||
'wellness.class_comparison_desc': 'Anonymized comparison with class average',
|
||||
'wellness.recommendations': 'Recommendations',
|
||||
'wellness.recommendations_desc': 'AI-powered wellness tips',
|
||||
'wellness.export_report': 'Export Report',
|
||||
'wellness.export_success': 'Report exported successfully!',
|
||||
'wellness.score_excellent': 'Excellent',
|
||||
'wellness.score_good': 'Good',
|
||||
'wellness.score_fair': 'Fair',
|
||||
'wellness.score_poor': 'Poor',
|
||||
'wellness.score_critical': 'Critical',
|
||||
'wellness.school_overview': 'School Overview',
|
||||
'wellness.class_overview': 'Class Overview',
|
||||
'wellness.students_checked_in': 'Students checked in',
|
||||
'wellness.avg_wellness_score': 'Avg Wellness Score',
|
||||
'wellness.low_wellness_alerts': 'Low Wellness Alerts',
|
||||
'wellness.student_wellness': 'Student Wellness',
|
||||
'wellness.view_details': 'View Details',
|
||||
'wellness.today': 'Today',
|
||||
'wellness.this_week': 'This Week',
|
||||
'wellness.this_month': 'This Month',
|
||||
'wellness.days': 'Days',
|
||||
'wellness.hours': 'Hours',
|
||||
'wellness.minutes': 'Minutes',
|
||||
'wellness.date': 'Date',
|
||||
'wellness.recent_checkins': 'Recent Check-ins',
|
||||
'wellness.wellness_radar': 'Wellness Radar',
|
||||
'wellness.wellness_radar_desc': 'Overview of all wellness areas',
|
||||
'wellness.sleep_poor': 'Poor',
|
||||
'wellness.sleep_fair': 'Fair',
|
||||
'wellness.sleep_good': 'Good',
|
||||
'wellness.sleep_excellent': 'Excellent',
|
||||
'wellness.no_data': 'No data available',
|
||||
'wellness.checkin_history': 'Check-in History',
|
||||
'wellness.save': 'Save',
|
||||
'wellness.cancel': 'Cancel',
|
||||
'wellness.loading': 'Loading...',
|
||||
|
||||
// Data Import/Export
|
||||
'nav.data_import_export': 'Data Import/Export',
|
||||
'data_import_export.title': 'Data Import & Export',
|
||||
'data_import_export.subtitle': 'Import, export and manage data',
|
||||
'data_import_export.tab_import': 'Import',
|
||||
'data_import_export.tab_export': 'Export',
|
||||
'data_import_export.tab_history': 'History',
|
||||
'data_import_export.tab_management': 'Data Management',
|
||||
'data_import_export.upload_title': 'Upload File',
|
||||
'data_import_export.upload_subtitle': 'Upload CSV or JSON file via drag & drop',
|
||||
'data_import_export.drag_drop': 'Drag file here or click to browse',
|
||||
'data_import_export.drag_drop_active': 'Drop file here',
|
||||
'data_import_export.supported_formats': 'Supported formats: CSV, JSON',
|
||||
'data_import_export.max_file_size': 'Max file size: 10 MB',
|
||||
'data_import_export.import_type': 'Import Type',
|
||||
'data_import_export.type_student': 'Students',
|
||||
'data_import_export.type_teacher': 'Teachers',
|
||||
'data_import_export.type_grade': 'Grades',
|
||||
'data_import_export.type_attendance': 'Attendance',
|
||||
'data_import_export.type_competency': 'Competencies',
|
||||
'data_import_export.column_mapping': 'Column Mapping',
|
||||
'data_import_export.column_mapping_desc': 'Map CSV columns to database fields',
|
||||
'data_import_export.csv_column': 'CSV Column',
|
||||
'data_import_export.db_field': 'Database Field',
|
||||
'data_import_export.preview': 'Data Preview',
|
||||
'data_import_export.preview_desc': 'Preview first 10 rows before importing',
|
||||
'data_import_export.row': 'Row',
|
||||
'data_import_export.start_import': 'Start Import',
|
||||
'data_import_export.importing': 'Importing...',
|
||||
'data_import_export.import_progress': 'Import Progress',
|
||||
'data_import_export.import_success': 'Import completed successfully',
|
||||
'data_import_export.import_error': 'Import failed',
|
||||
'data_import_export.success_rows': 'Successful Rows',
|
||||
'data_import_export.error_rows': 'Error Rows',
|
||||
'data_import_export.total_rows': 'Total Rows',
|
||||
'data_import_export.validation_errors': 'Validation Errors',
|
||||
'data_import_export.no_file': 'No file selected',
|
||||
'data_import_export.invalid_format': 'Invalid file format',
|
||||
'data_import_export.file_too_large': 'File too large',
|
||||
'data_import_export.required_field_missing': 'Required field missing',
|
||||
'data_import_export.invalid_data_type': 'Invalid data type',
|
||||
'data_import_export.export_title': 'Export Data',
|
||||
'data_import_export.export_type': 'Export Type',
|
||||
'data_import_export.type_report': 'Reports',
|
||||
'data_import_export.export_format': 'Export Format',
|
||||
'data_import_export.format_csv': 'CSV',
|
||||
'data_import_export.format_json': 'JSON',
|
||||
'data_import_export.date_range': 'Date Range',
|
||||
'data_import_export.date_from': 'From',
|
||||
'data_import_export.date_to': 'To',
|
||||
'data_import_export.class_filter': 'Class Filter',
|
||||
'data_import_export.subject_filter': 'Subject Filter',
|
||||
'data_import_export.all_classes': 'All Classes',
|
||||
'data_import_export.all_subjects': 'All Subjects',
|
||||
'data_import_export.export_templates': 'Export Templates',
|
||||
'data_import_export.template_full': 'Full Export',
|
||||
'data_import_export.template_grades_only': 'Grades Only',
|
||||
'data_import_export.template_attendance_only': 'Attendance Only',
|
||||
'data_import_export.template_competency_only': 'Competencies Only',
|
||||
'data_import_export.schedule_export': 'Schedule Export',
|
||||
'data_import_export.schedule_daily': 'Daily',
|
||||
'data_import_export.schedule_weekly': 'Weekly',
|
||||
'data_import_export.schedule_monthly': 'Monthly',
|
||||
'data_import_export.start_export': 'Start Export',
|
||||
'data_import_export.exporting': 'Exporting...',
|
||||
'data_import_export.export_success': 'Export created successfully',
|
||||
'data_import_export.export_error': 'Export failed',
|
||||
'data_import_export.download': 'Download',
|
||||
'data_import_export.history_title': 'Export History',
|
||||
'data_import_export.import_history': 'Import History',
|
||||
'data_import_export.file_name': 'File Name',
|
||||
'data_import_export.file_size': 'File Size',
|
||||
'data_import_export.status': 'Status',
|
||||
'data_import_export.created_at': 'Created',
|
||||
'data_import_export.status_pending': 'Pending',
|
||||
'data_import_export.status_processing': 'Processing',
|
||||
'data_import_export.status_completed': 'Completed',
|
||||
'data_import_export.status_failed': 'Failed',
|
||||
'data_import_export.no_history': 'No history available',
|
||||
'data_import_export.management_title': 'Data Management',
|
||||
'data_import_export.db_statistics': 'Database Statistics',
|
||||
'data_import_export.total_records': 'Total Records',
|
||||
'data_import_export.bulk_delete': 'Bulk Delete',
|
||||
'data_import_export.bulk_delete_desc': 'Delete multiple records at once',
|
||||
'data_import_export.cleanup_tools': 'Cleanup Tools',
|
||||
'data_import_export.remove_orphans': 'Remove Orphaned Records',
|
||||
'data_import_export.remove_orphans_desc': 'Clean up records without valid references',
|
||||
'data_import_export.backup': 'Create Backup',
|
||||
'data_import_export.backup_desc': 'Create a full database backup',
|
||||
'data_import_export.restore': 'Restore Backup',
|
||||
'data_import_export.restore_desc': 'Restore database from backup',
|
||||
'data_import_export.confirm_delete': 'Confirm Delete',
|
||||
'data_import_export.confirm_delete_msg': 'Are you sure you want to delete the selected records?',
|
||||
'data_import_export.confirm_cleanup': 'Confirm Cleanup',
|
||||
'data_import_export.confirm_cleanup_msg': 'Are you sure you want to remove orphaned records?',
|
||||
'data_import_export.operation_success': 'Operation successful',
|
||||
'data_import_export.operation_error': 'Operation failed',
|
||||
'data_import_export.no_access': 'No Access',
|
||||
'data_import_export.no_access_desc': 'You do not have permission for this feature',
|
||||
'data_import_export.records': 'Records',
|
||||
'data_import_export.students': 'Students',
|
||||
'data_import_export.teachers': 'Teachers',
|
||||
'data_import_export.grades': 'Grades',
|
||||
'data_import_export.attendance_records': 'Attendance Records',
|
||||
'data_import_export.competency_records': 'Competency Records',
|
||||
'data_import_export.select_all': 'Select All',
|
||||
'data_import_export.deselect_all': 'Deselect All',
|
||||
'data_import_export.apply_template': 'Apply Template',
|
||||
'data_import_export.custom_export': 'Custom Export',
|
||||
'data_import_export.export_options': 'Export Options',
|
||||
'data_import_export.include_headers': 'Include Headers',
|
||||
'data_import_export.include_metadata': 'Include Metadata',
|
||||
'data_import_export.clear_filters': 'Clear Filters',
|
||||
|
||||
// School Events
|
||||
'nav.school_events': 'School Events',
|
||||
'se.title': 'School Events',
|
||||
'se.subtitle': 'Manage school events, registrations, and feedback',
|
||||
'se.create_event': 'Create Event',
|
||||
'se.edit_event': 'Edit Event',
|
||||
'se.create_event_desc': 'Plan a new school event',
|
||||
'se.edit_event_desc': 'Edit the event details',
|
||||
'se.event_title': 'Title',
|
||||
'se.event_title_placeholder': 'e.g. Sports Day 2025',
|
||||
'se.event_description': 'Description',
|
||||
'se.event_description_placeholder': 'Describe the event...',
|
||||
'se.event_type': 'Event Type',
|
||||
'se.start_date': 'Start Date',
|
||||
'se.end_date': 'End Date',
|
||||
'se.location': 'Location',
|
||||
'se.location_placeholder': 'e.g. Gymnasium, Room 204',
|
||||
'se.budget': 'Budget',
|
||||
'se.notes': 'Notes',
|
||||
'se.notes_placeholder': 'Additional notes...',
|
||||
'se.status': 'Status',
|
||||
'se.status_draft': 'Draft',
|
||||
'se.status_published': 'Published',
|
||||
'se.status_cancelled': 'Cancelled',
|
||||
'se.status_completed': 'Completed',
|
||||
'se.registration_settings': 'Registration Settings',
|
||||
'se.requires_registration': 'Requires Registration',
|
||||
'se.capacity': 'Capacity',
|
||||
'se.registration_deadline': 'Registration Deadline',
|
||||
'se.recurring_settings': 'Recurring Event',
|
||||
'se.is_recurring': 'Recurring',
|
||||
'se.recurrence_type': 'Recurrence Type',
|
||||
'se.recurrence_daily': 'Daily',
|
||||
'se.recurrence_weekly': 'Weekly',
|
||||
'se.recurrence_monthly': 'Monthly',
|
||||
'se.recurrence_custom': 'Custom',
|
||||
'se.recurring': 'Recurring',
|
||||
'se.banner_image_url': 'Banner Image URL',
|
||||
'se.search_placeholder': 'Search events...',
|
||||
'se.filter_type': 'Type',
|
||||
'se.filter_status': 'Status',
|
||||
'se.all_types': 'All Types',
|
||||
'se.all_statuses': 'All Statuses',
|
||||
'se.date_from': 'From',
|
||||
'se.date_to': 'To',
|
||||
'se.total_events': 'Total Events',
|
||||
'se.upcoming_events': 'Upcoming',
|
||||
'se.total_registrations': 'Total Registrations',
|
||||
'se.total_budget': 'Total Budget',
|
||||
'se.event_statistics': 'Event Statistics',
|
||||
'se.events_by_type': 'Events by Type',
|
||||
'se.overview': 'Overview',
|
||||
'se.published_events': 'Published Events',
|
||||
'se.completed_events': 'Completed Events',
|
||||
'se.avg_rating': 'Average Rating',
|
||||
'se.loading': 'Loading...',
|
||||
'se.no_events': 'No events found',
|
||||
'se.create_first_event': 'Create First Event',
|
||||
'se.more': 'more',
|
||||
'se.registered': 'Registered',
|
||||
'se.full': 'Full',
|
||||
'se.deadline_in': 'Deadline in',
|
||||
'se.days': 'days',
|
||||
'se.in': 'in',
|
||||
'se.register': 'Register',
|
||||
'se.cancel_registration': 'Cancel Registration',
|
||||
'se.registration_success': 'Successfully registered',
|
||||
'se.registration_cancelled': 'Registration cancelled',
|
||||
'se.event_full_waitlist': 'Event is full',
|
||||
'se.error_register': 'Error registering',
|
||||
'se.error_cancel_registration': 'Error cancelling registration',
|
||||
'se.registrations': 'Registrations',
|
||||
'se.no_registrations': 'No registrations yet',
|
||||
'se.capacity_used': 'Capacity used',
|
||||
'se.mark_attended': 'Mark Attended',
|
||||
'se.attended': 'Attended',
|
||||
'se.attendance_updated': 'Attendance updated',
|
||||
'se.feedback': 'Feedback',
|
||||
'se.no_feedback': 'No feedback yet',
|
||||
'se.leave_feedback': 'Leave Feedback',
|
||||
'se.feedback_desc': 'Rate this event',
|
||||
'se.submit_feedback': 'Submit Feedback',
|
||||
'se.feedback_submitted': 'Feedback submitted',
|
||||
'se.error_feedback': 'Error submitting feedback',
|
||||
'se.feedback_comment_placeholder': 'Your feedback about the event...',
|
||||
'se.rating_poor': 'Poor',
|
||||
'se.rating_fair': 'Fair',
|
||||
'se.rating_good': 'Good',
|
||||
'se.rating_very_good': 'Very Good',
|
||||
'se.rating_excellent': 'Excellent',
|
||||
'se.reviews': 'reviews',
|
||||
'se.publish': 'Publish',
|
||||
'se.event_published': 'Event published',
|
||||
'se.event_created': 'Event created successfully',
|
||||
'se.event_updated': 'Event updated successfully',
|
||||
'se.event_deleted': 'Event deleted successfully',
|
||||
'se.saving': 'Saving...',
|
||||
'se.error_fetch': 'Error loading events',
|
||||
'se.error_create': 'Error creating event',
|
||||
'se.error_update': 'Error updating event',
|
||||
'se.error_delete': 'Error deleting event',
|
||||
'se.error_required_fields': 'Please fill in all required fields',
|
||||
'se.delete_confirm_title': 'Delete Event',
|
||||
'se.delete_confirm_desc': 'Are you sure you want to delete the event "{title}"? This action cannot be undone.',
|
||||
'se.type_sports': 'Sports Day',
|
||||
'se.type_cultural': 'Cultural',
|
||||
'se.type_academic': 'Academic',
|
||||
'se.type_social': 'Social',
|
||||
'se.type_parent_meeting': 'Parent Meeting',
|
||||
'se.type_holiday': 'Holiday',
|
||||
'se.type_field_trip': 'Field Trip',
|
||||
'se.type_assembly': 'Assembly',
|
||||
'se.type_concert': 'Concert',
|
||||
'se.type_graduation': 'Graduation',
|
||||
'se.type_fair': 'School Fair',
|
||||
};
|
||||
|
||||
const translations: Record<string, TranslationDict> = { de, en };
|
||||
|
||||
+4
-1
@@ -47,7 +47,10 @@ export type ViewName =
|
||||
| 'student-study-planner'
|
||||
| 'seating-chart'
|
||||
| 'school-library'
|
||||
| 'report-cards';
|
||||
| 'report-cards'
|
||||
| 'data-import-export'
|
||||
| 'student-wellness'
|
||||
| 'school-events';
|
||||
|
||||
export interface CurrentUser {
|
||||
id: string;
|
||||
|
||||
+238
@@ -1466,3 +1466,241 @@ Unresolved issues / Next phase priorities:
|
||||
- More schlaukopf.de exercises and questions
|
||||
- School event management enhancements
|
||||
- Data import/export with CSV/Excel
|
||||
|
||||
## Task ID: 2-d
|
||||
Agent: Styling Enhancer Round 24
|
||||
Task: Professional styling enhancements across auth-view and dashboard-view
|
||||
|
||||
Work Log:
|
||||
- Added 16 new CSS utility classes and keyframe animations to globals.css:
|
||||
- `.animate-pulse-slow` — Slower pulse animation (3s)
|
||||
- `.hover-lift` — Hover lift effect with shadow
|
||||
- `.gradient-border` — Border with gradient via ::before pseudo
|
||||
- `.pattern-dots` — Dot pattern background (radial gradient)
|
||||
- `.pattern-grid` — Grid pattern background (linear gradient)
|
||||
- `.animate-slide-in-up` — Slide in from bottom (keyframes slideInUp)
|
||||
- `.animate-scale-in` — Scale in from center (keyframes scaleIn)
|
||||
- `.animate-fade-in-scale` — Fade + scale combined (keyframes fadeInScale)
|
||||
- `.animate-badge-pulse` — Notification badge pulse animation
|
||||
- `.animate-header-gradient` — Animated gradient header banner
|
||||
- `.timeline-connector` — Timeline connector line for activity entries
|
||||
- `.input-valid` / `.input-invalid` — Input validation visual states
|
||||
- `.social-btn` — Social login button hover effect
|
||||
- Enhanced auth-view.tsx:
|
||||
- Added input validation visual feedback (green CheckCircle2 / red XCircle icons) for email, password, firstName, lastName fields
|
||||
- Added password strength indicator with 4-segment bar for register mode
|
||||
- Added "Forgot Password" modal using Dialog component with email input, success state, and demo credentials reminder
|
||||
- Added social login buttons (Google/Chrome, GitHub, Moodle) with visual-only OAuth (shows toast.info)
|
||||
- Added "or continue with" divider between form and social buttons
|
||||
- Improved form input styling with conditional input-valid/input-invalid classes
|
||||
- Added new imports: CheckCircle2, XCircle, Globe, Github, Chrome, Dialog components
|
||||
- Enhanced dashboard-view.tsx:
|
||||
- Replaced plain welcome header with animated gradient header banner (rounded-2xl, emerald-to-teal gradient, pattern-dots overlay, floating shapes)
|
||||
- Added glassmorphism stat cards (glass-card + hover-lift classes) with enhanced hover effects
|
||||
- Added "Quick Links" section with 6 card-based navigation buttons (Classes, Flower, Assessments, Grading, Reports, Attendance)
|
||||
- Added "Today's Schedule" mini-calendar with 5 time slots showing completed/current/upcoming states with animated indicators
|
||||
- Added pulse animation on notification badges (animate-badge-pulse) for unread notifications
|
||||
- Added CTA button in header banner for quick progress entry
|
||||
- All lint checks pass (0 errors)
|
||||
- Pre-existing TypeScript errors (Variants type in auth-view, enrollments property in dashboard-view) are not introduced by this round
|
||||
|
||||
Stage Summary:
|
||||
- Auth view now has professional form validation, password strength indicator, forgot password modal, and social login buttons
|
||||
- Dashboard has animated gradient header, glassmorphism stat cards, quick links navigation, today's schedule, and notification badge pulse
|
||||
- All new CSS utility classes support dark mode
|
||||
|
||||
## Task ID: 2-a
|
||||
Agent: Data Import/Export Builder
|
||||
Task: Build Data Import/Export View with comprehensive import/export capabilities
|
||||
|
||||
Work Log:
|
||||
- Added DataImportJob and DataExportJob models to Prisma schema with fields: schoolId, userId, type, status, totalRows, successRows, errorRows, errors, fileName, fileSize, format, filters, fileData
|
||||
- Added reverse relations to School and User models (dataImportJobs, dataExportJobs)
|
||||
- Also added missing WellnessCheckin and WellnessScore models that were referenced but not defined
|
||||
- Fixed pre-existing route conflict: removed duplicate health-records/[studentId] route that conflicted with [id] route
|
||||
- Updated store.ts ViewName type with 'data-import-export'
|
||||
- Added 112 i18n keys in both German and English for data import/export view
|
||||
- Added Database icon import and navigation entry in app-layout.tsx setup section
|
||||
- Created API route `/api/data-import/route.ts` with POST (multipart form data import, CSV/JSON parsing, column mapping, validation, creates DataImportJob) and GET (list import jobs)
|
||||
- Created API route `/api/data-export/jobs/route.ts` with POST (generate export with type/format/filters, creates DataExportJob, returns base64 file data) and GET (list export jobs)
|
||||
- Created API route `/api/data-export/jobs/[id]/route.ts` with GET (download specific export) and DELETE (remove export)
|
||||
- Created API route `/api/data-cleanup/route.ts` with GET (database statistics, cleanup info) and POST (remove_orphans, bulk_delete, backup operations)
|
||||
- Built comprehensive frontend component `data-import-export-view.tsx` (1526 lines) with:
|
||||
- Import tab: drag-and-drop file upload zone with visual feedback, import type selection (Student/Teacher/Grade/Attendance/Competency), column mapping interface with auto-detection, data preview table (first 10 rows), import progress bar with animated progress, import result display with success/error counts
|
||||
- Export tab: export type selection (Student/Grade/Attendance/Competency/Report), format selection (CSV/JSON), date range filters, class/subject filters, export templates (Full/Grades Only/Attendance Only/Competencies Only), export options (include headers, metadata), export result with download button
|
||||
- History tab: import history table with status badges, export history table with download and delete buttons, refresh button
|
||||
- Management tab: database statistics cards with animated counters, cleanup tools (remove orphaned records, bulk delete), backup/restore functionality, best practices info banner
|
||||
- Role-based access: ADMIN full access, VICE_PRINCIPAL school-level access, TEACHER import/export own data, STUDENT/PARENT no access
|
||||
- Gradient header banner with emerald/teal theme, animated stat cards, framer-motion transitions
|
||||
- Responsive design with dark mode support, toast notifications
|
||||
- All lint checks pass
|
||||
- Dev server runs successfully
|
||||
|
||||
Stage Summary:
|
||||
- Data Import/Export view fully functional with import (CSV/JSON), export (CSV/JSON), history, and management tabs
|
||||
- 4 API routes created for data import, export, and cleanup operations
|
||||
- 2 new Prisma models (DataImportJob, DataExportJob) for tracking import/export jobs
|
||||
- Component supports role-based access control with appropriate UI restrictions
|
||||
- Fixed pre-existing route slug conflict that was preventing dev server startup
|
||||
|
||||
## Task ID: 2-b
|
||||
Agent: School Event Management Builder
|
||||
Task: Build School Event Management View with event CRUD, registration, and feedback
|
||||
|
||||
Work Log:
|
||||
- Updated SchoolEvent model in Prisma schema with new fields: budget, registrationDeadline, capacity, isRecurring, recurrenceRule, bannerImageUrl, status, feedbackForm
|
||||
- Added EventFeedback model to Prisma schema with fields: id, eventId, userId, rating (1-5), comment, createdAt
|
||||
- Added reverse relations: feedbacks on SchoolEvent, eventFeedbacks on User
|
||||
- Ran prisma db push successfully
|
||||
- Updated existing API route `/api/school-events/route.ts` with enhanced GET (search, status filter, date range, requiresRegistration filter) and POST (new fields: budget, registrationDeadline, capacity, isRecurring, recurrenceRule, bannerImageUrl, status, feedbackForm)
|
||||
- Updated existing API route `/api/school-events/[id]/route.ts` with GET (includes feedbacks), PUT (supports all new fields), DELETE (soft delete)
|
||||
- Updated existing API route `/api/school-events/[id]/register/route.ts` with enhanced POST (registration deadline check, capacity using capacity field), PUT (attendance status), and new DELETE (cancel registration)
|
||||
- Created API route `/api/school-events/[id]/feedback/route.ts` with GET (list feedbacks with stats: average rating, distribution) and POST (submit feedback with rating 1-5 and comment)
|
||||
- Built comprehensive frontend component `school-events-view.tsx` (1844 lines) with:
|
||||
- Monthly calendar view with event markers (color-coded by type), click-to-create on day cells
|
||||
- List view with sortable/filterable event cards with type badges, countdown, capacity indicators
|
||||
- Event type filtering (Sports, Cultural, Academic, Social, Parent Meeting, Holiday, Field Trip, Assembly, Concert, Graduation, Fair)
|
||||
- Date range filter and search by event name
|
||||
- Status filter (Draft, Published, Cancelled, Completed)
|
||||
- Create/edit event dialog with: title, description, type, date/time, location, budget, registration settings (requires registration, capacity, deadline), recurring event support (daily/weekly/monthly/custom), banner image URL, notes
|
||||
- Registration section with capacity tracking, deadline countdown, attendance marking (admin), cancel registration
|
||||
- Feedback section with star rating (1-5), comment, average rating display, feedback list
|
||||
- Admin statistics panel with event type distribution, published/completed counts, average rating, total budget
|
||||
- Animated stat counters (total events, upcoming, registrations, budget)
|
||||
- Gradient header banner with emerald/teal theme
|
||||
- Role-based views: ADMIN/VICE_PRINCIPAL full CRUD, approve events, manage budgets, mark attendance; TEACHER create/edit own events, register classes, view all; STUDENT view events, register, view own registrations; PARENT view events, register children, cancel registration
|
||||
- Toast notifications, delete confirmation dialog, responsive design, dark mode support
|
||||
- Added 'school-events' to ViewName type in store.ts
|
||||
- Added 113+ i18n keys in both German and English (226 total across both languages)
|
||||
- Added navigation entry in app-layout.tsx for admin, student, and parent nav sections with CalendarDays icon
|
||||
- Added SchoolEventsView component import and render case in app-layout.tsx
|
||||
- No TypeScript errors in any of the new/modified files
|
||||
|
||||
Stage Summary:
|
||||
- School Event Management view fully functional with calendar/list views, event CRUD, registration, and feedback
|
||||
- 4 API routes created/updated for school events, registration, and feedback
|
||||
- 1 new Prisma model (EventFeedback) for event feedback with rating system
|
||||
- Enhanced SchoolEvent model with 8 new fields for budget, registration, recurring, and status management
|
||||
- Component supports role-based access control with appropriate UI restrictions per role
|
||||
- All TypeScript compilation passes with no errors in new code
|
||||
|
||||
---
|
||||
Task ID: 2-a
|
||||
Agent: Data Import/Export Builder
|
||||
Task: Build Data Import/Export View with CSV/Excel support
|
||||
|
||||
Work Log:
|
||||
- Built comprehensive Data Import/Export View (data-import-export-view.tsx, 1526 lines)
|
||||
- Features: CSV/JSON file import with drag-and-drop, column mapping, data preview, validation, export with filters, scheduled exports, data cleanup, database statistics
|
||||
- Created API routes: /api/data-import/route.ts, /api/data-export/route.ts, /api/data-export/[id]/route.ts, /api/data-export/csv/route.ts, /api/data-export/jobs/route.ts, /api/data-cleanup/route.ts
|
||||
- Added Prisma models: DataImportJob, DataExportJob with relations to School and User
|
||||
- Added 'data-import-export' to ViewName type in store.ts
|
||||
- Added i18n keys (DE + EN) for navigation and view content
|
||||
- Added navigation entry in app-layout.tsx with Database icon
|
||||
- All lint checks pass
|
||||
|
||||
Stage Summary:
|
||||
- Data Import/Export view with tabbed interface (Import, Export, History, Data Management)
|
||||
- Drag-and-drop file upload with visual feedback
|
||||
- Column mapping interface for CSV imports
|
||||
- Export with type, date range, and format filters
|
||||
- Database statistics and cleanup tools
|
||||
- 2 new Prisma models (DataImportJob, DataExportJob)
|
||||
- 6+ API route files created
|
||||
|
||||
---
|
||||
Task ID: 2-b
|
||||
Agent: School Event Management Builder
|
||||
Task: Build School Event Management View (enhanced)
|
||||
|
||||
Work Log:
|
||||
- Built comprehensive School Events View (school-events-view.tsx, 1844 lines)
|
||||
- Features: Calendar view, list view, event creation with recurring support, registration, feedback, statistics
|
||||
- Created/updated API routes: /api/school-events/route.ts, /api/school-events/[id]/route.ts, /api/school-events/[id]/register/route.ts, /api/school-events/[id]/feedback/route.ts
|
||||
- Added EventFeedback model to Prisma schema
|
||||
- Enhanced SchoolEvent model with budget, registration, recurring, and status fields
|
||||
- Added 'school-events' to ViewName type in store.ts
|
||||
- Added 113+ i18n keys in both German and English
|
||||
- Added navigation entry in app-layout.tsx with PartyPopper icon
|
||||
- All lint checks pass
|
||||
|
||||
Stage Summary:
|
||||
- School Event Management view with calendar/list views, event CRUD, registration, and feedback
|
||||
- 4 API routes created/updated
|
||||
- 1 new Prisma model (EventFeedback) + 8 new fields on SchoolEvent
|
||||
- Role-based access control for all views
|
||||
|
||||
---
|
||||
Task ID: 2-c
|
||||
Agent: Student Wellness & Health Tracking Builder
|
||||
Task: Build Student Wellness & Health Tracking View
|
||||
|
||||
Work Log:
|
||||
- Built comprehensive Student Wellness View (student-wellness-view.tsx, 1778 lines)
|
||||
- Features: Wellness dashboard with score gauge, daily check-in (mood, sleep, stress, activity), health records, wellness reports, trends
|
||||
- Created API routes: /api/wellness/route.ts, /api/wellness/summary/route.ts, /api/wellness/trends/route.ts, /api/health-records/[studentId]/route.ts
|
||||
- Added Prisma models: WellnessCheckin, WellnessScore with relations to School and Student
|
||||
- Added 'student-wellness' to ViewName type in store.ts
|
||||
- Added i18n keys (DE + EN) for navigation and view content
|
||||
- Added navigation entry in app-layout.tsx with Heart icon
|
||||
- All lint checks pass
|
||||
|
||||
Stage Summary:
|
||||
- Student Wellness view with daily check-in, mood tracking, sleep quality, stress level, activity log
|
||||
- Wellness score with animated circular gauge (SVG-based)
|
||||
- Recharts trend charts for wellness over time
|
||||
- Health records section with alert badges
|
||||
- 2 new Prisma models (WellnessCheckin, WellnessScore)
|
||||
- 4+ API route files created
|
||||
|
||||
---
|
||||
Task ID: 2-d
|
||||
Agent: Styling Enhancer Round 24
|
||||
Task: Enhance styling across auth-view, dashboard-view, and global CSS
|
||||
|
||||
Work Log:
|
||||
- Enhanced auth-view.tsx with glassmorphism card, gradient background, animated floating shapes, improved form styling
|
||||
- Enhanced dashboard-view.tsx with gradient header banner, animated stat counters, welcome section, quick action buttons
|
||||
- Added global CSS utility classes and keyframe animations to globals.css
|
||||
- All changes pass ESLint validation
|
||||
|
||||
Stage Summary:
|
||||
- Auth view: glassmorphism login card, animated floating shapes background, smooth transitions
|
||||
- Dashboard: gradient header, animated stat counters, welcome section with date, quick action cards
|
||||
- Global CSS: glass-card, gradient-text, animate-float, animate-shimmer, hover-lift, gradient-border, pattern-dots utilities
|
||||
|
||||
---
|
||||
Task ID: Round-24
|
||||
Agent: main
|
||||
Task: Round 24 — Data Import/Export, School Events, Student Wellness, Styling
|
||||
|
||||
Work Log:
|
||||
- Built 3 new views: data-import-export, school-events, student-wellness
|
||||
- Integrated all views into app-layout.tsx navigation, store, and i18n
|
||||
- Added 4 new Prisma models (DataImportJob, DataExportJob, WellnessCheckin, WellnessScore)
|
||||
- Enhanced SchoolEvent model with 8 new fields
|
||||
- Added EventFeedback model
|
||||
- Enhanced auth-view and dashboard-view styling
|
||||
- Added global CSS utility classes and animations
|
||||
- Re-created demo accounts after database reset
|
||||
- All lint checks pass, dev server running, browser verified all views render correctly
|
||||
- All new views accessible via sidebar navigation
|
||||
|
||||
Stage Summary:
|
||||
- 3 new views built (5,148 lines total)
|
||||
- 14+ new API route files
|
||||
- 4+ new Prisma models
|
||||
- Enhanced styling on auth and dashboard views
|
||||
- All views verified working in browser
|
||||
- 300+ i18n keys added
|
||||
|
||||
Unresolved issues / Next phase priorities:
|
||||
- WebSocket connection timeout errors (Caddy proxy)
|
||||
- Need more schlaukopf.de content cloning
|
||||
- Voice communication support in rooms
|
||||
- More styling improvements across all views
|
||||
- Student achievements view needs badge seed data
|
||||
- Calendar reminders on tablet
|
||||
- More schlaukopf.de exercises and questions
|
||||
- School event management needs seeded data
|
||||
- Wellness view needs seeded check-in data
|
||||
Reference in new issue
Block a user