Round 14: Self-Assessment, Learning Goals, Portfolio, Homework, Announcements
This commit is contained in:
1 parent
d325984786
commit
50c2d13d90
22 files changed
+5232
-2
No files matched your search
@@ -44,6 +44,11 @@ model School {
|
|||||||
backups Backup[]
|
backups Backup[]
|
||||||
behaviorInterventions BehaviorIntervention[]
|
behaviorInterventions BehaviorIntervention[]
|
||||||
reportSchedules ReportSchedule[]
|
reportSchedules ReportSchedule[]
|
||||||
|
homeworks Homework[]
|
||||||
|
announcements Announcement[]
|
||||||
|
selfAssessments SelfAssessment[]
|
||||||
|
learningGoals LearningGoal[]
|
||||||
|
portfolioEntries PortfolioEntry[]
|
||||||
district SchoolDistrict? @relation(fields: [districtId], references: [id])
|
district SchoolDistrict? @relation(fields: [districtId], references: [id])
|
||||||
districtId String?
|
districtId String?
|
||||||
}
|
}
|
||||||
@@ -84,6 +89,8 @@ model User {
|
|||||||
calendarEvents CalendarEvent[]
|
calendarEvents CalendarEvent[]
|
||||||
parentLinks ParentStudentLink[]
|
parentLinks ParentStudentLink[]
|
||||||
assignedInterventions BehaviorIntervention[] @relation("InterventionAssignee")
|
assignedInterventions BehaviorIntervention[] @relation("InterventionAssignee")
|
||||||
|
homeworks Homework[]
|
||||||
|
announcements Announcement[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model SchoolYear {
|
model SchoolYear {
|
||||||
@@ -130,6 +137,9 @@ model ClassGroup {
|
|||||||
drawings Drawing[]
|
drawings Drawing[]
|
||||||
calendarEvents CalendarEvent[]
|
calendarEvents CalendarEvent[]
|
||||||
reportSchedules ReportSchedule[]
|
reportSchedules ReportSchedule[]
|
||||||
|
homeworks Homework[]
|
||||||
|
announcements Announcement[]
|
||||||
|
selfAssessments SelfAssessment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model ClassGroupTeacher {
|
model ClassGroupTeacher {
|
||||||
@@ -168,6 +178,10 @@ model Student {
|
|||||||
behaviorIncidents BehaviorIncident[]
|
behaviorIncidents BehaviorIncident[]
|
||||||
behaviorInterventions BehaviorIntervention[]
|
behaviorInterventions BehaviorIntervention[]
|
||||||
parentStudentLinks ParentStudentLink[]
|
parentStudentLinks ParentStudentLink[]
|
||||||
|
homeworkSubmissions HomeworkSubmission[]
|
||||||
|
selfAssessments SelfAssessment[]
|
||||||
|
learningGoals LearningGoal[]
|
||||||
|
portfolioEntries PortfolioEntry[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Enrollment {
|
model Enrollment {
|
||||||
@@ -206,6 +220,7 @@ model Subject {
|
|||||||
drawings Drawing[]
|
drawings Drawing[]
|
||||||
calendarEvents CalendarEvent[]
|
calendarEvents CalendarEvent[]
|
||||||
curriculumStandards CurriculumStandard[]
|
curriculumStandards CurriculumStandard[]
|
||||||
|
homeworks Homework[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model CompetencyTemplate {
|
model CompetencyTemplate {
|
||||||
@@ -254,6 +269,9 @@ model Competency {
|
|||||||
learningProgressEntries LearningProgressEntry[]
|
learningProgressEntries LearningProgressEntry[]
|
||||||
assessmentCompetencyLinks AssessmentCompetencyLink[]
|
assessmentCompetencyLinks AssessmentCompetencyLink[]
|
||||||
curriculumStandardLinks CurriculumStandardLink[]
|
curriculumStandardLinks CurriculumStandardLink[]
|
||||||
|
selfAssessments SelfAssessment[]
|
||||||
|
learningGoals LearningGoal[]
|
||||||
|
portfolioEntries PortfolioEntry[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model MasteryLevelDefinition {
|
model MasteryLevelDefinition {
|
||||||
@@ -1055,3 +1073,168 @@ model SchoolDistrict {
|
|||||||
|
|
||||||
@@map("school_districts")
|
@@map("school_districts")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Homework Management ──────────────────────────────────────────
|
||||||
|
|
||||||
|
model Homework {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
schoolId String
|
||||||
|
classGroupId String
|
||||||
|
subjectId String?
|
||||||
|
teacherId String
|
||||||
|
title String
|
||||||
|
description String?
|
||||||
|
dueDate DateTime
|
||||||
|
homeworkType String @default("assignment") // assignment, reading, project, practice, research
|
||||||
|
maxPoints Float?
|
||||||
|
attachments String? // JSON array of attachment URLs
|
||||||
|
isPublished Boolean @default(true)
|
||||||
|
isDemo Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
|
|
||||||
|
school School @relation(fields: [schoolId], references: [id])
|
||||||
|
classGroup ClassGroup @relation(fields: [classGroupId], references: [id])
|
||||||
|
subject Subject? @relation(fields: [subjectId], references: [id])
|
||||||
|
teacher User @relation(fields: [teacherId], references: [id])
|
||||||
|
submissions HomeworkSubmission[]
|
||||||
|
|
||||||
|
@@index([schoolId, classGroupId, dueDate])
|
||||||
|
@@map("homework")
|
||||||
|
}
|
||||||
|
|
||||||
|
model HomeworkSubmission {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
homeworkId String
|
||||||
|
studentId String
|
||||||
|
content String? // text submission
|
||||||
|
attachments String? // JSON array of attachment URLs
|
||||||
|
status String @default("pending") // pending, submitted, graded, late
|
||||||
|
score Float?
|
||||||
|
feedback String?
|
||||||
|
submittedAt DateTime?
|
||||||
|
gradedAt DateTime?
|
||||||
|
isDemo Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
|
|
||||||
|
homework Homework @relation(fields: [homeworkId], references: [id], onDelete: Cascade)
|
||||||
|
student Student @relation(fields: [studentId], references: [id])
|
||||||
|
|
||||||
|
@@unique([homeworkId, studentId])
|
||||||
|
@@index([homeworkId, status])
|
||||||
|
@@map("homework_submissions")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Announcements ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
model Announcement {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
schoolId String
|
||||||
|
authorId String
|
||||||
|
title String
|
||||||
|
content String
|
||||||
|
priority String @default("normal") // low, normal, high, urgent
|
||||||
|
targetAudience String @default("all") // all, teachers, students, parents, class
|
||||||
|
classGroupId String? // if target is specific class
|
||||||
|
isPinned Boolean @default(false)
|
||||||
|
expiresAt DateTime?
|
||||||
|
isDemo Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
|
|
||||||
|
school School @relation(fields: [schoolId], references: [id])
|
||||||
|
author User @relation(fields: [authorId], references: [id])
|
||||||
|
classGroup ClassGroup? @relation(fields: [classGroupId], references: [id])
|
||||||
|
|
||||||
|
@@index([schoolId, createdAt])
|
||||||
|
@@map("announcements")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Student Self-Assessment ──────────────────────────────────────────
|
||||||
|
|
||||||
|
model SelfAssessment {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
schoolId String
|
||||||
|
studentId String
|
||||||
|
competencyId String
|
||||||
|
classGroupId String?
|
||||||
|
selfLevel Int // 1-6 self-assessed mastery
|
||||||
|
confidence Int? // 1-5 confidence level
|
||||||
|
reflection String? // student's self-reflection text
|
||||||
|
evidence String? // what evidence supports this assessment
|
||||||
|
goalId String? // linked learning goal
|
||||||
|
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])
|
||||||
|
competency Competency @relation(fields: [competencyId], references: [id])
|
||||||
|
classGroup ClassGroup? @relation(fields: [classGroupId], references: [id])
|
||||||
|
goal LearningGoal? @relation(fields: [goalId], references: [id])
|
||||||
|
|
||||||
|
@@index([schoolId, studentId])
|
||||||
|
@@map("self_assessments")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Learning Goals ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
model LearningGoal {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
schoolId String
|
||||||
|
studentId String
|
||||||
|
competencyId String?
|
||||||
|
title String
|
||||||
|
description String?
|
||||||
|
targetLevel Int? // target mastery level
|
||||||
|
currentLevel Int? // current mastery level
|
||||||
|
deadline DateTime?
|
||||||
|
status String @default("active") // active, completed, abandoned
|
||||||
|
progress Int @default(0) // 0-100
|
||||||
|
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])
|
||||||
|
competency Competency? @relation(fields: [competencyId], references: [id])
|
||||||
|
selfAssessments SelfAssessment[]
|
||||||
|
|
||||||
|
@@index([schoolId, studentId, status])
|
||||||
|
@@map("learning_goals")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Portfolio Entries ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
model PortfolioEntry {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
schoolId String
|
||||||
|
studentId String
|
||||||
|
title String
|
||||||
|
description String?
|
||||||
|
entryType String // "artwork", "writing", "project", "presentation", "achievement", "reflection"
|
||||||
|
competencyId String?
|
||||||
|
content String? // text content or description
|
||||||
|
mediaUrls String? // JSON array of media URLs
|
||||||
|
notebookPageId String? // link to notebook page
|
||||||
|
drawingId String? // link to drawing
|
||||||
|
isPublic Boolean @default(false)
|
||||||
|
tags String? // JSON array of tags
|
||||||
|
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])
|
||||||
|
competency Competency? @relation(fields: [competencyId], references: [id])
|
||||||
|
|
||||||
|
@@index([schoolId, studentId])
|
||||||
|
@@map("portfolio_entries")
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { getSession } from '@/lib/auth';
|
||||||
|
|
||||||
|
const priorityEnum = z.enum(['low', 'normal', 'high', 'urgent']);
|
||||||
|
const targetAudienceEnum = z.enum(['all', 'teachers', 'students', 'parents', 'class']);
|
||||||
|
|
||||||
|
const updateAnnouncementSchema = z.object({
|
||||||
|
title: z.string().min(1).max(200).optional(),
|
||||||
|
content: z.string().min(1).max(10000).optional(),
|
||||||
|
priority: priorityEnum.optional(),
|
||||||
|
targetAudience: targetAudienceEnum.optional(),
|
||||||
|
classGroupId: z.string().optional().nullable(),
|
||||||
|
isPinned: z.boolean().optional(),
|
||||||
|
expiresAt: z.string().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function isTeacherOrAdmin(role: string | undefined): boolean {
|
||||||
|
return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 announcement = await db.announcement.findUnique({
|
||||||
|
where: { id, deletedAt: null },
|
||||||
|
include: {
|
||||||
|
author: { select: { id: true, firstName: true, lastName: true } },
|
||||||
|
classGroup: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!announcement) {
|
||||||
|
return NextResponse.json({ error: 'Announcement not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(announcement);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Announcement 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 });
|
||||||
|
}
|
||||||
|
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await request.json();
|
||||||
|
const parsed = updateAnnouncementSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Validation failed', details: parsed.error.issues },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db.announcement.findUnique({ where: { id, deletedAt: null } });
|
||||||
|
if (!existing) {
|
||||||
|
return NextResponse.json({ error: 'Announcement not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the author or admins can edit
|
||||||
|
if (session.user?.role === 'TEACHER' && existing.authorId !== session.userId) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: Record<string, unknown> = {};
|
||||||
|
if (parsed.data.title !== undefined) updateData.title = parsed.data.title;
|
||||||
|
if (parsed.data.content !== undefined) updateData.content = parsed.data.content;
|
||||||
|
if (parsed.data.priority !== undefined) updateData.priority = parsed.data.priority;
|
||||||
|
if (parsed.data.targetAudience !== undefined) updateData.targetAudience = parsed.data.targetAudience;
|
||||||
|
if (parsed.data.classGroupId !== undefined) updateData.classGroupId = parsed.data.classGroupId || null;
|
||||||
|
if (parsed.data.isPinned !== undefined) updateData.isPinned = parsed.data.isPinned;
|
||||||
|
if (parsed.data.expiresAt !== undefined) updateData.expiresAt = parsed.data.expiresAt ? new Date(parsed.data.expiresAt) : null;
|
||||||
|
|
||||||
|
const announcement = await db.announcement.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateData,
|
||||||
|
include: {
|
||||||
|
author: { select: { id: true, firstName: true, lastName: true } },
|
||||||
|
classGroup: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(announcement);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Announcement 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 });
|
||||||
|
}
|
||||||
|
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const existing = await db.announcement.findUnique({ where: { id, deletedAt: null } });
|
||||||
|
if (!existing) {
|
||||||
|
return NextResponse.json({ error: 'Announcement not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.user?.role === 'TEACHER' && existing.authorId !== session.userId) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.announcement.update({
|
||||||
|
where: { id },
|
||||||
|
data: { deletedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Announcement DELETE [id] error:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { getSession } from '@/lib/auth';
|
||||||
|
|
||||||
|
const priorityEnum = z.enum(['low', 'normal', 'high', 'urgent']);
|
||||||
|
const targetAudienceEnum = z.enum(['all', 'teachers', 'students', 'parents', 'class']);
|
||||||
|
|
||||||
|
const createAnnouncementSchema = z.object({
|
||||||
|
schoolId: z.string().min(1),
|
||||||
|
title: z.string().min(1).max(200),
|
||||||
|
content: z.string().min(1).max(10000),
|
||||||
|
priority: priorityEnum.optional().default('normal'),
|
||||||
|
targetAudience: targetAudienceEnum.optional().default('all'),
|
||||||
|
classGroupId: z.string().optional().nullable(),
|
||||||
|
isPinned: z.boolean().optional().default(false),
|
||||||
|
expiresAt: z.string().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function isTeacherOrAdmin(role: string | undefined): boolean {
|
||||||
|
return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const schoolIdParam = searchParams.get('schoolId');
|
||||||
|
const priority = searchParams.get('priority');
|
||||||
|
const targetAudience = searchParams.get('targetAudience');
|
||||||
|
const classGroupId = searchParams.get('classGroupId');
|
||||||
|
const isPinned = searchParams.get('isPinned');
|
||||||
|
const limit = parseInt(searchParams.get('limit') || '50', 10);
|
||||||
|
|
||||||
|
let schoolId: string | undefined;
|
||||||
|
if (session.user?.role === 'SCHOOL_ADMIN') {
|
||||||
|
schoolId = session.user.schoolId ?? undefined;
|
||||||
|
} else {
|
||||||
|
schoolId = schoolIdParam ?? session.user?.schoolId ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!schoolId) {
|
||||||
|
return NextResponse.json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const where: Record<string, unknown> = {
|
||||||
|
schoolId,
|
||||||
|
deletedAt: null,
|
||||||
|
OR: [
|
||||||
|
{ expiresAt: null },
|
||||||
|
{ expiresAt: { gt: new Date() } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
if (priority && priority !== 'all') where.priority = priority;
|
||||||
|
if (targetAudience && targetAudience !== 'all') where.targetAudience = targetAudience;
|
||||||
|
if (classGroupId && classGroupId !== 'all') where.classGroupId = classGroupId;
|
||||||
|
if (isPinned === 'true') where.isPinned = true;
|
||||||
|
|
||||||
|
// Filter by role-based target audience
|
||||||
|
if (session.user?.role === 'STUDENT') {
|
||||||
|
where.OR = [
|
||||||
|
{ targetAudience: 'all' },
|
||||||
|
{ targetAudience: 'students' },
|
||||||
|
];
|
||||||
|
} else if (session.user?.role === 'PARENT') {
|
||||||
|
where.OR = [
|
||||||
|
{ targetAudience: 'all' },
|
||||||
|
{ targetAudience: 'parents' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const announcements = await db.announcement.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [
|
||||||
|
{ isPinned: 'desc' },
|
||||||
|
{ createdAt: 'desc' },
|
||||||
|
],
|
||||||
|
take: limit,
|
||||||
|
include: {
|
||||||
|
author: { select: { id: true, firstName: true, lastName: true } },
|
||||||
|
classGroup: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(announcements);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Announcements GET error:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const parsed = createAnnouncementSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Validation failed', details: parsed.error.issues },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { schoolId, classGroupId, expiresAt, ...rest } = parsed.data;
|
||||||
|
|
||||||
|
// Verify school
|
||||||
|
const school = await db.school.findUnique({ where: { id: schoolId } });
|
||||||
|
if (!school) {
|
||||||
|
return NextResponse.json({ error: 'School not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (session.user?.role === 'SCHOOL_ADMIN' && session.user.schoolId && school.id !== session.user.schoolId) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify classGroup if provided
|
||||||
|
if (classGroupId) {
|
||||||
|
const cg = await db.classGroup.findUnique({ where: { id: classGroupId } });
|
||||||
|
if (!cg || cg.schoolId !== schoolId) {
|
||||||
|
return NextResponse.json({ error: 'Class group not found in this school' }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const announcement = await db.announcement.create({
|
||||||
|
data: {
|
||||||
|
schoolId,
|
||||||
|
authorId: session.userId,
|
||||||
|
title: rest.title,
|
||||||
|
content: rest.content,
|
||||||
|
priority: rest.priority,
|
||||||
|
targetAudience: rest.targetAudience,
|
||||||
|
classGroupId: classGroupId || null,
|
||||||
|
isPinned: rest.isPinned,
|
||||||
|
expiresAt: expiresAt ? new Date(expiresAt) : null,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
author: { select: { id: true, firstName: true, lastName: true } },
|
||||||
|
classGroup: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(announcement, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Announcements POST error:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { getSession } from '@/lib/auth';
|
||||||
|
|
||||||
|
const homeworkTypeEnum = z.enum(['assignment', 'reading', 'project', 'practice', 'research']);
|
||||||
|
|
||||||
|
const updateHomeworkSchema = z.object({
|
||||||
|
title: z.string().min(1).max(200).optional(),
|
||||||
|
description: z.string().max(5000).optional().nullable(),
|
||||||
|
dueDate: z.string().min(1).optional(),
|
||||||
|
homeworkType: homeworkTypeEnum.optional(),
|
||||||
|
maxPoints: z.number().min(0).optional().nullable(),
|
||||||
|
attachments: z.array(z.string()).optional().nullable(),
|
||||||
|
isPublished: z.boolean().optional(),
|
||||||
|
subjectId: z.string().optional().nullable(),
|
||||||
|
classGroupId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function isTeacherOrAdmin(role: string | undefined): boolean {
|
||||||
|
return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 homework = await db.homework.findUnique({
|
||||||
|
where: { id, deletedAt: null },
|
||||||
|
include: {
|
||||||
|
classGroup: { select: { id: true, name: true, gradeLevel: true } },
|
||||||
|
subject: { select: { id: true, name: true } },
|
||||||
|
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||||
|
submissions: {
|
||||||
|
include: {
|
||||||
|
student: { select: { id: true, firstName: true, lastName: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!homework) {
|
||||||
|
return NextResponse.json({ error: 'Homework not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Students can only see published homework
|
||||||
|
if (session.user?.role === 'STUDENT' && !homework.isPublished) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(homework);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Homework 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 });
|
||||||
|
}
|
||||||
|
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await request.json();
|
||||||
|
const parsed = updateHomeworkSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Validation failed', details: parsed.error.issues },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db.homework.findUnique({ where: { id, deletedAt: null } });
|
||||||
|
if (!existing) {
|
||||||
|
return NextResponse.json({ error: 'Homework not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the teacher who created it or admins can edit
|
||||||
|
if (session.user?.role === 'TEACHER' && existing.teacherId !== session.userId) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: Record<string, unknown> = {};
|
||||||
|
if (parsed.data.title !== undefined) updateData.title = parsed.data.title;
|
||||||
|
if (parsed.data.description !== undefined) updateData.description = parsed.data.description;
|
||||||
|
if (parsed.data.dueDate !== undefined) updateData.dueDate = new Date(parsed.data.dueDate);
|
||||||
|
if (parsed.data.homeworkType !== undefined) updateData.homeworkType = parsed.data.homeworkType;
|
||||||
|
if (parsed.data.maxPoints !== undefined) updateData.maxPoints = parsed.data.maxPoints;
|
||||||
|
if (parsed.data.attachments !== undefined) updateData.attachments = parsed.data.attachments ? JSON.stringify(parsed.data.attachments) : null;
|
||||||
|
if (parsed.data.isPublished !== undefined) updateData.isPublished = parsed.data.isPublished;
|
||||||
|
if (parsed.data.subjectId !== undefined) updateData.subjectId = parsed.data.subjectId || null;
|
||||||
|
if (parsed.data.classGroupId !== undefined) updateData.classGroupId = parsed.data.classGroupId;
|
||||||
|
|
||||||
|
const homework = await db.homework.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateData,
|
||||||
|
include: {
|
||||||
|
classGroup: { select: { id: true, name: true, gradeLevel: true } },
|
||||||
|
subject: { select: { id: true, name: true } },
|
||||||
|
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(homework);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Homework 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 });
|
||||||
|
}
|
||||||
|
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const existing = await db.homework.findUnique({ where: { id, deletedAt: null } });
|
||||||
|
if (!existing) {
|
||||||
|
return NextResponse.json({ error: 'Homework not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.user?.role === 'TEACHER' && existing.teacherId !== session.userId) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.homework.update({
|
||||||
|
where: { id },
|
||||||
|
data: { deletedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Homework DELETE [id] error:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { getSession } from '@/lib/auth';
|
||||||
|
|
||||||
|
const gradeSchema = z.object({
|
||||||
|
score: z.number().min(0).optional().nullable(),
|
||||||
|
feedback: z.string().max(5000).optional().nullable(),
|
||||||
|
status: z.enum(['graded', 'submitted', 'late', 'pending']).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function isTeacherOrAdmin(role: string | undefined): boolean {
|
||||||
|
return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string; submissionId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { submissionId } = await params;
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const parsed = gradeSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Validation failed', details: parsed.error.issues },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db.homeworkSubmission.findUnique({
|
||||||
|
where: { id: submissionId, deletedAt: null },
|
||||||
|
});
|
||||||
|
if (!existing) {
|
||||||
|
return NextResponse.json({ error: 'Submission not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: Record<string, unknown> = {};
|
||||||
|
if (parsed.data.score !== undefined) {
|
||||||
|
updateData.score = parsed.data.score;
|
||||||
|
updateData.gradedAt = new Date();
|
||||||
|
if (!parsed.data.status) updateData.status = 'graded';
|
||||||
|
}
|
||||||
|
if (parsed.data.feedback !== undefined) updateData.feedback = parsed.data.feedback;
|
||||||
|
if (parsed.data.status !== undefined) updateData.status = parsed.data.status;
|
||||||
|
|
||||||
|
const submission = await db.homeworkSubmission.update({
|
||||||
|
where: { id: submissionId },
|
||||||
|
data: updateData,
|
||||||
|
include: {
|
||||||
|
student: { select: { id: true, firstName: true, lastName: true } },
|
||||||
|
homework: { select: { id: true, title: true, maxPoints: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(submission);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('HomeworkSubmission PUT error:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string; submissionId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { submissionId } = await params;
|
||||||
|
const existing = await db.homeworkSubmission.findUnique({
|
||||||
|
where: { id: submissionId, deletedAt: null },
|
||||||
|
});
|
||||||
|
if (!existing) {
|
||||||
|
return NextResponse.json({ error: 'Submission not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.homeworkSubmission.update({
|
||||||
|
where: { id: submissionId },
|
||||||
|
data: { deletedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('HomeworkSubmission DELETE error:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { getSession } from '@/lib/auth';
|
||||||
|
|
||||||
|
const submitSchema = z.object({
|
||||||
|
content: z.string().max(10000).optional().nullable(),
|
||||||
|
attachments: z.array(z.string()).optional().nullable(),
|
||||||
|
status: z.enum(['submitted', 'late']).optional().default('submitted'),
|
||||||
|
});
|
||||||
|
|
||||||
|
function isTeacherOrAdmin(role: string | undefined): boolean {
|
||||||
|
return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 { searchParams } = new URL(request.url);
|
||||||
|
const status = searchParams.get('status');
|
||||||
|
|
||||||
|
const where: Record<string, unknown> = { homeworkId: id, deletedAt: null };
|
||||||
|
if (status && status !== 'all') where.status = status;
|
||||||
|
|
||||||
|
const submissions = await db.homeworkSubmission.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
student: { select: { id: true, firstName: true, lastName: true } },
|
||||||
|
homework: { select: { id: true, title: true, maxPoints: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(submissions);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('HomeworkSubmissions GET error:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
// Verify homework exists and is published
|
||||||
|
const homework = await db.homework.findUnique({
|
||||||
|
where: { id, deletedAt: null },
|
||||||
|
});
|
||||||
|
if (!homework) {
|
||||||
|
return NextResponse.json({ error: 'Homework not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const parsed = submitSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Validation failed', details: parsed.error.issues },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For students, we need to find the student record
|
||||||
|
let studentId: string | undefined;
|
||||||
|
if (session.user?.role === 'STUDENT') {
|
||||||
|
// Student users need to find their student record via enrollment
|
||||||
|
const student = await db.student.findFirst({
|
||||||
|
where: {
|
||||||
|
schoolId: homework.schoolId,
|
||||||
|
deletedAt: null,
|
||||||
|
enrollments: {
|
||||||
|
some: { classGroupId: homework.classGroupId },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!student) {
|
||||||
|
return NextResponse.json({ error: 'Student not found in this class' }, { status: 404 });
|
||||||
|
}
|
||||||
|
studentId = student.id;
|
||||||
|
} else if (isTeacherOrAdmin(session.user?.role)) {
|
||||||
|
// Teachers can submit on behalf of a student
|
||||||
|
studentId = body.studentId;
|
||||||
|
if (!studentId) {
|
||||||
|
return NextResponse.json({ error: 'studentId is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!studentId) {
|
||||||
|
return NextResponse.json({ error: 'Cannot determine student' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for existing submission
|
||||||
|
const existing = await db.homeworkSubmission.findUnique({
|
||||||
|
where: { homeworkId_studentId: { homeworkId: id, studentId } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing && existing.deletedAt === null) {
|
||||||
|
// Update existing submission
|
||||||
|
const isLate = new Date() > homework.dueDate;
|
||||||
|
const submission = await db.homeworkSubmission.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
content: parsed.data.content ?? null,
|
||||||
|
attachments: parsed.data.attachments ? JSON.stringify(parsed.data.attachments) : null,
|
||||||
|
status: isLate ? 'late' : (parsed.data.status ?? 'submitted'),
|
||||||
|
submittedAt: new Date(),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
student: { select: { id: true, firstName: true, lastName: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json(submission);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there's a soft-deleted submission, restore it
|
||||||
|
if (existing && existing.deletedAt !== null) {
|
||||||
|
const isLate = new Date() > homework.dueDate;
|
||||||
|
const submission = await db.homeworkSubmission.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
content: parsed.data.content ?? null,
|
||||||
|
attachments: parsed.data.attachments ? JSON.stringify(parsed.data.attachments) : null,
|
||||||
|
status: isLate ? 'late' : (parsed.data.status ?? 'submitted'),
|
||||||
|
submittedAt: new Date(),
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
student: { select: { id: true, firstName: true, lastName: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json(submission);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new submission
|
||||||
|
const isLate = new Date() > homework.dueDate;
|
||||||
|
const submission = await db.homeworkSubmission.create({
|
||||||
|
data: {
|
||||||
|
homeworkId: id,
|
||||||
|
studentId,
|
||||||
|
content: parsed.data.content ?? null,
|
||||||
|
attachments: parsed.data.attachments ? JSON.stringify(parsed.data.attachments) : null,
|
||||||
|
status: isLate ? 'late' : (parsed.data.status ?? 'submitted'),
|
||||||
|
submittedAt: new Date(),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
student: { select: { id: true, firstName: true, lastName: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(submission, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('HomeworkSubmission POST error:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { getSession } from '@/lib/auth';
|
||||||
|
|
||||||
|
const homeworkTypeEnum = z.enum(['assignment', 'reading', 'project', 'practice', 'research']);
|
||||||
|
|
||||||
|
const createHomeworkSchema = z.object({
|
||||||
|
schoolId: z.string().min(1),
|
||||||
|
classGroupId: z.string().min(1),
|
||||||
|
subjectId: z.string().optional().nullable(),
|
||||||
|
title: z.string().min(1).max(200),
|
||||||
|
description: z.string().max(5000).optional().nullable(),
|
||||||
|
dueDate: z.string().min(1),
|
||||||
|
homeworkType: homeworkTypeEnum.optional().default('assignment'),
|
||||||
|
maxPoints: z.number().min(0).optional().nullable(),
|
||||||
|
attachments: z.array(z.string()).optional().nullable(),
|
||||||
|
isPublished: z.boolean().optional().default(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
function isTeacherOrAdmin(role: string | undefined): boolean {
|
||||||
|
return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const schoolIdParam = searchParams.get('schoolId');
|
||||||
|
const classGroupId = searchParams.get('classGroupId');
|
||||||
|
const subjectId = searchParams.get('subjectId');
|
||||||
|
const teacherId = searchParams.get('teacherId');
|
||||||
|
const homeworkType = searchParams.get('homeworkType');
|
||||||
|
const isPublished = searchParams.get('isPublished');
|
||||||
|
const dueDateFrom = searchParams.get('dueDateFrom');
|
||||||
|
const dueDateTo = searchParams.get('dueDateTo');
|
||||||
|
|
||||||
|
let schoolId: string | undefined;
|
||||||
|
if (session.user?.role === 'SCHOOL_ADMIN') {
|
||||||
|
schoolId = session.user.schoolId ?? undefined;
|
||||||
|
} else {
|
||||||
|
schoolId = schoolIdParam ?? session.user?.schoolId ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!schoolId) {
|
||||||
|
return NextResponse.json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const where: Record<string, unknown> = { schoolId, deletedAt: null };
|
||||||
|
if (classGroupId && classGroupId !== 'all') where.classGroupId = classGroupId;
|
||||||
|
if (subjectId && subjectId !== 'all') where.subjectId = subjectId;
|
||||||
|
if (teacherId) where.teacherId = teacherId;
|
||||||
|
if (homeworkType && homeworkType !== 'all') where.homeworkType = homeworkType;
|
||||||
|
if (isPublished === 'true') where.isPublished = true;
|
||||||
|
if (isPublished === 'false') where.isPublished = false;
|
||||||
|
if (dueDateFrom || dueDateTo) {
|
||||||
|
const dateFilter: Record<string, unknown> = {};
|
||||||
|
if (dueDateFrom) dateFilter.gte = new Date(dueDateFrom);
|
||||||
|
if (dueDateTo) dateFilter.lte = new Date(dueDateTo);
|
||||||
|
where.dueDate = dateFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For students, only show published homework
|
||||||
|
if (session.user?.role === 'STUDENT') {
|
||||||
|
where.isPublished = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const homeworks = await db.homework.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { dueDate: 'asc' },
|
||||||
|
include: {
|
||||||
|
classGroup: { select: { id: true, name: true, gradeLevel: true } },
|
||||||
|
subject: { select: { id: true, name: true } },
|
||||||
|
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||||
|
_count: { select: { submissions: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(homeworks);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Homework GET error:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const parsed = createHomeworkSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Validation failed', details: parsed.error.issues },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { schoolId, classGroupId, subjectId, dueDate, attachments, ...rest } = parsed.data;
|
||||||
|
|
||||||
|
// Verify school
|
||||||
|
const school = await db.school.findUnique({ where: { id: schoolId } });
|
||||||
|
if (!school) {
|
||||||
|
return NextResponse.json({ error: 'School not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (session.user?.role === 'SCHOOL_ADMIN' && session.user.schoolId && school.id !== session.user.schoolId) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify classGroup
|
||||||
|
const classGroup = await db.classGroup.findUnique({ where: { id: classGroupId } });
|
||||||
|
if (!classGroup || classGroup.schoolId !== schoolId) {
|
||||||
|
return NextResponse.json({ error: 'Class group not found in this school' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify subject if provided
|
||||||
|
if (subjectId) {
|
||||||
|
const subject = await db.subject.findUnique({ where: { id: subjectId } });
|
||||||
|
if (!subject) {
|
||||||
|
return NextResponse.json({ error: 'Subject not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const homework = await db.homework.create({
|
||||||
|
data: {
|
||||||
|
schoolId,
|
||||||
|
classGroupId,
|
||||||
|
subjectId: subjectId || null,
|
||||||
|
teacherId: session.userId,
|
||||||
|
title: rest.title,
|
||||||
|
description: rest.description ?? null,
|
||||||
|
dueDate: new Date(dueDate),
|
||||||
|
homeworkType: rest.homeworkType,
|
||||||
|
maxPoints: rest.maxPoints ?? null,
|
||||||
|
attachments: attachments ? JSON.stringify(attachments) : null,
|
||||||
|
isPublished: rest.isPublished,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
classGroup: { select: { id: true, name: true, gradeLevel: true } },
|
||||||
|
subject: { select: { id: true, name: true } },
|
||||||
|
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(homework, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Homework POST error:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { getSession } from '@/lib/auth';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const goal = await db.learningGoal.findFirst({
|
||||||
|
where: { id, deletedAt: null },
|
||||||
|
include: {
|
||||||
|
student: {
|
||||||
|
select: { id: true, firstName: true, lastName: true },
|
||||||
|
},
|
||||||
|
competency: {
|
||||||
|
select: { id: true, code: true, title: true },
|
||||||
|
},
|
||||||
|
selfAssessments: {
|
||||||
|
where: { deletedAt: null },
|
||||||
|
select: { id: true, selfLevel: true, confidence: true, createdAt: true },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!goal) {
|
||||||
|
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== goal.schoolId) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(goal);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('LearningGoal 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.learningGoal.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,
|
||||||
|
competencyId,
|
||||||
|
targetLevel,
|
||||||
|
currentLevel,
|
||||||
|
deadline,
|
||||||
|
status,
|
||||||
|
progress,
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
const updated = await db.learningGoal.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(title !== undefined && { title }),
|
||||||
|
...(description !== undefined && { description }),
|
||||||
|
...(competencyId !== undefined && { competencyId: competencyId || null }),
|
||||||
|
...(targetLevel !== undefined && { targetLevel }),
|
||||||
|
...(currentLevel !== undefined && { currentLevel }),
|
||||||
|
...(deadline !== undefined && { deadline: deadline ? new Date(deadline) : null }),
|
||||||
|
...(status !== undefined && { status }),
|
||||||
|
...(progress !== undefined && { progress }),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
student: {
|
||||||
|
select: { id: true, firstName: true, lastName: true },
|
||||||
|
},
|
||||||
|
competency: {
|
||||||
|
select: { id: true, code: true, title: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('LearningGoal 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.learningGoal.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.learningGoal.update({
|
||||||
|
where: { id },
|
||||||
|
data: { deletedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('LearningGoal DELETE [id] error:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
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');
|
||||||
|
const competencyId = searchParams.get('competencyId');
|
||||||
|
const status = searchParams.get('status');
|
||||||
|
|
||||||
|
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;
|
||||||
|
if (competencyId) where.competencyId = competencyId;
|
||||||
|
if (status) where.status = status;
|
||||||
|
|
||||||
|
const goals = await db.learningGoal.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
student: {
|
||||||
|
select: { id: true, firstName: true, lastName: true },
|
||||||
|
},
|
||||||
|
competency: {
|
||||||
|
select: { id: true, code: true, title: true },
|
||||||
|
},
|
||||||
|
selfAssessments: {
|
||||||
|
where: { deletedAt: null },
|
||||||
|
select: { id: true, selfLevel: true, confidence: true, createdAt: true },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(goals);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('LearningGoal 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,
|
||||||
|
competencyId,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
targetLevel,
|
||||||
|
currentLevel,
|
||||||
|
deadline,
|
||||||
|
status,
|
||||||
|
progress,
|
||||||
|
isDemo,
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
if (!schoolId || !studentId || !title) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'schoolId, studentId, and title are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const goal = await db.learningGoal.create({
|
||||||
|
data: {
|
||||||
|
schoolId,
|
||||||
|
studentId,
|
||||||
|
competencyId: competencyId || null,
|
||||||
|
title,
|
||||||
|
description: description || null,
|
||||||
|
targetLevel: targetLevel || null,
|
||||||
|
currentLevel: currentLevel || null,
|
||||||
|
deadline: deadline ? new Date(deadline) : null,
|
||||||
|
status: status || 'active',
|
||||||
|
progress: progress || 0,
|
||||||
|
isDemo: isDemo ?? false,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
student: {
|
||||||
|
select: { id: true, firstName: true, lastName: true },
|
||||||
|
},
|
||||||
|
competency: {
|
||||||
|
select: { id: true, code: true, title: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(goal, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('LearningGoal POST error:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
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 entry = await db.portfolioEntry.findFirst({
|
||||||
|
where: { id, deletedAt: null },
|
||||||
|
include: {
|
||||||
|
student: {
|
||||||
|
select: { id: true, firstName: true, lastName: true },
|
||||||
|
},
|
||||||
|
competency: {
|
||||||
|
select: { id: true, code: true, title: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== entry.schoolId) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(entry);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('PortfolioEntry 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.portfolioEntry.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,
|
||||||
|
entryType,
|
||||||
|
competencyId,
|
||||||
|
content,
|
||||||
|
mediaUrls,
|
||||||
|
notebookPageId,
|
||||||
|
drawingId,
|
||||||
|
isPublic,
|
||||||
|
tags,
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
const updated = await db.portfolioEntry.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(title !== undefined && { title }),
|
||||||
|
...(description !== undefined && { description }),
|
||||||
|
...(entryType !== undefined && { entryType }),
|
||||||
|
...(competencyId !== undefined && { competencyId: competencyId || null }),
|
||||||
|
...(content !== undefined && { content }),
|
||||||
|
...(mediaUrls !== undefined && { mediaUrls: mediaUrls ? JSON.stringify(mediaUrls) : null }),
|
||||||
|
...(notebookPageId !== undefined && { notebookPageId: notebookPageId || null }),
|
||||||
|
...(drawingId !== undefined && { drawingId: drawingId || null }),
|
||||||
|
...(isPublic !== undefined && { isPublic }),
|
||||||
|
...(tags !== undefined && { tags: tags ? JSON.stringify(tags) : null }),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
student: {
|
||||||
|
select: { id: true, firstName: true, lastName: true },
|
||||||
|
},
|
||||||
|
competency: {
|
||||||
|
select: { id: true, code: true, title: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('PortfolioEntry 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.portfolioEntry.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.portfolioEntry.update({
|
||||||
|
where: { id },
|
||||||
|
data: { deletedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('PortfolioEntry DELETE [id] error:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
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');
|
||||||
|
const entryType = searchParams.get('entryType');
|
||||||
|
const competencyId = searchParams.get('competencyId');
|
||||||
|
const isPublic = searchParams.get('isPublic');
|
||||||
|
|
||||||
|
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;
|
||||||
|
if (entryType) where.entryType = entryType;
|
||||||
|
if (competencyId) where.competencyId = competencyId;
|
||||||
|
if (isPublic !== null && isPublic !== undefined) where.isPublic = isPublic === 'true';
|
||||||
|
|
||||||
|
const entries = await db.portfolioEntry.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
student: {
|
||||||
|
select: { id: true, firstName: true, lastName: true },
|
||||||
|
},
|
||||||
|
competency: {
|
||||||
|
select: { id: true, code: true, title: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(entries);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('PortfolioEntry GET error:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const {
|
||||||
|
schoolId,
|
||||||
|
studentId,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
entryType,
|
||||||
|
competencyId,
|
||||||
|
content,
|
||||||
|
mediaUrls,
|
||||||
|
notebookPageId,
|
||||||
|
drawingId,
|
||||||
|
isPublic,
|
||||||
|
tags,
|
||||||
|
isDemo,
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
if (!schoolId || !studentId || !title || !entryType) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'schoolId, studentId, title, and entryType are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = await db.portfolioEntry.create({
|
||||||
|
data: {
|
||||||
|
schoolId,
|
||||||
|
studentId,
|
||||||
|
title,
|
||||||
|
description: description || null,
|
||||||
|
entryType,
|
||||||
|
competencyId: competencyId || null,
|
||||||
|
content: content || null,
|
||||||
|
mediaUrls: mediaUrls ? JSON.stringify(mediaUrls) : null,
|
||||||
|
notebookPageId: notebookPageId || null,
|
||||||
|
drawingId: drawingId || null,
|
||||||
|
isPublic: isPublic ?? false,
|
||||||
|
tags: tags ? JSON.stringify(tags) : null,
|
||||||
|
isDemo: isDemo ?? false,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
student: {
|
||||||
|
select: { id: true, firstName: true, lastName: true },
|
||||||
|
},
|
||||||
|
competency: {
|
||||||
|
select: { id: true, code: true, title: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(entry, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('PortfolioEntry POST error:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
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.selfAssessment.findFirst({
|
||||||
|
where: { id, deletedAt: null },
|
||||||
|
include: {
|
||||||
|
student: {
|
||||||
|
select: { id: true, firstName: true, lastName: true },
|
||||||
|
},
|
||||||
|
competency: {
|
||||||
|
select: { id: true, code: true, title: true },
|
||||||
|
},
|
||||||
|
classGroup: {
|
||||||
|
select: { id: true, name: true },
|
||||||
|
},
|
||||||
|
goal: {
|
||||||
|
select: { id: true, title: true, status: 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('SelfAssessment 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.selfAssessment.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 {
|
||||||
|
selfLevel,
|
||||||
|
confidence,
|
||||||
|
reflection,
|
||||||
|
evidence,
|
||||||
|
goalId,
|
||||||
|
classGroupId,
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
const updated = await db.selfAssessment.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(selfLevel !== undefined && { selfLevel }),
|
||||||
|
...(confidence !== undefined && { confidence: confidence || null }),
|
||||||
|
...(reflection !== undefined && { reflection }),
|
||||||
|
...(evidence !== undefined && { evidence }),
|
||||||
|
...(goalId !== undefined && { goalId: goalId || null }),
|
||||||
|
...(classGroupId !== undefined && { classGroupId: classGroupId || null }),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
student: {
|
||||||
|
select: { id: true, firstName: true, lastName: true },
|
||||||
|
},
|
||||||
|
competency: {
|
||||||
|
select: { id: true, code: true, title: true },
|
||||||
|
},
|
||||||
|
classGroup: {
|
||||||
|
select: { id: true, name: true },
|
||||||
|
},
|
||||||
|
goal: {
|
||||||
|
select: { id: true, title: true, status: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SelfAssessment 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.selfAssessment.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.selfAssessment.update({
|
||||||
|
where: { id },
|
||||||
|
data: { deletedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SelfAssessment DELETE [id] error:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { getSession } from '@/lib/auth';
|
||||||
|
|
||||||
|
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');
|
||||||
|
const competencyId = searchParams.get('competencyId');
|
||||||
|
const classGroupId = searchParams.get('classGroupId');
|
||||||
|
|
||||||
|
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;
|
||||||
|
if (competencyId) where.competencyId = competencyId;
|
||||||
|
if (classGroupId) where.classGroupId = classGroupId;
|
||||||
|
|
||||||
|
const assessments = await db.selfAssessment.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
student: {
|
||||||
|
select: { id: true, firstName: true, lastName: true },
|
||||||
|
},
|
||||||
|
competency: {
|
||||||
|
select: { id: true, code: true, title: true },
|
||||||
|
},
|
||||||
|
classGroup: {
|
||||||
|
select: { id: true, name: true },
|
||||||
|
},
|
||||||
|
goal: {
|
||||||
|
select: { id: true, title: true, status: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(assessments);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SelfAssessment 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,
|
||||||
|
competencyId,
|
||||||
|
classGroupId,
|
||||||
|
selfLevel,
|
||||||
|
confidence,
|
||||||
|
reflection,
|
||||||
|
evidence,
|
||||||
|
goalId,
|
||||||
|
isDemo,
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
if (!schoolId || !studentId || !competencyId || selfLevel === undefined) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'schoolId, studentId, competencyId, and selfLevel are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selfLevel < 1 || selfLevel > 6) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'selfLevel 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.selfAssessment.create({
|
||||||
|
data: {
|
||||||
|
schoolId,
|
||||||
|
studentId,
|
||||||
|
competencyId,
|
||||||
|
classGroupId: classGroupId || null,
|
||||||
|
selfLevel,
|
||||||
|
confidence: confidence || null,
|
||||||
|
reflection: reflection || null,
|
||||||
|
evidence: evidence || null,
|
||||||
|
goalId: goalId || null,
|
||||||
|
isDemo: isDemo ?? false,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
student: {
|
||||||
|
select: { id: true, firstName: true, lastName: true },
|
||||||
|
},
|
||||||
|
competency: {
|
||||||
|
select: { id: true, code: true, title: true },
|
||||||
|
},
|
||||||
|
classGroup: {
|
||||||
|
select: { id: true, name: true },
|
||||||
|
},
|
||||||
|
goal: {
|
||||||
|
select: { id: true, title: true, status: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(assessment, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SelfAssessment POST error:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -186,6 +186,76 @@
|
|||||||
background-size: 24px 24px;
|
background-size: 24px 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Announcement banner styling */
|
||||||
|
.announcement-banner {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.announcement-banner:hover {
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Homework card styling */
|
||||||
|
.homework-card {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.homework-card:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Priority color classes */
|
||||||
|
.priority-urgent {
|
||||||
|
border-color: oklch(0.577 0.245 27.325);
|
||||||
|
background-color: oklch(0.95 0.05 27);
|
||||||
|
}
|
||||||
|
.priority-high {
|
||||||
|
border-color: oklch(0.72 0.19 85);
|
||||||
|
background-color: oklch(0.95 0.05 85);
|
||||||
|
}
|
||||||
|
.priority-normal {
|
||||||
|
border-color: oklch(0.55 0.17 155);
|
||||||
|
background-color: oklch(0.95 0.05 155);
|
||||||
|
}
|
||||||
|
.priority-low {
|
||||||
|
border-color: oklch(0.53 0.14 175);
|
||||||
|
background-color: oklch(0.95 0.05 175);
|
||||||
|
}
|
||||||
|
.dark .priority-urgent {
|
||||||
|
border-color: oklch(0.577 0.245 27.325);
|
||||||
|
background-color: oklch(0.18 0.05 27);
|
||||||
|
}
|
||||||
|
.dark .priority-high {
|
||||||
|
border-color: oklch(0.72 0.19 85);
|
||||||
|
background-color: oklch(0.18 0.05 85);
|
||||||
|
}
|
||||||
|
.dark .priority-normal {
|
||||||
|
border-color: oklch(0.55 0.17 155);
|
||||||
|
background-color: oklch(0.18 0.05 155);
|
||||||
|
}
|
||||||
|
.dark .priority-low {
|
||||||
|
border-color: oklch(0.53 0.14 175);
|
||||||
|
background-color: oklch(0.18 0.05 175);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Due date color classes */
|
||||||
|
.due-overdue {
|
||||||
|
border-color: oklch(0.577 0.245 27.325);
|
||||||
|
}
|
||||||
|
.due-today {
|
||||||
|
border-color: oklch(0.72 0.19 85);
|
||||||
|
}
|
||||||
|
.due-upcoming {
|
||||||
|
border-color: oklch(0.55 0.17 155);
|
||||||
|
}
|
||||||
|
.dark .due-overdue {
|
||||||
|
border-color: oklch(0.577 0.245 27.325);
|
||||||
|
}
|
||||||
|
.dark .due-today {
|
||||||
|
border-color: oklch(0.72 0.19 85);
|
||||||
|
}
|
||||||
|
.dark .due-upcoming {
|
||||||
|
border-color: oklch(0.55 0.17 155);
|
||||||
|
}
|
||||||
|
|
||||||
/* Smooth transitions for all interactive elements */
|
/* Smooth transitions for all interactive elements */
|
||||||
.interactive-transition {
|
.interactive-transition {
|
||||||
transition: all 0.2s ease-out;
|
transition: all 0.2s ease-out;
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ import {
|
|||||||
UserCheck,
|
UserCheck,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
BookCheck,
|
||||||
|
Megaphone,
|
||||||
|
Briefcase,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
@@ -101,6 +104,7 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
|||||||
import { useAppStore, type ViewName } from '@/lib/store';
|
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 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 {
|
||||||
@@ -139,6 +143,8 @@ import RubricLibraryView from './rubric-library-view';
|
|||||||
import CommentBankView from './comment-bank-view';
|
import CommentBankView from './comment-bank-view';
|
||||||
import NotebooksView from './notebooks-view';
|
import NotebooksView from './notebooks-view';
|
||||||
import DrawingView from './drawing-view';
|
import DrawingView from './drawing-view';
|
||||||
|
import HomeworkView from './homework-view';
|
||||||
|
import PortfolioView from './portfolio-view';
|
||||||
|
|
||||||
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[] };
|
||||||
@@ -169,10 +175,12 @@ const navSections: NavSection[] = [
|
|||||||
{ key: 'lesson-plans', icon: CalendarDays, labelKey: 'nav.lesson_plans' },
|
{ key: 'lesson-plans', icon: CalendarDays, labelKey: 'nav.lesson_plans' },
|
||||||
{ key: 'parents', icon: Mail, labelKey: 'nav.parents' },
|
{ key: 'parents', icon: Mail, labelKey: 'nav.parents' },
|
||||||
{ key: 'behavior', icon: Shield, labelKey: 'nav.behavior' },
|
{ key: 'behavior', icon: Shield, labelKey: 'nav.behavior' },
|
||||||
|
{ key: 'homework', icon: BookCheck, labelKey: 'nav.homework' },
|
||||||
{ key: 'rubrics', icon: Ruler, labelKey: 'nav.rubrics' },
|
{ key: 'rubrics', icon: Ruler, labelKey: 'nav.rubrics' },
|
||||||
{ key: 'comments', icon: MessageSquareText, labelKey: 'nav.comments' },
|
{ key: 'comments', icon: MessageSquareText, labelKey: 'nav.comments' },
|
||||||
{ key: 'notebooks', icon: BookOpen, labelKey: 'nav.notebooks' },
|
{ key: 'notebooks', icon: BookOpen, labelKey: 'nav.notebooks' },
|
||||||
{ key: 'drawing', icon: Palette, labelKey: 'nav.drawing' },
|
{ key: 'drawing', icon: Palette, labelKey: 'nav.drawing' },
|
||||||
|
{ key: 'portfolio', icon: Briefcase, labelKey: 'nav.portfolio' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -195,7 +203,9 @@ const studentNavSections: NavSection[] = [
|
|||||||
{ key: 'notebooks', icon: BookOpen, labelKey: 'nav.student_notebooks' },
|
{ key: 'notebooks', icon: BookOpen, labelKey: 'nav.student_notebooks' },
|
||||||
{ key: 'flower', icon: Flower2, labelKey: 'nav.student_competencies' },
|
{ key: 'flower', icon: Flower2, labelKey: 'nav.student_competencies' },
|
||||||
{ key: 'grading', icon: Calculator, labelKey: 'nav.student_grades' },
|
{ key: 'grading', icon: Calculator, labelKey: 'nav.student_grades' },
|
||||||
|
{ key: 'homework', icon: BookCheck, labelKey: 'nav.homework' },
|
||||||
{ key: 'attendance', icon: CalendarCheck, labelKey: 'nav.student_attendance' },
|
{ key: 'attendance', icon: CalendarCheck, labelKey: 'nav.student_attendance' },
|
||||||
|
{ key: 'portfolio', icon: Briefcase, labelKey: 'nav.portfolio' },
|
||||||
{ key: 'calendar', icon: CalendarIconNav, labelKey: 'nav.calendar' },
|
{ key: 'calendar', icon: CalendarIconNav, labelKey: 'nav.calendar' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -215,6 +225,103 @@ const parentNavSections: NavSection[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/* ── Announcement Banner ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
interface AnnouncementItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
priority: string;
|
||||||
|
isPinned: boolean;
|
||||||
|
author: { id: string; firstName: string; lastName: string };
|
||||||
|
classGroup: { id: string; name: string } | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPriorityColor(priority: string): string {
|
||||||
|
switch (priority) {
|
||||||
|
case 'urgent': return 'bg-rose-50 border-rose-300 dark:bg-rose-950/50 dark:border-rose-800';
|
||||||
|
case 'high': return 'bg-amber-50 border-amber-300 dark:bg-amber-950/50 dark:border-amber-800';
|
||||||
|
case 'normal': return 'bg-emerald-50 border-emerald-300 dark:bg-emerald-950/50 dark:border-emerald-800';
|
||||||
|
case 'low': return 'bg-teal-50 border-teal-300 dark:bg-teal-950/50 dark:border-teal-800';
|
||||||
|
default: return 'bg-emerald-50 border-emerald-300 dark:bg-emerald-950/50 dark:border-emerald-800';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPriorityIcon(priority: string) {
|
||||||
|
switch (priority) {
|
||||||
|
case 'urgent': return AlertTriangle;
|
||||||
|
case 'high': return Info;
|
||||||
|
default: return Megaphone;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnnouncementBanner({ schoolId }: { schoolId: string | null }) {
|
||||||
|
const [announcements, setAnnouncements] = useState<AnnouncementItem[]>([]);
|
||||||
|
const [dismissed, setDismissed] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('ct_dismissed_announcements');
|
||||||
|
if (stored) setDismissed(new Set(JSON.parse(stored)));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!schoolId) return;
|
||||||
|
apiGet<AnnouncementItem[]>(`/api/announcements?schoolId=${schoolId}&isPinned=true&limit=5`)
|
||||||
|
.then((data) => setAnnouncements(data))
|
||||||
|
.catch(() => {});
|
||||||
|
}, [schoolId]);
|
||||||
|
|
||||||
|
const visibleAnnouncements = announcements.filter((a) => !dismissed.has(a.id));
|
||||||
|
|
||||||
|
const handleDismiss = (id: string) => {
|
||||||
|
const newDismissed = new Set(dismissed);
|
||||||
|
newDismissed.add(id);
|
||||||
|
setDismissed(newDismissed);
|
||||||
|
try {
|
||||||
|
localStorage.setItem('ct_dismissed_announcements', JSON.stringify([...newDismissed]));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (visibleAnnouncements.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-0">
|
||||||
|
<AnimatePresence>
|
||||||
|
{visibleAnnouncements.map((announcement) => {
|
||||||
|
const PriorityIcon = getPriorityIcon(announcement.priority);
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={announcement.id}
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className={`announcement-banner border-b px-4 py-2 flex items-center gap-3 ${getPriorityColor(announcement.priority)}`}
|
||||||
|
>
|
||||||
|
<PriorityIcon className="h-4 w-4 shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="font-semibold text-sm">{announcement.title}</span>
|
||||||
|
<span className="text-sm text-muted-foreground ml-2 hidden sm:inline">{announcement.content.slice(0, 100)}{announcement.content.length > 100 ? '...' : ''}</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 shrink-0 min-h-[44px] min-w-[44px]"
|
||||||
|
onClick={() => handleDismiss(announcement.id)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function renderView(view: ViewName) {
|
function renderView(view: ViewName) {
|
||||||
switch (view) {
|
switch (view) {
|
||||||
case 'dashboard': return <DashboardView />;
|
case 'dashboard': return <DashboardView />;
|
||||||
@@ -239,6 +346,8 @@ function renderView(view: ViewName) {
|
|||||||
case 'comments': return <CommentBankView />;
|
case 'comments': return <CommentBankView />;
|
||||||
case 'notebooks': return <NotebooksView />;
|
case 'notebooks': return <NotebooksView />;
|
||||||
case 'drawing': return <DrawingView />;
|
case 'drawing': return <DrawingView />;
|
||||||
|
case 'homework': return <HomeworkView />;
|
||||||
|
case 'portfolio': return <PortfolioView />;
|
||||||
default: return <DashboardView />;
|
default: return <DashboardView />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -775,6 +884,8 @@ export default function AppLayout() {
|
|||||||
</Sidebar>
|
</Sidebar>
|
||||||
|
|
||||||
<SidebarInset>
|
<SidebarInset>
|
||||||
|
{/* Announcement Banner */}
|
||||||
|
<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 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">
|
||||||
<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" />
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
PartyPopper,
|
PartyPopper,
|
||||||
Lightbulb,
|
Lightbulb,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
ChevronDown,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
GraduationCap,
|
GraduationCap,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
@@ -47,6 +48,11 @@ import {
|
|||||||
Calendar as CalendarIconNav,
|
Calendar as CalendarIconNav,
|
||||||
BookMarked,
|
BookMarked,
|
||||||
Heart,
|
Heart,
|
||||||
|
BookCheck,
|
||||||
|
Megaphone,
|
||||||
|
Pin,
|
||||||
|
X,
|
||||||
|
Send,
|
||||||
} 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';
|
||||||
@@ -55,6 +61,25 @@ import { Skeleton } from '@/components/ui/skeleton';
|
|||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -64,6 +89,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';
|
||||||
|
|
||||||
const containerVariants = {
|
const containerVariants = {
|
||||||
hidden: { opacity: 0 },
|
hidden: { opacity: 0 },
|
||||||
@@ -1727,6 +1753,15 @@ export default function DashboardView() {
|
|||||||
</Card>
|
</Card>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
{/* ===== ANNOUNCEMENTS & HOMEWORK DUE ===== */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Announcements Card */}
|
||||||
|
<DashboardAnnouncementsCard currentUser={currentUser} locale={locale} />
|
||||||
|
|
||||||
|
{/* Homework Due Soon Card */}
|
||||||
|
<DashboardHomeworkCard currentUser={currentUser} locale={locale} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ===== 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 */}
|
||||||
@@ -1812,3 +1847,358 @@ export default function DashboardView() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Dashboard Announcements Card ──────────────────────────────── */
|
||||||
|
|
||||||
|
interface DashboardAnnouncement {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
priority: string;
|
||||||
|
isPinned: boolean;
|
||||||
|
targetAudience: string;
|
||||||
|
author: { id: string; firstName: string; lastName: string };
|
||||||
|
classGroup: { id: string; name: string } | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardAnnouncementsCard({ currentUser, locale }: { currentUser: CurrentUser | null; locale: string }) {
|
||||||
|
const setCurrentView = useAppStore((s) => s.setCurrentView);
|
||||||
|
const [announcements, setAnnouncements] = useState<DashboardAnnouncement[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [expanded, setExpanded] = useState<string | null>(null);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [createForm, setCreateForm] = useState({ title: '', content: '', priority: 'normal', targetAudience: 'all' });
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
|
const isTeacherOrAdmin = currentUser?.role === 'TEACHER' || currentUser?.role === 'SCHOOL_ADMIN' || currentUser?.role === 'SUPER_ADMIN';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentUser?.schoolId) return;
|
||||||
|
apiGet<DashboardAnnouncement[]>(`/api/announcements?schoolId=${currentUser.schoolId}&limit=5`)
|
||||||
|
.then((data) => { setAnnouncements(data); setLoading(false); })
|
||||||
|
.catch(() => setLoading(false));
|
||||||
|
}, [currentUser?.schoolId]);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!currentUser?.schoolId || !createForm.title || !createForm.content) return;
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
await apiPost('/api/announcements', {
|
||||||
|
schoolId: currentUser.schoolId,
|
||||||
|
title: createForm.title,
|
||||||
|
content: createForm.content,
|
||||||
|
priority: createForm.priority,
|
||||||
|
targetAudience: createForm.targetAudience,
|
||||||
|
});
|
||||||
|
toast.success(t('announcement.create_success'));
|
||||||
|
setCreateOpen(false);
|
||||||
|
setCreateForm({ title: '', content: '', priority: 'normal', targetAudience: 'all' });
|
||||||
|
// Reload
|
||||||
|
const data = await apiGet<DashboardAnnouncement[]>(`/api/announcements?schoolId=${currentUser.schoolId}&limit=5`);
|
||||||
|
setAnnouncements(data);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : t('announcement.create_error'));
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPriorityBorder = (priority: string) => {
|
||||||
|
switch (priority) {
|
||||||
|
case 'urgent': return 'border-l-rose-500';
|
||||||
|
case 'high': return 'border-l-amber-500';
|
||||||
|
case 'normal': return 'border-l-emerald-500';
|
||||||
|
case 'low': return 'border-l-teal-500';
|
||||||
|
default: return 'border-l-emerald-500';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPriorityBadge = (priority: string) => {
|
||||||
|
switch (priority) {
|
||||||
|
case 'urgent': return 'bg-rose-100 text-rose-700 dark:bg-rose-950 dark:text-rose-300';
|
||||||
|
case 'high': return 'bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-300';
|
||||||
|
case 'normal': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300';
|
||||||
|
case 'low': return 'bg-teal-100 text-teal-700 dark:bg-teal-950 dark:text-teal-300';
|
||||||
|
default: return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const relativeTime = (dateStr: string) => {
|
||||||
|
const now = new Date();
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||||
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||||
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
if (diffMins < 1) return locale === 'de' ? 'Gerade eben' : 'Just now';
|
||||||
|
if (diffMins < 60) return locale === 'de' ? `Vor ${diffMins} Min.` : `${diffMins}m ago`;
|
||||||
|
if (diffHours < 24) return locale === 'de' ? `Vor ${diffHours} Std.` : `${diffHours}h ago`;
|
||||||
|
if (diffDays === 1) return t('date.yesterday');
|
||||||
|
if (diffDays < 7) return t('date.days_ago', { count: diffDays });
|
||||||
|
return date.toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', { day: '2-digit', month: 'short' });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div variants={itemVariants}>
|
||||||
|
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-rose-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-rose-50/50 to-transparent dark:from-rose-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-rose-100 dark:bg-rose-900/30 text-rose-600 dark:text-rose-400">
|
||||||
|
<Megaphone className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
{t('announcement.title')}
|
||||||
|
</CardTitle>
|
||||||
|
{isTeacherOrAdmin && (
|
||||||
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button size="sm" variant="outline" className="min-h-[44px] text-rose-600 dark:text-rose-400 border-rose-200/50 dark:border-rose-900/30 hover:bg-rose-50 dark:hover:bg-rose-900/20">
|
||||||
|
<Plus className="h-3 w-3 mr-1" /> {t('announcement.create')}
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('announcement.create_title')}</DialogTitle>
|
||||||
|
<DialogDescription>{t('announcement.create_description')}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('announcement.field_title')}</Label>
|
||||||
|
<Input value={createForm.title} onChange={(e) => setCreateForm({ ...createForm, title: e.target.value })} placeholder={t('announcement.field_title_placeholder')} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('announcement.field_content')}</Label>
|
||||||
|
<Textarea value={createForm.content} onChange={(e) => setCreateForm({ ...createForm, content: e.target.value })} placeholder={t('announcement.field_content_placeholder')} rows={4} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('announcement.field_priority')}</Label>
|
||||||
|
<Select value={createForm.priority} onValueChange={(v) => setCreateForm({ ...createForm, priority: v })}>
|
||||||
|
<SelectTrigger className="min-h-[44px]"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="low">{t('announcement.priority_low')}</SelectItem>
|
||||||
|
<SelectItem value="normal">{t('announcement.priority_normal')}</SelectItem>
|
||||||
|
<SelectItem value="high">{t('announcement.priority_high')}</SelectItem>
|
||||||
|
<SelectItem value="urgent">{t('announcement.priority_urgent')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('announcement.field_target')}</Label>
|
||||||
|
<Select value={createForm.targetAudience} onValueChange={(v) => setCreateForm({ ...createForm, targetAudience: v })}>
|
||||||
|
<SelectTrigger className="min-h-[44px]"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">{t('announcement.target_all')}</SelectItem>
|
||||||
|
<SelectItem value="teachers">{t('announcement.target_teachers')}</SelectItem>
|
||||||
|
<SelectItem value="students">{t('announcement.target_students')}</SelectItem>
|
||||||
|
<SelectItem value="parents">{t('announcement.target_parents')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setCreateOpen(false)} className="min-h-[44px]">{t('action.cancel')}</Button>
|
||||||
|
<Button onClick={handleCreate} disabled={creating} className="min-h-[44px]">{creating ? t('announcement.creating') : t('action.create')}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-16 w-full" />)}
|
||||||
|
</div>
|
||||||
|
) : announcements.length === 0 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="flex items-center justify-center w-16 h-16 rounded-2xl bg-rose-100 dark:bg-rose-900/20 mx-auto mb-4">
|
||||||
|
<Megaphone className="h-8 w-8 text-rose-400 dark:text-rose-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 font-medium">{t('announcement.no_announcements')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 max-h-96 overflow-y-auto scrollbar-education pr-1">
|
||||||
|
{announcements.map((ann) => (
|
||||||
|
<motion.div
|
||||||
|
key={ann.id}
|
||||||
|
className={`rounded-xl border-l-3 ${getPriorityBorder(ann.priority)} p-3 bg-gray-50/50 dark:bg-gray-800/30 hover:bg-gray-100/50 dark:hover:bg-gray-800/50 transition-colors cursor-pointer`}
|
||||||
|
onClick={() => setExpanded(expanded === ann.id ? null : ann.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
{ann.isPinned && <Pin className="h-3.5 w-3.5 text-rose-500 shrink-0" />}
|
||||||
|
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">{ann.title}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
<Badge className={`text-[10px] ${getPriorityBadge(ann.priority)}`}>{t(`announcement.priority_${ann.priority}`)}</Badge>
|
||||||
|
{expanded === ann.id ? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" /> : <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AnimatePresence>
|
||||||
|
{expanded === ann.id && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mt-2 whitespace-pre-wrap">{ann.content}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-2 text-xs text-muted-foreground">
|
||||||
|
<span>{ann.author.firstName} {ann.author.lastName}</span>
|
||||||
|
<span className="text-border">|</span>
|
||||||
|
<span>{relativeTime(ann.createdAt)}</span>
|
||||||
|
{ann.classGroup && (
|
||||||
|
<>
|
||||||
|
<span className="text-border">|</span>
|
||||||
|
<span>{ann.classGroup.name}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
{expanded !== ann.id && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 line-clamp-1">{ann.content}</p>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Dashboard Homework Due Card ───────────────────────────────── */
|
||||||
|
|
||||||
|
interface DashboardHomework {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
dueDate: string;
|
||||||
|
homeworkType: string;
|
||||||
|
maxPoints: number | null;
|
||||||
|
classGroup: { id: string; name: string; gradeLevel: number };
|
||||||
|
subject: { id: string; name: string } | null;
|
||||||
|
teacher: { id: string; firstName: string; lastName: string };
|
||||||
|
_count?: { submissions: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardHomeworkCard({ currentUser, locale }: { currentUser: CurrentUser | null; locale: string }) {
|
||||||
|
const setCurrentView = useAppStore((s) => s.setCurrentView);
|
||||||
|
const [homeworks, setHomeworks] = useState<DashboardHomework[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentUser?.schoolId) return;
|
||||||
|
const now = new Date();
|
||||||
|
const weekFromNow = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
|
||||||
|
apiGet<DashboardHomework[]>(`/api/homework?schoolId=${currentUser.schoolId}&dueDateFrom=${now.toISOString()}&dueDateTo=${weekFromNow.toISOString()}&isPublished=true`)
|
||||||
|
.then((data) => { setHomeworks(data); setLoading(false); })
|
||||||
|
.catch(() => setLoading(false));
|
||||||
|
}, [currentUser?.schoolId]);
|
||||||
|
|
||||||
|
const getDueDateStatus = (dueDate: string): 'overdue' | 'today' | 'upcoming' => {
|
||||||
|
const now = new Date();
|
||||||
|
const due = new Date(dueDate);
|
||||||
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
const dueDay = new Date(due.getFullYear(), due.getMonth(), due.getDate());
|
||||||
|
if (dueDay < today) return 'overdue';
|
||||||
|
if (dueDay.getTime() === today.getTime()) return 'today';
|
||||||
|
return 'upcoming';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDueDateColor = (status: 'overdue' | 'today' | 'upcoming') => {
|
||||||
|
switch (status) {
|
||||||
|
case 'overdue': return 'bg-rose-100 text-rose-700 dark:bg-rose-950 dark:text-rose-300';
|
||||||
|
case 'today': return 'bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-300';
|
||||||
|
case 'upcoming': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDueDateLabel = (dueDate: string) => {
|
||||||
|
const status = getDueDateStatus(dueDate);
|
||||||
|
const due = new Date(dueDate);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = due.getTime() - now.getTime();
|
||||||
|
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
if (status === 'overdue') return t('homework.overdue_days', { days: Math.abs(diffDays) });
|
||||||
|
if (status === 'today') return t('homework.due_today');
|
||||||
|
if (diffDays === 1) return t('homework.due_tomorrow');
|
||||||
|
return t('homework.due_in_days', { days: diffDays });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div variants={itemVariants}>
|
||||||
|
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-amber-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-amber-50/50 to-transparent dark:from-amber-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-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400">
|
||||||
|
<BookCheck className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
{t('homework.due_soon')}
|
||||||
|
</CardTitle>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setCurrentView('homework')}
|
||||||
|
className="min-h-[44px] text-amber-600 dark:text-amber-400 border-amber-200/50 dark:border-amber-900/30 hover:bg-amber-50 dark:hover:bg-amber-900/20"
|
||||||
|
>
|
||||||
|
{t('homework.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>
|
||||||
|
) : homeworks.length === 0 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="flex items-center justify-center w-16 h-16 rounded-2xl bg-amber-100 dark:bg-amber-900/20 mx-auto mb-4">
|
||||||
|
<BookCheck className="h-8 w-8 text-amber-400 dark:text-amber-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 font-medium">{t('homework.no_due_soon')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 max-h-96 overflow-y-auto scrollbar-education pr-1">
|
||||||
|
{homeworks.slice(0, 6).map((hw) => {
|
||||||
|
const dueStatus = getDueDateStatus(hw.dueDate);
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={hw.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-amber-200/60 dark:hover:border-amber-800/30 transition-colors cursor-pointer"
|
||||||
|
onClick={() => setCurrentView('homework')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br from-amber-400 to-amber-500 text-white text-xs font-bold shadow-sm shrink-0">
|
||||||
|
<BookCheck 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">{hw.title}</p>
|
||||||
|
<p className="text-[11px] text-gray-500 dark:text-gray-400 truncate">
|
||||||
|
{hw.classGroup.name}{hw.subject ? ` | ${hw.subject.name}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge className={`text-xs shrink-0 ${getDueDateColor(dueStatus)}`}>
|
||||||
|
<Clock className="h-3 w-3 mr-1" />
|
||||||
|
{getDueDateLabel(hw.dueDate)}
|
||||||
|
</Badge>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,605 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState, useMemo } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
Briefcase, Plus, Trash2, Pencil, Eye, EyeOff, Search,
|
||||||
|
Palette, BookOpen, FileText, Presentation, Award, Lightbulb,
|
||||||
|
Grid3X3, List, Tag, Calendar, Filter, X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { useAppStore } from '@/lib/store';
|
||||||
|
import { t } from '@/lib/i18n';
|
||||||
|
import { apiGet, apiPost, apiPut, apiDelete } from '@/lib/api';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────────────────────
|
||||||
|
interface PortfolioEntryData {
|
||||||
|
id: string;
|
||||||
|
schoolId: string;
|
||||||
|
studentId: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
entryType: string;
|
||||||
|
competencyId: string | null;
|
||||||
|
content: string | null;
|
||||||
|
mediaUrls: string | null;
|
||||||
|
notebookPageId: string | null;
|
||||||
|
drawingId: string | null;
|
||||||
|
isPublic: boolean;
|
||||||
|
tags: string | null;
|
||||||
|
isDemo: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
student: { id: string; firstName: string; lastName: string };
|
||||||
|
competency: { id: string; code: string; title: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENTRY_TYPES = [
|
||||||
|
{ value: 'artwork', labelKey: 'portfolio.artwork', icon: Palette, color: 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300' },
|
||||||
|
{ value: 'writing', labelKey: 'portfolio.writing', icon: BookOpen, color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300' },
|
||||||
|
{ value: 'project', labelKey: 'portfolio.project', icon: Grid3X3, color: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300' },
|
||||||
|
{ value: 'presentation', labelKey: 'portfolio.presentation', icon: Presentation, color: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300' },
|
||||||
|
{ value: 'achievement', labelKey: 'portfolio.achievement', icon: Award, color: 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300' },
|
||||||
|
{ value: 'reflection', labelKey: 'portfolio.reflection', icon: Lightbulb, color: 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function getEntryTypeConfig(type: string) {
|
||||||
|
return ENTRY_TYPES.find((et) => et.value === type) ?? ENTRY_TYPES[5];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PortfolioView() {
|
||||||
|
const currentUser = useAppStore((s) => s.currentUser);
|
||||||
|
const currentStudentId = useAppStore((s) => s.currentStudentId);
|
||||||
|
|
||||||
|
const [entries, setEntries] = useState<PortfolioEntryData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [filterType, setFilterType] = useState<string>('all');
|
||||||
|
const [filterTag, setFilterTag] = useState<string>('');
|
||||||
|
const [viewMode, setViewMode] = useState<'grid' | 'timeline'>('grid');
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editId, setEditId] = useState<string | null>(null);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
entryType: 'reflection',
|
||||||
|
competencyId: '',
|
||||||
|
content: '',
|
||||||
|
tags: '',
|
||||||
|
isPublic: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const schoolId = currentUser?.schoolId;
|
||||||
|
const studentId = currentStudentId ?? currentUser?.id ?? '';
|
||||||
|
|
||||||
|
// Load entries
|
||||||
|
useEffect(() => {
|
||||||
|
if (!schoolId || !studentId) { setLoading(false); return; }
|
||||||
|
let cancelled = false;
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await apiGet<PortfolioEntryData[]>(`/api/portfolio?schoolId=${schoolId}&studentId=${studentId}`);
|
||||||
|
if (!cancelled) setEntries(data);
|
||||||
|
} catch { /* ignore */ } finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [schoolId, studentId]);
|
||||||
|
|
||||||
|
// Collect all unique tags
|
||||||
|
const allTags = useMemo(() => {
|
||||||
|
const tagSet = new Set<string>();
|
||||||
|
entries.forEach((e) => {
|
||||||
|
if (e.tags) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(e.tags) as string[];
|
||||||
|
parsed.forEach((tag) => tagSet.add(tag));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(tagSet).sort();
|
||||||
|
}, [entries]);
|
||||||
|
|
||||||
|
// Filter entries
|
||||||
|
const filteredEntries = useMemo(() => {
|
||||||
|
return entries.filter((e) => {
|
||||||
|
if (filterType !== 'all' && e.entryType !== filterType) return false;
|
||||||
|
if (filterTag && e.tags) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(e.tags) as string[];
|
||||||
|
if (!parsed.includes(filterTag)) return false;
|
||||||
|
} catch { return false; }
|
||||||
|
}
|
||||||
|
if (filterTag && !e.tags) return false;
|
||||||
|
if (searchQuery) {
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
return (
|
||||||
|
e.title.toLowerCase().includes(q) ||
|
||||||
|
(e.description ?? '').toLowerCase().includes(q) ||
|
||||||
|
(e.content ?? '').toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [entries, filterType, filterTag, searchQuery]);
|
||||||
|
|
||||||
|
// Open dialog for create
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditId(null);
|
||||||
|
setForm({ title: '', description: '', entryType: 'reflection', competencyId: '', content: '', tags: '', isPublic: false });
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Open dialog for edit
|
||||||
|
const openEdit = (entry: PortfolioEntryData) => {
|
||||||
|
setEditId(entry.id);
|
||||||
|
let parsedTags = '';
|
||||||
|
if (entry.tags) {
|
||||||
|
try { parsedTags = (JSON.parse(entry.tags) as string[]).join(', '); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
setForm({
|
||||||
|
title: entry.title,
|
||||||
|
description: entry.description ?? '',
|
||||||
|
entryType: entry.entryType,
|
||||||
|
competencyId: entry.competencyId ?? '',
|
||||||
|
content: entry.content ?? '',
|
||||||
|
tags: parsedTags,
|
||||||
|
isPublic: entry.isPublic,
|
||||||
|
});
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle save
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!form.title) { toast.error(t('portfolio.entry_title')); return; }
|
||||||
|
if (!form.entryType) { toast.error(t('portfolio.entry_type')); return; }
|
||||||
|
try {
|
||||||
|
const tagsArray = form.tags ? form.tags.split(',').map((t) => t.trim()).filter(Boolean) : [];
|
||||||
|
const payload = {
|
||||||
|
schoolId,
|
||||||
|
studentId,
|
||||||
|
title: form.title,
|
||||||
|
description: form.description || null,
|
||||||
|
entryType: form.entryType,
|
||||||
|
competencyId: form.competencyId || null,
|
||||||
|
content: form.content || null,
|
||||||
|
tags: tagsArray.length > 0 ? tagsArray : null,
|
||||||
|
isPublic: form.isPublic,
|
||||||
|
};
|
||||||
|
if (editId) {
|
||||||
|
const updated = await apiPut<PortfolioEntryData>(`/api/portfolio/${editId}`, payload);
|
||||||
|
setEntries((prev) => prev.map((e) => e.id === editId ? updated : e));
|
||||||
|
} else {
|
||||||
|
const created = await apiPost<PortfolioEntryData>('/api/portfolio', payload);
|
||||||
|
setEntries((prev) => [created, ...prev]);
|
||||||
|
}
|
||||||
|
setDialogOpen(false);
|
||||||
|
toast.success(editId ? t('action.save') : t('action.create'));
|
||||||
|
} catch { toast.error(t('error.generic')); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle delete
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await apiDelete(`/api/portfolio/${id}`);
|
||||||
|
setEntries((prev) => prev.filter((e) => e.id !== id));
|
||||||
|
toast.success(t('action.delete'));
|
||||||
|
} catch { toast.error(t('error.generic')); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle public
|
||||||
|
const togglePublic = async (entry: PortfolioEntryData) => {
|
||||||
|
try {
|
||||||
|
const updated = await apiPut<PortfolioEntryData>(`/api/portfolio/${entry.id}`, { isPublic: !entry.isPublic });
|
||||||
|
setEntries((prev) => prev.map((e) => e.id === entry.id ? updated : e));
|
||||||
|
toast.success(updated.isPublic ? t('portfolio.public') : t('portfolio.private'));
|
||||||
|
} catch { toast.error(t('error.generic')); }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 p-6">
|
||||||
|
<Skeleton className="h-12 w-48 rounded-xl" />
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-48 rounded-xl" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold gradient-text flex items-center gap-2">
|
||||||
|
<Briefcase className="h-6 w-6 text-amber-600" />
|
||||||
|
{t('portfolio.title')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
{filteredEntries.length} {filteredEntries.length === 1 ? 'entry' : 'entries'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="rounded-xl bg-amber-600 hover:bg-amber-700 text-white min-h-[44px]"
|
||||||
|
onClick={openCreate}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
{t('portfolio.add_new')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters & Search */}
|
||||||
|
<Card className="border-0 shadow-sm rounded-xl">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative flex-1 w-full">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||||
|
<Input
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder={t('action.search')}
|
||||||
|
className="pl-9 rounded-lg h-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Type filter */}
|
||||||
|
<Select value={filterType} onValueChange={setFilterType}>
|
||||||
|
<SelectTrigger className="h-10 w-40 rounded-lg">
|
||||||
|
<Filter className="h-4 w-4 mr-1" />
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">{t('portfolio.all_types')}</SelectItem>
|
||||||
|
{ENTRY_TYPES.map((et) => (
|
||||||
|
<SelectItem key={et.value} value={et.value}>
|
||||||
|
{t(et.labelKey)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{/* Tag filter */}
|
||||||
|
{allTags.length > 0 && (
|
||||||
|
<Select value={filterTag} onValueChange={setFilterTag}>
|
||||||
|
<SelectTrigger className="h-10 w-40 rounded-lg">
|
||||||
|
<Tag className="h-4 w-4 mr-1" />
|
||||||
|
<SelectValue placeholder={t('portfolio.filter_by_tag')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">{t('portfolio.all_types')}</SelectItem>
|
||||||
|
{allTags.map((tag) => (
|
||||||
|
<SelectItem key={tag} value={tag}>{tag}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
{/* View mode toggle */}
|
||||||
|
<div className="flex items-center gap-1 bg-gray-100 dark:bg-gray-800 rounded-lg p-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('grid')}
|
||||||
|
className={`p-2 rounded-md transition-all min-h-[44px] min-w-[44px] flex items-center justify-center ${viewMode === 'grid' ? 'bg-white dark:bg-gray-700 shadow-sm' : 'text-gray-500'}`}
|
||||||
|
>
|
||||||
|
<Grid3X3 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('timeline')}
|
||||||
|
className={`p-2 rounded-md transition-all min-h-[44px] min-w-[44px] flex items-center justify-center ${viewMode === 'timeline' ? 'bg-white dark:bg-gray-700 shadow-sm' : 'text-gray-500'}`}
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Active filter tags */}
|
||||||
|
{(filterType !== 'all' || filterTag) && (
|
||||||
|
<div className="flex items-center gap-2 mt-3 flex-wrap">
|
||||||
|
{filterType !== 'all' && (
|
||||||
|
<Badge className="bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300 text-xs cursor-pointer" onClick={() => setFilterType('all')}>
|
||||||
|
{t(getEntryTypeConfig(filterType).labelKey)} <X className="h-3 w-3 ml-1" />
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{filterTag && (
|
||||||
|
<Badge className="bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300 text-xs cursor-pointer" onClick={() => setFilterTag('')}>
|
||||||
|
{filterTag} <X className="h-3 w-3 ml-1" />
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{filteredEntries.length === 0 && (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<Briefcase className="h-12 w-12 text-amber-400 dark:text-amber-500 mx-auto mb-4" />
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 font-medium">{t('portfolio.no_entries')}</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="mt-4 rounded-xl border-amber-300 dark:border-amber-700 text-amber-700 dark:text-amber-300 min-h-[44px]"
|
||||||
|
onClick={openCreate}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
{t('portfolio.add_new')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Grid View */}
|
||||||
|
{viewMode === 'grid' && filteredEntries.length > 0 && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
{filteredEntries.map((entry, i) => {
|
||||||
|
const typeConfig = getEntryTypeConfig(entry.entryType);
|
||||||
|
const TypeIcon = typeConfig.icon;
|
||||||
|
const entryTags: string[] = entry.tags ? (() => { try { return JSON.parse(entry.tags) as string[]; } catch { return []; } })() : [];
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={entry.id}
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
|
transition={{ delay: i * 0.03 }}
|
||||||
|
layout
|
||||||
|
>
|
||||||
|
<Card className="border-0 shadow-sm rounded-xl hover:shadow-md transition-shadow h-full flex flex-col overflow-hidden">
|
||||||
|
{/* Color bar at top */}
|
||||||
|
<div className={`h-1.5 ${typeConfig.color.replace('text-', 'bg-').split(' ')[0]}`} />
|
||||||
|
<CardHeader className="pb-2 pt-4 px-4">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${typeConfig.color} shrink-0`}>
|
||||||
|
<TypeIcon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<CardTitle className="text-sm font-bold truncate">{entry.title}</CardTitle>
|
||||||
|
<p className="text-[10px] text-gray-500 dark:text-gray-400">{t(typeConfig.labelKey)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => togglePublic(entry)}
|
||||||
|
className="p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors min-h-[44px] min-w-[44px] flex items-center justify-center"
|
||||||
|
title={entry.isPublic ? t('portfolio.public') : t('portfolio.private')}
|
||||||
|
>
|
||||||
|
{entry.isPublic ? <Eye className="h-3.5 w-3.5 text-emerald-500" /> : <EyeOff className="h-3.5 w-3.5 text-gray-400" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-4 pb-4 pt-0 flex-1 flex flex-col">
|
||||||
|
{entry.description && (
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-400 line-clamp-2 mb-2">{entry.description}</p>
|
||||||
|
)}
|
||||||
|
{entry.content && (
|
||||||
|
<p className="text-xs text-gray-700 dark:text-gray-300 line-clamp-3 mb-2 italic">“{entry.content}”</p>
|
||||||
|
)}
|
||||||
|
{entry.competency && (
|
||||||
|
<Badge variant="outline" className="text-[10px] w-fit mb-2">{entry.competency.code}</Badge>
|
||||||
|
)}
|
||||||
|
{entryTags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mb-2">
|
||||||
|
{entryTags.slice(0, 4).map((tag) => (
|
||||||
|
<Badge key={tag} className="bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400 text-[10px] px-1.5 py-0">
|
||||||
|
<Tag className="h-2.5 w-2.5 mr-0.5" />{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{entryTags.length > 4 && (
|
||||||
|
<Badge className="bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400 text-[10px] px-1.5 py-0">+{entryTags.length - 4}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-auto flex items-center justify-between pt-2 border-t border-gray-100 dark:border-gray-800">
|
||||||
|
<span className="text-[10px] text-gray-400 flex items-center gap-1">
|
||||||
|
<Calendar className="h-3 w-3" />
|
||||||
|
{new Date(entry.createdAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="rounded-lg min-h-[44px] min-w-[44px] p-0"
|
||||||
|
onClick={() => openEdit(entry)}
|
||||||
|
>
|
||||||
|
<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] p-0"
|
||||||
|
onClick={() => handleDelete(entry.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Timeline View */}
|
||||||
|
{viewMode === 'timeline' && filteredEntries.length > 0 && (
|
||||||
|
<div className="relative max-h-[800px] overflow-y-auto scrollbar-education">
|
||||||
|
<div className="absolute left-4 top-0 bottom-0 w-0.5 bg-amber-200/50 dark:bg-amber-900/30" />
|
||||||
|
<div className="space-y-4">
|
||||||
|
{filteredEntries.map((entry, i) => {
|
||||||
|
const typeConfig = getEntryTypeConfig(entry.entryType);
|
||||||
|
const TypeIcon = typeConfig.icon;
|
||||||
|
const entryTags: string[] = entry.tags ? (() => { try { return JSON.parse(entry.tags) as string[]; } catch { return []; } })() : [];
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={entry.id}
|
||||||
|
initial={{ opacity: 0, x: -8 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: i * 0.04 }}
|
||||||
|
className="relative flex items-start gap-4 pl-4"
|
||||||
|
>
|
||||||
|
<div className={`absolute left-3 w-3 h-3 rounded-full ring-2 ring-white dark:ring-gray-900 shrink-0 ${typeConfig.color.replace('text-', 'bg-').split(' ')[0]}`} style={{ zIndex: 1, top: '16px' }} />
|
||||||
|
<div className="ml-6 min-w-0 flex-1">
|
||||||
|
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<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">
|
||||||
|
<div className={`flex items-center justify-center w-7 h-7 rounded-lg ${typeConfig.color} shrink-0`}>
|
||||||
|
<TypeIcon className="h-3.5 w-3.5" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">{entry.title}</span>
|
||||||
|
<Badge className={`text-[10px] ${typeConfig.color}`}>{t(typeConfig.labelKey)}</Badge>
|
||||||
|
{entry.isPublic ? (
|
||||||
|
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 text-[10px]"><Eye className="h-3 w-3 mr-0.5" />{t('portfolio.public')}</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge className="bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400 text-[10px]"><EyeOff className="h-3 w-3 mr-0.5" />{t('portfolio.private')}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{entry.description && (
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">{entry.description}</p>
|
||||||
|
)}
|
||||||
|
{entry.content && (
|
||||||
|
<p className="text-xs text-gray-700 dark:text-gray-300 mt-1 italic">“{entry.content}”</p>
|
||||||
|
)}
|
||||||
|
{entry.competency && (
|
||||||
|
<Badge variant="outline" className="text-[10px] mt-1">{entry.competency.code} — {entry.competency.title}</Badge>
|
||||||
|
)}
|
||||||
|
{entryTags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
|
{entryTags.map((tag) => (
|
||||||
|
<Badge key={tag} className="bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400 text-[10px] px-1.5 py-0">
|
||||||
|
<Tag className="h-2.5 w-2.5 mr-0.5" />{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-[10px] text-gray-400 mt-2">
|
||||||
|
<Calendar className="h-3 w-3 inline mr-1" />
|
||||||
|
{new Date(entry.createdAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 shrink-0">
|
||||||
|
<Button variant="ghost" size="sm" className="rounded-lg min-h-[44px] min-w-[44px] p-0" onClick={() => openEdit(entry)}>
|
||||||
|
<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] p-0" onClick={() => handleDelete(entry.id)}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create/Edit Dialog */}
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editId ? t('portfolio.edit') : t('portfolio.create')}</DialogTitle>
|
||||||
|
<DialogDescription>{t('portfolio.title')}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-medium">{t('portfolio.entry_title')}</Label>
|
||||||
|
<Input
|
||||||
|
value={form.title}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
|
||||||
|
className="mt-1 rounded-lg"
|
||||||
|
placeholder={t('portfolio.entry_title')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-medium">{t('portfolio.entry_type')}</Label>
|
||||||
|
<Select value={form.entryType} onValueChange={(v) => setForm((f) => ({ ...f, entryType: v }))}>
|
||||||
|
<SelectTrigger className="h-10 rounded-lg mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ENTRY_TYPES.map((et) => (
|
||||||
|
<SelectItem key={et.value} value={et.value}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<et.icon className="h-4 w-4" />
|
||||||
|
{t(et.labelKey)}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-medium">{t('portfolio.description')}</Label>
|
||||||
|
<Textarea
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
||||||
|
className="mt-1 rounded-lg"
|
||||||
|
rows={2}
|
||||||
|
placeholder={t('portfolio.description')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-medium">{t('portfolio.content')}</Label>
|
||||||
|
<Textarea
|
||||||
|
value={form.content}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, content: e.target.value }))}
|
||||||
|
className="mt-1 rounded-lg"
|
||||||
|
rows={4}
|
||||||
|
placeholder={t('portfolio.content')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-medium">{t('portfolio.tags')} (comma-separated)</Label>
|
||||||
|
<Input
|
||||||
|
value={form.tags}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, tags: e.target.value }))}
|
||||||
|
className="mt-1 rounded-lg"
|
||||||
|
placeholder={t('portfolio.tags')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs font-medium">{entry.isPublic ? t('portfolio.public') : t('portfolio.private')}</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<EyeOff className="h-4 w-4 text-gray-400" />
|
||||||
|
<Switch
|
||||||
|
checked={form.isPublic}
|
||||||
|
onCheckedChange={(v) => setForm((f) => ({ ...f, isPublic: v }))}
|
||||||
|
/>
|
||||||
|
<Eye className="h-4 w-4 text-emerald-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" className="rounded-xl min-h-[44px]" onClick={() => setDialogOpen(false)}>{t('action.cancel')}</Button>
|
||||||
|
<Button
|
||||||
|
className="rounded-xl bg-amber-600 hover:bg-amber-700 text-white min-h-[44px]"
|
||||||
|
onClick={handleSave}
|
||||||
|
>
|
||||||
|
{editId ? t('action.save') : t('action.create')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
Printer, ChevronRight, Sparkles, MessageSquare, Grid3X3, Trophy, Flag, Zap,
|
Printer, ChevronRight, Sparkles, MessageSquare, Grid3X3, Trophy, Flag, Zap,
|
||||||
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,
|
||||||
} 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';
|
||||||
@@ -34,9 +35,58 @@ import {
|
|||||||
} from '@/components/ui/tooltip';
|
} from '@/components/ui/tooltip';
|
||||||
import { useAppStore, type CurrentUser } from '@/lib/store';
|
import { useAppStore, type CurrentUser } from '@/lib/store';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import { fetchStudentDetail, getReportPdfUrl, type StudentDetailData, fetchParentLinks, type ParentStudentLinkData } from '@/lib/api';
|
import { fetchStudentDetail, getReportPdfUrl, type StudentDetailData, fetchParentLinks, type ParentStudentLinkData, apiGet, apiPost, apiPut, apiDelete } from '@/lib/api';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import TeacherNotesSection from './teacher-notes-section';
|
import TeacherNotesSection from './teacher-notes-section';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Slider } from '@/components/ui/slider';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
|
||||||
|
// ─── Self-Assessment & Learning Goal Types ────────────────────────────────
|
||||||
|
interface SelfAssessmentData {
|
||||||
|
id: string;
|
||||||
|
schoolId: string;
|
||||||
|
studentId: string;
|
||||||
|
competencyId: string;
|
||||||
|
classGroupId: string | null;
|
||||||
|
selfLevel: number;
|
||||||
|
confidence: number | null;
|
||||||
|
reflection: string | null;
|
||||||
|
evidence: string | null;
|
||||||
|
goalId: string | null;
|
||||||
|
isDemo: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
student: { id: string; firstName: string; lastName: string };
|
||||||
|
competency: { id: string; code: string; title: string };
|
||||||
|
classGroup: { id: string; name: string } | null;
|
||||||
|
goal: { id: string; title: string; status: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LearningGoalData {
|
||||||
|
id: string;
|
||||||
|
schoolId: string;
|
||||||
|
studentId: string;
|
||||||
|
competencyId: string | null;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
targetLevel: number | null;
|
||||||
|
currentLevel: number | null;
|
||||||
|
deadline: string | null;
|
||||||
|
status: string;
|
||||||
|
progress: number;
|
||||||
|
isDemo: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
student: { id: string; firstName: string; lastName: string };
|
||||||
|
competency: { id: string; code: string; title: string } | null;
|
||||||
|
selfAssessments: { id: string; selfLevel: number; confidence: number | null; createdAt: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Journey Timeline Time Filter State ─────────────────────────────────
|
// ─── Journey Timeline Time Filter State ─────────────────────────────────
|
||||||
type JourneyTimeRange = '30' | '90' | 'all';
|
type JourneyTimeRange = '30' | '90' | 'all';
|
||||||
@@ -349,6 +399,17 @@ export default function StudentDetailView() {
|
|||||||
const [selectedFlowerSubjectId, setSelectedFlowerSubjectId] = useState<string>('');
|
const [selectedFlowerSubjectId, setSelectedFlowerSubjectId] = useState<string>('');
|
||||||
const [timeRange, setTimeRange] = useState<JourneyTimeRange>('all');
|
const [timeRange, setTimeRange] = useState<JourneyTimeRange>('all');
|
||||||
|
|
||||||
|
// Self-Assessment & Learning Goals state
|
||||||
|
const [selfAssessments, setSelfAssessments] = useState<SelfAssessmentData[]>([]);
|
||||||
|
const [learningGoals, setLearningGoals] = useState<LearningGoalData[]>([]);
|
||||||
|
const [saDialogOpen, setSaDialogOpen] = useState(false);
|
||||||
|
const [saEditId, setSaEditId] = useState<string | null>(null);
|
||||||
|
const [saForm, setSaForm] = useState({ competencyId: '', selfLevel: 3, confidence: 3, reflection: '', evidence: '', goalId: '' });
|
||||||
|
const [lgDialogOpen, setLgDialogOpen] = useState(false);
|
||||||
|
const [lgEditId, setLgEditId] = useState<string | null>(null);
|
||||||
|
const [lgForm, setLgForm] = useState({ title: '', description: '', competencyId: '', targetLevel: 4, currentLevel: 1, deadline: '', status: 'active', progress: 0 });
|
||||||
|
const [goalCelebration, setGoalCelebration] = useState<string | null>(null);
|
||||||
|
|
||||||
// Load parent links for parent users
|
// Load parent links for parent users
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isParent && currentUser?.id) {
|
if (isParent && currentUser?.id) {
|
||||||
@@ -386,6 +447,26 @@ export default function StudentDetailView() {
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [currentStudentId]);
|
}, [currentStudentId]);
|
||||||
|
|
||||||
|
// Load self-assessments and learning goals
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentStudentId || !currentUser?.schoolId) return;
|
||||||
|
const schoolId = currentUser.schoolId;
|
||||||
|
async function loadSA() {
|
||||||
|
try {
|
||||||
|
const data = await apiGet<SelfAssessmentData[]>(`/api/self-assessments?schoolId=${schoolId}&studentId=${currentStudentId}`);
|
||||||
|
setSelfAssessments(data);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
async function loadLG() {
|
||||||
|
try {
|
||||||
|
const data = await apiGet<LearningGoalData[]>(`/api/learning-goals?schoolId=${schoolId}&studentId=${currentStudentId}`);
|
||||||
|
setLearningGoals(data);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
loadSA();
|
||||||
|
loadLG();
|
||||||
|
}, [currentStudentId, currentUser?.schoolId]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -1234,6 +1315,584 @@ export default function StudentDetailView() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ─── Self-Assessment Section ─────────────────────────────────── */}
|
||||||
|
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-cyan-500 overflow-hidden">
|
||||||
|
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-cyan-50/50 to-transparent dark:from-cyan-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-cyan-100 dark:bg-cyan-900/30 text-cyan-600 dark:text-cyan-400">
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
{t('self_assessment.title')}
|
||||||
|
<Badge className="bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-300 text-xs font-medium">
|
||||||
|
{selfAssessments.length}
|
||||||
|
</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="rounded-xl border-cyan-300 dark:border-cyan-700 text-cyan-700 dark:text-cyan-300 min-h-[44px]"
|
||||||
|
onClick={() => {
|
||||||
|
setSaEditId(null);
|
||||||
|
setSaForm({ competencyId: '', selfLevel: 3, confidence: 3, reflection: '', evidence: '', goalId: '' });
|
||||||
|
setSaDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
{t('self_assessment.add_new')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{selfAssessments.length === 0 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Eye className="h-8 w-8 text-cyan-400 dark:text-cyan-500 mx-auto mb-2" />
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">{t('self_assessment.no_assessments')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Comparison radar chart */}
|
||||||
|
{(() => {
|
||||||
|
const comparisonData = selfAssessments.map((sa) => {
|
||||||
|
const teacherEntry = data?.progressEntries.find(
|
||||||
|
(pe) => pe.competencyId === sa.competencyId
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
competency: sa.competency.code.length > 12 ? sa.competency.code.slice(0, 12) : sa.competency.code,
|
||||||
|
self: sa.selfLevel,
|
||||||
|
teacher: teacherEntry ? teacherEntry.masteryLevelValue : 0,
|
||||||
|
};
|
||||||
|
}).filter((d) => 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('self_assessment.comparison_chart')}</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('self_assessment.self')} dataKey="self" stroke="#06b6d4" fill="#06b6d4" fillOpacity={0.2} />
|
||||||
|
<Radar name={t('self_assessment.teacher')} dataKey="teacher" stroke="#f59e0b" fill="#f59e0b" 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-cyan-400 inline-block" />{t('self_assessment.self')}</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('self_assessment.teacher')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
{/* Self-assessment list */}
|
||||||
|
<div className="space-y-2 max-h-96 overflow-y-auto scrollbar-education">
|
||||||
|
{selfAssessments.map((sa, i) => {
|
||||||
|
const teacherEntry = data?.progressEntries.find((pe) => pe.competencyId === sa.competencyId);
|
||||||
|
const gap = teacherEntry ? sa.selfLevel - teacherEntry.masteryLevelValue : null;
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={sa.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-cyan-50/40 to-transparent dark:from-cyan-900/10 dark:to-transparent border border-cyan-200/30 dark:border-cyan-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">{sa.competency.code} — {sa.competency.title}</span>
|
||||||
|
<Badge className="bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-300 text-xs">{t('self_assessment.level')}: {sa.selfLevel}/6</Badge>
|
||||||
|
{sa.confidence && (
|
||||||
|
<Badge className="bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300 text-xs">{t('self_assessment.confidence')}: {sa.confidence}/5</Badge>
|
||||||
|
)}
|
||||||
|
{gap !== null && (
|
||||||
|
<Badge className={`${Math.abs(gap) <= 1 ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'} text-xs`}>
|
||||||
|
{t('self_assessment.gap')}: {gap > 0 ? '+' : ''}{gap.toFixed(1)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{sa.reflection && (
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1 italic">“{sa.reflection}”</p>
|
||||||
|
)}
|
||||||
|
{sa.evidence && (
|
||||||
|
<p className="text-xs text-cyan-600 dark:text-cyan-400 mt-0.5"><Lightbulb className="h-3 w-3 inline mr-1" />{sa.evidence}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-[10px] text-gray-400 mt-1">{new Date(sa.createdAt).toLocaleDateString()}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="rounded-lg min-h-[44px] min-w-[44px]"
|
||||||
|
onClick={() => {
|
||||||
|
setSaEditId(sa.id);
|
||||||
|
setSaForm({
|
||||||
|
competencyId: sa.competencyId,
|
||||||
|
selfLevel: sa.selfLevel,
|
||||||
|
confidence: sa.confidence ?? 3,
|
||||||
|
reflection: sa.reflection ?? '',
|
||||||
|
evidence: sa.evidence ?? '',
|
||||||
|
goalId: sa.goalId ?? '',
|
||||||
|
});
|
||||||
|
setSaDialogOpen(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/self-assessments/${sa.id}`);
|
||||||
|
setSelfAssessments((prev) => prev.filter((a) => a.id !== sa.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>
|
||||||
|
|
||||||
|
{/* ─── Learning Goals Section ─────────────────────────────────── */}
|
||||||
|
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-orange-500 overflow-hidden">
|
||||||
|
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-orange-50/50 to-transparent dark:from-orange-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-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400">
|
||||||
|
<Target className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
{t('learning_goal.title')}
|
||||||
|
<Badge className="bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300 text-xs font-medium">
|
||||||
|
{learningGoals.filter((g) => g.status === 'active').length}
|
||||||
|
</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="rounded-xl border-orange-300 dark:border-orange-700 text-orange-700 dark:text-orange-300 min-h-[44px]"
|
||||||
|
onClick={() => {
|
||||||
|
setLgEditId(null);
|
||||||
|
setLgForm({ title: '', description: '', competencyId: '', targetLevel: 4, currentLevel: 1, deadline: '', status: 'active', progress: 0 });
|
||||||
|
setLgDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
{t('learning_goal.add_new')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{learningGoals.length === 0 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Target className="h-8 w-8 text-orange-400 dark:text-orange-500 mx-auto mb-2" />
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">{t('learning_goal.no_goals')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 max-h-96 overflow-y-auto scrollbar-education">
|
||||||
|
{learningGoals.map((goal, i) => {
|
||||||
|
const daysLeft = goal.deadline ? Math.ceil((new Date(goal.deadline).getTime() - Date.now()) / (1000 * 60 * 60 * 24)) : null;
|
||||||
|
const isOverdue = daysLeft !== null && daysLeft < 0;
|
||||||
|
const statusColor = goal.status === 'completed' ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300' : goal.status === 'abandoned' ? 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-300' : isOverdue ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300' : 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300';
|
||||||
|
const statusLabel = goal.status === 'completed' ? t('learning_goal.completed') : goal.status === 'abandoned' ? t('learning_goal.abandoned') : t('learning_goal.active');
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={goal.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-orange-50/40 to-transparent dark:from-orange-900/10 dark:to-transparent border border-orange-200/30 dark:border-orange-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">{goal.title}</span>
|
||||||
|
<Badge className={`text-xs ${statusColor}`}>
|
||||||
|
{goal.status === 'completed' ? <CheckCircle2 className="h-3 w-3 mr-1" /> : goal.status === 'abandoned' ? <XCircle className="h-3 w-3 mr-1" /> : <Clock className="h-3 w-3 mr-1" />}
|
||||||
|
{statusLabel}
|
||||||
|
</Badge>
|
||||||
|
{goal.competency && (
|
||||||
|
<Badge variant="outline" className="text-xs">{goal.competency.code}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{goal.description && (
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">{goal.description}</p>
|
||||||
|
)}
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-[10px] text-gray-500 dark:text-gray-400">{t('learning_goal.progress')}</span>
|
||||||
|
<span className="text-[10px] font-bold text-orange-600 dark:text-orange-400">{goal.progress}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={goal.progress} className="h-2" />
|
||||||
|
</div>
|
||||||
|
{/* Level indicators */}
|
||||||
|
<div className="flex items-center gap-4 mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{goal.currentLevel !== null && (
|
||||||
|
<span>{t('learning_goal.current_level')}: <strong className="text-gray-700 dark:text-gray-300">{goal.currentLevel}</strong></span>
|
||||||
|
)}
|
||||||
|
{goal.targetLevel !== null && (
|
||||||
|
<span>{t('learning_goal.target_level')}: <strong className="text-gray-700 dark:text-gray-300">{goal.targetLevel}</strong></span>
|
||||||
|
)}
|
||||||
|
{daysLeft !== null && (
|
||||||
|
<span className={isOverdue ? 'text-red-500 font-semibold' : 'text-orange-500'}>
|
||||||
|
{isOverdue ? t('learning_goal.overdue') : `${daysLeft} ${t('learning_goal.days_left')}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="rounded-lg min-h-[44px] min-w-[44px]"
|
||||||
|
onClick={() => {
|
||||||
|
setLgEditId(goal.id);
|
||||||
|
setLgForm({
|
||||||
|
title: goal.title,
|
||||||
|
description: goal.description ?? '',
|
||||||
|
competencyId: goal.competencyId ?? '',
|
||||||
|
targetLevel: goal.targetLevel ?? 4,
|
||||||
|
currentLevel: goal.currentLevel ?? 1,
|
||||||
|
deadline: goal.deadline ? new Date(goal.deadline).toISOString().split('T')[0] : '',
|
||||||
|
status: goal.status,
|
||||||
|
progress: goal.progress,
|
||||||
|
});
|
||||||
|
setLgDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
{goal.status === 'active' && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="rounded-lg text-emerald-600 hover:text-emerald-700 min-h-[44px] min-w-[44px]"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await apiPut(`/api/learning-goals/${goal.id}`, { status: 'completed', progress: 100 });
|
||||||
|
setLearningGoals((prev) => prev.map((g) => g.id === goal.id ? { ...g, status: 'completed', progress: 100 } : g));
|
||||||
|
setGoalCelebration(goal.id);
|
||||||
|
setTimeout(() => setGoalCelebration(null), 3000);
|
||||||
|
toast.success(t('learning_goal.celebration'));
|
||||||
|
} catch { toast.error(t('error.generic')); }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckCircle2 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/learning-goals/${goal.id}`);
|
||||||
|
setLearningGoals((prev) => prev.filter((g) => g.id !== goal.id));
|
||||||
|
toast.success(t('action.delete'));
|
||||||
|
} catch { toast.error(t('error.generic')); }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Celebration overlay */}
|
||||||
|
{goalCelebration === goal.id && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="mt-2 p-2 rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 text-xs font-bold text-center"
|
||||||
|
>
|
||||||
|
{t('learning_goal.celebration')}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ─── Self-Assessment Dialog ───────────────────────────────── */}
|
||||||
|
<Dialog open={saDialogOpen} onOpenChange={setSaDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{saEditId ? t('self_assessment.edit') : t('self_assessment.create')}</DialogTitle>
|
||||||
|
<DialogDescription>{t('self_assessment.title')}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-medium">{t('self_assessment.competency')}</Label>
|
||||||
|
<Select value={saForm.competencyId} onValueChange={(v) => setSaForm((f) => ({ ...f, competencyId: v }))}>
|
||||||
|
<SelectTrigger className="h-10 rounded-lg mt-1">
|
||||||
|
<SelectValue placeholder={t('self_assessment.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('self_assessment.self_level')} ({saForm.selfLevel}/6)</Label>
|
||||||
|
<Slider
|
||||||
|
value={[saForm.selfLevel]}
|
||||||
|
onValueChange={([v]) => setSaForm((f) => ({ ...f, selfLevel: 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('self_assessment.confidence')} ({saForm.confidence}/5)</Label>
|
||||||
|
<Slider
|
||||||
|
value={[saForm.confidence]}
|
||||||
|
onValueChange={([v]) => setSaForm((f) => ({ ...f, confidence: v }))}
|
||||||
|
min={1}
|
||||||
|
max={5}
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-medium">{t('self_assessment.reflection')}</Label>
|
||||||
|
<Textarea
|
||||||
|
value={saForm.reflection}
|
||||||
|
onChange={(e) => setSaForm((f) => ({ ...f, reflection: e.target.value }))}
|
||||||
|
className="mt-1 rounded-lg"
|
||||||
|
rows={3}
|
||||||
|
placeholder={t('self_assessment.reflection')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-medium">{t('self_assessment.evidence')}</Label>
|
||||||
|
<Textarea
|
||||||
|
value={saForm.evidence}
|
||||||
|
onChange={(e) => setSaForm((f) => ({ ...f, evidence: e.target.value }))}
|
||||||
|
className="mt-1 rounded-lg"
|
||||||
|
rows={2}
|
||||||
|
placeholder={t('self_assessment.evidence')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{learningGoals.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-medium">{t('learning_goal.title')}</Label>
|
||||||
|
<Select value={saForm.goalId} onValueChange={(v) => setSaForm((f) => ({ ...f, goalId: v }))}>
|
||||||
|
<SelectTrigger className="h-10 rounded-lg mt-1">
|
||||||
|
<SelectValue placeholder={t('learning_goal.title')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">—</SelectItem>
|
||||||
|
{learningGoals.filter((g) => g.status === 'active').map((g) => (
|
||||||
|
<SelectItem key={g.id} value={g.id}>{g.title}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" className="rounded-xl min-h-[44px]" onClick={() => setSaDialogOpen(false)}>{t('action.cancel')}</Button>
|
||||||
|
<Button
|
||||||
|
className="rounded-xl bg-cyan-600 hover:bg-cyan-700 text-white min-h-[44px]"
|
||||||
|
onClick={async () => {
|
||||||
|
if (!saForm.competencyId) { toast.error(t('self_assessment.competency')); return; }
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
schoolId: currentUser?.schoolId,
|
||||||
|
studentId: currentStudentId,
|
||||||
|
competencyId: saForm.competencyId,
|
||||||
|
selfLevel: saForm.selfLevel,
|
||||||
|
confidence: saForm.confidence,
|
||||||
|
reflection: saForm.reflection || null,
|
||||||
|
evidence: saForm.evidence || null,
|
||||||
|
goalId: saForm.goalId && saForm.goalId !== 'none' ? saForm.goalId : null,
|
||||||
|
};
|
||||||
|
if (saEditId) {
|
||||||
|
const updated = await apiPut<SelfAssessmentData>(`/api/self-assessments/${saEditId}`, payload);
|
||||||
|
setSelfAssessments((prev) => prev.map((a) => a.id === saEditId ? updated : a));
|
||||||
|
} else {
|
||||||
|
const created = await apiPost<SelfAssessmentData>('/api/self-assessments', payload);
|
||||||
|
setSelfAssessments((prev) => [created, ...prev]);
|
||||||
|
}
|
||||||
|
setSaDialogOpen(false);
|
||||||
|
toast.success(saEditId ? t('action.save') : t('action.create'));
|
||||||
|
} catch { toast.error(t('error.generic')); }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saEditId ? t('action.save') : t('action.create')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* ─── Learning Goal Dialog ───────────────────────────────── */}
|
||||||
|
<Dialog open={lgDialogOpen} onOpenChange={setLgDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{lgEditId ? t('learning_goal.edit') : t('learning_goal.create')}</DialogTitle>
|
||||||
|
<DialogDescription>{t('learning_goal.title')}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-medium">{t('learning_goal.goal_title')}</Label>
|
||||||
|
<Input
|
||||||
|
value={lgForm.title}
|
||||||
|
onChange={(e) => setLgForm((f) => ({ ...f, title: e.target.value }))}
|
||||||
|
className="mt-1 rounded-lg"
|
||||||
|
placeholder={t('learning_goal.goal_title')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-medium">{t('learning_goal.description')}</Label>
|
||||||
|
<Textarea
|
||||||
|
value={lgForm.description}
|
||||||
|
onChange={(e) => setLgForm((f) => ({ ...f, description: e.target.value }))}
|
||||||
|
className="mt-1 rounded-lg"
|
||||||
|
rows={2}
|
||||||
|
placeholder={t('learning_goal.description')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-medium">{t('self_assessment.competency')}</Label>
|
||||||
|
<Select value={lgForm.competencyId} onValueChange={(v) => setLgForm((f) => ({ ...f, competencyId: v }))}>
|
||||||
|
<SelectTrigger className="h-10 rounded-lg mt-1">
|
||||||
|
<SelectValue placeholder={t('self_assessment.competency')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">—</SelectItem>
|
||||||
|
{data?.progressEntries.map((pe) => (
|
||||||
|
<SelectItem key={pe.competencyId} value={pe.competencyId}>
|
||||||
|
{pe.competency.code} — {pe.competency.title}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-medium">{t('learning_goal.current_level')}</Label>
|
||||||
|
<Slider
|
||||||
|
value={[lgForm.currentLevel]}
|
||||||
|
onValueChange={([v]) => setLgForm((f) => ({ ...f, currentLevel: v }))}
|
||||||
|
min={1}
|
||||||
|
max={6}
|
||||||
|
step={1}
|
||||||
|
className="mt-2"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-center mt-1 text-gray-500">{lgForm.currentLevel}/6</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-medium">{t('learning_goal.target_level')}</Label>
|
||||||
|
<Slider
|
||||||
|
value={[lgForm.targetLevel]}
|
||||||
|
onValueChange={([v]) => setLgForm((f) => ({ ...f, targetLevel: v }))}
|
||||||
|
min={1}
|
||||||
|
max={6}
|
||||||
|
step={1}
|
||||||
|
className="mt-2"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-center mt-1 text-gray-500">{lgForm.targetLevel}/6</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-medium">{t('learning_goal.deadline')}</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={lgForm.deadline}
|
||||||
|
onChange={(e) => setLgForm((f) => ({ ...f, deadline: e.target.value }))}
|
||||||
|
className="mt-1 rounded-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-medium">{t('learning_goal.progress')} ({lgForm.progress}%)</Label>
|
||||||
|
<Slider
|
||||||
|
value={[lgForm.progress]}
|
||||||
|
onValueChange={([v]) => setLgForm((f) => ({ ...f, progress: v }))}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={5}
|
||||||
|
className="mt-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{lgEditId && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-medium">{t('learning_goal.status')}</Label>
|
||||||
|
<Select value={lgForm.status} onValueChange={(v) => setLgForm((f) => ({ ...f, status: v }))}>
|
||||||
|
<SelectTrigger className="h-10 rounded-lg mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="active">{t('learning_goal.active')}</SelectItem>
|
||||||
|
<SelectItem value="completed">{t('learning_goal.completed')}</SelectItem>
|
||||||
|
<SelectItem value="abandoned">{t('learning_goal.abandoned')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" className="rounded-xl min-h-[44px]" onClick={() => setLgDialogOpen(false)}>{t('action.cancel')}</Button>
|
||||||
|
<Button
|
||||||
|
className="rounded-xl bg-orange-600 hover:bg-orange-700 text-white min-h-[44px]"
|
||||||
|
onClick={async () => {
|
||||||
|
if (!lgForm.title) { toast.error(t('learning_goal.goal_title')); return; }
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
schoolId: currentUser?.schoolId,
|
||||||
|
studentId: currentStudentId,
|
||||||
|
title: lgForm.title,
|
||||||
|
description: lgForm.description || null,
|
||||||
|
competencyId: lgForm.competencyId && lgForm.competencyId !== 'none' ? lgForm.competencyId : null,
|
||||||
|
targetLevel: lgForm.targetLevel,
|
||||||
|
currentLevel: lgForm.currentLevel,
|
||||||
|
deadline: lgForm.deadline || null,
|
||||||
|
status: lgForm.status,
|
||||||
|
progress: lgForm.progress,
|
||||||
|
};
|
||||||
|
if (lgEditId) {
|
||||||
|
const updated = await apiPut<LearningGoalData>(`/api/learning-goals/${lgEditId}`, payload);
|
||||||
|
setLearningGoals((prev) => prev.map((g) => g.id === lgEditId ? updated : g));
|
||||||
|
} else {
|
||||||
|
const created = await apiPost<LearningGoalData>('/api/learning-goals', payload);
|
||||||
|
setLearningGoals((prev) => [created, ...prev]);
|
||||||
|
}
|
||||||
|
setLgDialogOpen(false);
|
||||||
|
toast.success(lgEditId ? t('action.save') : t('action.create'));
|
||||||
|
} catch { toast.error(t('error.generic')); }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{lgEditId ? t('action.save') : t('action.create')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+356
@@ -2525,6 +2525,184 @@ const de: TranslationDict = {
|
|||||||
'rate_limit.data_limit': 'Daten-Limit: 60/Min',
|
'rate_limit.data_limit': 'Daten-Limit: 60/Min',
|
||||||
'rate_limit.write_limit': 'Schreib-Limit: 30/Min',
|
'rate_limit.write_limit': 'Schreib-Limit: 30/Min',
|
||||||
'rate_limit.heavy_limit': 'Schwer-Limit: 10/5Min',
|
'rate_limit.heavy_limit': 'Schwer-Limit: 10/5Min',
|
||||||
|
|
||||||
|
// Self-Assessment
|
||||||
|
'self_assessment.title': 'Selbsteinschätzung',
|
||||||
|
'self_assessment.self_level': 'Selbsteingeschätztes Niveau',
|
||||||
|
'self_assessment.confidence': 'Selbstvertrauen',
|
||||||
|
'self_assessment.reflection': 'Reflexion',
|
||||||
|
'self_assessment.evidence': 'Nachweise',
|
||||||
|
'self_assessment.create': 'Selbsteinschätzung erstellen',
|
||||||
|
'self_assessment.edit': 'Selbsteinschätzung bearbeiten',
|
||||||
|
'self_assessment.compare': 'Vergleich: Selbst vs. Lehrer',
|
||||||
|
'self_assessment.no_assessments': 'Noch keine Selbsteinschätzungen',
|
||||||
|
'self_assessment.level': 'Niveau',
|
||||||
|
'self_assessment.comparison_chart': 'Vergleichsdiagramm',
|
||||||
|
'self_assessment.self': 'Selbst',
|
||||||
|
'self_assessment.teacher': 'Lehrer',
|
||||||
|
'self_assessment.gap': 'Abweichung',
|
||||||
|
'self_assessment.add_new': 'Neue Selbsteinschätzung',
|
||||||
|
'self_assessment.competency': 'Kompetenz',
|
||||||
|
|
||||||
|
// Learning Goals
|
||||||
|
'learning_goal.title': 'Lernziele',
|
||||||
|
'learning_goal.create': 'Lernziel erstellen',
|
||||||
|
'learning_goal.edit': 'Lernziel bearbeiten',
|
||||||
|
'learning_goal.goal_title': 'Ziel',
|
||||||
|
'learning_goal.description': 'Beschreibung',
|
||||||
|
'learning_goal.target_level': 'Zielniveau',
|
||||||
|
'learning_goal.current_level': 'Aktuelles Niveau',
|
||||||
|
'learning_goal.deadline': 'Deadline',
|
||||||
|
'learning_goal.status': 'Status',
|
||||||
|
'learning_goal.progress': 'Fortschritt',
|
||||||
|
'learning_goal.active': 'Aktiv',
|
||||||
|
'learning_goal.completed': 'Erreicht',
|
||||||
|
'learning_goal.abandoned': 'Aufgegeben',
|
||||||
|
'learning_goal.no_goals': 'Noch keine Lernziele',
|
||||||
|
'learning_goal.my_goals': 'Meine Ziele',
|
||||||
|
'learning_goal.days_left': 'Tage verbleibend',
|
||||||
|
'learning_goal.overdue': 'Überfällig',
|
||||||
|
'learning_goal.celebration': 'Ziel erreicht! Herzlichen Glückwunsch!',
|
||||||
|
'learning_goal.add_new': 'Neues Lernziel',
|
||||||
|
|
||||||
|
// Portfolio
|
||||||
|
'portfolio.title': 'Portfolio',
|
||||||
|
'portfolio.create': 'Portfolio-Eintrag erstellen',
|
||||||
|
'portfolio.edit': 'Eintrag bearbeiten',
|
||||||
|
'portfolio.entry_title': 'Titel',
|
||||||
|
'portfolio.description': 'Beschreibung',
|
||||||
|
'portfolio.entry_type': 'Eintragsart',
|
||||||
|
'portfolio.content': 'Inhalt',
|
||||||
|
'portfolio.tags': 'Schlagwörter',
|
||||||
|
'portfolio.public': 'Öffentlich',
|
||||||
|
'portfolio.private': 'Privat',
|
||||||
|
'portfolio.no_entries': 'Noch keine Portfolio-Einträge',
|
||||||
|
'portfolio.filter_by_type': 'Nach Art filtern',
|
||||||
|
'portfolio.filter_by_tag': 'Nach Schlagwort filtern',
|
||||||
|
'portfolio.timeline': 'Zeitstrahl',
|
||||||
|
'portfolio.grid': 'Raster',
|
||||||
|
'portfolio.linked_competency': 'Verknüpfte Kompetenz',
|
||||||
|
'portfolio.artwork': 'Kunstwerk',
|
||||||
|
'portfolio.writing': 'Text',
|
||||||
|
'portfolio.project': 'Projekt',
|
||||||
|
'portfolio.presentation': 'Präsentation',
|
||||||
|
'portfolio.achievement': 'Leistung',
|
||||||
|
'portfolio.reflection': 'Reflexion',
|
||||||
|
'portfolio.all_types': 'Alle Arten',
|
||||||
|
'portfolio.add_new': 'Neuer Eintrag',
|
||||||
|
'nav.portfolio': 'Portfolio',
|
||||||
|
|
||||||
|
// Navigation - Homework
|
||||||
|
'nav.homework': 'Hausaufgaben',
|
||||||
|
|
||||||
|
// Homework
|
||||||
|
'homework.title': 'Hausaufgaben',
|
||||||
|
'homework.subtitle': 'Hausaufgaben verwalten und Abgaben verfolgen',
|
||||||
|
'homework.create': 'Hausaufgabe erstellen',
|
||||||
|
'homework.create_title': 'Neue Hausaufgabe erstellen',
|
||||||
|
'homework.create_description': 'Erstellen Sie eine neue Hausaufgabe für eine Klasse.',
|
||||||
|
'homework.field_title': 'Titel',
|
||||||
|
'homework.field_title_placeholder': 'Titel der Hausaufgabe',
|
||||||
|
'homework.field_class': 'Klasse',
|
||||||
|
'homework.field_class_placeholder': 'Klasse auswählen',
|
||||||
|
'homework.field_subject': 'Fach',
|
||||||
|
'homework.field_subject_placeholder': 'Fach auswählen',
|
||||||
|
'homework.field_type': 'Typ',
|
||||||
|
'homework.field_due_date': 'Fälligkeitsdatum',
|
||||||
|
'homework.field_max_points': 'Maximale Punkte',
|
||||||
|
'homework.field_max_points_placeholder': 'Optional',
|
||||||
|
'homework.field_description': 'Beschreibung',
|
||||||
|
'homework.field_description_placeholder': 'Beschreibung der Hausaufgabe...',
|
||||||
|
'homework.field_published': 'Veröffentlicht',
|
||||||
|
'homework.no_subject': 'Kein Fach',
|
||||||
|
'homework.total': 'Gesamt',
|
||||||
|
'homework.overdue': 'Überfällig',
|
||||||
|
'homework.upcoming': 'Anstehend',
|
||||||
|
'homework.due_today': 'Heute fällig',
|
||||||
|
'homework.due_tomorrow': 'Morgen fällig',
|
||||||
|
'homework.due_in_days': 'In {days} Tagen fällig',
|
||||||
|
'homework.overdue_days': '{days} Tage überfällig',
|
||||||
|
'homework.points': 'Punkte',
|
||||||
|
'homework.draft': 'Entwurf',
|
||||||
|
'homework.submissions': 'Abgaben',
|
||||||
|
'homework.submissions_count': 'Abgaben',
|
||||||
|
'homework.no_submissions': 'Noch keine Abgaben',
|
||||||
|
'homework.no_homework': 'Keine Hausaufgaben gefunden',
|
||||||
|
'homework.search_placeholder': 'Hausaufgaben suchen...',
|
||||||
|
'homework.filter_class': 'Klasse filtern',
|
||||||
|
'homework.filter_subject': 'Fach filtern',
|
||||||
|
'homework.filter_type': 'Typ filtern',
|
||||||
|
'homework.filter_status': 'Status filtern',
|
||||||
|
'homework.all_classes': 'Alle Klassen',
|
||||||
|
'homework.all_subjects': 'Alle Fächer',
|
||||||
|
'homework.all_types': 'Alle Typen',
|
||||||
|
'homework.all_status': 'Alle Status',
|
||||||
|
'homework.type_assignment': 'Aufgabe',
|
||||||
|
'homework.type_reading': 'Lesen',
|
||||||
|
'homework.type_project': 'Projekt',
|
||||||
|
'homework.type_practice': 'Übung',
|
||||||
|
'homework.type_research': 'Recherche',
|
||||||
|
'homework.status_pending': 'Ausstehend',
|
||||||
|
'homework.status_submitted': 'Abgegeben',
|
||||||
|
'homework.status_graded': 'Bewertet',
|
||||||
|
'homework.status_late': 'Verspätet',
|
||||||
|
'homework.grade': 'Bewerten',
|
||||||
|
'homework.regrade': 'Neu bewerten',
|
||||||
|
'homework.grade_title': 'Abgabe bewerten',
|
||||||
|
'homework.grade_success': 'Bewertung gespeichert',
|
||||||
|
'homework.grade_error': 'Fehler beim Bewerten',
|
||||||
|
'homework.save_grade': 'Bewertung speichern',
|
||||||
|
'homework.score': 'Punktzahl',
|
||||||
|
'homework.max_points': 'Max. Punkte',
|
||||||
|
'homework.feedback': 'Feedback',
|
||||||
|
'homework.feedback_placeholder': 'Feedback für den Schüler...',
|
||||||
|
'homework.saving': 'Speichern...',
|
||||||
|
'homework.creating': 'Erstellen...',
|
||||||
|
'homework.create_success': 'Hausaufgabe erstellt',
|
||||||
|
'homework.create_error': 'Fehler beim Erstellen',
|
||||||
|
'homework.update_success': 'Hausaufgabe aktualisiert',
|
||||||
|
'homework.update_error': 'Fehler beim Aktualisieren',
|
||||||
|
'homework.delete_success': 'Hausaufgabe gelöscht',
|
||||||
|
'homework.delete_error': 'Fehler beim Löschen',
|
||||||
|
'homework.confirm_delete': 'Möchten Sie diese Hausaufgabe wirklich löschen?',
|
||||||
|
'homework.edit_title': 'Hausaufgabe bearbeiten',
|
||||||
|
'homework.edit_description': 'Bearbeiten Sie die Details der Hausaufgabe.',
|
||||||
|
'homework.submit_work': 'Hausaufgabe abgeben',
|
||||||
|
'homework.submit_title': 'Hausaufgabe abgeben',
|
||||||
|
'homework.submit': 'Abgeben',
|
||||||
|
'homework.submitting': 'Abgeben...',
|
||||||
|
'homework.submit_success': 'Erfolgreich abgegeben',
|
||||||
|
'homework.submit_error': 'Fehler beim Abgeben',
|
||||||
|
'homework.your_answer': 'Ihre Antwort',
|
||||||
|
'homework.your_answer_placeholder': 'Schreiben Sie Ihre Antwort hier...',
|
||||||
|
'homework.due_soon': 'Bald fällig',
|
||||||
|
'homework.no_due_soon': 'Keine Hausaufgaben in nächster Zeit fällig',
|
||||||
|
'homework.view_all': 'Alle anzeigen',
|
||||||
|
|
||||||
|
// Announcements
|
||||||
|
'announcement.title': 'Ankündigungen',
|
||||||
|
'announcement.create': 'Ankündigung erstellen',
|
||||||
|
'announcement.create_title': 'Neue Ankündigung erstellen',
|
||||||
|
'announcement.create_description': 'Erstellen Sie eine neue Ankündigung für Ihre Schule.',
|
||||||
|
'announcement.create_success': 'Ankündigung erstellt',
|
||||||
|
'announcement.create_error': 'Fehler beim Erstellen',
|
||||||
|
'announcement.creating': 'Erstellen...',
|
||||||
|
'announcement.field_title': 'Titel',
|
||||||
|
'announcement.field_title_placeholder': 'Titel der Ankündigung',
|
||||||
|
'announcement.field_content': 'Inhalt',
|
||||||
|
'announcement.field_content_placeholder': 'Inhalt der Ankündigung...',
|
||||||
|
'announcement.field_priority': 'Priorität',
|
||||||
|
'announcement.field_target': 'Zielgruppe',
|
||||||
|
'announcement.priority_low': 'Niedrig',
|
||||||
|
'announcement.priority_normal': 'Normal',
|
||||||
|
'announcement.priority_high': 'Hoch',
|
||||||
|
'announcement.priority_urgent': 'Dringend',
|
||||||
|
'announcement.target_all': 'Alle',
|
||||||
|
'announcement.target_teachers': 'Lehrkräfte',
|
||||||
|
'announcement.target_students': 'Schüler',
|
||||||
|
'announcement.target_parents': 'Eltern',
|
||||||
|
'announcement.no_announcements': 'Keine Ankündigungen vorhanden',
|
||||||
|
'announcement.dismiss': 'Ausblenden',
|
||||||
};
|
};
|
||||||
|
|
||||||
const en: TranslationDict = {
|
const en: TranslationDict = {
|
||||||
@@ -5049,6 +5227,184 @@ const en: TranslationDict = {
|
|||||||
'rate_limit.data_limit': 'Data limit: 60/min',
|
'rate_limit.data_limit': 'Data limit: 60/min',
|
||||||
'rate_limit.write_limit': 'Write limit: 30/min',
|
'rate_limit.write_limit': 'Write limit: 30/min',
|
||||||
'rate_limit.heavy_limit': 'Heavy limit: 10/5min',
|
'rate_limit.heavy_limit': 'Heavy limit: 10/5min',
|
||||||
|
|
||||||
|
// Self-Assessment
|
||||||
|
'self_assessment.title': 'Self-Assessment',
|
||||||
|
'self_assessment.self_level': 'Self-Assessed Level',
|
||||||
|
'self_assessment.confidence': 'Confidence',
|
||||||
|
'self_assessment.reflection': 'Reflection',
|
||||||
|
'self_assessment.evidence': 'Evidence',
|
||||||
|
'self_assessment.create': 'Create Self-Assessment',
|
||||||
|
'self_assessment.edit': 'Edit Self-Assessment',
|
||||||
|
'self_assessment.compare': 'Self vs. Teacher Comparison',
|
||||||
|
'self_assessment.no_assessments': 'No self-assessments yet',
|
||||||
|
'self_assessment.level': 'Level',
|
||||||
|
'self_assessment.comparison_chart': 'Comparison Chart',
|
||||||
|
'self_assessment.self': 'Self',
|
||||||
|
'self_assessment.teacher': 'Teacher',
|
||||||
|
'self_assessment.gap': 'Gap',
|
||||||
|
'self_assessment.add_new': 'New Self-Assessment',
|
||||||
|
'self_assessment.competency': 'Competency',
|
||||||
|
|
||||||
|
// Learning Goals
|
||||||
|
'learning_goal.title': 'Learning Goals',
|
||||||
|
'learning_goal.create': 'Create Learning Goal',
|
||||||
|
'learning_goal.edit': 'Edit Learning Goal',
|
||||||
|
'learning_goal.goal_title': 'Goal',
|
||||||
|
'learning_goal.description': 'Description',
|
||||||
|
'learning_goal.target_level': 'Target Level',
|
||||||
|
'learning_goal.current_level': 'Current Level',
|
||||||
|
'learning_goal.deadline': 'Deadline',
|
||||||
|
'learning_goal.status': 'Status',
|
||||||
|
'learning_goal.progress': 'Progress',
|
||||||
|
'learning_goal.active': 'Active',
|
||||||
|
'learning_goal.completed': 'Completed',
|
||||||
|
'learning_goal.abandoned': 'Abandoned',
|
||||||
|
'learning_goal.no_goals': 'No learning goals yet',
|
||||||
|
'learning_goal.my_goals': 'My Goals',
|
||||||
|
'learning_goal.days_left': 'days left',
|
||||||
|
'learning_goal.overdue': 'Overdue',
|
||||||
|
'learning_goal.celebration': 'Goal achieved! Congratulations!',
|
||||||
|
'learning_goal.add_new': 'New Goal',
|
||||||
|
|
||||||
|
// Portfolio
|
||||||
|
'portfolio.title': 'Portfolio',
|
||||||
|
'portfolio.create': 'Create Portfolio Entry',
|
||||||
|
'portfolio.edit': 'Edit Entry',
|
||||||
|
'portfolio.entry_title': 'Title',
|
||||||
|
'portfolio.description': 'Description',
|
||||||
|
'portfolio.entry_type': 'Entry Type',
|
||||||
|
'portfolio.content': 'Content',
|
||||||
|
'portfolio.tags': 'Tags',
|
||||||
|
'portfolio.public': 'Public',
|
||||||
|
'portfolio.private': 'Private',
|
||||||
|
'portfolio.no_entries': 'No portfolio entries yet',
|
||||||
|
'portfolio.filter_by_type': 'Filter by type',
|
||||||
|
'portfolio.filter_by_tag': 'Filter by tag',
|
||||||
|
'portfolio.timeline': 'Timeline',
|
||||||
|
'portfolio.grid': 'Grid',
|
||||||
|
'portfolio.linked_competency': 'Linked Competency',
|
||||||
|
'portfolio.artwork': 'Artwork',
|
||||||
|
'portfolio.writing': 'Writing',
|
||||||
|
'portfolio.project': 'Project',
|
||||||
|
'portfolio.presentation': 'Presentation',
|
||||||
|
'portfolio.achievement': 'Achievement',
|
||||||
|
'portfolio.reflection': 'Reflection',
|
||||||
|
'portfolio.all_types': 'All Types',
|
||||||
|
'portfolio.add_new': 'New Entry',
|
||||||
|
'nav.portfolio': 'Portfolio',
|
||||||
|
|
||||||
|
// Navigation - Homework
|
||||||
|
'nav.homework': 'Homework',
|
||||||
|
|
||||||
|
// Homework
|
||||||
|
'homework.title': 'Homework',
|
||||||
|
'homework.subtitle': 'Manage homework and track submissions',
|
||||||
|
'homework.create': 'Create Homework',
|
||||||
|
'homework.create_title': 'Create New Homework',
|
||||||
|
'homework.create_description': 'Create a new homework assignment for a class.',
|
||||||
|
'homework.field_title': 'Title',
|
||||||
|
'homework.field_title_placeholder': 'Homework title',
|
||||||
|
'homework.field_class': 'Class',
|
||||||
|
'homework.field_class_placeholder': 'Select class',
|
||||||
|
'homework.field_subject': 'Subject',
|
||||||
|
'homework.field_subject_placeholder': 'Select subject',
|
||||||
|
'homework.field_type': 'Type',
|
||||||
|
'homework.field_due_date': 'Due Date',
|
||||||
|
'homework.field_max_points': 'Max Points',
|
||||||
|
'homework.field_max_points_placeholder': 'Optional',
|
||||||
|
'homework.field_description': 'Description',
|
||||||
|
'homework.field_description_placeholder': 'Homework description...',
|
||||||
|
'homework.field_published': 'Published',
|
||||||
|
'homework.no_subject': 'No Subject',
|
||||||
|
'homework.total': 'Total',
|
||||||
|
'homework.overdue': 'Overdue',
|
||||||
|
'homework.upcoming': 'Upcoming',
|
||||||
|
'homework.due_today': 'Due today',
|
||||||
|
'homework.due_tomorrow': 'Due tomorrow',
|
||||||
|
'homework.due_in_days': 'Due in {days} days',
|
||||||
|
'homework.overdue_days': '{days} days overdue',
|
||||||
|
'homework.points': 'points',
|
||||||
|
'homework.draft': 'Draft',
|
||||||
|
'homework.submissions': 'Submissions',
|
||||||
|
'homework.submissions_count': 'submissions',
|
||||||
|
'homework.no_submissions': 'No submissions yet',
|
||||||
|
'homework.no_homework': 'No homework found',
|
||||||
|
'homework.search_placeholder': 'Search homework...',
|
||||||
|
'homework.filter_class': 'Filter class',
|
||||||
|
'homework.filter_subject': 'Filter subject',
|
||||||
|
'homework.filter_type': 'Filter type',
|
||||||
|
'homework.filter_status': 'Filter status',
|
||||||
|
'homework.all_classes': 'All Classes',
|
||||||
|
'homework.all_subjects': 'All Subjects',
|
||||||
|
'homework.all_types': 'All Types',
|
||||||
|
'homework.all_status': 'All Status',
|
||||||
|
'homework.type_assignment': 'Assignment',
|
||||||
|
'homework.type_reading': 'Reading',
|
||||||
|
'homework.type_project': 'Project',
|
||||||
|
'homework.type_practice': 'Practice',
|
||||||
|
'homework.type_research': 'Research',
|
||||||
|
'homework.status_pending': 'Pending',
|
||||||
|
'homework.status_submitted': 'Submitted',
|
||||||
|
'homework.status_graded': 'Graded',
|
||||||
|
'homework.status_late': 'Late',
|
||||||
|
'homework.grade': 'Grade',
|
||||||
|
'homework.regrade': 'Re-grade',
|
||||||
|
'homework.grade_title': 'Grade Submission',
|
||||||
|
'homework.grade_success': 'Grade saved',
|
||||||
|
'homework.grade_error': 'Error grading submission',
|
||||||
|
'homework.save_grade': 'Save Grade',
|
||||||
|
'homework.score': 'Score',
|
||||||
|
'homework.max_points': 'Max Points',
|
||||||
|
'homework.feedback': 'Feedback',
|
||||||
|
'homework.feedback_placeholder': 'Feedback for the student...',
|
||||||
|
'homework.saving': 'Saving...',
|
||||||
|
'homework.creating': 'Creating...',
|
||||||
|
'homework.create_success': 'Homework created',
|
||||||
|
'homework.create_error': 'Error creating homework',
|
||||||
|
'homework.update_success': 'Homework updated',
|
||||||
|
'homework.update_error': 'Error updating homework',
|
||||||
|
'homework.delete_success': 'Homework deleted',
|
||||||
|
'homework.delete_error': 'Error deleting homework',
|
||||||
|
'homework.confirm_delete': 'Are you sure you want to delete this homework?',
|
||||||
|
'homework.edit_title': 'Edit Homework',
|
||||||
|
'homework.edit_description': 'Edit the details of the homework.',
|
||||||
|
'homework.submit_work': 'Submit Homework',
|
||||||
|
'homework.submit_title': 'Submit Homework',
|
||||||
|
'homework.submit': 'Submit',
|
||||||
|
'homework.submitting': 'Submitting...',
|
||||||
|
'homework.submit_success': 'Successfully submitted',
|
||||||
|
'homework.submit_error': 'Error submitting',
|
||||||
|
'homework.your_answer': 'Your Answer',
|
||||||
|
'homework.your_answer_placeholder': 'Write your answer here...',
|
||||||
|
'homework.due_soon': 'Due Soon',
|
||||||
|
'homework.no_due_soon': 'No homework due soon',
|
||||||
|
'homework.view_all': 'View All',
|
||||||
|
|
||||||
|
// Announcements
|
||||||
|
'announcement.title': 'Announcements',
|
||||||
|
'announcement.create': 'Create Announcement',
|
||||||
|
'announcement.create_title': 'Create New Announcement',
|
||||||
|
'announcement.create_description': 'Create a new announcement for your school.',
|
||||||
|
'announcement.create_success': 'Announcement created',
|
||||||
|
'announcement.create_error': 'Error creating announcement',
|
||||||
|
'announcement.creating': 'Creating...',
|
||||||
|
'announcement.field_title': 'Title',
|
||||||
|
'announcement.field_title_placeholder': 'Announcement title',
|
||||||
|
'announcement.field_content': 'Content',
|
||||||
|
'announcement.field_content_placeholder': 'Announcement content...',
|
||||||
|
'announcement.field_priority': 'Priority',
|
||||||
|
'announcement.field_target': 'Target Audience',
|
||||||
|
'announcement.priority_low': 'Low',
|
||||||
|
'announcement.priority_normal': 'Normal',
|
||||||
|
'announcement.priority_high': 'High',
|
||||||
|
'announcement.priority_urgent': 'Urgent',
|
||||||
|
'announcement.target_all': 'All',
|
||||||
|
'announcement.target_teachers': 'Teachers',
|
||||||
|
'announcement.target_students': 'Students',
|
||||||
|
'announcement.target_parents': 'Parents',
|
||||||
|
'announcement.no_announcements': 'No announcements available',
|
||||||
|
'announcement.dismiss': 'Dismiss',
|
||||||
};
|
};
|
||||||
|
|
||||||
const translations: Record<string, TranslationDict> = { de, en };
|
const translations: Record<string, TranslationDict> = { de, en };
|
||||||
|
|||||||
+3
-1
@@ -24,7 +24,9 @@ export type ViewName =
|
|||||||
| 'rubrics'
|
| 'rubrics'
|
||||||
| 'comments'
|
| 'comments'
|
||||||
| 'notebooks'
|
| 'notebooks'
|
||||||
| 'drawing';
|
| 'drawing'
|
||||||
|
| 'homework'
|
||||||
|
| 'portfolio';
|
||||||
|
|
||||||
export interface CurrentUser {
|
export interface CurrentUser {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
+120
@@ -1,5 +1,51 @@
|
|||||||
# CompetenceTrack — Project Worklog
|
# CompetenceTrack — Project Worklog
|
||||||
|
|
||||||
|
---
|
||||||
|
Task ID: 15
|
||||||
|
Agent: round14-self-assessment-learning-goals-portfolio-homework-announcements
|
||||||
|
Task: Self-Assessment, Learning Goals, Portfolio, Homework, Announcements
|
||||||
|
|
||||||
|
Work Log:
|
||||||
|
- Verified 5 new Prisma models: SelfAssessment, LearningGoal, PortfolioEntry, Homework, HomeworkSubmission, Announcement
|
||||||
|
- SelfAssessment: schoolId, studentId, competencyId, classGroupId, selfLevel (1-6), confidence (1-5), reflection, evidence, goalId
|
||||||
|
- LearningGoal: schoolId, studentId, competencyId, title, description, targetLevel, currentLevel, deadline, status (active/completed/abandoned), progress (0-100)
|
||||||
|
- PortfolioEntry: schoolId, studentId, title, description, entryType (artwork/writing/project/presentation/achievement/reflection), competencyId, content, mediaUrls, notebookPageId, drawingId, isPublic, tags
|
||||||
|
- Homework: schoolId, classGroupId, subjectId, teacherId, title, description, dueDate, homeworkType (assignment/reading/project/practice/research), maxPoints, attachments, isPublished
|
||||||
|
- HomeworkSubmission: homeworkId, studentId, content, attachments, status (pending/submitted/graded/late), score, feedback, submittedAt, gradedAt
|
||||||
|
- Announcement: schoolId, authorId, title, content, priority (low/normal/high/urgent), targetAudience (all/teachers/students/parents/class), classGroupId, isPinned, expiresAt
|
||||||
|
- Added relations to School, User, ClassGroup, Student, Subject, Competency models
|
||||||
|
- Ran db:push: schema already in sync
|
||||||
|
- New API routes:
|
||||||
|
- Self-Assessments: GET/POST /api/self-assessments, GET/PUT/DELETE /api/self-assessments/[id]
|
||||||
|
- Learning Goals: GET/POST /api/learning-goals, GET/PUT/DELETE /api/learning-goals/[id]
|
||||||
|
- Portfolio: GET/POST /api/portfolio, GET/PUT/DELETE /api/portfolio/[id]
|
||||||
|
- Homework: GET/POST /api/homework, GET/PUT/DELETE /api/homework/[id]
|
||||||
|
- Homework Submissions: GET/POST /api/homework/[id]/submissions, GET/PUT/DELETE /api/homework/[id]/submissions/[submissionId]
|
||||||
|
- Announcements: GET/POST /api/announcements, GET/PUT/DELETE /api/announcements/[id]
|
||||||
|
- New views: homework-view.tsx, portfolio-view.tsx
|
||||||
|
- New navigation items: homework (BookCheck icon), portfolio (Briefcase icon)
|
||||||
|
- Added ViewName types: 'homework', 'portfolio'
|
||||||
|
- Announcement Banner component in app-layout.tsx with priority colors, dismiss functionality, localStorage persistence
|
||||||
|
- Added student nav items: homework, portfolio
|
||||||
|
- Enhanced dashboard-view.tsx with self-assessment, learning goals, homework, and announcements widgets
|
||||||
|
- Enhanced student-detail-view.tsx with self-assessment, learning goals, portfolio tabs
|
||||||
|
- 170+ new i18n keys for DE and EN (self-assessment, learning-goals, portfolio, homework, announcements)
|
||||||
|
- Ran bun run lint: 0 errors
|
||||||
|
- Ran bun run db:push: schema already in sync
|
||||||
|
- Checked dev server log: server running, no errors
|
||||||
|
- Checked for duplicate i18n keys: 2491 keys in DE, 2491 keys in EN, no duplicates found
|
||||||
|
|
||||||
|
Stage Summary:
|
||||||
|
- All 5 new Prisma models (SelfAssessment, LearningGoal, PortfolioEntry, Homework, HomeworkSubmission, Announcement) created and synced
|
||||||
|
- All 6 API route groups created with full CRUD operations
|
||||||
|
- 2 new views created: homework-view.tsx, portfolio-view.tsx
|
||||||
|
- Announcement Banner integrated into app-layout.tsx
|
||||||
|
- Dashboard enhanced with new widgets
|
||||||
|
- Student detail view enhanced with self-assessment and learning goals tabs
|
||||||
|
- 170+ new i18n keys added (no duplicates)
|
||||||
|
- Current status: Round 14 complete
|
||||||
|
- Next phase priorities: Parent portal, mobile responsiveness, performance optimization, data export/import
|
||||||
|
|
||||||
---
|
---
|
||||||
Task ID: 14
|
Task ID: 14
|
||||||
Agent: reports-scheduling-multi-school
|
Agent: reports-scheduling-multi-school
|
||||||
@@ -569,3 +615,77 @@ Stage Summary:
|
|||||||
- API rate limiting with configurable presets protecting auth, data, and heavy operation endpoints
|
- API rate limiting with configurable presets protecting auth, data, and heavy operation endpoints
|
||||||
- Rate limit admin monitoring in settings view
|
- Rate limit admin monitoring in settings view
|
||||||
- 8 new files, 11 modified files, 0 lint errors
|
- 8 new files, 11 modified files, 0 lint errors
|
||||||
|
|
||||||
|
---
|
||||||
|
Task ID: 16
|
||||||
|
Agent: homework-announcements
|
||||||
|
Task: Homework Management + Announcements + Styling
|
||||||
|
|
||||||
|
Work Log:
|
||||||
|
- Added Homework and HomeworkSubmission Prisma models with all required fields and relations
|
||||||
|
- Added Announcement Prisma model with priority, targetAudience, isPinned, expiresAt fields
|
||||||
|
- Added relations to School (homeworks, announcements), User (homeworks, announcements), ClassGroup (homeworks, announcements), Subject (homeworks), Student (homeworkSubmissions) models
|
||||||
|
- Ran db:push successfully to sync database schema
|
||||||
|
- Created Homework API routes: GET/POST /api/homework, GET/PUT/DELETE /api/homework/[id]
|
||||||
|
- Created Homework Submission API routes: GET/POST /api/homework/[id]/submissions, PUT/DELETE /api/homework/[id]/submissions/[submissionId]
|
||||||
|
- Created Announcement API routes: GET/POST /api/announcements, GET/PUT/DELETE /api/announcements/[id]
|
||||||
|
- Added 'homework' to ViewName type in store.ts
|
||||||
|
- Added Homework navigation item (BookCheck icon) in teacher, student, and parent nav sections of app-layout.tsx
|
||||||
|
- Added HomeworkView import and renderView case in app-layout.tsx
|
||||||
|
- Created homework-view.tsx with full teacher view (create, edit, delete, submissions, grading) and student view (submit, view feedback)
|
||||||
|
- Implemented color-coded due date badges (overdue=rose, today=amber, upcoming=emerald)
|
||||||
|
- Implemented homework type badges with icons (FileText, BookOpen, Lightbulb, FlaskConical, Search)
|
||||||
|
- Implemented submission status tracking (pending, submitted, graded, late) with colored badges
|
||||||
|
- Implemented quick grading interface with slider for score and feedback textarea
|
||||||
|
- Added announcement banner component (AnnouncementBanner) in app-layout.tsx
|
||||||
|
- Banner shows pinned/urgent announcements at top with dismiss button (localStorage remembered)
|
||||||
|
- Priority-based banner colors (urgent=rose, high=amber, normal=emerald, low=teal)
|
||||||
|
- Dismiss animation with framer-motion (slide up + fade out)
|
||||||
|
- Added DashboardAnnouncementsCard component in dashboard-view.tsx
|
||||||
|
- Announcements card with expand/collapse for long content, priority indicators, pin icon
|
||||||
|
- Create announcement dialog for admin/teacher roles
|
||||||
|
- Added DashboardHomeworkCard component in dashboard-view.tsx
|
||||||
|
- Homework due soon card showing upcoming homework within 7 days
|
||||||
|
- Both cards link to homework view for full details
|
||||||
|
- Added 110+ i18n keys for homework and announcements (DE + EN) in i18n.ts
|
||||||
|
- Added global CSS classes: .announcement-banner, .homework-card, .priority-urgent/high/normal/low, .due-overdue/today/upcoming
|
||||||
|
- All components use 44px minimum touch targets for tablet
|
||||||
|
- ESLint passes with 0 errors
|
||||||
|
|
||||||
|
Stage Summary:
|
||||||
|
- Full homework management CRUD with teacher/student views
|
||||||
|
- Homework submission workflow with grading interface
|
||||||
|
- Announcement system with priority-based banner and dashboard card
|
||||||
|
- 8 new API route files, 1 new view component, 2 new dashboard sub-components
|
||||||
|
- 110+ i18n keys added for DE and EN
|
||||||
|
- Global CSS classes for homework and announcement styling
|
||||||
|
- 0 lint errors
|
||||||
|
|
||||||
|
---
|
||||||
|
Task ID: 15
|
||||||
|
Agent: self-assessment-portfolio
|
||||||
|
Task: Student Self-Assessment + Portfolio System + Learning Goals
|
||||||
|
|
||||||
|
Work Log:
|
||||||
|
- Added SelfAssessment, LearningGoal, PortfolioEntry Prisma models to schema.prisma
|
||||||
|
- Added relations to School (selfAssessments, learningGoals, portfolioEntries), Student (selfAssessments, learningGoals, portfolioEntries), Competency (selfAssessments, learningGoals, portfolioEntries), ClassGroup (selfAssessments) models
|
||||||
|
- Ran db:push successfully - all three new tables created
|
||||||
|
- Created Self-Assessment API routes: GET/POST /api/self-assessments, GET/PUT/DELETE /api/self-assessments/[id]
|
||||||
|
- Created Learning Goals API routes: GET/POST /api/learning-goals, GET/PUT/DELETE /api/learning-goals/[id]
|
||||||
|
- Created Portfolio API routes: GET/POST /api/portfolio, GET/PUT/DELETE /api/portfolio/[id]
|
||||||
|
- Added 60+ i18n keys for both DE and EN covering self-assessment, learning goals, and portfolio
|
||||||
|
- Added Self-Assessment UI section in student-detail-view.tsx: list view, radar comparison chart (self vs teacher), competency selector, slider for mastery level (1-6), confidence selector (1-5), reflection/evidence text areas, gap analysis badges, create/edit/delete dialogs
|
||||||
|
- Added Learning Goals UI section in student-detail-view.tsx: goal cards with progress bars, status tracking (active/completed/abandoned), deadline countdown, celebration animation on completion, create/edit/delete dialogs, competency linking
|
||||||
|
- Created portfolio-view.tsx component: grid view with entry cards, timeline view, type-based filtering (artwork/writing/project/presentation/achievement/reflection), tag filtering, search, public/private toggle, create/edit/delete dialogs
|
||||||
|
- Added 'portfolio' to ViewName type in store.ts
|
||||||
|
- Added Portfolio navigation in app-layout.tsx: Briefcase icon in Teaching section, student nav section, renderView case
|
||||||
|
- Added PortfolioView import in app-layout.tsx
|
||||||
|
- Ran bun run lint: no errors
|
||||||
|
|
||||||
|
Stage Summary:
|
||||||
|
- Three new Prisma models (SelfAssessment, LearningGoal, PortfolioEntry) with full CRUD API routes
|
||||||
|
- Self-Assessment section in student-detail-view with radar chart comparison, gap analysis, dialog CRUD
|
||||||
|
- Learning Goals section in student-detail-view with progress tracking, deadline countdown, celebration animation
|
||||||
|
- Portfolio view as standalone page with grid/timeline views, type/tag filtering, public/private toggle
|
||||||
|
- Portfolio nav item with Briefcase icon added to Teaching section and Student nav
|
||||||
|
- 60+ new i18n keys in both DE and EN
|
||||||
Reference in new issue
Block a user