Round 16: Peer Assessment, Emergency Contacts, School Events, School Branding, Email Templates, Performance, Styling Polish, Onboarding 24 Steps
This commit is contained in:
1 parent
d45ba030f5
commit
6174f127f9
30 files changed
+4835
-142
No files matched your search
@@ -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")
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
+1095
-30
File diff suppressed because it is too large.
Load diff
@@ -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">“{pa.comment}”</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
@@ -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: {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
Reference in new issue
Block a user