Round 22: Exam Calendar, Tablet Grading, Student Achievements, Notification Center, Announcements, Styling

- Exam Calendar View: Teacher exam planning with calendar integration
  - Teachers create/modify exams, admin full CRUD, students read-only
  - CalendarEvent integration, 2-week warning, AI test availability badge
  - Monthly/weekly calendar view, list view, statistics

- Tablet Grading View: Stylus annotation and AI grading audit
  - HTML5 Canvas with PointerEvent pressure sensitivity
  - Tool palette: pen, highlighter, eraser, text, stamp
  - Quick grade buttons (German 1-6 scale)
  - AI grading audit with detailed feedback and suggestions

- Student Achievements View: Badges, XP, leaderboard
  - Badge collection grid with category filters
  - Level/XP system with progress bars
  - Leaderboard with opt-in
  - Daily/weekly challenges

- Student Study Planner View: Pomodoro timer, weekly schedule
  - Pomodoro timer with focus score
  - Weekly study schedule with drag-and-drop
  - AI study recommendations
  - Session history and statistics

- Notification Center View: Categories, filters, bulk actions
  - 6 categories, 4 priority levels
  - Real-time polling (10 seconds)
  - Preferences with quiet hours
  - Statistics charts

- School Announcements View: CRUD, read receipts
  - 6 announcement types with gradient headers
  - Pin-to-top, read receipts, target audience
  - Admin/teacher create, students/parents view

- Bug fixes: AnnouncementRead model user relation, User API schoolId field
- i18n: 200+ new keys, breadcrumb navigation fixes
- Styling: Gradient headers, animations, hover effects across all new views
This commit is contained in:
Mustafa Cetin committed 2026-07-29 22:56:40 +00:00
1 parent 9e22214470
commit 490bd0650f
30 files changed
+10844 -31

No files matched your search

