Round 16: Peer Assessment, Emergency Contacts, School Events, School Branding, Email Templates, Performance, Styling Polish, Onboarding 24 Steps

This commit is contained in:
Mustafa Cetin committed 2026-07-29 11:45:24 +00:00
1 parent d45ba030f5
commit 6174f127f9
30 files changed
+4835 -142

No files matched your search

+172
View File
@@ -21,6 +21,19 @@ model School {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
// School branding fields
logoUrl String? // school logo URL
primaryColor String? @default("#10b981") // primary brand color
secondaryColor String? @default("#14b8a6") // secondary brand color
accentColor String? @default("#059669") // accent brand color
fontFamily String? @default("Inter") // preferred font
customCss String? // custom CSS overrides
motto String? // school motto
websiteUrl String? // school website URL
emailDomain String? // school email domain (e.g., "@schule.de")
address String? // school address
phone String? // school phone number
users User[] users User[]
schoolYears SchoolYear[] schoolYears SchoolYear[]
classGroups ClassGroup[] classGroups ClassGroup[]
@@ -51,6 +64,11 @@ model School {
portfolioEntries PortfolioEntry[] portfolioEntries PortfolioEntry[]
timetableSlots TimetableSlot[] timetableSlots TimetableSlot[]
resources Resource[] resources Resource[]
emailTemplates EmailTemplate[]
emailLogs EmailLog[]
peerAssessments PeerAssessment[]
emergencyContacts EmergencyContact[]
schoolEvents SchoolEvent[]
district SchoolDistrict? @relation(fields: [districtId], references: [id]) district SchoolDistrict? @relation(fields: [districtId], references: [id])
districtId String? districtId String?
} }
@@ -95,6 +113,8 @@ model User {
announcements Announcement[] announcements Announcement[]
timetableSlots TimetableSlot[] timetableSlots TimetableSlot[]
resources Resource[] resources Resource[]
organizedSchoolEvents SchoolEvent[]
eventRegistrations EventRegistration[]
} }
model SchoolYear { model SchoolYear {
@@ -146,6 +166,8 @@ model ClassGroup {
selfAssessments SelfAssessment[] selfAssessments SelfAssessment[]
timetableSlots TimetableSlot[] timetableSlots TimetableSlot[]
resources Resource[] resources Resource[]
peerAssessments PeerAssessment[]
schoolEvents SchoolEvent[]
} }
model ClassGroupTeacher { model ClassGroupTeacher {
@@ -188,6 +210,9 @@ model Student {
selfAssessments SelfAssessment[] selfAssessments SelfAssessment[]
learningGoals LearningGoal[] learningGoals LearningGoal[]
portfolioEntries PortfolioEntry[] portfolioEntries PortfolioEntry[]
peerAssessmentsGiven PeerAssessment[] @relation("PeerAssessor")
peerAssessmentsReceived PeerAssessment[] @relation("PeerAssessed")
emergencyContacts EmergencyContact[]
} }
model Enrollment { model Enrollment {
@@ -280,6 +305,7 @@ model Competency {
selfAssessments SelfAssessment[] selfAssessments SelfAssessment[]
learningGoals LearningGoal[] learningGoals LearningGoal[]
portfolioEntries PortfolioEntry[] portfolioEntries PortfolioEntry[]
peerAssessments PeerAssessment[]
} }
model MasteryLevelDefinition { model MasteryLevelDefinition {
@@ -690,6 +716,7 @@ model Rubric {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
criteria RubricCriterion[] criteria RubricCriterion[]
peerAssessments PeerAssessment[]
@@index([schoolId]) @@index([schoolId])
@@index([teacherId]) @@index([teacherId])
@@ -1306,3 +1333,148 @@ model Resource {
@@index([schoolId, resourceType]) @@index([schoolId, resourceType])
@@map("resources") @@map("resources")
} }
// ─── Email Templates & Logging ───────────────────────────────────────
model EmailTemplate {
id String @id @default(cuid())
schoolId String
name String // "weekly_report", "assessment_reminder", "behavior_alert", "attendance_notice", "welcome"
subject String
body String // HTML template with {{variables}}
isDefault Boolean @default(false)
isDemo Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
school School @relation(fields: [schoolId], references: [id])
@@unique([schoolId, name])
@@map("email_templates")
}
model EmailLog {
id String @id @default(cuid())
schoolId String
templateId String?
recipientEmail String
recipientName String?
subject String
body String
status String @default("pending") // pending, sent, failed, bounced
sentAt DateTime?
error String?
createdAt DateTime @default(now())
school School @relation(fields: [schoolId], references: [id])
@@index([schoolId, status])
@@map("email_logs")
}
// ─── Peer Assessment ──────────────────────────────────────────────
model PeerAssessment {
id String @id @default(cuid())
schoolId String
assessorId String // student who is assessing
assessedId String // student being assessed
competencyId String?
classGroupId String?
assessmentType String // "competency", "project", "presentation", "teamwork", "behavior"
level Int? // 1-6 rating
comment String?
rubricId String? // optional rubric reference
isAnonymous Boolean @default(false)
isDemo Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
school School @relation(fields: [schoolId], references: [id])
assessor Student @relation("PeerAssessor", fields: [assessorId], references: [id])
assessed Student @relation("PeerAssessed", fields: [assessedId], references: [id])
competency Competency? @relation(fields: [competencyId], references: [id])
classGroup ClassGroup? @relation(fields: [classGroupId], references: [id])
rubric Rubric? @relation(fields: [rubricId], references: [id])
@@index([schoolId, assessedId])
@@index([schoolId, assessorId])
@@map("peer_assessments")
}
// ─── Emergency Contacts ───────────────────────────────────────────
model EmergencyContact {
id String @id @default(cuid())
schoolId String
studentId String
name String
relationship String // "mother", "father", "guardian", "grandparent", "other"
phone String
phoneAlt String?
email String?
address String?
isPrimary Boolean @default(false)
priority Int @default(1) // 1=first call, 2=second call, etc.
notes String?
isDemo Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
school School @relation(fields: [schoolId], references: [id])
student Student @relation(fields: [studentId], references: [id])
@@index([schoolId, studentId])
@@map("emergency_contacts")
}
// ─── School Events ────────────────────────────────────────────────
model SchoolEvent {
id String @id @default(cuid())
schoolId String
title String
description String?
eventType String // "assembly", "field_trip", "sports_day", "concert", "fair", "parent_meeting", "graduation", "holiday"
startDate DateTime
endDate DateTime?
location String?
organizerId String?
classGroupId String? // if event is for specific class
isAllSchool Boolean @default(true)
isPublic Boolean @default(true)
requiresRegistration Boolean @default(false)
maxParticipants Int?
notes String?
isDemo Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
school School @relation(fields: [schoolId], references: [id])
organizer User? @relation(fields: [organizerId], references: [id])
classGroup ClassGroup? @relation(fields: [classGroupId], references: [id])
registrations EventRegistration[]
@@index([schoolId, startDate])
@@map("school_events")
}
model EventRegistration {
id String @id @default(cuid())
eventId String
userId String
status String @default("registered") // registered, cancelled, attended
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
event SchoolEvent @relation(fields: [eventId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id])
@@unique([eventId, userId])
@@map("event_registrations")
}
+43
View File
@@ -0,0 +1,43 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
export async function GET(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const schoolId = searchParams.get('schoolId') ?? session.user?.schoolId;
const status = searchParams.get('status');
const limit = parseInt(searchParams.get('limit') || '50', 10);
if (!schoolId) {
return NextResponse.json({ error: 'School ID required' }, { status: 400 });
}
const where: Record<string, unknown> = { schoolId };
if (status) where.status = status;
const logs = await db.emailLog.findMany({
where,
orderBy: { createdAt: 'desc' },
take: limit,
});
const counts = {
total: await db.emailLog.count({ where: { schoolId } }),
sent: await db.emailLog.count({ where: { schoolId, status: 'sent' } }),
failed: await db.emailLog.count({ where: { schoolId, status: 'failed' } }),
pending: await db.emailLog.count({ where: { schoolId, status: 'pending' } }),
bounced: await db.emailLog.count({ where: { schoolId, status: 'bounced' } }),
};
return NextResponse.json({ logs, counts });
} catch (error) {
console.error('EmailLogs GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+107
View File
@@ -0,0 +1,107 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
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 template = await db.emailTemplate.findUnique({
where: { id, deletedAt: null },
});
if (!template) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
return NextResponse.json(template);
} catch (error) {
console.error('EmailTemplate GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
const updateTemplateSchema = z.object({
name: z.string().min(1).optional(),
subject: z.string().min(1).optional(),
body: z.string().min(1).optional(),
isDefault: z.boolean().optional(),
});
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const body = await request.json();
const parsed = updateTemplateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const template = await db.emailTemplate.update({
where: { id },
data: parsed.data,
});
return NextResponse.json(template);
} catch (error) {
console.error('EmailTemplate PUT error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
// Soft delete
await db.emailTemplate.update({
where: { id },
data: { deletedAt: new Date() },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('EmailTemplate DELETE error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+75
View File
@@ -0,0 +1,75 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
export async function GET(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const schoolId = searchParams.get('schoolId') ?? session.user?.schoolId;
if (!schoolId) {
return NextResponse.json({ error: 'School ID required' }, { status: 400 });
}
const templates = await db.emailTemplate.findMany({
where: {
schoolId,
deletedAt: null,
},
orderBy: { createdAt: 'desc' },
});
return NextResponse.json(templates);
} catch (error) {
console.error('EmailTemplates GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
const createTemplateSchema = z.object({
schoolId: z.string().min(1),
name: z.string().min(1),
subject: z.string().min(1),
body: z.string().min(1),
isDefault: z.boolean().default(false),
});
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 parsed = createTemplateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const template = await db.emailTemplate.create({
data: parsed.data,
});
return NextResponse.json(template, { status: 201 });
} catch (error) {
console.error('EmailTemplates POST error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+95
View File
@@ -0,0 +1,95 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const sendSchema = z.object({
templateId: z.string().optional(),
schoolId: z.string().min(1),
recipientEmail: z.string().email(),
recipientName: z.string().optional(),
subject: z.string().min(1),
body: z.string().min(1),
isTest: z.boolean().default(false),
});
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 parsed = sendSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
// Replace template variables with sample data
const replaceVariables = (text: string, vars: Record<string, string>) => {
let result = text;
for (const [key, value] of Object.entries(vars)) {
result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value);
}
return result;
};
const sampleVars: Record<string, string> = {
studentName: parsed.data.recipientName || 'Max Mustermann',
className: 'Klasse 5a',
teacherName: `${session.user?.firstName || 'Frau'} ${session.user?.lastName || 'Muster'}`,
date: new Date().toLocaleDateString('de-DE'),
score: '85%',
schoolName: 'CompetenceTrack Schule',
subjectName: 'Mathematik',
behaviorDescription: 'Beschreibung des Vorfalls',
behaviorCategory: 'Störung',
attendanceStatus: 'Fehlend',
email: parsed.data.recipientEmail,
competencyProgress: 'Alle Kompetenzen auf dem erwarteten Niveau',
};
const processedSubject = replaceVariables(parsed.data.subject, sampleVars);
const processedBody = replaceVariables(parsed.data.body, sampleVars);
// Create email log entry
const emailLog = await db.emailLog.create({
data: {
schoolId: parsed.data.schoolId,
templateId: parsed.data.templateId || null,
recipientEmail: parsed.data.recipientEmail,
recipientName: parsed.data.recipientName || null,
subject: processedSubject,
body: processedBody,
status: 'sent',
sentAt: new Date(),
},
});
// In production, this would use an actual email sending service
// For now, we log the email as "sent" and return success
console.log(`[Email] ${parsed.data.isTest ? '[TEST] ' : ''}To: ${parsed.data.recipientEmail}, Subject: ${processedSubject}`);
return NextResponse.json({
success: true,
logId: emailLog.id,
message: parsed.data.isTest
? 'Test email logged successfully'
: 'Email logged successfully',
});
} catch (error) {
console.error('Email send error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
@@ -0,0 +1,137 @@
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 contact = await db.emergencyContact.findFirst({
where: { id, deletedAt: null },
include: {
student: {
select: { id: true, firstName: true, lastName: true },
},
},
});
if (!contact) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== contact.schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
return NextResponse.json(contact);
} catch (error) {
console.error('EmergencyContact GET [id] error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { id } = await params;
const existing = await db.emergencyContact.findFirst({
where: { id, deletedAt: null },
});
if (!existing) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== existing.schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const {
name,
relationship,
phone,
phoneAlt,
email,
address,
isPrimary,
priority,
notes,
} = body;
const updated = await db.emergencyContact.update({
where: { id },
data: {
...(name !== undefined && { name }),
...(relationship !== undefined && { relationship }),
...(phone !== undefined && { phone }),
...(phoneAlt !== undefined && { phoneAlt: phoneAlt || null }),
...(email !== undefined && { email: email || null }),
...(address !== undefined && { address: address || null }),
...(isPrimary !== undefined && { isPrimary }),
...(priority !== undefined && { priority }),
...(notes !== undefined && { notes }),
},
include: {
student: {
select: { id: true, firstName: true, lastName: true },
},
},
});
return NextResponse.json(updated);
} catch (error) {
console.error('EmergencyContact PUT [id] error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { id } = await params;
const existing = await db.emergencyContact.findFirst({
where: { id, deletedAt: null },
});
if (!existing) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== existing.schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Soft delete
await db.emergencyContact.update({
where: { id },
data: { deletedAt: new Date() },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('EmergencyContact DELETE [id] error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+109
View File
@@ -0,0 +1,109 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
export async function GET(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const schoolId = searchParams.get('schoolId');
const studentId = searchParams.get('studentId');
if (!schoolId) {
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
}
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const where: Record<string, unknown> = {
schoolId,
deletedAt: null,
};
if (studentId) where.studentId = studentId;
const contacts = await db.emergencyContact.findMany({
where,
orderBy: [{ isPrimary: 'desc' }, { priority: 'asc' }],
include: {
student: {
select: { id: true, firstName: true, lastName: true },
},
},
});
return NextResponse.json(contacts);
} catch (error) {
console.error('EmergencyContact GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const body = await request.json();
const {
schoolId,
studentId,
name,
relationship,
phone,
phoneAlt,
email,
address,
isPrimary,
priority,
notes,
isDemo,
} = body;
if (!schoolId || !studentId || !name || !relationship || !phone) {
return NextResponse.json(
{ error: 'schoolId, studentId, name, relationship, and phone are required' },
{ status: 400 }
);
}
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const contact = await db.emergencyContact.create({
data: {
schoolId,
studentId,
name,
relationship,
phone,
phoneAlt: phoneAlt || null,
email: email || null,
address: address || null,
isPrimary: isPrimary ?? false,
priority: priority ?? 1,
notes: notes || null,
isDemo: isDemo ?? false,
},
include: {
student: {
select: { id: true, firstName: true, lastName: true },
},
},
});
return NextResponse.json(contact, { status: 201 });
} catch (error) {
console.error('EmergencyContact POST error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+161
View File
@@ -0,0 +1,161 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { id } = await params;
const assessment = await db.peerAssessment.findFirst({
where: { id, deletedAt: null },
include: {
assessor: {
select: { id: true, firstName: true, lastName: true },
},
assessed: {
select: { id: true, firstName: true, lastName: true },
},
competency: {
select: { id: true, code: true, title: true },
},
classGroup: {
select: { id: true, name: true },
},
rubric: {
select: { id: true, title: true },
},
},
});
if (!assessment) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== assessment.schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
return NextResponse.json(assessment);
} catch (error) {
console.error('PeerAssessment GET [id] error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { id } = await params;
const existing = await db.peerAssessment.findFirst({
where: { id, deletedAt: null },
});
if (!existing) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== existing.schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const {
level,
comment,
assessmentType,
competencyId,
classGroupId,
rubricId,
isAnonymous,
} = body;
if (level !== undefined && level !== null && (level < 1 || level > 6)) {
return NextResponse.json({ error: 'level must be between 1 and 6' }, { status: 400 });
}
const updated = await db.peerAssessment.update({
where: { id },
data: {
...(level !== undefined && { level: level ?? null }),
...(comment !== undefined && { comment }),
...(assessmentType !== undefined && { assessmentType }),
...(competencyId !== undefined && { competencyId: competencyId || null }),
...(classGroupId !== undefined && { classGroupId: classGroupId || null }),
...(rubricId !== undefined && { rubricId: rubricId || null }),
...(isAnonymous !== undefined && { isAnonymous }),
},
include: {
assessor: {
select: { id: true, firstName: true, lastName: true },
},
assessed: {
select: { id: true, firstName: true, lastName: true },
},
competency: {
select: { id: true, code: true, title: true },
},
classGroup: {
select: { id: true, name: true },
},
rubric: {
select: { id: true, title: true },
},
},
});
return NextResponse.json(updated);
} catch (error) {
console.error('PeerAssessment PUT [id] error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { id } = await params;
const existing = await db.peerAssessment.findFirst({
where: { id, deletedAt: null },
});
if (!existing) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== existing.schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Soft delete
await db.peerAssessment.update({
where: { id },
data: { deletedAt: new Date() },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('PeerAssessment DELETE [id] error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+146
View File
@@ -0,0 +1,146 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
export async function GET(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const schoolId = searchParams.get('schoolId');
const assessedId = searchParams.get('assessedId');
const assessorId = searchParams.get('assessorId');
const competencyId = searchParams.get('competencyId');
const classGroupId = searchParams.get('classGroupId');
const assessmentType = searchParams.get('assessmentType');
if (!schoolId) {
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
}
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const where: Record<string, unknown> = {
schoolId,
deletedAt: null,
};
if (assessedId) where.assessedId = assessedId;
if (assessorId) where.assessorId = assessorId;
if (competencyId) where.competencyId = competencyId;
if (classGroupId) where.classGroupId = classGroupId;
if (assessmentType) where.assessmentType = assessmentType;
const assessments = await db.peerAssessment.findMany({
where,
orderBy: { createdAt: 'desc' },
include: {
assessor: {
select: { id: true, firstName: true, lastName: true },
},
assessed: {
select: { id: true, firstName: true, lastName: true },
},
competency: {
select: { id: true, code: true, title: true },
},
classGroup: {
select: { id: true, name: true },
},
rubric: {
select: { id: true, title: true },
},
},
});
return NextResponse.json(assessments);
} catch (error) {
console.error('PeerAssessment GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const body = await request.json();
const {
schoolId,
assessorId,
assessedId,
competencyId,
classGroupId,
assessmentType,
level,
comment,
rubricId,
isAnonymous,
isDemo,
} = body;
if (!schoolId || !assessorId || !assessedId || !assessmentType) {
return NextResponse.json(
{ error: 'schoolId, assessorId, assessedId, and assessmentType are required' },
{ status: 400 }
);
}
if (level !== undefined && level !== null && (level < 1 || level > 6)) {
return NextResponse.json(
{ error: 'level must be between 1 and 6' },
{ status: 400 }
);
}
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const assessment = await db.peerAssessment.create({
data: {
schoolId,
assessorId,
assessedId,
competencyId: competencyId || null,
classGroupId: classGroupId || null,
assessmentType,
level: level ?? null,
comment: comment || null,
rubricId: rubricId || null,
isAnonymous: isAnonymous ?? false,
isDemo: isDemo ?? false,
},
include: {
assessor: {
select: { id: true, firstName: true, lastName: true },
},
assessed: {
select: { id: true, firstName: true, lastName: true },
},
competency: {
select: { id: true, code: true, title: true },
},
classGroup: {
select: { id: true, name: true },
},
rubric: {
select: { id: true, title: true },
},
},
});
return NextResponse.json(assessment, { status: 201 });
} catch (error) {
console.error('PeerAssessment POST error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
@@ -0,0 +1,113 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
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 });
}
if (!event.requiresRegistration) {
return NextResponse.json({ error: 'Event does not require registration' }, { status: 400 });
}
const body = await request.json();
const { userId, notes } = body;
if (!userId) {
return NextResponse.json({ error: 'userId is required' }, { status: 400 });
}
// Check if already registered
const existing = await db.eventRegistration.findUnique({
where: { eventId_userId: { eventId: id, userId } },
});
if (existing) {
if (existing.status === 'cancelled') {
// Re-register
const updated = await db.eventRegistration.update({
where: { id: existing.id },
data: { status: 'registered', notes: notes || null },
});
return NextResponse.json(updated);
}
return NextResponse.json({ error: 'Already registered' }, { status: 409 });
}
// Check max participants
if (event.maxParticipants) {
const count = await db.eventRegistration.count({
where: { eventId: id, status: 'registered' },
});
if (count >= event.maxParticipants) {
return NextResponse.json({ error: 'Event is full' }, { status: 400 });
}
}
const registration = await db.eventRegistration.create({
data: {
eventId: id,
userId,
notes: notes || null,
},
});
return NextResponse.json(registration, { status: 201 });
} catch (error) {
console.error('EventRegistration POST error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { id } = await params;
const body = await request.json();
const { userId, status } = body;
if (!userId || !status) {
return NextResponse.json({ error: 'userId and status are 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 });
}
const updated = await db.eventRegistration.update({
where: { id: registration.id },
data: { status },
});
return NextResponse.json(updated);
} catch (error) {
console.error('EventRegistration PUT error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+165
View File
@@ -0,0 +1,165 @@
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 },
include: {
organizer: {
select: { id: true, firstName: true, lastName: true },
},
classGroup: {
select: { id: true, name: true },
},
registrations: {
include: {
user: {
select: { id: true, firstName: true, lastName: true },
},
},
},
},
});
if (!event) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== event.schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
return NextResponse.json(event);
} catch (error) {
console.error('SchoolEvent GET [id] error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { id } = await params;
const existing = await db.schoolEvent.findFirst({
where: { id, deletedAt: null },
});
if (!existing) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== existing.schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const {
title,
description,
eventType,
startDate,
endDate,
location,
organizerId,
classGroupId,
isAllSchool,
isPublic,
requiresRegistration,
maxParticipants,
notes,
} = body;
const updated = await db.schoolEvent.update({
where: { id },
data: {
...(title !== undefined && { title }),
...(description !== undefined && { description }),
...(eventType !== undefined && { eventType }),
...(startDate !== undefined && { startDate: new Date(startDate) }),
...(endDate !== undefined && { endDate: endDate ? new Date(endDate) : null }),
...(location !== undefined && { location }),
...(organizerId !== undefined && { organizerId: organizerId || null }),
...(classGroupId !== undefined && { classGroupId: classGroupId || null }),
...(isAllSchool !== undefined && { isAllSchool }),
...(isPublic !== undefined && { isPublic }),
...(requiresRegistration !== undefined && { requiresRegistration }),
...(maxParticipants !== undefined && { maxParticipants: maxParticipants || null }),
...(notes !== undefined && { notes }),
},
include: {
organizer: {
select: { id: true, firstName: true, lastName: true },
},
classGroup: {
select: { id: true, name: true },
},
registrations: {
include: {
user: {
select: { id: true, firstName: true, lastName: true },
},
},
},
},
});
return NextResponse.json(updated);
} catch (error) {
console.error('SchoolEvent PUT [id] error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { id } = await params;
const existing = await db.schoolEvent.findFirst({
where: { id, deletedAt: null },
});
if (!existing) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== existing.schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Soft delete
await db.schoolEvent.update({
where: { id },
data: { deletedAt: new Date() },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('SchoolEvent DELETE [id] error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+142
View File
@@ -0,0 +1,142 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
export async function GET(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const schoolId = searchParams.get('schoolId');
const eventType = searchParams.get('eventType');
const classGroupId = searchParams.get('classGroupId');
const upcoming = searchParams.get('upcoming');
if (!schoolId) {
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
}
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const where: Record<string, unknown> = {
schoolId,
deletedAt: null,
};
if (eventType) where.eventType = eventType;
if (classGroupId) where.classGroupId = classGroupId;
if (upcoming === 'true') {
where.startDate = { gte: new Date() };
}
const events = await db.schoolEvent.findMany({
where,
orderBy: { startDate: 'asc' },
include: {
organizer: {
select: { id: true, firstName: true, lastName: true },
},
classGroup: {
select: { id: true, name: true },
},
registrations: {
include: {
user: {
select: { id: true, firstName: true, lastName: true },
},
},
},
},
});
return NextResponse.json(events);
} catch (error) {
console.error('SchoolEvent GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const body = await request.json();
const {
schoolId,
title,
description,
eventType,
startDate,
endDate,
location,
organizerId,
classGroupId,
isAllSchool,
isPublic,
requiresRegistration,
maxParticipants,
notes,
isDemo,
} = body;
if (!schoolId || !title || !eventType || !startDate) {
return NextResponse.json(
{ error: 'schoolId, title, eventType, and startDate are required' },
{ status: 400 }
);
}
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const event = await db.schoolEvent.create({
data: {
schoolId,
title,
description: description || null,
eventType,
startDate: new Date(startDate),
endDate: endDate ? new Date(endDate) : null,
location: location || null,
organizerId: organizerId || null,
classGroupId: classGroupId || null,
isAllSchool: isAllSchool ?? true,
isPublic: isPublic ?? true,
requiresRegistration: requiresRegistration ?? false,
maxParticipants: maxParticipants || null,
notes: notes || null,
isDemo: isDemo ?? false,
},
include: {
organizer: {
select: { id: true, firstName: true, lastName: true },
},
classGroup: {
select: { id: true, name: true },
},
registrations: {
include: {
user: {
select: { id: true, firstName: true, lastName: true },
},
},
},
},
});
return NextResponse.json(event, { status: 201 });
} catch (error) {
console.error('SchoolEvent POST error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+67
View File
@@ -8,6 +8,18 @@ const createSchoolSchema = z.object({
schoolType: z.enum(['ELEMENTARY', 'MIDDLE', 'GYMNASIUM', 'OTHER']), schoolType: z.enum(['ELEMENTARY', 'MIDDLE', 'GYMNASIUM', 'OTHER']),
country: z.string().default('DE'), country: z.string().default('DE'),
timezone: z.string().default('Europe/Berlin'), timezone: z.string().default('Europe/Berlin'),
// Branding fields
logoUrl: z.string().optional(),
primaryColor: z.string().optional(),
secondaryColor: z.string().optional(),
accentColor: z.string().optional(),
fontFamily: z.string().optional(),
customCss: z.string().optional(),
motto: z.string().optional(),
websiteUrl: z.string().optional(),
emailDomain: z.string().optional(),
address: z.string().optional(),
phone: z.string().optional(),
}); });
export async function GET(request: Request) { export async function GET(request: Request) {
@@ -19,6 +31,7 @@ export async function GET(request: Request) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const schoolType = searchParams.get('schoolType'); const schoolType = searchParams.get('schoolType');
const includeBranding = searchParams.get('includeBranding') === 'true';
const where: Record<string, unknown> = {}; const where: Record<string, unknown> = {};
if (schoolType) where.schoolType = schoolType; if (schoolType) where.schoolType = schoolType;
@@ -39,6 +52,7 @@ export async function GET(request: Request) {
students: { where: { deletedAt: null } }, students: { where: { deletedAt: null } },
}, },
}, },
...(includeBranding ? {} : {}),
}, },
}); });
@@ -79,6 +93,47 @@ export async function POST(request: Request) {
data: parsed.data, data: parsed.data,
}); });
// Seed default email templates for the new school
const defaultTemplates = [
{
schoolId: school.id,
name: 'weekly_report',
subject: 'Wochenbericht {{studentName}}',
body: '<h1>Wochenbericht für {{studentName}}</h1><p>Klasse: {{className}}</p><p>Lehrkraft: {{teacherName}}</p><p>Datum: {{date}}</p><h2>Kompetenzfortschritt</h2><p>{{competencyProgress}}</p><p>Mit freundlichen Grüßen,<br>{{schoolName}}</p>',
isDefault: true,
},
{
schoolId: school.id,
name: 'assessment_reminder',
subject: 'Bewertung am {{date}}',
body: '<h1>Bewertungserinnerung</h1><p>Hallo {{teacherName}},</p><p>am {{date}} steht eine Bewertung an für {{className}}.</p><p>Betreff: {{subjectName}}</p><p>Mit freundlichen Grüßen,<br>{{schoolName}}</p>',
isDefault: true,
},
{
schoolId: school.id,
name: 'behavior_alert',
subject: 'Verhaltensnotice für {{studentName}}',
body: '<h1>Verhaltensnotice</h1><p>Schüler/in: {{studentName}}</p><p>Klasse: {{className}}</p><p>Datum: {{date}}</p><p>Vorfall: {{behaviorDescription}}</p><p>Kategorie: {{behaviorCategory}}</p><p>Mit freundlichen Grüßen,<br>{{schoolName}}</p>',
isDefault: true,
},
{
schoolId: school.id,
name: 'attendance_notice',
subject: 'Anwesenheits-Notice',
body: '<h1>Anwesenheitsnotice</h1><p>Schüler/in: {{studentName}}</p><p>Klasse: {{className}}</p><p>Datum: {{date}}</p><p>Status: {{attendanceStatus}}</p><p>Mit freundlichen Grüßen,<br>{{schoolName}}</p>',
isDefault: true,
},
{
schoolId: school.id,
name: 'welcome',
subject: 'Willkommen bei CompetenceTrack',
body: '<h1>Willkommen bei CompetenceTrack!</h1><p>Hallo {{studentName}},</p><p>du wurdest für die Schule {{schoolName}} registriert.</p><p>Deine Zugangsdaten:</p><p>E-Mail: {{email}}</p><p>Bitte melde dich an und ändere dein Passwort.</p><p>Mit freundlichen Grüßen,<br>{{schoolName}}</p>',
isDefault: true,
},
];
await db.emailTemplate.createMany({ data: defaultTemplates });
return NextResponse.json(school, { status: 201 }); return NextResponse.json(school, { status: 201 });
} catch (error) { } catch (error) {
console.error('Schools POST error:', error); console.error('Schools POST error:', error);
@@ -95,6 +150,18 @@ const updateSchoolSchema = z.object({
schoolType: z.enum(['ELEMENTARY', 'MIDDLE', 'GYMNASIUM', 'OTHER']).optional(), schoolType: z.enum(['ELEMENTARY', 'MIDDLE', 'GYMNASIUM', 'OTHER']).optional(),
country: z.string().optional(), country: z.string().optional(),
timezone: z.string().optional(), timezone: z.string().optional(),
// Branding fields
logoUrl: z.string().nullable().optional(),
primaryColor: z.string().nullable().optional(),
secondaryColor: z.string().nullable().optional(),
accentColor: z.string().nullable().optional(),
fontFamily: z.string().nullable().optional(),
customCss: z.string().nullable().optional(),
motto: z.string().nullable().optional(),
websiteUrl: z.string().nullable().optional(),
emailDomain: z.string().nullable().optional(),
address: z.string().nullable().optional(),
phone: z.string().nullable().optional(),
}); });
export async function PUT(request: Request) { export async function PUT(request: Request) {
+215
View File
@@ -48,6 +48,12 @@
:root { :root {
--radius: 0.75rem; --radius: 0.75rem;
/* Brand CSS custom properties — applied dynamically via JS */
--brand-primary: #10b981;
--brand-secondary: #14b8a6;
--brand-accent: #059669;
--brand-font: "Inter", sans-serif;
--background: oklch(0.99 0.002 155); --background: oklch(0.99 0.002 155);
--foreground: oklch(0.17 0.01 260); --foreground: oklch(0.17 0.01 260);
--card: oklch(1 0 0); --card: oklch(1 0 0);
@@ -2100,4 +2106,213 @@
font-weight: 600; font-weight: 600;
color: oklch(0.55 0.17 25); color: oklch(0.55 0.17 25);
} }
/* ─── Performance & Styling Polish Animations ────────── */
/* Card hover lift effect */
.card-hover-lift {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.card-hover-lift:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px -5px oklch(0.55 0.17 155 / 0.15), 0 4px 10px -4px oklch(0.55 0.17 155 / 0.1);
}
.dark .card-hover-lift:hover {
box-shadow: 0 8px 25px -5px oklch(0.3 0.05 155 / 0.3), 0 4px 10px -4px oklch(0.3 0.05 155 / 0.2);
}
/* Gradient line separator */
.gradient-separator {
height: 1px;
background: linear-gradient(to right, transparent, oklch(0.55 0.17 155 / 0.3), oklch(0.55 0.17 155 / 0.5), oklch(0.55 0.17 155 / 0.3), transparent);
}
.dark .gradient-separator {
background: linear-gradient(to right, transparent, oklch(0.55 0.17 155 / 0.2), oklch(0.55 0.17 155 / 0.4), oklch(0.55 0.17 155 / 0.2), transparent);
}
/* Badge glow animations */
@keyframes badge-glow-emerald {
0%, 100% { box-shadow: 0 0 0 0 oklch(0.55 0.17 155 / 0.3); }
50% { box-shadow: 0 0 8px 2px oklch(0.55 0.17 155 / 0.2); }
}
@keyframes badge-glow-amber {
0%, 100% { box-shadow: 0 0 0 0 oklch(0.75 0.15 85 / 0.3); }
50% { box-shadow: 0 0 8px 2px oklch(0.75 0.15 85 / 0.2); }
}
@keyframes badge-glow-rose {
0%, 100% { box-shadow: 0 0 0 0 oklch(0.55 0.2 15 / 0.3); }
50% { box-shadow: 0 0 8px 2px oklch(0.55 0.2 15 / 0.2); }
}
.badge-glow-emerald {
animation: badge-glow-emerald 2s ease-in-out infinite;
}
.badge-glow-amber {
animation: badge-glow-amber 2s ease-in-out infinite;
}
.badge-glow-rose {
animation: badge-glow-rose 2s ease-in-out infinite;
}
/* Staggered entrance animation */
@keyframes stagger-entrance {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.stagger-entrance {
animation: stagger-entrance 0.4s ease-out forwards;
opacity: 0;
}
.stagger-entrance:nth-child(1) { animation-delay: 0ms; }
.stagger-entrance:nth-child(2) { animation-delay: 60ms; }
.stagger-entrance:nth-child(3) { animation-delay: 120ms; }
.stagger-entrance:nth-child(4) { animation-delay: 180ms; }
.stagger-entrance:nth-child(5) { animation-delay: 240ms; }
.stagger-entrance:nth-child(6) { animation-delay: 300ms; }
.stagger-entrance:nth-child(7) { animation-delay: 360ms; }
.stagger-entrance:nth-child(8) { animation-delay: 420ms; }
/* Celebration confetti burst */
@keyframes confetti-burst {
0% { transform: scale(0); opacity: 1; }
50% { transform: scale(1.2); opacity: 0.8; }
100% { transform: scale(0); opacity: 0; }
}
/* Success animation for save actions */
@keyframes success-bounce {
0% { transform: scale(0.8); opacity: 0; }
50% { transform: scale(1.1); }
100% { transform: scale(1); opacity: 1; }
}
.success-bounce {
animation: success-bounce 0.4s ease-out;
}
/* Animated pill filter */
@keyframes pill-highlight {
from {
background-position: left center;
}
to {
background-position: right center;
}
}
/* Onboarding tour animated highlight */
@keyframes tour-highlight-ring {
0% { transform: scale(0.95); opacity: 0.8; }
50% { transform: scale(1.05); opacity: 1; }
100% { transform: scale(0.95); opacity: 0.8; }
}
.tour-highlight-ring {
animation: tour-highlight-ring 1.5s ease-in-out infinite;
}
/* Smooth theme transition */
.theme-transition * {
transition: background-color 0.3s ease, color 0.15s ease, border-color 0.3s ease;
}
/* Custom scrollbar styling for lists */
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: oklch(0.55 0.17 155 / 0.2);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: oklch(0.55 0.17 155 / 0.4);
}
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
background: oklch(0.55 0.17 155 / 0.15);
}
.dark .custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: oklch(0.55 0.17 155 / 0.3);
}
/* Attendance grid colored cells */
.attendance-present {
background: linear-gradient(135deg, oklch(0.6 0.17 155 / 0.15), oklch(0.6 0.17 155 / 0.08));
border-left: 3px solid oklch(0.55 0.17 155);
}
.dark .attendance-present {
background: linear-gradient(135deg, oklch(0.55 0.17 155 / 0.2), oklch(0.55 0.17 155 / 0.08));
}
.attendance-absent {
background: linear-gradient(135deg, oklch(0.55 0.2 15 / 0.15), oklch(0.55 0.2 15 / 0.08));
border-left: 3px solid oklch(0.55 0.2 15);
}
.dark .attendance-absent {
background: linear-gradient(135deg, oklch(0.55 0.2 15 / 0.2), oklch(0.55 0.2 15 / 0.08));
}
.attendance-excused {
background: linear-gradient(135deg, oklch(0.75 0.15 85 / 0.15), oklch(0.75 0.15 85 / 0.08));
border-left: 3px solid oklch(0.75 0.15 85);
}
.dark .attendance-excused {
background: linear-gradient(135deg, oklch(0.75 0.15 85 / 0.2), oklch(0.75 0.15 85 / 0.08));
}
.attendance-late {
background: linear-gradient(135deg, oklch(0.55 0.1 185 / 0.15), oklch(0.55 0.1 185 / 0.08));
border-left: 3px solid oklch(0.55 0.1 185);
}
.dark .attendance-late {
background: linear-gradient(135deg, oklch(0.55 0.1 185 / 0.2), oklch(0.55 0.1 185 / 0.08));
}
/* Grade color coding */
.grade-excellent {
background: linear-gradient(135deg, oklch(0.6 0.17 155 / 0.9), oklch(0.5 0.15 165 / 0.9));
color: white;
}
.grade-good {
background: linear-gradient(135deg, oklch(0.65 0.13 155 / 0.85), oklch(0.55 0.12 165 / 0.85));
color: white;
}
.grade-satisfactory {
background: linear-gradient(135deg, oklch(0.75 0.15 85 / 0.8), oklch(0.65 0.13 75 / 0.8));
color: white;
}
.grade-sufficient {
background: linear-gradient(135deg, oklch(0.75 0.12 55 / 0.8), oklch(0.65 0.1 45 / 0.8));
color: white;
}
.grade-poor {
background: linear-gradient(135deg, oklch(0.6 0.18 25 / 0.85), oklch(0.55 0.16 15 / 0.85));
color: white;
}
.grade-failing {
background: linear-gradient(135deg, oklch(0.55 0.2 15 / 0.9), oklch(0.5 0.18 25 / 0.9));
color: white;
}
/* Glass effect for cards */
.glass-card {
background: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.dark .glass-card {
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.05);
}
/* Min touch target size (44px) */
.min-touch {
min-height: 44px;
min-width: 44px;
}
} }
+83 -14
View File
@@ -107,6 +107,7 @@ import { useAppStore, type ViewName } from '@/lib/store';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import { fetchSchoolYears, fetchStudents, fetchClasses, fetchDBNotifications, markAllNotificationsRead, markSingleNotificationRead, type SchoolYear, type Student, type ClassGroup, type DBNotification, type DBNotificationData } from '@/lib/api'; import { fetchSchoolYears, fetchStudents, fetchClasses, fetchDBNotifications, markAllNotificationsRead, markSingleNotificationRead, type SchoolYear, type Student, type ClassGroup, type DBNotification, type DBNotificationData } from '@/lib/api';
import { apiGet } from '@/lib/api'; import { apiGet } from '@/lib/api';
import type { School as SchoolType } from '@/lib/api';
import OnboardingTour, { isOnboardingCompleted } from '@/components/onboarding-tour'; import OnboardingTour, { isOnboardingCompleted } from '@/components/onboarding-tour';
import KeyboardShortcutsDialog from '@/components/keyboard-shortcuts-dialog'; import KeyboardShortcutsDialog from '@/components/keyboard-shortcuts-dialog';
import { import {
@@ -123,6 +124,10 @@ import { toast } from 'sonner';
import { useWebSocket, usePushNotifications, playNotificationSound, getNotificationSoundPref } from '@/lib/websocket'; import { useWebSocket, usePushNotifications, playNotificationSound, getNotificationSoundPref } from '@/lib/websocket';
import { OfflineIndicator, PWAInstallPrompt, useServiceWorker, OfflineSyncManager } from '@/components/offline-indicator'; import { OfflineIndicator, PWAInstallPrompt, useServiceWorker, OfflineSyncManager } from '@/components/offline-indicator';
import dynamic from 'next/dynamic';
import { Skeleton } from '@/components/ui/skeleton';
// Static imports for lightweight views
import DashboardView from './dashboard-view'; import DashboardView from './dashboard-view';
import ClassesView from './classes-view'; import ClassesView from './classes-view';
import CompetencyGridView from './competency-grid-view'; import CompetencyGridView from './competency-grid-view';
@@ -133,23 +138,37 @@ import GradingView from './grading-view';
import ReportsView from './reports-view'; import ReportsView from './reports-view';
import SettingsView from './settings-view'; import SettingsView from './settings-view';
import StudentDetailView from './student-detail-view'; import StudentDetailView from './student-detail-view';
import AnalyticsView from './analytics-view';
import MasteryMatrixView from './mastery-matrix-view'; import MasteryMatrixView from './mastery-matrix-view';
import AttendanceView from './attendance-view'; import AttendanceView from './attendance-view';
import LessonPlansView from './lesson-plans-view'; import LessonPlansView from './lesson-plans-view';
import CalendarView from './calendar-view';
import ParentCommunicationView from './parent-communication-view'; import ParentCommunicationView from './parent-communication-view';
import BehaviorTrackingView from './behavior-tracking-view'; import BehaviorTrackingView from './behavior-tracking-view';
import CurriculumCoverageView from './curriculum-coverage-view'; import CurriculumCoverageView from './curriculum-coverage-view';
import RubricLibraryView from './rubric-library-view'; import RubricLibraryView from './rubric-library-view';
import CommentBankView from './comment-bank-view'; import CommentBankView from './comment-bank-view';
import NotebooksView from './notebooks-view';
import DrawingView from './drawing-view';
import HomeworkView from './homework-view'; import HomeworkView from './homework-view';
import PortfolioView from './portfolio-view'; import PortfolioView from './portfolio-view';
import TimetableView from './timetable-view'; import TimetableView from './timetable-view';
import ResourceLibraryView from './resource-library-view'; import ResourceLibraryView from './resource-library-view';
// Dynamic imports for heavy components with loading skeletons
const AnalyticsView = dynamic(() => import('./analytics-view'), {
loading: () => <div className="space-y-4 p-4"><Skeleton className="h-8 w-48" /><div className="grid grid-cols-1 md:grid-cols-3 gap-4">{[1,2,3].map(i => <Skeleton key={i} className="h-32" />)}</div><Skeleton className="h-64" /></div>,
});
const CalendarView = dynamic(() => import('./calendar-view'), {
loading: () => <div className="space-y-4 p-4"><Skeleton className="h-8 w-48" /><Skeleton className="h-96" /></div>,
});
const NotebooksView = dynamic(() => import('./notebooks-view'), {
loading: () => <div className="space-y-4 p-4"><Skeleton className="h-8 w-48" /><div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">{[1,2,3].map(i => <Skeleton key={i} className="h-48" />)}</div></div>,
});
const DrawingView = dynamic(() => import('./drawing-view'), {
ssr: false,
loading: () => <div className="flex items-center justify-center h-96"><div className="flex items-center gap-3"><div className="h-5 w-5 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" /><span className="text-gray-500">Loading canvas...</span></div></div>,
});
type NavItem = { key: ViewName; icon: React.ElementType; labelKey: string }; type NavItem = { key: ViewName; icon: React.ElementType; labelKey: string };
type NavSection = { id: string; labelKey: string; items: NavItem[] }; type NavSection = { id: string; labelKey: string; items: NavItem[] };
@@ -375,6 +394,9 @@ export default function AppLayout() {
const [schoolYears, setSchoolYears] = useState<SchoolYear[]>([]); const [schoolYears, setSchoolYears] = useState<SchoolYear[]>([]);
const [selectedYearId, setSelectedYearId] = useState<string>(storeSchoolYearId ?? ''); const [selectedYearId, setSelectedYearId] = useState<string>(storeSchoolYearId ?? '');
// School branding state
const [schoolBranding, setSchoolBranding] = useState<SchoolType | null>(null);
// Service Worker registration for PWA // Service Worker registration for PWA
useServiceWorker(); useServiceWorker();
@@ -755,6 +777,33 @@ export default function AppLayout() {
loadYears(); loadYears();
}, [currentUser?.schoolId]); }, [currentUser?.schoolId]);
// Load school branding and apply CSS custom properties
useEffect(() => {
async function loadBranding() {
if (!currentUser?.schoolId) return;
try {
const schools = await apiGet<SchoolType[]>('/api/schools');
const mySchool = schools.find((s) => s.id === currentUser.schoolId);
if (mySchool) {
setSchoolBranding(mySchool);
// Apply branding CSS variables
const root = document.documentElement;
if (mySchool.primaryColor) root.style.setProperty('--brand-primary', mySchool.primaryColor);
if (mySchool.secondaryColor) root.style.setProperty('--brand-secondary', mySchool.secondaryColor);
if (mySchool.accentColor) root.style.setProperty('--brand-accent', mySchool.accentColor);
if (mySchool.fontFamily) root.style.setProperty('--brand-font', `"${mySchool.fontFamily}", sans-serif`);
// Apply font family globally
if (mySchool.fontFamily) {
root.style.fontFamily = `"${mySchool.fontFamily}", sans-serif`;
}
}
} catch {
// ignore branding errors
}
}
loadBranding();
}, [currentUser?.schoolId]);
return ( return (
<SidebarProvider> <SidebarProvider>
{/* Offline indicator bar */} {/* Offline indicator bar */}
@@ -774,11 +823,19 @@ export default function AppLayout() {
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
className="flex items-center gap-3 group-data-[collapsible=icon]:justify-center" className="flex items-center gap-3 group-data-[collapsible=icon]:justify-center"
> >
<div className="flex items-center justify-center w-9 h-9 rounded-xl bg-gradient-to-br from-emerald-400 to-teal-500 text-white shrink-0 shadow-lg shadow-emerald-300/40 dark:shadow-emerald-900/40"> {schoolBranding?.logoUrl ? (
<BookOpen className="w-5 h-5" /> <img
</div> src={schoolBranding.logoUrl}
alt={schoolBranding.name}
className="w-9 h-9 rounded-xl object-contain shrink-0"
/>
) : (
<div className="flex items-center justify-center w-9 h-9 rounded-xl bg-gradient-to-br from-emerald-400 to-teal-500 text-white shrink-0 shadow-lg shadow-emerald-300/40 dark:shadow-emerald-900/40">
<BookOpen className="w-5 h-5" />
</div>
)}
<div className="group-data-[collapsible=icon]:hidden"> <div className="group-data-[collapsible=icon]:hidden">
<h2 className="text-lg font-bold text-gray-900 dark:text-gray-100 leading-tight">CompetenceTrack</h2> <h2 className="text-lg font-bold text-gray-900 dark:text-gray-100 leading-tight">{schoolBranding?.name || 'CompetenceTrack'}</h2>
<p className="text-[10px] text-emerald-600/70 dark:text-emerald-400/50 leading-tight">{t('app.subtitle')}</p> <p className="text-[10px] text-emerald-600/70 dark:text-emerald-400/50 leading-tight">{t('app.subtitle')}</p>
</div> </div>
</motion.div> </motion.div>
@@ -827,7 +884,7 @@ export default function AppLayout() {
</SidebarContent> </SidebarContent>
<SidebarFooter className="p-3"> <SidebarFooter className="p-3">
{/* Environmental message */} {/* School motto or Environmental message */}
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
@@ -836,7 +893,7 @@ export default function AppLayout() {
> >
<Leaf className="h-4 w-4 text-emerald-500 shrink-0" /> <Leaf className="h-4 w-4 text-emerald-500 shrink-0" />
<p className="text-xs text-emerald-600/70 dark:text-emerald-400/50 font-medium leading-snug"> <p className="text-xs text-emerald-600/70 dark:text-emerald-400/50 font-medium leading-snug">
{t('sidebar.eco_message')} {schoolBranding?.motto || t('sidebar.eco_message')}
</p> </p>
</motion.div> </motion.div>
<Separator className="mb-3 bg-emerald-200/50 dark:bg-emerald-900/30" /> <Separator className="mb-3 bg-emerald-200/50 dark:bg-emerald-900/30" />
@@ -866,7 +923,14 @@ export default function AppLayout() {
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')} onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
title={t('theme.toggle')} title={t('theme.toggle')}
> >
{theme === 'dark' ? <Sun className="h-4 w-4 text-amber-500" /> : <Moon className="h-4 w-4 text-gray-500" />} <motion.div
key={theme}
initial={{ rotate: -90, opacity: 0 }}
animate={{ rotate: 0, opacity: 1 }}
transition={{ duration: 0.3, type: 'spring', stiffness: 200 }}
>
{theme === 'dark' ? <Sun className="h-4 w-4 text-amber-500" /> : <Moon className="h-4 w-4 text-gray-500" />}
</motion.div>
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
@@ -894,7 +958,7 @@ export default function AppLayout() {
<SidebarInset> <SidebarInset>
{/* Announcement Banner */} {/* Announcement Banner */}
<AnnouncementBanner schoolId={currentUser?.schoolId ?? null} /> <AnnouncementBanner schoolId={currentUser?.schoolId ?? null} />
<header className="flex h-14 items-center gap-2 border-b border-emerald-200/50 dark:border-emerald-900/30 bg-white dark:bg-gray-950 backdrop-blur-sm px-4 sticky top-0 z-10 shadow-sm shadow-emerald-100/50 dark:shadow-emerald-900/10"> <header className="flex h-14 items-center gap-2 border-b border-emerald-200/50 dark:border-emerald-900/30 bg-white/80 dark:bg-gray-950/80 backdrop-blur-xl px-4 sticky top-0 z-10 shadow-sm shadow-emerald-100/50 dark:shadow-emerald-900/10 transition-all duration-300">
<SidebarTrigger className="-ml-1 h-10 w-10 hover:bg-emerald-100 dark:hover:bg-emerald-900/20 shrink-0" /> <SidebarTrigger className="-ml-1 h-10 w-10 hover:bg-emerald-100 dark:hover:bg-emerald-900/20 shrink-0" />
<Separator orientation="vertical" className="h-6 bg-emerald-200/50 dark:bg-emerald-900/30 shrink-0" /> <Separator orientation="vertical" className="h-6 bg-emerald-200/50 dark:bg-emerald-900/30 shrink-0" />
<Breadcrumb className="min-w-0 flex-1"> <Breadcrumb className="min-w-0 flex-1">
@@ -907,7 +971,7 @@ export default function AppLayout() {
{t('polish.breadcrumb_home')} {t('polish.breadcrumb_home')}
</BreadcrumbLink> </BreadcrumbLink>
</BreadcrumbItem> </BreadcrumbItem>
<BreadcrumbSeparator /> <BreadcrumbSeparator><ChevronRight className="h-3 w-3 text-emerald-400/60" /></BreadcrumbSeparator>
<BreadcrumbItem> <BreadcrumbItem>
<BreadcrumbPage className="font-semibold text-gray-900 dark:text-gray-100 truncate"> <BreadcrumbPage className="font-semibold text-gray-900 dark:text-gray-100 truncate">
{breadcrumbLabel} {breadcrumbLabel}
@@ -979,7 +1043,12 @@ export default function AppLayout() {
className="h-9 w-9 hover:bg-emerald-100 dark:hover:bg-emerald-900/20 relative text-emerald-600 dark:text-emerald-400" className="h-9 w-9 hover:bg-emerald-100 dark:hover:bg-emerald-900/20 relative text-emerald-600 dark:text-emerald-400"
title={t('notifications.title')} title={t('notifications.title')}
> >
<Bell className="h-4 w-4" /> <motion.div
animate={notifData && notifData.unreadCount > 0 ? { rotate: [0, -15, 15, -10, 10, 0] } : {}}
transition={{ duration: 0.6, ease: 'easeInOut' }}
>
<Bell className="h-4 w-4" />
</motion.div>
{notifData && notifData.unreadCount > 0 && ( {notifData && notifData.unreadCount > 0 && (
<motion.span <motion.span
initial={{ scale: 0 }} initial={{ scale: 0 }}
+1 -1
View File
@@ -485,7 +485,7 @@ export default function AssessmentsView() {
</Card> </Card>
</motion.div> </motion.div>
) : ( ) : (
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden"> <Card className="card-hover-lift border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent"> <CardHeader className="pb-3 pt-6 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
<CardTitle className="text-lg font-bold flex items-center gap-2"> <CardTitle className="text-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400"> <div className="flex items-center justify-center w-8 h-8 rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
+2 -2
View File
@@ -287,7 +287,7 @@ function MonthlyHeatmap({ sessions }: { sessions: AttendanceSession[] }) {
} }
return ( return (
<Card className="border-0 shadow-lg rounded-xl bg-gradient-to-br from-white to-emerald-50/30 dark:from-gray-900 dark:to-emerald-950/20 overflow-hidden"> <Card className="card-hover-lift border-0 shadow-lg rounded-xl bg-gradient-to-br from-white to-emerald-50/30 dark:from-gray-900 dark:to-emerald-950/20 overflow-hidden">
<CardHeader className="pb-2 bg-gradient-to-r from-emerald-50/50 to-teal-50/30 dark:from-emerald-900/10 dark:to-teal-900/5"> <CardHeader className="pb-2 bg-gradient-to-r from-emerald-50/50 to-teal-50/30 dark:from-emerald-900/10 dark:to-teal-900/5">
<CardTitle className="text-lg flex items-center gap-2"> <CardTitle className="text-lg flex items-center gap-2">
<CalendarCheck className="h-5 w-5 text-emerald-500" /> <CalendarCheck className="h-5 w-5 text-emerald-500" />
@@ -429,7 +429,7 @@ function StatsCards({ sessions }: { sessions: AttendanceSession[] }) {
transition={{ duration: 0.3, delay: idx * 0.06 }} transition={{ duration: 0.3, delay: idx * 0.06 }}
whileHover={{ y: -2 }} whileHover={{ y: -2 }}
> >
<Card className={`border-2 ${card.border} shadow-md hover:shadow-lg transition-shadow rounded-xl overflow-hidden`}> <Card className={`card-hover-lift border-2 ${card.border} shadow-md hover:shadow-lg transition-shadow rounded-xl overflow-hidden`}>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<card.icon className="w-4 h-4" /> <card.icon className="w-4 h-4" />
+1 -1
View File
@@ -608,7 +608,7 @@ export default function ClassesView() {
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
onClick={() => handleSelectClass(cls)} onClick={() => handleSelectClass(cls)}
className={`w-full text-left rounded-xl transition-all duration-200 border-l-3 ${schoolTypeAccent[cls.schoolType] ?? 'border-l-emerald-500'} bg-gradient-to-r ${schoolTypeGradient[cls.schoolType] ?? schoolTypeGradient.OTHER} hover:shadow-xl hover:shadow-emerald-500/8 lift-on-hover ${ className={`w-full text-left rounded-xl transition-all duration-200 border-l-3 ${schoolTypeAccent[cls.schoolType] ?? 'border-l-emerald-500'} bg-gradient-to-r ${schoolTypeGradient[cls.schoolType] ?? schoolTypeGradient.OTHER} hover:shadow-xl hover:shadow-emerald-500/8 card-hover-lift ${
selectedClass?.id === cls.id selectedClass?.id === cls.id
? 'shadow-lg ring-2 ring-emerald-300/60 dark:ring-emerald-700/50' ? 'shadow-lg ring-2 ring-emerald-300/60 dark:ring-emerald-700/50'
: 'hover:shadow-lg hover:shadow-emerald-200/30 dark:hover:shadow-emerald-900/20' : 'hover:shadow-lg hover:shadow-emerald-200/30 dark:hover:shadow-emerald-900/20'
+211 -4
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import React, { useEffect, useState, useMemo } from 'react'; import React, { useEffect, useState, useMemo, useRef, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { import {
Users, Users,
@@ -53,6 +53,12 @@ import {
Pin, Pin,
X, X,
Send, Send,
MapPin,
Trophy,
Users as UsersIcon,
Music,
Flag,
Tent,
} from 'lucide-react'; } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -89,7 +95,7 @@ import {
import { useAppStore, type ViewName, type CurrentUser } from '@/lib/store'; import { useAppStore, type ViewName, type CurrentUser } from '@/lib/store';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import { fetchDashboard, type DashboardData, getStoredNotifications, type AppNotification, addNotification, markNotificationsRead, fetchParentLinks, type ParentStudentLinkData, fetchStudents } from '@/lib/api'; import { fetchDashboard, type DashboardData, getStoredNotifications, type AppNotification, addNotification, markNotificationsRead, fetchParentLinks, type ParentStudentLinkData, fetchStudents } from '@/lib/api';
import { apiGet, apiPost } from '@/lib/api'; import { apiGet, apiPost, apiPut } from '@/lib/api';
const containerVariants = { const containerVariants = {
hidden: { opacity: 0 }, hidden: { opacity: 0 },
@@ -101,6 +107,37 @@ const itemVariants = {
visible: { opacity: 1, y: 0 }, visible: { opacity: 1, y: 0 },
}; };
// ─── CountUp Animation Component ────────────────────────────────────
function CountUp({ target, duration = 800 }: { target: number; duration?: number }) {
const [count, setCount] = useState(0);
const prevTarget = useRef(0);
useEffect(() => {
if (target === prevTarget.current) return;
const start = prevTarget.current;
const diff = target - start;
if (diff === 0) { setCount(target); return; }
const startTime = performance.now();
let rafId: number;
function step(now: number) {
const elapsed = now - startTime;
const progress = Math.min(elapsed / duration, 1);
// Ease out cubic
const eased = 1 - Math.pow(1 - progress, 3);
setCount(Math.round(start + diff * eased));
if (progress < 1) {
rafId = requestAnimationFrame(step);
} else {
prevTarget.current = target;
}
}
rafId = requestAnimationFrame(step);
return () => cancelAnimationFrame(rafId);
}, [target, duration]);
return <>{count}</>;
}
const statCardColors: Record<string, { bg: string; gradient: string; iconBg: string; iconText: string; borderAccent: string; glowColor: string; hoverShadow: string }> = { const statCardColors: Record<string, { bg: string; gradient: string; iconBg: string; iconText: string; borderAccent: string; glowColor: string; hoverShadow: string }> = {
emerald: { emerald: {
bg: 'bg-gradient-to-br from-emerald-50 to-emerald-100/50 dark:from-emerald-900/20 dark:to-emerald-800/10', bg: 'bg-gradient-to-br from-emerald-50 to-emerald-100/50 dark:from-emerald-900/20 dark:to-emerald-800/10',
@@ -1000,7 +1037,7 @@ export default function DashboardView() {
<TooltipProvider delayDuration={200}> <TooltipProvider delayDuration={200}>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Card className={`border-0 shadow-sm rounded-xl border-l-3 ${colors.borderAccent} 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-emerald-200/40 dark:hover:ring-emerald-700/30`}> <Card className={`card-hover-lift border-0 shadow-sm rounded-xl border-l-3 ${colors.borderAccent} 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-emerald-200/40 dark:hover:ring-emerald-700/30`}>
<CardContent className={`p-5 ${colors.bg}`}> <CardContent className={`p-5 ${colors.bg}`}>
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<p className="text-[10px] font-semibold text-gray-500 dark:text-gray-400 leading-tight line-clamp-2 min-h-[28px] break-words min-w-0"> <p className="text-[10px] font-semibold text-gray-500 dark:text-gray-400 leading-tight line-clamp-2 min-h-[28px] break-words min-w-0">
@@ -1011,7 +1048,7 @@ export default function DashboardView() {
</div> </div>
</div> </div>
<div className="mt-2 flex items-baseline gap-1.5"> <div className="mt-2 flex items-baseline gap-1.5">
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100 leading-none">{stat.value}</p> <p className="text-3xl font-bold text-gray-900 dark:text-gray-100 leading-none"><CountUp target={stat.value} /></p>
{stat.value > 0 && stat.trend === 'up' && ( {stat.value > 0 && stat.trend === 'up' && (
<div className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-md bg-emerald-100/80 dark:bg-emerald-900/30"> <div className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-md bg-emerald-100/80 dark:bg-emerald-900/30">
<ArrowUpRight className="h-3 w-3 text-emerald-600 dark:text-emerald-400" /> <ArrowUpRight className="h-3 w-3 text-emerald-600 dark:text-emerald-400" />
@@ -1762,6 +1799,9 @@ export default function DashboardView() {
<DashboardHomeworkCard currentUser={currentUser} locale={locale} /> <DashboardHomeworkCard currentUser={currentUser} locale={locale} />
</div> </div>
{/* ===== UPCOMING SCHOOL EVENTS ===== */}
<DashboardSchoolEventsCard currentUser={currentUser} locale={locale} />
{/* ===== PAPER SAVED & ENVIRONMENTAL SECTION ===== */} {/* ===== PAPER SAVED & ENVIRONMENTAL SECTION ===== */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Paper Saved Counter */} {/* Paper Saved Counter */}
@@ -2202,3 +2242,170 @@ function DashboardHomeworkCard({ currentUser, locale }: { currentUser: CurrentUs
</motion.div> </motion.div>
); );
} }
// ─── School Event Types ──────────────────────────────────────────────
interface SchoolEventData {
id: string;
schoolId: string;
title: string;
description: string | null;
eventType: string;
startDate: string;
endDate: string | null;
location: string | null;
organizerId: string | null;
classGroupId: string | null;
isAllSchool: boolean;
isPublic: boolean;
requiresRegistration: boolean;
maxParticipants: number | null;
notes: string | null;
isDemo: boolean;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
organizer: { id: string; firstName: string; lastName: string } | null;
classGroup: { id: string; name: string } | null;
registrations: { id: string; userId: string; status: string; user: { id: string; firstName: string; lastName: string } }[];
}
const eventTypeIcon: Record<string, React.ElementType> = {
assembly: UsersIcon,
field_trip: MapPin,
sports_day: Trophy,
concert: Music,
fair: Tent,
parent_meeting: UsersIcon,
graduation: GraduationCap,
holiday: CalendarDays,
};
const eventTypeColor: Record<string, string> = {
assembly: 'bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400',
field_trip: 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400',
sports_day: 'bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400',
concert: 'bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400',
fair: 'bg-rose-100 dark:bg-rose-900/30 text-rose-600 dark:text-rose-400',
parent_meeting: 'bg-cyan-100 dark:bg-cyan-900/30 text-cyan-600 dark:text-cyan-400',
graduation: 'bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400',
holiday: 'bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400',
};
function DashboardSchoolEventsCard({ currentUser, locale }: { currentUser: CurrentUser | null; locale: string }) {
const setCurrentView = useAppStore((s) => s.setCurrentView);
const [events, setEvents] = useState<SchoolEventData[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!currentUser?.schoolId) return;
setLoading(true);
apiGet<SchoolEventData[]>(`/api/school-events?schoolId=${currentUser.schoolId}&upcoming=true`)
.then((data) => {
setEvents(data.slice(0, 6));
setLoading(false);
})
.catch(() => setLoading(false));
}, [currentUser?.schoolId]);
const formatDate = (dateStr: string) => {
const d = new Date(dateStr);
return d.toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US', { month: 'short', day: 'numeric' });
};
return (
<motion.div variants={itemVariants}>
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden transition-shadow duration-200 hover:shadow-md ring-1 ring-black/[0.03] dark:ring-white/[0.05]">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent">
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
<CalendarDays className="h-4 w-4" />
</div>
{t('events.upcoming')}
</CardTitle>
<Button
size="sm"
variant="outline"
className="min-h-[44px] text-teal-600 dark:text-teal-400 border-teal-200/50 dark:border-teal-900/30 hover:bg-teal-50 dark:hover:bg-teal-900/20"
onClick={() => setCurrentView('calendar')}
>
{t('events.view_all')} <ArrowUpRight className="h-3 w-3 ml-1" />
</Button>
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-16 w-full" />)}
</div>
) : events.length === 0 ? (
<div className="text-center py-8">
<div className="flex items-center justify-center w-16 h-16 rounded-2xl bg-teal-100 dark:bg-teal-900/20 mx-auto mb-4">
<CalendarDays className="h-8 w-8 text-teal-400 dark:text-teal-500" />
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 font-medium">{t('events.no_events')}</p>
</div>
) : (
<div className="space-y-2 max-h-96 overflow-y-auto scrollbar-education pr-1">
{events.map((event) => {
const Icon = eventTypeIcon[event.eventType] ?? CalendarDays;
const iconColor = eventTypeColor[event.eventType] ?? 'bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400';
const isRegistered = event.registrations.some((r) => r.userId === currentUser?.id && r.status === 'registered');
return (
<motion.div
key={event.id}
whileHover={{ scale: 1.01, x: 2 }}
transition={{ duration: 0.15 }}
className="flex items-center justify-between p-3 rounded-xl bg-gray-50/50 dark:bg-gray-800/30 border border-gray-100/60 dark:border-gray-800/40 hover:border-teal-200/60 dark:hover:border-teal-800/30 transition-colors"
>
<div className="flex items-center gap-3 min-w-0">
<div className={`flex items-center justify-center w-9 h-9 rounded-lg ${iconColor} shrink-0`}>
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0">
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">{event.title}</p>
<div className="flex items-center gap-2 text-[11px] text-gray-500 dark:text-gray-400">
<span>{formatDate(event.startDate)}</span>
{event.location && (
<span className="flex items-center gap-0.5"><MapPin className="h-3 w-3" />{event.location}</span>
)}
{event.isAllSchool && (
<Badge className="bg-teal-50 text-teal-700 dark:bg-teal-900/20 dark:text-teal-300 text-[9px] px-1 py-0">{t('events.all_school')}</Badge>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{event.requiresRegistration && (
isRegistered ? (
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 text-xs">
{t('events.registered')}
</Badge>
) : (
<Button
size="sm"
variant="outline"
className="min-h-[36px] text-xs rounded-lg border-teal-300 dark:border-teal-700 text-teal-700 dark:text-teal-300 hover:bg-teal-50 dark:hover:bg-teal-900/20"
onClick={async () => {
try {
await apiPost(`/api/school-events/${event.id}/register`, { userId: currentUser?.id });
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...e, registrations: [...e.registrations, { id: 'new', userId: currentUser?.id ?? '', status: 'registered', user: { id: currentUser?.id ?? '', firstName: currentUser?.firstName ?? '', lastName: currentUser?.lastName ?? '' } }] } : e));
toast.success(t('events.register'));
} catch { toast.error(t('error.generic')); }
}}
>
{t('events.register')}
</Button>
)
)}
</div>
</motion.div>
);
})}
</div>
)}
</CardContent>
</Card>
</motion.div>
);
}
+4 -4
View File
@@ -368,7 +368,7 @@ export default function GradingView() {
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15 }} transition={{ delay: 0.15 }}
> >
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-amber-400 overflow-hidden"> <Card className="card-hover-lift border-0 shadow-sm rounded-xl border-l-3 border-l-amber-400 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-amber-50/50 to-transparent dark:from-amber-900/10 dark:to-transparent"> <CardHeader className="pb-3 pt-6 bg-gradient-to-r from-amber-50/50 to-transparent dark:from-amber-900/10 dark:to-transparent">
<CardTitle className="text-base font-bold flex items-center gap-2"> <CardTitle className="text-base font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400"> <div className="flex items-center justify-center w-8 h-8 rounded-lg bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400">
@@ -467,7 +467,7 @@ export default function GradingView() {
) : ( ) : (
<> <>
{/* Schemes */} {/* Schemes */}
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden"> <Card className="card-hover-lift border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent"> <CardHeader className="pb-3 pt-6 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
<CardTitle className="text-lg font-bold flex items-center gap-2"> <CardTitle className="text-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400"> <div className="flex items-center justify-center w-8 h-8 rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
@@ -548,7 +548,7 @@ export default function GradingView() {
</Card> </Card>
{/* Computed grades */} {/* Computed grades */}
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden"> <Card className="card-hover-lift border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent"> <CardHeader className="pb-3 pt-6 bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent">
<CardTitle className="text-lg font-bold flex items-center gap-2"> <CardTitle className="text-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400"> <div className="flex items-center justify-center w-8 h-8 rounded-lg bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
@@ -769,7 +769,7 @@ export default function GradingView() {
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }} transition={{ duration: 0.4, delay: 0.1 }}
> >
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-violet-500 overflow-hidden"> <Card className="card-hover-lift border-0 shadow-sm rounded-xl border-l-3 border-l-violet-500 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-violet-50/50 to-transparent dark:from-violet-900/10 dark:to-transparent"> <CardHeader className="pb-3 pt-6 bg-gradient-to-r from-violet-50/50 to-transparent dark:from-violet-900/10 dark:to-transparent">
<CardTitle className="text-lg font-bold flex items-center gap-2"> <CardTitle className="text-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400"> <div className="flex items-center justify-center w-8 h-8 rounded-lg bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400">
+1 -1
View File
@@ -494,7 +494,7 @@ export default function LessonPlansView() {
</div> </div>
{/* ── Filter bar ──────────────────────────────────────────────── */} {/* ── Filter bar ──────────────────────────────────────────────── */}
<Card className="border-emerald-100 dark:border-emerald-900/40"> <Card className="card-hover-lift border-emerald-100 dark:border-emerald-900/40">
<CardContent className="p-4"> <CardContent className="p-4">
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-4"> <div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
+254 -76
View File
@@ -1,14 +1,17 @@
'use client'; 'use client';
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { import {
X, BookOpen, BarChart3, Pencil, Flower2, FileText, PartyPopper, X, BookOpen, BarChart3, Pencil, Flower2, FileText, PartyPopper,
Users, ClipboardCheck, GraduationCap, Palette, Leaf, Users, ClipboardCheck, GraduationCap, Palette, Leaf,
TreePine, CalendarDays, MessageSquare, BarChart, Target, TreePine, CalendarDays, MessageSquare, Target,
Zap, Award, Shield, Notebook, Zap, Award, Shield, Notebook, Grid3X3, Calculator,
CalendarCheck, BookCheck, Briefcase, Clock, Library,
Settings as SettingsIcon, Ruler, MessageSquareText, Heart,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -19,6 +22,7 @@ import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
const ONBOARDING_KEY = 'ct_onboarding_completed'; const ONBOARDING_KEY = 'ct_onboarding_completed';
const DONT_SHOW_KEY = 'ct_onboarding_dont_show';
interface OnboardingStep { interface OnboardingStep {
iconComponent: React.ElementType; iconComponent: React.ElementType;
@@ -30,86 +34,146 @@ interface OnboardingStep {
const steps: OnboardingStep[] = [ const steps: OnboardingStep[] = [
{ {
iconComponent: BookOpen, iconComponent: BookOpen,
titleKey: 'onboarding.welcome_title', titleKey: 'onboarding.step_1_title',
descKey: 'onboarding.welcome_desc', descKey: 'onboarding.step_1_desc',
accent: 'from-emerald-400 to-teal-500', accent: 'from-emerald-400 to-teal-500',
}, },
{ {
iconComponent: BarChart3, iconComponent: BarChart3,
titleKey: 'onboarding.dashboard_title', titleKey: 'onboarding.step_2_title',
descKey: 'onboarding.dashboard_desc', descKey: 'onboarding.step_2_desc',
accent: 'from-teal-400 to-emerald-500', accent: 'from-teal-400 to-emerald-500',
}, },
{ {
iconComponent: Users, iconComponent: Users,
titleKey: 'onboarding.classes_title', titleKey: 'onboarding.step_3_title',
descKey: 'onboarding.classes_desc', descKey: 'onboarding.step_3_desc',
accent: 'from-emerald-400 to-cyan-500', accent: 'from-emerald-400 to-cyan-500',
}, },
{
iconComponent: Grid3X3,
titleKey: 'onboarding.step_4_title',
descKey: 'onboarding.step_4_desc',
accent: 'from-cyan-400 to-emerald-500',
},
{ {
iconComponent: Pencil, iconComponent: Pencil,
titleKey: 'onboarding.progress_title', titleKey: 'onboarding.step_5_title',
descKey: 'onboarding.progress_desc', descKey: 'onboarding.step_5_desc',
accent: 'from-amber-400 to-emerald-500', accent: 'from-amber-400 to-emerald-500',
}, },
{ {
iconComponent: Flower2, iconComponent: Flower2,
titleKey: 'onboarding.flower_title', titleKey: 'onboarding.step_6_title',
descKey: 'onboarding.flower_desc', descKey: 'onboarding.step_6_desc',
accent: 'from-rose-400 to-emerald-500', accent: 'from-rose-400 to-emerald-500',
}, },
{
iconComponent: Grid3X3,
titleKey: 'onboarding.step_7_title',
descKey: 'onboarding.step_7_desc',
accent: 'from-teal-400 to-cyan-500',
},
{ {
iconComponent: ClipboardCheck, iconComponent: ClipboardCheck,
titleKey: 'onboarding.assessments_title', titleKey: 'onboarding.step_8_title',
descKey: 'onboarding.assessments_desc', descKey: 'onboarding.step_8_desc',
accent: 'from-amber-400 to-rose-500', accent: 'from-amber-400 to-rose-500',
}, },
{ {
iconComponent: GraduationCap, iconComponent: Calculator,
titleKey: 'onboarding.grading_title', titleKey: 'onboarding.step_9_title',
descKey: 'onboarding.grading_desc', descKey: 'onboarding.step_9_desc',
accent: 'from-violet-400 to-emerald-500', accent: 'from-violet-400 to-emerald-500',
}, },
{ {
iconComponent: FileText, iconComponent: CalendarCheck,
titleKey: 'onboarding.reports_title', titleKey: 'onboarding.step_10_title',
descKey: 'onboarding.reports_desc', descKey: 'onboarding.step_10_desc',
accent: 'from-violet-400 to-emerald-500', accent: 'from-emerald-400 to-teal-500',
},
{
iconComponent: Notebook,
titleKey: 'onboarding.notebooks_title',
descKey: 'onboarding.notebooks_desc',
accent: 'from-teal-400 to-emerald-500',
},
{
iconComponent: Palette,
titleKey: 'onboarding.drawing_title',
descKey: 'onboarding.drawing_desc',
accent: 'from-rose-400 to-violet-500',
}, },
{ {
iconComponent: CalendarDays, iconComponent: CalendarDays,
titleKey: 'onboarding.calendar_title', titleKey: 'onboarding.step_11_title',
descKey: 'onboarding.calendar_desc', descKey: 'onboarding.step_11_desc',
accent: 'from-amber-400 to-teal-500', accent: 'from-amber-400 to-teal-500',
}, },
{
iconComponent: CalendarDays,
titleKey: 'onboarding.step_12_title',
descKey: 'onboarding.step_12_desc',
accent: 'from-teal-400 to-amber-500',
},
{
iconComponent: Shield,
titleKey: 'onboarding.step_13_title',
descKey: 'onboarding.step_13_desc',
accent: 'from-rose-400 to-amber-500',
},
{ {
iconComponent: MessageSquare, iconComponent: MessageSquare,
titleKey: 'onboarding.parents_title', titleKey: 'onboarding.step_14_title',
descKey: 'onboarding.parents_desc', descKey: 'onboarding.step_14_desc',
accent: 'from-cyan-400 to-emerald-500', accent: 'from-cyan-400 to-emerald-500',
}, },
{ {
iconComponent: Leaf, iconComponent: Ruler,
titleKey: 'onboarding.environmental_title', titleKey: 'onboarding.step_15_title',
descKey: 'onboarding.environmental_desc', descKey: 'onboarding.step_15_desc',
accent: 'from-emerald-400 to-green-500', accent: 'from-amber-400 to-teal-500',
}, },
{ {
iconComponent: PartyPopper, iconComponent: MessageSquareText,
titleKey: 'onboarding.done_title', titleKey: 'onboarding.step_16_title',
descKey: 'onboarding.done_desc', descKey: 'onboarding.step_16_desc',
accent: 'from-teal-400 to-emerald-500',
},
{
iconComponent: Notebook,
titleKey: 'onboarding.step_17_title',
descKey: 'onboarding.step_17_desc',
accent: 'from-emerald-400 to-teal-500',
},
{
iconComponent: Palette,
titleKey: 'onboarding.step_18_title',
descKey: 'onboarding.step_18_desc',
accent: 'from-rose-400 to-violet-500',
},
{
iconComponent: Briefcase,
titleKey: 'onboarding.step_19_title',
descKey: 'onboarding.step_19_desc',
accent: 'from-violet-400 to-emerald-500',
},
{
iconComponent: BookCheck,
titleKey: 'onboarding.step_20_title',
descKey: 'onboarding.step_20_desc',
accent: 'from-amber-400 to-emerald-500',
},
{
iconComponent: Clock,
titleKey: 'onboarding.step_21_title',
descKey: 'onboarding.step_21_desc',
accent: 'from-teal-400 to-cyan-500',
},
{
iconComponent: Library,
titleKey: 'onboarding.step_22_title',
descKey: 'onboarding.step_22_desc',
accent: 'from-emerald-400 to-amber-500',
},
{
iconComponent: BarChart3,
titleKey: 'onboarding.step_23_title',
descKey: 'onboarding.step_23_desc',
accent: 'from-teal-400 to-violet-500',
},
{
iconComponent: SettingsIcon,
titleKey: 'onboarding.step_24_title',
descKey: 'onboarding.step_24_desc',
accent: 'from-emerald-400 to-teal-500', accent: 'from-emerald-400 to-teal-500',
}, },
]; ];
@@ -121,6 +185,15 @@ interface OnboardingTourProps {
export default function OnboardingTour({ open, onClose }: OnboardingTourProps) { export default function OnboardingTour({ open, onClose }: OnboardingTourProps) {
const [currentStep, setCurrentStep] = useState(0); const [currentStep, setCurrentStep] = useState(0);
const [dontShowAgain, setDontShowAgain] = useState(false);
const [showCelebration, setShowCelebration] = useState(false);
useEffect(() => {
if (open) {
setCurrentStep(0);
setShowCelebration(false);
}
}, [open]);
const handleNext = useCallback(() => { const handleNext = useCallback(() => {
if (currentStep < steps.length - 1) { if (currentStep < steps.length - 1) {
@@ -129,12 +202,18 @@ export default function OnboardingTour({ open, onClose }: OnboardingTourProps) {
// Complete onboarding // Complete onboarding
try { try {
localStorage.setItem(ONBOARDING_KEY, 'true'); localStorage.setItem(ONBOARDING_KEY, 'true');
if (dontShowAgain) {
localStorage.setItem(DONT_SHOW_KEY, 'true');
}
} catch { } catch {
// ignore // ignore
} }
onClose(); setShowCelebration(true);
setTimeout(() => {
onClose();
}, 2000);
} }
}, [currentStep, onClose]); }, [currentStep, onClose, dontShowAgain]);
const handleBack = useCallback(() => { const handleBack = useCallback(() => {
if (currentStep > 0) { if (currentStep > 0) {
@@ -145,15 +224,19 @@ export default function OnboardingTour({ open, onClose }: OnboardingTourProps) {
const handleSkip = useCallback(() => { const handleSkip = useCallback(() => {
try { try {
localStorage.setItem(ONBOARDING_KEY, 'true'); localStorage.setItem(ONBOARDING_KEY, 'true');
if (dontShowAgain) {
localStorage.setItem(DONT_SHOW_KEY, 'true');
}
} catch { } catch {
// ignore // ignore
} }
onClose(); onClose();
}, [onClose]); }, [onClose, dontShowAgain]);
const step = steps[currentStep]; const step = steps[currentStep];
const isLastStep = currentStep === steps.length - 1; const isLastStep = currentStep === steps.length - 1;
const isFirstStep = currentStep === 0; const isFirstStep = currentStep === 0;
const progress = ((currentStep + 1) / steps.length) * 100;
return ( return (
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) handleSkip(); }}> <Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) handleSkip(); }}>
@@ -167,13 +250,23 @@ export default function OnboardingTour({ open, onClose }: OnboardingTourProps) {
{/* Close button */} {/* Close button */}
<button <button
onClick={handleSkip} onClick={handleSkip}
className="absolute right-3 top-3 z-10 rounded-full p-1 hover:bg-emerald-100 dark:hover:bg-emerald-900/20 transition-colors text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" className="absolute right-3 top-3 z-10 rounded-full p-1 hover:bg-emerald-100 dark:hover:bg-emerald-900/20 transition-colors text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 min-h-[44px] min-w-[44px] flex items-center justify-center"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
{/* Progress bar */}
<div className="h-1 bg-gray-100 dark:bg-gray-800">
<motion.div
className="h-full bg-gradient-to-r from-emerald-400 to-teal-500"
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.4, ease: 'easeInOut' }}
/>
</div>
{/* Step content with animation */} {/* Step content with animation */}
<div className="relative overflow-hidden" style={{ minHeight: '320px' }}> <div className="relative overflow-hidden" style={{ minHeight: '300px' }}>
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
<motion.div <motion.div
key={currentStep} key={currentStep}
@@ -181,16 +274,34 @@ export default function OnboardingTour({ open, onClose }: OnboardingTourProps) {
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -60 }} exit={{ opacity: 0, x: -60 }}
transition={{ duration: 0.3, ease: 'easeInOut' }} transition={{ duration: 0.3, ease: 'easeInOut' }}
className="flex flex-col items-center text-center px-8 pt-10 pb-6" className="flex flex-col items-center text-center px-8 pt-8 pb-6"
> >
{/* Icon illustration */} {/* Icon illustration with animated highlight ring */}
<div className="relative">
<motion.div
className="absolute inset-0 rounded-3xl bg-gradient-to-br opacity-30 tour-highlight-ring"
style={{ background: `linear-gradient(135deg, var(--tw-gradient-from), var(--tw-gradient-to))` }}
/>
<motion.div
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.15, duration: 0.4, type: 'spring', stiffness: 200 }}
className={`w-20 h-20 rounded-3xl bg-gradient-to-br ${step.accent} flex items-center justify-center shadow-lg mb-5`}
>
<step.iconComponent className="w-9 h-9 text-white" />
</motion.div>
</div>
{/* Step number badge */}
<motion.div <motion.div
initial={{ scale: 0.5, opacity: 0 }} initial={{ opacity: 0, y: -5 }}
animate={{ scale: 1, opacity: 1 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15, duration: 0.4, type: 'spring', stiffness: 200 }} transition={{ delay: 0.1, duration: 0.2 }}
className={`w-24 h-24 rounded-3xl bg-gradient-to-br ${step.accent} flex items-center justify-center shadow-lg mb-6`} className="mb-3"
> >
<step.iconComponent className="w-10 h-10 text-white" /> <span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full bg-emerald-100 dark:bg-emerald-900/30 text-[10px] font-bold text-emerald-600 dark:text-emerald-400">
{t('onboarding.step', { current: String(currentStep + 1), total: String(steps.length) })}
</span>
</motion.div> </motion.div>
{/* Title */} {/* Title */}
@@ -198,7 +309,7 @@ export default function OnboardingTour({ open, onClose }: OnboardingTourProps) {
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, duration: 0.3 }} transition={{ delay: 0.2, duration: 0.3 }}
className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-3" className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-2"
> >
{t(step.titleKey)} {t(step.titleKey)}
</motion.h2> </motion.h2>
@@ -214,45 +325,104 @@ export default function OnboardingTour({ open, onClose }: OnboardingTourProps) {
</motion.p> </motion.p>
</motion.div> </motion.div>
</AnimatePresence> </AnimatePresence>
{/* Celebration overlay */}
<AnimatePresence>
{showCelebration && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 flex flex-col items-center justify-center bg-white/90 dark:bg-gray-950/90 backdrop-blur-sm px-8"
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 300, damping: 15 }}
>
<PartyPopper className="w-16 h-16 text-emerald-500" />
</motion.div>
<motion.h2
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="text-2xl font-bold text-gray-900 dark:text-gray-100 mt-4"
>
{t('onboarding.celebration')}
</motion.h2>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5 }}
className="text-sm text-gray-500 dark:text-gray-400 mt-2"
>
{t('onboarding.complete')}
</motion.p>
{/* Confetti-like particles */}
{[...Array(8)].map((_, i) => (
<motion.div
key={i}
className="absolute w-2 h-2 rounded-full"
style={{
background: ['#10b981', '#14b8a6', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16'][i],
left: `${30 + Math.random() * 40}%`,
top: `${30 + Math.random() * 40}%`,
}}
initial={{ scale: 0, opacity: 1 }}
animate={{
scale: [0, 1.5, 0],
opacity: [1, 0.8, 0],
y: [0, -30 - Math.random() * 40],
x: [(Math.random() - 0.5) * 60],
}}
transition={{ duration: 1.2, delay: i * 0.08, ease: 'easeOut' }}
/>
))}
</motion.div>
)}
</AnimatePresence>
</div> </div>
{/* Step indicator dots */} {/* Step indicator dots (scrollable for 24 steps) */}
<div className="flex items-center justify-center gap-1.5 pb-4 flex-wrap px-4"> <div className="flex items-center justify-center gap-1 pb-3 px-4 overflow-x-auto max-w-full">
{steps.map((_, i) => ( {steps.map((_, i) => (
<button <button
key={i} key={i}
onClick={() => setCurrentStep(i)} onClick={() => setCurrentStep(i)}
className={`rounded-full transition-all duration-300 ${ className={`rounded-full transition-all duration-300 shrink-0 ${
i === currentStep i === currentStep
? 'w-6 h-2 bg-gradient-to-r from-emerald-400 to-teal-500' ? 'w-5 h-1.5 bg-gradient-to-r from-emerald-400 to-teal-500'
: i < currentStep : i < currentStep
? 'w-2 h-2 bg-emerald-400/60 dark:bg-emerald-500/40' ? 'w-1.5 h-1.5 bg-emerald-400/60 dark:bg-emerald-500/40'
: 'w-2 h-2 bg-gray-200 dark:bg-gray-700' : 'w-1.5 h-1.5 bg-gray-200 dark:bg-gray-700'
}`} }`}
title={t(steps[i].titleKey)}
/> />
))} ))}
</div> </div>
{/* Step counter */}
<div className="text-center pb-2">
<span className="text-[10px] text-gray-400 dark:text-gray-500 font-medium">
{t('onboarding.step', { current: String(currentStep + 1), total: String(steps.length) })}
</span>
</div>
{/* Action buttons */} {/* Action buttons */}
<div className="flex items-center justify-between px-6 py-4 border-t border-emerald-100/50 dark:border-emerald-900/30 bg-emerald-50/30 dark:bg-emerald-950/10"> <div className="flex items-center justify-between px-6 py-4 border-t border-emerald-100/50 dark:border-emerald-900/30 bg-emerald-50/30 dark:bg-emerald-950/10">
<div className="flex-1"> <div className="flex-1 flex items-center gap-3">
{!isFirstStep && ( {!isFirstStep && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={handleBack} onClick={handleBack}
className="text-gray-500 dark:text-gray-400 hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-900/20" className="text-gray-500 dark:text-gray-400 hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-900/20 min-h-[44px]"
> >
{t('onboarding.back')} {t('onboarding.previous')}
</Button> </Button>
)} )}
{/* Don't show again checkbox */}
<label className="flex items-center gap-1.5 cursor-pointer select-none">
<Checkbox
checked={dontShowAgain}
onCheckedChange={(checked) => setDontShowAgain(checked === true)}
className="data-[state=checked]:bg-emerald-500 data-[state=checked]:border-emerald-500"
/>
<span className="text-[10px] text-gray-400 dark:text-gray-500 hidden sm:inline">{t('onboarding.dont_show_again')}</span>
</label>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{!isLastStep && ( {!isLastStep && (
@@ -260,7 +430,7 @@ export default function OnboardingTour({ open, onClose }: OnboardingTourProps) {
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={handleSkip} onClick={handleSkip}
className="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300" className="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 min-h-[44px]"
> >
{t('onboarding.skip')} {t('onboarding.skip')}
</Button> </Button>
@@ -268,7 +438,7 @@ export default function OnboardingTour({ open, onClose }: OnboardingTourProps) {
<Button <Button
size="sm" size="sm"
onClick={handleNext} onClick={handleNext}
className={`bg-gradient-to-r ${step.accent} text-white hover:opacity-90 shadow-md shadow-emerald-200/50 dark:shadow-emerald-900/30 px-6`} className={`bg-gradient-to-r ${step.accent} text-white hover:opacity-90 shadow-md shadow-emerald-200/50 dark:shadow-emerald-900/30 px-6 min-h-[44px]`}
> >
{isLastStep ? t('onboarding.start') : t('onboarding.next')} {isLastStep ? t('onboarding.start') : t('onboarding.next')}
</Button> </Button>
@@ -286,3 +456,11 @@ export function isOnboardingCompleted(): boolean {
return false; return false;
} }
} }
export function isDontShowAgain(): boolean {
try {
return localStorage.getItem(DONT_SHOW_KEY) === 'true';
} catch {
return false;
}
}
+1 -1
View File
@@ -903,7 +903,7 @@ export default function ProgressEntriesView() {
<motion.div <motion.div
whileHover={{ scale: 1.005 }} whileHover={{ scale: 1.005 }}
transition={{ duration: 0.15 }} transition={{ duration: 0.15 }}
className={`flex-1 p-4 rounded-xl bg-gradient-to-r from-gray-50 to-gray-50/0 dark:from-gray-800/50 dark:to-gray-800/0 border-l-3 transition-shadow duration-200 hover:shadow-lg ${ className={`card-hover-lift flex-1 p-4 rounded-xl bg-gradient-to-r from-gray-50 to-gray-50/0 dark:from-gray-800/50 dark:to-gray-800/0 border-l-3 transition-shadow duration-200 hover:shadow-lg ${
isSelected && batchMode ? 'ring-2 ring-red-300/60 dark:ring-red-700/50 bg-red-50/30 dark:bg-red-900/10' : '' isSelected && batchMode ? 'ring-2 ring-red-300/60 dark:ring-red-700/50 bg-red-50/30 dark:bg-red-900/10' : ''
}`} }`}
style={{ borderLeftColor: catColor }} style={{ borderLeftColor: catColor }}
+6 -6
View File
@@ -500,7 +500,7 @@ export default function ReportsView() {
{!selectedClass ? ( {!selectedClass ? (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="space-y-4"> <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="space-y-4">
<Card className="relative border-0 shadow-sm rounded-xl overflow-hidden"> <Card className="card-hover-lift relative border-0 shadow-sm rounded-xl overflow-hidden">
<div className="absolute inset-0 bg-dots opacity-30 pointer-events-none" /> <div className="absolute inset-0 bg-dots opacity-30 pointer-events-none" />
<CardContent className="relative py-16 text-center"> <CardContent className="relative py-16 text-center">
<motion.div <motion.div
@@ -522,7 +522,7 @@ export default function ReportsView() {
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15 }} transition={{ delay: 0.15 }}
> >
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-amber-400 overflow-hidden"> <Card className="card-hover-lift border-0 shadow-sm rounded-xl border-l-3 border-l-amber-400 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-amber-50/50 to-transparent dark:from-amber-900/10 dark:to-transparent"> <CardHeader className="pb-3 pt-6 bg-gradient-to-r from-amber-50/50 to-transparent dark:from-amber-900/10 dark:to-transparent">
<CardTitle className="text-base font-bold flex items-center gap-2"> <CardTitle className="text-base font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400"> <div className="flex items-center justify-center w-8 h-8 rounded-lg bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400">
@@ -717,7 +717,7 @@ export default function ReportsView() {
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3 }} transition={{ duration: 0.3 }}
> >
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden"> <Card className="card-hover-lift border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent"> <CardHeader className="pb-3 pt-6 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
@@ -837,7 +837,7 @@ export default function ReportsView() {
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3, delay: 0.1 }} transition={{ duration: 0.3, delay: 0.1 }}
> >
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden"> <Card className="card-hover-lift border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent"> <CardHeader className="pb-3 pt-6 bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent">
<CardTitle className="text-lg font-bold flex items-center gap-2"> <CardTitle className="text-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400"> <div className="flex items-center justify-center w-8 h-8 rounded-lg bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
@@ -897,7 +897,7 @@ export default function ReportsView() {
{/* PDF Template Selection + Batch Generate */} {/* PDF Template Selection + Batch Generate */}
{selectedClass && students.length > 0 && ( {selectedClass && students.length > 0 && (
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden mt-6"> <Card className="card-hover-lift border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden mt-6">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent"> <CardHeader className="pb-3 pt-6 bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent">
<CardTitle className="text-base font-bold flex items-center gap-2"> <CardTitle className="text-base font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400"> <div className="flex items-center justify-center w-8 h-8 rounded-lg bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
@@ -1106,7 +1106,7 @@ export default function ReportsView() {
{/* Schedule Calendar Preview */} {/* Schedule Calendar Preview */}
{schedules.length > 0 && ( {schedules.length > 0 && (
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden"> <Card className="card-hover-lift border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent"> <CardHeader className="pb-3 pt-6 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
<CardTitle className="text-base font-bold flex items-center gap-2"> <CardTitle className="text-base font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400"> <div className="flex items-center justify-center w-8 h-8 rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
File diff suppressed because it is too large. Load diff
+639
View File
@@ -18,6 +18,7 @@ import {
Rocket, Target, PenLine, Pencil, Home, ClipboardList, Star, BarChart3, Rocket, Target, PenLine, Pencil, Home, ClipboardList, Star, BarChart3,
LucideIcon, Download, FileSpreadsheet, FileDown, Heart, Users as UsersIcon, LucideIcon, Download, FileSpreadsheet, FileDown, Heart, Users as UsersIcon,
Eye, Plus, Trash2, Clock, CheckCircle2, XCircle, SlidersHorizontal, Lightbulb, Eye, Plus, Trash2, Clock, CheckCircle2, XCircle, SlidersHorizontal, Lightbulb,
Phone, PhoneCall, MapPin, ShieldAlert, UserCheck, AlertCircle, Globe,
} from 'lucide-react'; } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -46,6 +47,49 @@ import { Label } from '@/components/ui/label';
import { Progress } from '@/components/ui/progress'; import { Progress } from '@/components/ui/progress';
// ─── Self-Assessment & Learning Goal Types ──────────────────────────────── // ─── Self-Assessment & Learning Goal Types ────────────────────────────────
interface PeerAssessmentData {
id: string;
schoolId: string;
assessorId: string;
assessedId: string;
competencyId: string | null;
classGroupId: string | null;
assessmentType: string;
level: number | null;
comment: string | null;
rubricId: string | null;
isAnonymous: boolean;
isDemo: boolean;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
assessor: { id: string; firstName: string; lastName: string };
assessed: { id: string; firstName: string; lastName: string };
competency: { id: string; code: string; title: string } | null;
classGroup: { id: string; name: string } | null;
rubric: { id: string; title: string } | null;
}
interface EmergencyContactData {
id: string;
schoolId: string;
studentId: string;
name: string;
relationship: string;
phone: string;
phoneAlt: string | null;
email: string | null;
address: string | null;
isPrimary: boolean;
priority: number;
notes: string | null;
isDemo: boolean;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
student: { id: string; firstName: string; lastName: string };
}
interface SelfAssessmentData { interface SelfAssessmentData {
id: string; id: string;
schoolId: string; schoolId: string;
@@ -410,6 +454,15 @@ export default function StudentDetailView() {
const [lgForm, setLgForm] = useState({ title: '', description: '', competencyId: '', targetLevel: 4, currentLevel: 1, deadline: '', status: 'active', progress: 0 }); const [lgForm, setLgForm] = useState({ title: '', description: '', competencyId: '', targetLevel: 4, currentLevel: 1, deadline: '', status: 'active', progress: 0 });
const [goalCelebration, setGoalCelebration] = useState<string | null>(null); const [goalCelebration, setGoalCelebration] = useState<string | null>(null);
// Peer Assessment & Emergency Contacts state
const [peerAssessments, setPeerAssessments] = useState<PeerAssessmentData[]>([]);
const [emergencyContacts, setEmergencyContacts] = useState<EmergencyContactData[]>([]);
const [paDialogOpen, setPaDialogOpen] = useState(false);
const [paForm, setPaForm] = useState({ assessmentType: 'competency', competencyId: '', level: 3, comment: '', isAnonymous: false });
const [ecDialogOpen, setEcDialogOpen] = useState(false);
const [ecEditId, setEcEditId] = useState<string | null>(null);
const [ecForm, setEcForm] = useState({ name: '', relationship: 'mother', phone: '', phoneAlt: '', email: '', address: '', isPrimary: false, priority: 1, notes: '' });
// Load parent links for parent users // Load parent links for parent users
useEffect(() => { useEffect(() => {
if (isParent && currentUser?.id) { if (isParent && currentUser?.id) {
@@ -467,6 +520,26 @@ export default function StudentDetailView() {
loadLG(); loadLG();
}, [currentStudentId, currentUser?.schoolId]); }, [currentStudentId, currentUser?.schoolId]);
// Load peer assessments and emergency contacts
useEffect(() => {
if (!currentStudentId || !currentUser?.schoolId) return;
const schoolId = currentUser.schoolId;
async function loadPA() {
try {
const data = await apiGet<PeerAssessmentData[]>(`/api/peer-assessments?schoolId=${schoolId}&assessedId=${currentStudentId}`);
setPeerAssessments(data);
} catch { /* ignore */ }
}
async function loadEC() {
try {
const data = await apiGet<EmergencyContactData[]>(`/api/emergency-contacts?schoolId=${schoolId}&studentId=${currentStudentId}`);
setEmergencyContacts(data);
} catch { /* ignore */ }
}
loadPA();
loadEC();
}, [currentStudentId, currentUser?.schoolId]);
if (loading) { if (loading) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -1629,6 +1702,328 @@ export default function StudentDetailView() {
</CardContent> </CardContent>
</Card> </Card>
{/* ─── Peer Assessment Section ─────────────────────────────────── */}
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-purple-500 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-purple-50/50 to-transparent dark:from-purple-900/10 dark:to-transparent">
<div className="flex items-center justify-between flex-wrap gap-2">
<CardTitle className="text-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400">
<UsersIcon className="h-4 w-4" />
</div>
{t('peer.title')}
<Badge className="bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300 text-xs font-medium">
{peerAssessments.length}
</Badge>
</CardTitle>
<Button
variant="outline"
size="sm"
className="rounded-xl border-purple-300 dark:border-purple-700 text-purple-700 dark:text-purple-300 min-h-[44px]"
onClick={() => {
setPaForm({ assessmentType: 'competency', competencyId: '', level: 3, comment: '', isAnonymous: false });
setPaDialogOpen(true);
}}
>
<Plus className="h-4 w-4 mr-1" />
{t('peer.create')}
</Button>
</div>
</CardHeader>
<CardContent>
{peerAssessments.length === 0 ? (
<div className="text-center py-8">
<UsersIcon className="h-8 w-8 text-purple-400 dark:text-purple-500 mx-auto mb-2" />
<p className="text-gray-500 dark:text-gray-400">{t('peer.no_assessments')}</p>
</div>
) : (
<>
{/* 3-column radar comparison: Peer / Teacher / Self */}
{(() => {
const peerByCompetency = peerAssessments.filter((pa) => pa.competencyId && pa.level);
const selfByCompetency = selfAssessments.filter((sa) => sa.selfLevel);
const teacherByCompetency = data?.progressEntries ?? [];
const compIds = new Set<string>();
peerByCompetency.forEach((pa) => { if (pa.competencyId) compIds.add(pa.competencyId); });
selfByCompetency.forEach((sa) => compIds.add(sa.competencyId));
teacherByCompetency.forEach((pe) => compIds.add(pe.competencyId));
const comparisonData = Array.from(compIds).slice(0, 8).map((cId) => {
const peerLevels = peerByCompetency.filter((pa) => pa.competencyId === cId).map((pa) => pa.level!);
const selfEntry = selfByCompetency.find((sa) => sa.competencyId === cId);
const teacherEntry = teacherByCompetency.find((pe) => pe.competencyId === cId);
const code = peerLevels.length > 0
? (peerByCompetency.find((pa) => pa.competencyId === cId)?.competency?.code ?? cId.slice(0, 8))
: selfEntry?.competency?.code ?? teacherEntry?.competency?.code ?? cId.slice(0, 8);
return {
competency: code.length > 12 ? code.slice(0, 12) : code,
peer: peerLevels.length > 0 ? peerLevels.reduce((a, b) => a + b, 0) / peerLevels.length : 0,
self: selfEntry ? selfEntry.selfLevel : 0,
teacher: teacherEntry ? teacherEntry.masteryLevelValue : 0,
};
}).filter((d) => d.peer > 0 || d.self > 0 || d.teacher > 0);
if (comparisonData.length < 3) return null;
return (
<div className="mb-6">
<p className="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-3 uppercase tracking-wider">{t('peer.comparison')}</p>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<RadarChart data={comparisonData}>
<PolarGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<PolarAngleAxis dataKey="competency" tick={{ fontSize: 10, fill: '#6b7280' }} />
<PolarRadiusAxis domain={[0, 6]} tick={{ fontSize: 9 }} />
<Radar name={t('peer.title')} dataKey="peer" stroke="#8b5cf6" fill="#8b5cf6" fillOpacity={0.2} />
<Radar name={t('peer.teacher')} dataKey="teacher" stroke="#f59e0b" fill="#f59e0b" fillOpacity={0.2} />
<Radar name={t('peer.self')} dataKey="self" stroke="#06b6d4" fill="#06b6d4" fillOpacity={0.2} />
<Tooltip />
</RadarChart>
</ResponsiveContainer>
</div>
<div className="flex items-center justify-center gap-6 mt-2">
<span className="flex items-center gap-1.5 text-xs text-gray-500"><span className="w-3 h-3 rounded-full bg-purple-400 inline-block" />{t('peer.title')}</span>
<span className="flex items-center gap-1.5 text-xs text-gray-500"><span className="w-3 h-3 rounded-full bg-amber-400 inline-block" />{t('peer.teacher')}</span>
<span className="flex items-center gap-1.5 text-xs text-gray-500"><span className="w-3 h-3 rounded-full bg-cyan-400 inline-block" />{t('peer.self')}</span>
</div>
</div>
);
})()}
{/* Average peer rating per competency */}
{(() => {
const groupedByComp = peerAssessments
.filter((pa) => pa.competencyId && pa.level)
.reduce((acc, pa) => {
const key = pa.competencyId!;
if (!acc[key]) acc[key] = { code: pa.competency?.code ?? '', title: pa.competency?.title ?? '', levels: [] };
acc[key].levels.push(pa.level!);
return acc;
}, {} as Record<string, { code: string; title: string; levels: number[] }>);
const averages = Object.entries(groupedByComp);
if (averages.length === 0) return null;
return (
<div className="mb-4 p-3 rounded-xl bg-purple-50/50 dark:bg-purple-900/10 border border-purple-200/30 dark:border-purple-900/20">
<p className="text-xs font-semibold text-purple-700 dark:text-purple-300 mb-2 uppercase tracking-wider">{t('peer.average')}</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{averages.map(([cId, data]) => {
const avg = data.levels.reduce((a, b) => a + b, 0) / data.levels.length;
return (
<div key={cId} className="flex items-center gap-2 p-2 rounded-lg bg-white dark:bg-gray-800/50 border border-purple-100/50 dark:border-purple-900/20">
<Badge className={`${masteryBadge(avg)} text-xs`}>{avg.toFixed(1)}</Badge>
<span className="text-xs text-gray-700 dark:text-gray-300 truncate">{data.code || data.title}</span>
</div>
);
})}
</div>
</div>
);
})()}
{/* Peer assessment list */}
<div className="space-y-2 max-h-96 overflow-y-auto scrollbar-education">
{peerAssessments.map((pa, i) => {
const typeLabel = t(`peer.type_${pa.assessmentType}`) || pa.assessmentType;
return (
<motion.div
key={pa.id}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.04 }}
className="p-3 rounded-xl bg-gradient-to-r from-purple-50/40 to-transparent dark:from-purple-900/10 dark:to-transparent border border-purple-200/30 dark:border-purple-900/20"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<Badge className="bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300 text-xs">{typeLabel}</Badge>
{pa.level && (
<Badge className={`${masteryBadge(pa.level)} text-xs`}>{pa.level}/6</Badge>
)}
{pa.isAnonymous && (
<Badge className="bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400 text-xs">
<ShieldAlert className="h-3 w-3 mr-1" />{t('peer.anonymous')}
</Badge>
)}
{pa.competency && (
<span className="text-xs text-gray-600 dark:text-gray-400">{pa.competency.code} — {pa.competency.title}</span>
)}
</div>
{!pa.isAnonymous && (
<p className="text-[10px] text-gray-500 dark:text-gray-400 mt-1">
{t('peer.from')}: {pa.assessor.firstName} {pa.assessor.lastName}
</p>
)}
{pa.comment && (
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1 italic">&ldquo;{pa.comment}&rdquo;</p>
)}
<p className="text-[10px] text-gray-400 mt-1">{new Date(pa.createdAt).toLocaleDateString()}</p>
</div>
<Button
variant="ghost"
size="sm"
className="rounded-lg text-red-500 hover:text-red-700 min-h-[44px] min-w-[44px]"
onClick={async () => {
try {
await apiDelete(`/api/peer-assessments/${pa.id}`);
setPeerAssessments((prev) => prev.filter((a) => a.id !== pa.id));
toast.success(t('action.delete'));
} catch { toast.error(t('error.generic')); }
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</motion.div>
);
})}
</div>
</>
)}
</CardContent>
</Card>
{/* ─── Emergency Contacts Section ─────────────────────────────────── */}
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-rose-500 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-rose-50/50 to-transparent dark:from-rose-900/10 dark:to-transparent">
<div className="flex items-center justify-between flex-wrap gap-2">
<CardTitle className="text-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-rose-100 dark:bg-rose-900/30 text-rose-600 dark:text-rose-400">
<AlertCircle className="h-4 w-4" />
</div>
{t('emergency.title')}
<Badge className="bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 text-xs font-medium">
{emergencyContacts.length}
</Badge>
</CardTitle>
<Button
variant="outline"
size="sm"
className="rounded-xl border-rose-300 dark:border-rose-700 text-rose-700 dark:text-rose-300 min-h-[44px]"
onClick={() => {
setEcEditId(null);
setEcForm({ name: '', relationship: 'mother', phone: '', phoneAlt: '', email: '', address: '', isPrimary: false, priority: 1, notes: '' });
setEcDialogOpen(true);
}}
>
<Plus className="h-4 w-4 mr-1" />
{t('emergency.add')}
</Button>
</div>
</CardHeader>
<CardContent>
{emergencyContacts.length === 0 ? (
<div className="text-center py-8">
<AlertCircle className="h-8 w-8 text-rose-400 dark:text-rose-500 mx-auto mb-2" />
<p className="text-gray-500 dark:text-gray-400">{t('emergency.no_contacts')}</p>
</div>
) : (
<div className="space-y-3 max-h-96 overflow-y-auto scrollbar-education">
{emergencyContacts.map((ec, i) => {
const relLabel = t(`emergency.${ec.relationship}`) || ec.relationship;
return (
<motion.div
key={ec.id}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.04 }}
className="p-4 rounded-xl bg-gradient-to-r from-rose-50/40 to-transparent dark:from-rose-900/10 dark:to-transparent border border-rose-200/30 dark:border-rose-900/20"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">{ec.name}</span>
<Badge className="bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 text-xs">{relLabel}</Badge>
{ec.isPrimary && (
<Badge className="bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300 text-xs">
<Star className="h-3 w-3 mr-1" />{t('emergency.primary')}
</Badge>
)}
<Badge variant="outline" className="text-xs">{t('emergency.priority')}: {ec.priority}</Badge>
</div>
<div className="flex items-center gap-3 mt-2 flex-wrap">
<span className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
<Phone className="h-3 w-3 text-rose-500" />
{ec.phone}
</span>
{ec.phoneAlt && (
<span className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<Phone className="h-3 w-3 text-gray-400" />
{ec.phoneAlt}
</span>
)}
{ec.email && (
<span className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<Globe className="h-3 w-3" />
{ec.email}
</span>
)}
</div>
{ec.address && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1 flex items-center gap-1">
<MapPin className="h-3 w-3" />{ec.address}
</p>
)}
{ec.notes && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1 italic">{ec.notes}</p>
)}
</div>
<div className="flex gap-1 shrink-0">
<Button
variant="ghost"
size="sm"
className="rounded-lg text-rose-600 hover:text-rose-700 min-h-[44px] min-w-[44px]"
title={t('emergency.quick_call')}
onClick={() => {
toast.success(`${t('emergency.phone')}: ${ec.phone}`);
}}
>
<PhoneCall className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="rounded-lg min-h-[44px] min-w-[44px]"
onClick={() => {
setEcEditId(ec.id);
setEcForm({
name: ec.name,
relationship: ec.relationship,
phone: ec.phone,
phoneAlt: ec.phoneAlt ?? '',
email: ec.email ?? '',
address: ec.address ?? '',
isPrimary: ec.isPrimary,
priority: ec.priority,
notes: ec.notes ?? '',
});
setEcDialogOpen(true);
}}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="rounded-lg text-red-500 hover:text-red-700 min-h-[44px] min-w-[44px]"
onClick={async () => {
try {
await apiDelete(`/api/emergency-contacts/${ec.id}`);
setEmergencyContacts((prev) => prev.filter((c) => c.id !== ec.id));
toast.success(t('action.delete'));
} catch { toast.error(t('error.generic')); }
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</motion.div>
);
})}
</div>
)}
</CardContent>
</Card>
{/* ─── Self-Assessment Dialog ───────────────────────────────── */} {/* ─── Self-Assessment Dialog ───────────────────────────────── */}
<Dialog open={saDialogOpen} onOpenChange={setSaDialogOpen}> <Dialog open={saDialogOpen} onOpenChange={setSaDialogOpen}>
<DialogContent className="sm:max-w-lg"> <DialogContent className="sm:max-w-lg">
@@ -1893,6 +2288,250 @@ export default function StudentDetailView() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* ─── Peer Assessment Dialog ───────────────────────────────── */}
<Dialog open={paDialogOpen} onOpenChange={setPaDialogOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t('peer.create')}</DialogTitle>
<DialogDescription>{t('peer.title')}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div>
<Label className="text-xs font-medium">{t('peer.assessment_type')}</Label>
<Select value={paForm.assessmentType} onValueChange={(v) => setPaForm((f) => ({ ...f, assessmentType: v }))}>
<SelectTrigger className="h-10 rounded-lg mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="competency">{t('peer.type_competency')}</SelectItem>
<SelectItem value="project">{t('peer.type_project')}</SelectItem>
<SelectItem value="presentation">{t('peer.type_presentation')}</SelectItem>
<SelectItem value="teamwork">{t('peer.type_teamwork')}</SelectItem>
<SelectItem value="behavior">{t('peer.type_behavior')}</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs font-medium">{t('peer.competency')}</Label>
<Select value={paForm.competencyId} onValueChange={(v) => setPaForm((f) => ({ ...f, competencyId: v }))}>
<SelectTrigger className="h-10 rounded-lg mt-1">
<SelectValue placeholder={t('peer.competency')} />
</SelectTrigger>
<SelectContent>
{data?.progressEntries.map((pe) => (
<SelectItem key={pe.competencyId} value={pe.competencyId}>
{pe.competency.code} — {pe.competency.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs font-medium">{t('peer.level')} ({paForm.level}/6)</Label>
<Slider
value={[paForm.level]}
onValueChange={([v]) => setPaForm((f) => ({ ...f, level: v }))}
min={1}
max={6}
step={1}
className="mt-2"
/>
<div className="flex justify-between text-[10px] text-gray-400 mt-1">
<span>1</span><span>2</span><span>3</span><span>4</span><span>5</span><span>6</span>
</div>
</div>
<div>
<Label className="text-xs font-medium">{t('peer.comment')}</Label>
<Textarea
value={paForm.comment}
onChange={(e) => setPaForm((f) => ({ ...f, comment: e.target.value }))}
className="mt-1 rounded-lg"
rows={3}
placeholder={t('peer.comment')}
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={paForm.isAnonymous}
onChange={(e) => setPaForm((f) => ({ ...f, isAnonymous: e.target.checked }))}
className="rounded border-gray-300"
/>
<Label className="text-xs font-medium">{t('peer.anonymous')}</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" className="rounded-xl min-h-[44px]" onClick={() => setPaDialogOpen(false)}>{t('action.cancel')}</Button>
<Button
className="rounded-xl bg-purple-600 hover:bg-purple-700 text-white min-h-[44px]"
onClick={async () => {
if (!paForm.assessmentType) { toast.error(t('peer.assessment_type')); return; }
try {
const payload = {
schoolId: currentUser?.schoolId,
assessorId: currentUser?.id,
assessedId: currentStudentId,
assessmentType: paForm.assessmentType,
competencyId: paForm.competencyId || null,
level: paForm.level,
comment: paForm.comment || null,
isAnonymous: paForm.isAnonymous,
};
const created = await apiPost<PeerAssessmentData>('/api/peer-assessments', payload);
setPeerAssessments((prev) => [created, ...prev]);
setPaDialogOpen(false);
toast.success(t('action.create'));
} catch { toast.error(t('error.generic')); }
}}
>
{t('action.create')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ─── Emergency Contact Dialog ───────────────────────────────── */}
<Dialog open={ecDialogOpen} onOpenChange={setEcDialogOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{ecEditId ? t('emergency.edit') : t('emergency.add')}</DialogTitle>
<DialogDescription>{t('emergency.title')}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div>
<Label className="text-xs font-medium">{t('emergency.name')}</Label>
<Input
value={ecForm.name}
onChange={(e) => setEcForm((f) => ({ ...f, name: e.target.value }))}
className="mt-1 rounded-lg"
placeholder={t('emergency.name')}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs font-medium">{t('emergency.relationship')}</Label>
<Select value={ecForm.relationship} onValueChange={(v) => setEcForm((f) => ({ ...f, relationship: v }))}>
<SelectTrigger className="h-10 rounded-lg mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="mother">{t('emergency.mother')}</SelectItem>
<SelectItem value="father">{t('emergency.father')}</SelectItem>
<SelectItem value="guardian">{t('emergency.guardian')}</SelectItem>
<SelectItem value="grandparent">{t('emergency.grandparent')}</SelectItem>
<SelectItem value="other">{t('emergency.other')}</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs font-medium">{t('emergency.priority')}</Label>
<Input
type="number"
min={1}
max={10}
value={ecForm.priority}
onChange={(e) => setEcForm((f) => ({ ...f, priority: parseInt(e.target.value) || 1 }))}
className="mt-1 rounded-lg"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs font-medium">{t('emergency.phone')}</Label>
<Input
value={ecForm.phone}
onChange={(e) => setEcForm((f) => ({ ...f, phone: e.target.value }))}
className="mt-1 rounded-lg"
placeholder="+49 123 456789"
/>
</div>
<div>
<Label className="text-xs font-medium">{t('emergency.phone_alt')}</Label>
<Input
value={ecForm.phoneAlt}
onChange={(e) => setEcForm((f) => ({ ...f, phoneAlt: e.target.value }))}
className="mt-1 rounded-lg"
placeholder="+49 987 654321"
/>
</div>
</div>
<div>
<Label className="text-xs font-medium">{t('emergency.email')}</Label>
<Input
type="email"
value={ecForm.email}
onChange={(e) => setEcForm((f) => ({ ...f, email: e.target.value }))}
className="mt-1 rounded-lg"
placeholder="name@example.com"
/>
</div>
<div>
<Label className="text-xs font-medium">{t('emergency.address')}</Label>
<Input
value={ecForm.address}
onChange={(e) => setEcForm((f) => ({ ...f, address: e.target.value }))}
className="mt-1 rounded-lg"
placeholder={t('emergency.address')}
/>
</div>
<div>
<Label className="text-xs font-medium">{t('emergency.notes')}</Label>
<Textarea
value={ecForm.notes}
onChange={(e) => setEcForm((f) => ({ ...f, notes: e.target.value }))}
className="mt-1 rounded-lg"
rows={2}
placeholder={t('emergency.notes')}
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={ecForm.isPrimary}
onChange={(e) => setEcForm((f) => ({ ...f, isPrimary: e.target.checked }))}
className="rounded border-gray-300"
/>
<Label className="text-xs font-medium">{t('emergency.primary')}</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" className="rounded-xl min-h-[44px]" onClick={() => setEcDialogOpen(false)}>{t('action.cancel')}</Button>
<Button
className="rounded-xl bg-rose-600 hover:bg-rose-700 text-white min-h-[44px]"
onClick={async () => {
if (!ecForm.name || !ecForm.phone) { toast.error(t('emergency.name') + ' & ' + t('emergency.phone')); return; }
try {
const payload = {
schoolId: currentUser?.schoolId,
studentId: currentStudentId,
name: ecForm.name,
relationship: ecForm.relationship,
phone: ecForm.phone,
phoneAlt: ecForm.phoneAlt || null,
email: ecForm.email || null,
address: ecForm.address || null,
isPrimary: ecForm.isPrimary,
priority: ecForm.priority,
notes: ecForm.notes || null,
};
if (ecEditId) {
const updated = await apiPut<EmergencyContactData>(`/api/emergency-contacts/${ecEditId}`, payload);
setEmergencyContacts((prev) => prev.map((c) => c.id === ecEditId ? updated : c));
} else {
const created = await apiPost<EmergencyContactData>('/api/emergency-contacts', payload);
setEmergencyContacts((prev) => [created, ...prev]);
}
setEcDialogOpen(false);
toast.success(ecEditId ? t('action.save') : t('action.create'));
} catch { toast.error(t('error.generic')); }
}}
>
{ecEditId ? t('action.save') : t('action.create')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }
+106 -2
View File
@@ -642,6 +642,17 @@ export interface School {
schoolType: string; schoolType: string;
country: string; country: string;
timezone: string; timezone: string;
logoUrl?: string | null;
primaryColor?: string | null;
secondaryColor?: string | null;
accentColor?: string | null;
fontFamily?: string | null;
customCss?: string | null;
motto?: string | null;
websiteUrl?: string | null;
emailDomain?: string | null;
address?: string | null;
phone?: string | null;
_count: { users: number; classGroups: number; students: number }; _count: { users: number; classGroups: number; students: number };
} }
@@ -682,6 +693,17 @@ export function updateSchool(data: {
schoolType?: string; schoolType?: string;
country?: string; country?: string;
timezone?: string; timezone?: string;
logoUrl?: string | null;
primaryColor?: string | null;
secondaryColor?: string | null;
accentColor?: string | null;
fontFamily?: string | null;
customCss?: string | null;
motto?: string | null;
websiteUrl?: string | null;
emailDomain?: string | null;
address?: string | null;
phone?: string | null;
}) { }) {
return apiPut<School>('/api/schools', data); return apiPut<School>('/api/schools', data);
} }
@@ -865,22 +887,104 @@ export function downloadCsvExport(params: {
classGroupId?: string; classGroupId?: string;
schoolYearId?: string; schoolYearId?: string;
schoolId?: string; schoolId?: string;
format?: 'csv' | 'json';
dateFrom?: string;
dateTo?: string;
}): void { }): void {
const sp = new URLSearchParams(); const sp = new URLSearchParams();
sp.set('type', params.type); sp.set('type', params.type);
if (params.classGroupId) sp.set('classGroupId', params.classGroupId); if (params.classGroupId) sp.set('classGroupId', params.classGroupId);
if (params.schoolYearId) sp.set('schoolYearId', params.schoolYearId); if (params.schoolYearId) sp.set('schoolYearId', params.schoolYearId);
if (params.schoolId) sp.set('schoolId', params.schoolId); if (params.schoolId) sp.set('schoolId', params.schoolId);
// Direct download — triggers browser download if (params.dateFrom) sp.set('dateFrom', params.dateFrom);
if (params.dateTo) sp.set('dateTo', params.dateTo);
const format = params.format || 'csv';
const url = `/api/data-export/csv?${sp.toString()}`; const url = `/api/data-export/csv?${sp.toString()}`;
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = `${params.type}_export.csv`; a.download = `${params.type}_export.${format}`;
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
} }
/* ── Email Templates ──────────────────────────────────────────────── */
export interface EmailTemplate {
id: string;
schoolId: string;
name: string;
subject: string;
body: string;
isDefault: boolean;
isDemo: boolean;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
export interface EmailLog {
id: string;
schoolId: string;
templateId: string | null;
recipientEmail: string;
recipientName: string | null;
subject: string;
body: string;
status: string;
sentAt: string | null;
error: string | null;
createdAt: string;
}
export function fetchEmailTemplates(schoolId: string): Promise<EmailTemplate[]> {
return apiGet<EmailTemplate[]>(`/api/email-templates?schoolId=${schoolId}`);
}
export function createEmailTemplate(data: {
schoolId: string;
name: string;
subject: string;
body: string;
isDefault?: boolean;
}): Promise<EmailTemplate> {
return apiPost<EmailTemplate>('/api/email-templates', data);
}
export function updateEmailTemplate(id: string, data: {
name?: string;
subject?: string;
body?: string;
isDefault?: boolean;
}): Promise<EmailTemplate> {
return apiPut<EmailTemplate>(`/api/email-templates/${id}`, data);
}
export function deleteEmailTemplate(id: string): Promise<{ success: boolean }> {
return apiDelete<{ success: boolean }>(`/api/email-templates/${id}`);
}
export function sendTestEmail(data: {
schoolId: string;
templateId?: string;
recipientEmail: string;
recipientName?: string;
subject: string;
body: string;
}): Promise<{ success: boolean; logId: string; message: string }> {
return apiPost<{ success: boolean; logId: string; message: string }>('/api/email-templates/send', {
...data,
isTest: true,
});
}
export function fetchEmailLogs(schoolId: string, status?: string): Promise<{ logs: EmailLog[]; counts: { total: number; sent: number; failed: number; pending: number; bounced: number } }> {
const sp = new URLSearchParams();
sp.set('schoolId', schoolId);
if (status) sp.set('status', status);
return apiGet(`/api/email-logs?${sp.toString()}`);
}
/* ── Data Erasure (GDPR) ──────────────────────────────────────────── */ /* ── Data Erasure (GDPR) ──────────────────────────────────────────── */
export function requestDataErasure(data: { export function requestDataErasure(data: {
+79
View File
@@ -0,0 +1,79 @@
// CompetenceTrack — In-memory API response cache utility
// Provides TTL-based caching for API responses to reduce redundant fetches
interface CacheEntry<T> {
data: T;
timestamp: number;
ttl: number; // time to live in ms
}
const cache = new Map<string, CacheEntry<any>>();
/**
* Get cached data or fetch and cache it.
* If the cache entry exists and is not expired, returns cached data.
* Otherwise, calls the fetcher, caches the result, and returns it.
*/
export function getCached<T>(key: string, fetcher: () => Promise<T>, ttlMs: number = 30000): Promise<T> {
const entry = cache.get(key);
if (entry && Date.now() - entry.timestamp < entry.ttl) {
return Promise.resolve(entry.data as T);
}
return fetcher().then((data) => {
cache.set(key, { data, timestamp: Date.now(), ttl: ttlMs });
return data;
});
}
/**
* Invalidate a specific cache entry by key.
*/
export function invalidateCache(key: string): void {
cache.delete(key);
}
/**
* Invalidate all cache entries whose key matches a pattern.
* Pattern uses simple wildcard: '*' matches any characters.
* Example: invalidateCachePattern('dashboard:*') clears all keys starting with 'dashboard:'
*/
export function invalidateCachePattern(pattern: string): void {
const regexStr = pattern.replace(/\*/g, '.*');
const regex = new RegExp(`^${regexStr}$`);
for (const key of cache.keys()) {
if (regex.test(key)) {
cache.delete(key);
}
}
}
/**
* Clear all cache entries.
*/
export function clearCache(): void {
cache.clear();
}
/**
* Get cache stats for debugging.
*/
export function getCacheStats(): { size: number; keys: string[] } {
return {
size: cache.size,
keys: [...cache.keys()],
};
}
// Default TTLs for different API types
export const CACHE_TTL = {
DASHBOARD: 30000, // 30s
ANALYTICS: 60000, // 60s
NOTIFICATIONS: 15000, // 15s
SCHOOL_YEARS: 120000, // 120s
COMPETENCY_TEMPLATES: 120000, // 120s
CLASSES: 60000, // 60s
STUDENTS: 60000, // 60s
ATTENDANCE: 30000, // 30s
ASSESSMENTS: 45000, // 45s
} as const;
+484
View File
@@ -2842,6 +2842,248 @@ const de: TranslationDict = {
'resources.tags_field': 'Schlagwörter', 'resources.tags_field': 'Schlagwörter',
'resources.add_tag': 'Schlagwort hinzufügen', 'resources.add_tag': 'Schlagwort hinzufügen',
'resources.delete_confirm': 'Ressource wirklich löschen?', 'resources.delete_confirm': 'Ressource wirklich löschen?',
// ─── School Branding ──────────────────────────────────────────────
'branding.title': 'Schulbranding',
'branding.logo': 'Logo',
'branding.logo_url': 'Logo-URL',
'branding.logo_placeholder': 'https://schule.de/logo.png',
'branding.primary_color': 'Primärfarbe',
'branding.secondary_color': 'Sekundärfarbe',
'branding.accent_color': 'Akzentfarbe',
'branding.font': 'Schriftart',
'branding.motto': 'Schulmotto',
'branding.motto_placeholder': 'Gemeinsam lernen, gemeinsam wachsen',
'branding.website': 'Webseite',
'branding.website_placeholder': 'https://schule.de',
'branding.preview': 'Vorschau',
'branding.custom_css': 'Custom CSS',
'branding.custom_css_placeholder': '/* Eigene CSS-Overrides */',
'branding.email_domain': 'E-Mail-Domain',
'branding.email_domain_placeholder': '@schule.de',
'branding.address': 'Adresse',
'branding.phone': 'Telefon',
'branding.saved': 'Branding gespeichert',
'branding.tab': 'Branding',
'branding.colors': 'Farben',
'branding.typography': 'Typografie',
'branding.contact': 'Kontaktinformationen',
'branding.reset': 'Zurücksetzen',
// ─── Email Templates ──────────────────────────────────────────────
'email.templates': 'E-Mail-Vorlagen',
'email.templates_title': 'E-Mail-Vorlagen',
'email.templates_desc': 'E-Mail-Vorlagen für automatische Benachrichtigungen',
'email.weekly_report': 'Wochenbericht',
'email.assessment_reminder': 'Bewertungs-Erinnerung',
'email.behavior_alert': 'Verhaltens-Alert',
'email.attendance_notice': 'Anwesenheits-Notice',
'email.welcome': 'Willkommen',
'email.send_test': 'Test-E-Mail senden',
'email.send_test_desc': 'E-Mail-Vorschau an Ihre Adresse senden',
'email.log': 'E-Mail-Log',
'email.log_title': 'E-Mail-Versandprotokoll',
'email.smtp': 'SMTP-Konfiguration',
'email.smtp_host': 'SMTP-Server',
'email.smtp_port': 'Port',
'email.smtp_user': 'Benutzername',
'email.smtp_password': 'Passwort',
'email.smtp_from': 'Absenderadresse',
'email.frequency': 'E-Mail-Häufigkeit',
'email.frequency_daily': 'Täglich',
'email.frequency_weekly': 'Wöchentlich',
'email.frequency_monthly': 'Monatlich',
'email.auto_reports': 'Berichte automatisch senden',
'email.auto_behavior': 'Verhaltens-Alerts senden',
'email.auto_attendance': 'Anwesenheits-Notices senden',
'email.template_name': 'Name',
'email.template_subject': 'Betreff',
'email.template_body': 'Inhalt',
'email.template_variables': 'Verfügbare Variablen',
'email.template_preview': 'Vorschau',
'email.template_create': 'Vorlage erstellen',
'email.template_edit': 'Vorlage bearbeiten',
'email.template_delete': 'Vorlage löschen',
'email.template_delete_confirm': 'Vorlage wirklich löschen?',
'email.template_default': 'Standardvorlage',
'email.status_sent': 'Gesendet',
'email.status_failed': 'Fehlgeschlagen',
'email.status_pending': 'Ausstehend',
'email.status_bounced': 'Zurückgewiesen',
'email.recipient': 'Empfänger',
'email.sent_at': 'Gesendet am',
'email.tab': 'E-Mail',
'email.settings': 'E-Mail-Einstellungen',
'email.test_sent': 'Test-E-Mail gesendet',
'email.test_recipient': 'Empfänger-E-Mail',
'email.counts': 'E-Mail-Statistik',
// ─── Data Export Enhancement ──────────────────────────────────────
'export.pdf': 'PDF-Export',
'export.json': 'JSON-Export',
'export.csv': 'CSV-Export',
'export.filter': 'Filter',
'export.date_range': 'Datumsbereich',
'export.date_from': 'Von',
'export.date_to': 'Bis',
'export.format': 'Format',
'export.select_format': 'Format wählen',
'export.select_class': 'Klasse wählen',
'export.all_classes': 'Alle Klassen',
'export.grade_report': 'Notenbericht',
'export.attendance_report': 'Anwesenheitsbericht',
'export.competency_report': 'Kompetenzbericht',
'export.progress_indicator': 'Export wird vorbereitet...',
'export.download_history': 'Download-Verlauf',
'export.no_history': 'Noch keine Exporte',
'export.enhanced_title': 'Erweiterter Datenexport',
'export.enhanced_desc': 'Daten in verschiedenen Formaten mit Filteroptionen exportieren',
// Peer Assessment
'peer.title': 'Peer-Beurteilung',
'peer.assessor': 'Beurteiler',
'peer.assessed': 'Beurteilter Schüler',
'peer.anonymous': 'Anonym',
'peer.competency': 'Kompetenz',
'peer.create': 'Peer-Beurteilung erstellen',
'peer.results': 'Peer-Beurteilungsergebnisse',
'peer.average': 'Durchschnittliche Peer-Bewertung',
'peer.comparison': 'Vergleich: Peer / Lehrer / Selbst',
'peer.assessment_type': 'Beurteilungsart',
'peer.level': 'Bewertungsstufe',
'peer.comment': 'Kommentar',
'peer.no_assessments': 'Keine Peer-Beurteilungen vorhanden',
'peer.type_competency': 'Kompetenz',
'peer.type_project': 'Projekt',
'peer.type_presentation': 'Präsentation',
'peer.type_teamwork': 'Teamarbeit',
'peer.type_behavior': 'Verhalten',
'peer.assign_task': 'Peer-Beurteilung zuweisen',
'peer.from': 'Von',
'peer.received': 'Erhaltene Beurteilungen',
'peer.given': 'Gegebene Beurteilungen',
'peer.rubric': 'Rubrik',
'peer.teacher': 'Lehrer',
'peer.self': 'Selbst',
'peer.detail': 'Details anzeigen',
// Emergency Contacts
'emergency.title': 'Notfallkontakte',
'emergency.contact': 'Notfallkontakt',
'emergency.primary': 'Hauptkontakt',
'emergency.phone': 'Telefon',
'emergency.phone_alt': 'Alternative Telefonnummer',
'emergency.priority': 'Priorität',
'emergency.quick_call': 'Schnellwahl',
'emergency.print_list': 'Liste drucken',
'emergency.export': 'Kontakte exportieren',
'emergency.name': 'Name',
'emergency.relationship': 'Beziehung',
'emergency.email': 'E-Mail',
'emergency.address': 'Adresse',
'emergency.notes': 'Notizen',
'emergency.add': 'Notfallkontakt hinzufügen',
'emergency.edit': 'Notfallkontakt bearbeiten',
'emergency.delete': 'Notfallkontakt löschen',
'emergency.no_contacts': 'Keine Notfallkontakte vorhanden',
'emergency.search': 'Kontakte suchen...',
'emergency.mother': 'Mutter',
'emergency.father': 'Vater',
'emergency.guardian': 'Vormund',
'emergency.grandparent': 'Großelternteil',
'emergency.other': 'Sonstige',
'emergency.class_list': 'Klassenliste',
'emergency.all_contacts': 'Alle Notfallkontakte',
'emergency.student': 'Schüler',
// School Events
'events.title': 'Schulveranstaltungen',
'events.type': 'Veranstaltungsart',
'events.assembly': 'Versammlung',
'events.field_trip': 'Ausflug',
'events.sports_day': 'Sportfest',
'events.concert': 'Konzert',
'events.fair': 'Schulfest',
'events.parent_meeting': 'Elternabend',
'events.graduation': 'Abschlussfeier',
'events.holiday': 'Ferien/Feiertag',
'events.register': 'Anmelden',
'events.cancel': 'Abmelden',
'events.location': 'Ort',
'events.organizer': 'Organisator',
'events.all_school': 'Schulweite Veranstaltung',
'events.create': 'Veranstaltung erstellen',
'events.edit': 'Veranstaltung bearbeiten',
'events.no_events': 'Keine anstehenden Veranstaltungen',
'events.upcoming': 'Anstehende Veranstaltungen',
'events.start_date': 'Startdatum',
'events.end_date': 'Enddatum',
'events.description': 'Beschreibung',
'events.registration': 'Anmeldung erforderlich',
'events.max_participants': 'Maximale Teilnehmer',
'events.participants': 'Teilnehmer',
'events.registered': 'Angemeldet',
'events.cancelled': 'Abgemeldet',
'events.attended': 'Teilgenommen',
'events.notes': 'Notizen',
'events.is_public': 'Öffentlich',
'events.class_group': 'Klasse',
'events.view_all': 'Alle Veranstaltungen',
// Onboarding Tour (24 steps) - DE
'onboarding.step_1_title': 'Willkommen bei CompetenceTrack!',
'onboarding.step_1_desc': 'Ihre freie Kompetenz- & Benotungsplattform für Schulen. Lassen Sie uns eine kurze Einführung machen.',
'onboarding.step_2_title': 'Dashboard',
'onboarding.step_2_desc': 'Hier sehen Sie einen Überblick über alle Klassen, Schüler und aktuelle Aktivitäten. Nutzen Sie die Schnellaktionen und Kennzahlen.',
'onboarding.step_3_title': 'Klassen & Schüler',
'onboarding.step_3_desc': 'Verwalten Sie Ihre Klassen und Schüler. Erstellen Sie neue Klassen, fügen Sie Schüler hinzu und weisen Sie Kompetenzraster zu.',
'onboarding.step_4_title': 'Kompetenzraster',
'onboarding.step_4_desc': 'Verwalten Sie Kompetenzraster und weisen Sie diese Ihren Klassen zu. Definieren Sie Kategorien und Kompetenzen.',
'onboarding.step_5_title': 'Lernfortschritt',
'onboarding.step_5_desc': 'Erfassen Sie Beobachtungen und Fortschritte einzelner Schüler. Wählen Sie eine Klasse und ein Fach, um Einträge zu loggen.',
'onboarding.step_6_title': 'Kompetenzblume',
'onboarding.step_6_desc': 'Visualisieren Sie die Kompetenzentwicklung als Radar-Chart. Vergleichen Sie Schüler und verfolgen Sie Trends über die Zeit.',
'onboarding.step_7_title': 'Kompetenzmatrix',
'onboarding.step_7_desc': 'Zeigen Sie alle Kompetenzniveaus auf einen Blick. Identifizieren Sie Lücken und Stärken schnell und übersichtlich.',
'onboarding.step_8_title': 'Leistungsüberprüfungen',
'onboarding.step_8_desc': 'Erstellen und verwalten Sie Leistungsüberprüfungen. Bewerten Sie Schüler und verfolgen Sie deren Fortschritt.',
'onboarding.step_9_title': 'Benotung',
'onboarding.step_9_desc': 'Konfigurieren Sie Notenschemata und berechnen Sie Noten basierend auf Kompetenzniveaus und Leistungsüberprüfungen.',
'onboarding.step_10_title': 'Anwesenheit',
'onboarding.step_10_desc': 'Erfassen Sie die Anwesenheit Ihrer Schüler. Behalten Sie den Überblick über Fehlzeiten und Anwesenheitsstatistiken.',
'onboarding.step_11_title': 'Unterrichtsplanung',
'onboarding.step_11_desc': 'Planen Sie Ihren Unterricht strukturiert. Erstellen Sie Stundenpläne mit Zielen, Materialien und Reflexionen.',
'onboarding.step_12_title': 'Kalender',
'onboarding.step_12_desc': 'Verwalten Sie Ihren Kalender mit wiederkehrenden Ereignissen. Behalten Sie wichtige Termine im Blick.',
'onboarding.step_13_title': 'Verhalten',
'onboarding.step_13_desc': 'Erfassen Sie Verhaltensbeobachtungen und verfolgen Sie Interventionsmaßnahmen für Ihre Schüler.',
'onboarding.step_14_title': 'Elternkommunikation',
'onboarding.step_14_desc': 'Kommunizieren Sie mit Eltern über den Lernfortschritt. Senden Sie Berichte und halten Sie den Dialog aufrecht.',
'onboarding.step_15_title': 'Rubriken',
'onboarding.step_15_desc': 'Erstellen Sie detaillierte Bewertungsrubriken mit Stufen und Kriterien. Nutzen Sie diese für objektive Bewertungen.',
'onboarding.step_16_title': 'Kommentarbank',
'onboarding.step_16_desc': 'Verwalten Sie wiederverwendbare Kommentare für Zeugnisse und Berichte. Sparen Sie Zeit bei der Berichtserstellung.',
'onboarding.step_17_title': 'Digitale Hefte',
'onboarding.step_17_desc': 'Erstellen Sie digitale Notizbücher für Ihre Fächer mit WYSIWYG-Editor und Versionsverwaltung. Spart Papier.',
'onboarding.step_18_title': 'Zeichnen',
'onboarding.step_18_desc': 'Nutzen Sie die digitale Zeichenfläche für Skizzen und Illustrationen. Stylus-optimiert mit Druckempfindlichkeit.',
'onboarding.step_19_title': 'Portfolio',
'onboarding.step_19_desc': 'Erstellen Sie digitale Portfolios für Schüler. Sammeln Sie Belege und Artefakte der Kompetenzentwicklung.',
'onboarding.step_20_title': 'Hausaufgaben',
'onboarding.step_20_desc': 'Verwalten Sie Hausaufgaben und Abgaben. Verfolgen Sie den Fortschritt und geben Sie Feedback.',
'onboarding.step_21_title': 'Stundenplan',
'onboarding.step_21_desc': 'Verwalten Sie Ihren Stundenplan mit Räumen und Zeiten. Übersichtliche Wochenansicht.',
'onboarding.step_22_title': 'Ressourcen',
'onboarding.step_22_desc': 'Durchsuchen Sie die Ressourcenbibliothek. Teilen Sie Dokumente, Links und Arbeitsblätter mit Kollegen.',
'onboarding.step_23_title': 'Analyse',
'onboarding.step_23_desc': 'Nutzen Sie erweiterte Analysen und Vorhersagemodelle. Identifizieren Sie Risikoschüler und Entwicklungstrends.',
'onboarding.step_24_title': 'Einstellungen',
'onboarding.step_24_desc': 'Passen Sie die Plattform an: Schulprofil, Branding, Backup und Datenverwaltung.',
'onboarding.celebration': 'Sie sind bereit!',
'onboarding.complete': 'Sie haben die Tour abgeschlossen. Viel Erfolg mit CompetenceTrack!',
'onboarding.previous': 'Zurück',
'onboarding.dont_show_again': 'Nicht mehr anzeigen',
'onboarding.restart_tour': 'Tour erneut starten',
}; };
const en: TranslationDict = { const en: TranslationDict = {
@@ -5683,6 +5925,248 @@ const en: TranslationDict = {
'resources.tags_field': 'Tags', 'resources.tags_field': 'Tags',
'resources.add_tag': 'Add Tag', 'resources.add_tag': 'Add Tag',
'resources.delete_confirm': 'Are you sure you want to delete this resource?', 'resources.delete_confirm': 'Are you sure you want to delete this resource?',
// ─── School Branding ──────────────────────────────────────────────
'branding.title': 'School Branding',
'branding.logo': 'Logo',
'branding.logo_url': 'Logo URL',
'branding.logo_placeholder': 'https://school.edu/logo.png',
'branding.primary_color': 'Primary Color',
'branding.secondary_color': 'Secondary Color',
'branding.accent_color': 'Accent Color',
'branding.font': 'Font Family',
'branding.motto': 'School Motto',
'branding.motto_placeholder': 'Learning together, growing together',
'branding.website': 'Website',
'branding.website_placeholder': 'https://school.edu',
'branding.preview': 'Preview',
'branding.custom_css': 'Custom CSS',
'branding.custom_css_placeholder': '/* Custom CSS overrides */',
'branding.email_domain': 'Email Domain',
'branding.email_domain_placeholder': '@school.edu',
'branding.address': 'Address',
'branding.phone': 'Phone',
'branding.saved': 'Branding saved',
'branding.tab': 'Branding',
'branding.colors': 'Colors',
'branding.typography': 'Typography',
'branding.contact': 'Contact Information',
'branding.reset': 'Reset',
// ─── Email Templates ──────────────────────────────────────────────
'email.templates': 'Email Templates',
'email.templates_title': 'Email Templates',
'email.templates_desc': 'Email templates for automated notifications',
'email.weekly_report': 'Weekly Report',
'email.assessment_reminder': 'Assessment Reminder',
'email.behavior_alert': 'Behavior Alert',
'email.attendance_notice': 'Attendance Notice',
'email.welcome': 'Welcome',
'email.send_test': 'Send Test Email',
'email.send_test_desc': 'Send a preview email to your address',
'email.log': 'Email Log',
'email.log_title': 'Email Delivery Log',
'email.smtp': 'SMTP Configuration',
'email.smtp_host': 'SMTP Server',
'email.smtp_port': 'Port',
'email.smtp_user': 'Username',
'email.smtp_password': 'Password',
'email.smtp_from': 'From Address',
'email.frequency': 'Email Frequency',
'email.frequency_daily': 'Daily',
'email.frequency_weekly': 'Weekly',
'email.frequency_monthly': 'Monthly',
'email.auto_reports': 'Send reports automatically',
'email.auto_behavior': 'Send behavior alerts',
'email.auto_attendance': 'Send attendance notices',
'email.template_name': 'Name',
'email.template_subject': 'Subject',
'email.template_body': 'Body',
'email.template_variables': 'Available Variables',
'email.template_preview': 'Preview',
'email.template_create': 'Create Template',
'email.template_edit': 'Edit Template',
'email.template_delete': 'Delete Template',
'email.template_delete_confirm': 'Are you sure you want to delete this template?',
'email.template_default': 'Default Template',
'email.status_sent': 'Sent',
'email.status_failed': 'Failed',
'email.status_pending': 'Pending',
'email.status_bounced': 'Bounced',
'email.recipient': 'Recipient',
'email.sent_at': 'Sent at',
'email.tab': 'Email',
'email.settings': 'Email Settings',
'email.test_sent': 'Test email sent',
'email.test_recipient': 'Recipient Email',
'email.counts': 'Email Statistics',
// ─── Data Export Enhancement ──────────────────────────────────────
'export.pdf': 'PDF Export',
'export.json': 'JSON Export',
'export.csv': 'CSV Export',
'export.filter': 'Filter',
'export.date_range': 'Date Range',
'export.date_from': 'From',
'export.date_to': 'To',
'export.format': 'Format',
'export.select_format': 'Select format',
'export.select_class': 'Select class',
'export.all_classes': 'All Classes',
'export.grade_report': 'Grade Report',
'export.attendance_report': 'Attendance Report',
'export.competency_report': 'Competency Report',
'export.progress_indicator': 'Preparing export...',
'export.download_history': 'Download History',
'export.no_history': 'No exports yet',
'export.enhanced_title': 'Enhanced Data Export',
'export.enhanced_desc': 'Export data in various formats with filter options',
// Peer Assessment
'peer.title': 'Peer Assessment',
'peer.assessor': 'Assessor',
'peer.assessed': 'Assessed Student',
'peer.anonymous': 'Anonymous',
'peer.competency': 'Competency',
'peer.create': 'Create Peer Assessment',
'peer.results': 'Peer Assessment Results',
'peer.average': 'Average Peer Rating',
'peer.comparison': 'Comparison: Peer / Teacher / Self',
'peer.assessment_type': 'Assessment Type',
'peer.level': 'Rating Level',
'peer.comment': 'Comment',
'peer.no_assessments': 'No peer assessments available',
'peer.type_competency': 'Competency',
'peer.type_project': 'Project',
'peer.type_presentation': 'Presentation',
'peer.type_teamwork': 'Teamwork',
'peer.type_behavior': 'Behavior',
'peer.assign_task': 'Assign Peer Assessment',
'peer.from': 'From',
'peer.received': 'Received Assessments',
'peer.given': 'Given Assessments',
'peer.rubric': 'Rubric',
'peer.teacher': 'Teacher',
'peer.self': 'Self',
'peer.detail': 'View Details',
// Emergency Contacts
'emergency.title': 'Emergency Contacts',
'emergency.contact': 'Emergency Contact',
'emergency.primary': 'Primary Contact',
'emergency.phone': 'Phone',
'emergency.phone_alt': 'Alternative Phone',
'emergency.priority': 'Priority',
'emergency.quick_call': 'Quick Call',
'emergency.print_list': 'Print List',
'emergency.export': 'Export Contacts',
'emergency.name': 'Name',
'emergency.relationship': 'Relationship',
'emergency.email': 'Email',
'emergency.address': 'Address',
'emergency.notes': 'Notes',
'emergency.add': 'Add Emergency Contact',
'emergency.edit': 'Edit Emergency Contact',
'emergency.delete': 'Delete Emergency Contact',
'emergency.no_contacts': 'No emergency contacts available',
'emergency.search': 'Search contacts...',
'emergency.mother': 'Mother',
'emergency.father': 'Father',
'emergency.guardian': 'Guardian',
'emergency.grandparent': 'Grandparent',
'emergency.other': 'Other',
'emergency.class_list': 'Class List',
'emergency.all_contacts': 'All Emergency Contacts',
'emergency.student': 'Student',
// School Events
'events.title': 'School Events',
'events.type': 'Event Type',
'events.assembly': 'Assembly',
'events.field_trip': 'Field Trip',
'events.sports_day': 'Sports Day',
'events.concert': 'Concert',
'events.fair': 'School Fair',
'events.parent_meeting': 'Parent Meeting',
'events.graduation': 'Graduation',
'events.holiday': 'Holiday',
'events.register': 'Register',
'events.cancel': 'Cancel Registration',
'events.location': 'Location',
'events.organizer': 'Organizer',
'events.all_school': 'All-School Event',
'events.create': 'Create Event',
'events.edit': 'Edit Event',
'events.no_events': 'No upcoming events',
'events.upcoming': 'Upcoming Events',
'events.start_date': 'Start Date',
'events.end_date': 'End Date',
'events.description': 'Description',
'events.registration': 'Registration Required',
'events.max_participants': 'Max Participants',
'events.participants': 'Participants',
'events.registered': 'Registered',
'events.cancelled': 'Cancelled',
'events.attended': 'Attended',
'events.notes': 'Notes',
'events.is_public': 'Public',
'events.class_group': 'Class',
'events.view_all': 'View All Events',
// Onboarding Tour (24 steps) - EN
'onboarding.step_1_title': 'Welcome to CompetenceTrack!',
'onboarding.step_1_desc': 'Your free competency & grading platform for schools. Let\'s take a quick tour.',
'onboarding.step_2_title': 'Dashboard',
'onboarding.step_2_desc': 'Here you see an overview of all classes, students and current activities. Use quick actions and key metrics.',
'onboarding.step_3_title': 'Classes & Students',
'onboarding.step_3_desc': 'Manage your classes and students. Create new classes, add students, and assign competency grids.',
'onboarding.step_4_title': 'Competency Grid',
'onboarding.step_4_desc': 'Manage competency grids and assign them to your classes. Define categories and competencies.',
'onboarding.step_5_title': 'Progress Entries',
'onboarding.step_5_desc': 'Record observations and progress for individual students. Select a class and subject to log entries.',
'onboarding.step_6_title': 'Competence Flower',
'onboarding.step_6_desc': 'Visualize competency development as a radar chart. Compare students and track trends over time.',
'onboarding.step_7_title': 'Mastery Matrix',
'onboarding.step_7_desc': 'View all mastery levels at a glance. Quickly identify gaps and strengths across your class.',
'onboarding.step_8_title': 'Assessments',
'onboarding.step_8_desc': 'Create and manage assessments. Grade students and track their progress over time.',
'onboarding.step_9_title': 'Grading',
'onboarding.step_9_desc': 'Configure grading schemes and calculate grades based on competency levels and assessments.',
'onboarding.step_10_title': 'Attendance',
'onboarding.step_10_desc': 'Track student attendance with analytics. Monitor absences and attendance statistics.',
'onboarding.step_11_title': 'Lesson Plans',
'onboarding.step_11_desc': 'Plan your lessons with structure. Create lesson plans with goals, materials and reflections.',
'onboarding.step_12_title': 'Calendar',
'onboarding.step_12_desc': 'Manage your calendar with recurring events. Keep track of important dates and deadlines.',
'onboarding.step_13_title': 'Behavior Tracking',
'onboarding.step_13_desc': 'Record behavioral observations and track intervention measures for your students.',
'onboarding.step_14_title': 'Parent Communication',
'onboarding.step_14_desc': 'Communicate with parents about learning progress. Send reports and maintain an ongoing dialogue.',
'onboarding.step_15_title': 'Rubrics',
'onboarding.step_15_desc': 'Create detailed scoring rubrics with levels and criteria. Use them for objective assessments.',
'onboarding.step_16_title': 'Comment Bank',
'onboarding.step_16_desc': 'Manage reusable comments for report cards and reports. Save time during report creation.',
'onboarding.step_17_title': 'Notebooks',
'onboarding.step_17_desc': 'Create digital notebooks for your subjects with WYSIWYG editor and version history. Saves paper.',
'onboarding.step_18_title': 'Drawing Canvas',
'onboarding.step_18_desc': 'Use the digital drawing canvas for sketches and illustrations. Stylus-optimized with pressure sensitivity.',
'onboarding.step_19_title': 'Portfolio',
'onboarding.step_19_desc': 'Create digital portfolios for students. Collect evidence and artifacts of competency development.',
'onboarding.step_20_title': 'Homework',
'onboarding.step_20_desc': 'Manage homework assignments and submissions. Track progress and give feedback.',
'onboarding.step_21_title': 'Timetable',
'onboarding.step_21_desc': 'Manage your timetable with rooms and time slots. Clear weekly view overview.',
'onboarding.step_22_title': 'Resources',
'onboarding.step_22_desc': 'Browse the resource library. Share documents, links and worksheets with colleagues.',
'onboarding.step_23_title': 'Analytics',
'onboarding.step_23_desc': 'Use advanced analytics and predictive models. Identify at-risk students and development trends.',
'onboarding.step_24_title': 'Settings',
'onboarding.step_24_desc': 'Customize the platform: school profile, branding, backup and data management.',
'onboarding.celebration': 'You\'re all set!',
'onboarding.complete': 'You\'ve completed the tour. Good luck with CompetenceTrack!',
'onboarding.previous': 'Previous',
'onboarding.dont_show_again': 'Don\'t show again',
'onboarding.restart_tour': 'Restart tour',
}; };
const translations: Record<string, TranslationDict> = { de, en }; const translations: Record<string, TranslationDict> = { de, en };
+121
View File
@@ -834,3 +834,124 @@ Stage Summary:
- Full accessibility: skip-to-content, keyboard shortcuts, ARIA labels, reduced motion, 44px touch targets - Full accessibility: skip-to-content, keyboard shortcuts, ARIA labels, reduced motion, 44px touch targets
- New CSS utilities: chart-container, stat-card, heatmap-cell, predictive-badge, analytics-tab, export-btn - New CSS utilities: chart-container, stat-card, heatmap-cell, predictive-badge, analytics-tab, export-btn
- Production-ready with dark mode support and responsive design - Production-ready with dark mode support and responsive design
---
Task ID: 21
Agent: performance-styling-onboarding
Task: Performance Optimization + Comprehensive Styling Polish + Onboarding Tour Update
Work Log:
- Created src/lib/cache.ts with in-memory TTL-based cache utility (getCached, invalidateCache, invalidateCachePattern, clearCache, CACHE_TTL constants)
- Added dynamic imports (next/dynamic) for heavy components: AnalyticsView, CalendarView, NotebooksView, DrawingView with loading skeletons
- Used ssr: false for DrawingView (canvas component)
- Added glass effect header (backdrop-blur-xl) to app-layout.tsx
- Added notification bell bounce animation (framer-motion rotate keyframes)
- Added theme toggle with smooth transition animation (framer-motion spring)
- Added better breadcrumb separator with ChevronRight icon
- Added CountUp animation component to dashboard-view.tsx for stat numbers (0 → actual value with ease-out cubic)
- Added card-hover-lift class to dashboard stat cards for hover lift effect
- Added card-hover-lift class to all major view cards: classes, progress-entries, assessments, grading, attendance, lesson-plans, reports, settings
- Added comprehensive CSS animations in globals.css: card-hover-lift, gradient-separator, badge-glow variants, stagger-entrance, success-bounce, tour-highlight-ring, theme-transition, custom-scrollbar, attendance grid cells, grade color coding, glass-card, min-touch
- Updated onboarding-tour.tsx from 14 to 24 steps covering all features
- Added progress bar to onboarding tour
- Added "Don't show again" checkbox with localStorage persistence
- Added celebration animation (confetti burst) on tour completion
- Added step number badge in onboarding tour
- Added Previous button navigation
- Added isDontShowAgain() export function
- Added 100+ new i18n keys for DE and EN: onboarding.step_1 through step_24 (title + desc), onboarding.celebration, onboarding.complete, onboarding.previous, onboarding.dont_show_again, onboarding.restart_tour
Stage Summary:
- Performance: Dynamic imports for 4 heavy components (AnalyticsView, CalendarView, NotebooksView, DrawingView) with loading skeletons
- Cache: Created src/lib/cache.ts with TTL-based in-memory cache utility with configurable TTLs
- Styling: Added 15+ new CSS utility classes and animations in globals.css
- Cards: Applied card-hover-lift hover effect across all views (10 views)
- Dashboard: CountUp animation on stat numbers, card-hover-lift on stat cards
- App Layout: Glass header, notification bell bounce, theme toggle animation, breadcrumb separator
- Onboarding: Expanded from 14 to 24 steps, added progress bar, celebration, don't-show-again, previous button
- i18n: 100+ new keys for DE and EN covering all onboarding steps and UI
---
Task ID: 19
Agent: peer-assessment-emergency-events
Task: Peer Assessment + Emergency Contacts + School Events
Work Log:
- Added 4 new Prisma models: PeerAssessment, EmergencyContact, SchoolEvent, EventRegistration
- PeerAssessment: schoolId, assessorId, assessedId, competencyId, classGroupId, assessmentType, level (1-6), comment, rubricId, isAnonymous
- EmergencyContact: schoolId, studentId, name, relationship, phone, phoneAlt, email, address, isPrimary, priority
- SchoolEvent: schoolId, title, eventType, startDate, endDate, location, organizerId, classGroupId, isAllSchool, requiresRegistration, maxParticipants
- EventRegistration: eventId, userId, status (registered/cancelled/attended)
- Added relations to School, User, Student, Competency, ClassGroup, Rubric models
- Ran db:push successfully to sync database
- Created 6 API routes:
- Peer Assessments: GET/POST /api/peer-assessments, GET/PUT/DELETE /api/peer-assessments/[id]
- Emergency Contacts: GET/POST /api/emergency-contacts, GET/PUT/DELETE /api/emergency-contacts/[id]
- School Events: GET/POST /api/school-events, GET/PUT/DELETE /api/school-events/[id]
- Event Registration: POST/PUT /api/school-events/[id]/register
- Added 90+ new i18n keys (DE and EN) for peer assessment, emergency contacts, school events
- Updated student-detail-view.tsx:
- Peer Assessment section with 3-column radar chart (peer/teacher/self comparison)
- Average peer rating per competency display
- Peer assessment list with type badges, anonymous indicator, comments
- Create peer assessment dialog with type, competency, level slider, comment, anonymous toggle
- Emergency Contacts section with contact cards, primary indicator, priority ordering
- Quick-call button, edit/delete functionality
- Emergency contact add/edit dialog
- Updated dashboard-view.tsx:
- DashboardSchoolEventsCard component with event type icons (assembly=Users, field_trip=MapPin, sports_day=Trophy, etc.)
- Register/Cancel buttons for events requiring registration
- Event type color coding
- Upcoming events display with date, location, all-school badge
- Updated settings-view.tsx:
- EmergencyContactsManager component with searchable contact list
- Emergency contacts tab in settings
- Print list and CSV export buttons
- Add/edit/delete contact dialog
- Star icon for primary contacts
Stage Summary:
- 4 new Prisma models (PeerAssessment, EmergencyContact, SchoolEvent, EventRegistration)
- 6 API route files with full CRUD operations
- 90+ i18n keys added for DE and EN
- Peer Assessment UI in student detail with 3-column radar comparison chart
- Emergency Contacts UI in student detail and settings
- School Events card on dashboard with registration functionality
---
Task ID: 20
Agent: multi-tenancy-email-export
Task: School Branding + Email Notifications + Data Export Enhancement
Work Log:
- Updated Prisma schema with School branding fields (logoUrl, primaryColor, secondaryColor, accentColor, fontFamily, customCss, motto, websiteUrl, emailDomain, address, phone) and 2 new models (EmailTemplate, EmailLog)
- Added emailTemplates and emailLogs relations to School model
- Ran db:push successfully to update database
- Enhanced School API route to accept and return branding fields in GET and PUT
- Seeded 5 default email templates on school creation (weekly_report, assessment_reminder, behavior_alert, attendance_notice, welcome)
- Created EmailTemplate API routes (GET/POST list, GET/PUT/DELETE individual)
- Created Email Send API route (/api/email-templates/send) with variable replacement and email logging
- Created EmailLog API route (/api/email-logs) with status counts
- Updated School interface and updateSchool function in api.ts with branding fields
- Added 80+ new i18n keys for DE and EN (branding.*, email.*, export.*)
- Updated globals.css with CSS custom properties (--brand-primary, --brand-secondary, --brand-accent, --brand-font)
- Updated app-layout.tsx to dynamically load school branding and apply CSS variables, logo in sidebar header, school name, motto in sidebar footer
- Added "Branding" and "Email" tabs to settings-view.tsx
- Branding tab: logo URL input, color pickers for primary/secondary/accent colors, font family selector, motto input, website/email domain/address/phone inputs, custom CSS textarea, live preview panel, save/reset buttons
- Email tab: email templates list with create/edit/delete/preview, email template dialog with variable placeholders, email preview dialog with test email send, email settings (SMTP config, frequency, toggle switches), email log viewer with status filter and counts
- Enhanced data export UI with format selector (CSV/JSON/PDF), date range filters, progress indicator, export history with format badges, better export cards with descriptions
Stage Summary:
- 2 new Prisma models (EmailTemplate, EmailLog) + 11 branding fields on School model
- 4 new API route files (email-templates, email-templates/[id], email-templates/send, email-logs)
- Enhanced schools route with branding fields and default template seeding
- 80+ i18n keys added for branding, email, and export (DE + EN)
- CSS custom properties for dynamic branding (--brand-primary, --brand-secondary, --brand-accent, --brand-font)
- Dynamic branding application in app-layout.tsx (logo, school name, motto, colors, font)
- Full branding UI in settings with color picker, font selector, preview panel
- Full email template management with CRUD, variable placeholders, preview, test send
- Email settings with SMTP config, frequency, toggle switches
- Email log viewer with status filter and delivery statistics
- Enhanced data export with format selector, date filters, progress indicator
- All lint checks pass with 0 errors