@@ -0,0 +1,69 @@
// CompetenceTrack — Announcement Read Receipt API
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getSession();
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
// Verify announcement exists
const announcement = await db.announcement.findUnique({
where: { id, deletedAt: null },
});
if (!announcement) {
return NextResponse.json({ error: 'Announcement not found' }, { status: 404 });
}
// Upsert read receipt
const readReceipt = await db.announcementRead.upsert({
where: {
announcementId_userId: {
announcementId: id,
userId: session.user.id,
},
},
update: {
readAt: new Date(),
},
create: {
announcementId: id,
userId: session.user.id,
},
});
return NextResponse.json({ readReceipt });
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getSession();
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
// Get all read receipts for this announcement
const reads = await db.announcementRead.findMany({
where: { announcementId: id },
include: {
user: {
select: { id: true, firstName: true, lastName: true },
},
},
orderBy: { readAt: 'desc' },
});
return NextResponse.json({ reads, totalReads: reads.length });
}
+25 -2
View File
@@ -5,11 +5,13 @@ import { getSession } from '@/lib/auth';
const priorityEnum = z.enum(['low', 'normal', 'high', 'urgent']);
const targetAudienceEnum = z.enum(['all', 'teachers', 'students', 'parents', 'class']);
const announcementTypeEnum = z.enum(['general', 'urgent', 'event', 'holiday', 'exam', 'deadline']);
const updateAnnouncementSchema = z.object({
title: z.string().min(1).max(200).optional(),
content: z.string().min(1).max(10000).optional(),
priority: priorityEnum.optional(),
announcementType: announcementTypeEnum.optional(),
targetAudience: targetAudienceEnum.optional(),
classGroupId: z.string().optional().nullable(),
isPinned: z.boolean().optional(),
@@ -17,7 +19,7 @@ const updateAnnouncementSchema = z.object({
});
function isTeacherOrAdmin(role: string | undefined): boolean {
return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN';
return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN' || role === 'VICE_PRINCIPAL';
}
export async function GET(
@@ -36,6 +38,12 @@ export async function GET(
include: {
author: { select: { id: true, firstName: true, lastName: true } },
classGroup: { select: { id: true, name: true } },
reads: {
include: {
user: { select: { id: true, firstName: true, lastName: true } },
},
orderBy: { readAt: 'desc' },
},
},
});
@@ -43,7 +51,21 @@ export async function GET(
return NextResponse.json({ error: 'Announcement not found' }, { status: 404 });
}
return NextResponse.json(announcement);
// Check if current user has read it
const readReceipt = await db.announcementRead.findUnique({
where: {
announcementId_userId: {
announcementId: id,
userId: session.userId,
},
},
});
return NextResponse.json({
...announcement,
isReadByCurrentUser: !!readReceipt,
totalReads: announcement.reads.length,
});
} catch (error) {
console.error('Announcement GET [id] error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
@@ -87,6 +109,7 @@ export async function PUT(
if (parsed.data.title !== undefined) updateData.title = parsed.data.title;
if (parsed.data.content !== undefined) updateData.content = parsed.data.content;
if (parsed.data.priority !== undefined) updateData.priority = parsed.data.priority;
if (parsed.data.announcementType !== undefined) updateData.announcementType = parsed.data.announcementType;
if (parsed.data.targetAudience !== undefined) updateData.targetAudience = parsed.data.targetAudience;
if (parsed.data.classGroupId !== undefined) updateData.classGroupId = parsed.data.classGroupId || null;
if (parsed.data.isPinned !== undefined) updateData.isPinned = parsed.data.isPinned;
+51 -3
View File
@@ -5,12 +5,14 @@ import { getSession } from '@/lib/auth';
const priorityEnum = z.enum(['low', 'normal', 'high', 'urgent']);
const targetAudienceEnum = z.enum(['all', 'teachers', 'students', 'parents', 'class']);
const announcementTypeEnum = z.enum(['general', 'urgent', 'event', 'holiday', 'exam', 'deadline']);
const createAnnouncementSchema = z.object({
schoolId: z.string().min(1),
title: z.string().min(1).max(200),
content: z.string().min(1).max(10000),
priority: priorityEnum.optional().default('normal'),
announcementType: announcementTypeEnum.optional().default('general'),
targetAudience: targetAudienceEnum.optional().default('all'),
classGroupId: z.string().optional().nullable(),
isPinned: z.boolean().optional().default(false),
@@ -18,7 +20,7 @@ const createAnnouncementSchema = z.object({
});
function isTeacherOrAdmin(role: string | undefined): boolean {
return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN';
return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN' || role === 'VICE_PRINCIPAL';
}
export async function GET(request: Request) {
@@ -34,10 +36,12 @@ export async function GET(request: Request) {
const targetAudience = searchParams.get('targetAudience');
const classGroupId = searchParams.get('classGroupId');
const isPinned = searchParams.get('isPinned');
const announcementType = searchParams.get('announcementType');
const limit = parseInt(searchParams.get('limit') || '50', 10);
const includeReads = searchParams.get('includeReads') === 'true';
let schoolId: string | undefined;
if (session.user?.role === 'SCHOOL_ADMIN') {
if (session.user?.role === 'SCHOOL_ADMIN' || session.user?.role === 'VICE_PRINCIPAL') {
schoolId = session.user.schoolId ?? undefined;
} else {
schoolId = schoolIdParam ?? session.user?.schoolId ?? undefined;
@@ -59,6 +63,7 @@ export async function GET(request: Request) {
if (targetAudience && targetAudience !== 'all') where.targetAudience = targetAudience;
if (classGroupId && classGroupId !== 'all') where.classGroupId = classGroupId;
if (isPinned === 'true') where.isPinned = true;
if (announcementType && announcementType !== 'all') where.announcementType = announcementType;
// Filter by role-based target audience
if (session.user?.role === 'STUDENT') {
@@ -83,10 +88,52 @@ export async function GET(request: Request) {
include: {
author: { select: { id: true, firstName: true, lastName: true } },
classGroup: { select: { id: true, name: true } },
...(includeReads ? {
reads: {
include: {
user: { select: { id: true, firstName: true, lastName: true } },
},
orderBy: { readAt: 'desc' as const },
},
} : {}),
},
});
return NextResponse.json(announcements);
// Add read status for the current user
const currentUserId = session.userId;
const announcementsWithReadStatus = await Promise.all(
announcements.map(async (a) => {
if (!includeReads) {
const readReceipt = await db.announcementRead.findUnique({
where: {
announcementId_userId: {
announcementId: a.id,
userId: currentUserId,
},
},
});
return {
...a,
isReadByCurrentUser: !!readReceipt,
};
}
const readReceipt = await db.announcementRead.findUnique({
where: {
announcementId_userId: {
announcementId: a.id,
userId: currentUserId,
},
},
});
return {
...a,
isReadByCurrentUser: !!readReceipt,
totalReads: a.reads?.length ?? 0,
};
})
);
return NextResponse.json(announcementsWithReadStatus);
} catch (error) {
console.error('Announcements GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
@@ -138,6 +185,7 @@ export async function POST(request: Request) {
title: rest.title,
content: rest.content,
priority: rest.priority,
announcementType: rest.announcementType,
targetAudience: rest.targetAudience,
classGroupId: classGroupId || null,
isPinned: rest.isPinned,