Round 10: WYSIWYG Editor, Notification System, Calendar Events, Student UI, Drag-Reorder, Styling
This commit is contained in:
1 parent
62f4a150e0
commit
b90ad85b91
18 files changed
+3105
-626
No files matched your search
@@ -259,7 +259,7 @@ export async function GET(request: Request) {
|
||||
let enrollments: Array<{
|
||||
studentId: string;
|
||||
classGroupId: string;
|
||||
student: { id: string; firstName: string; lastName: string; deletedAt: string | null } | null;
|
||||
student: { id: string; firstName: string; lastName: string; deletedAt: Date | null } | null;
|
||||
classGroup: { id: string; name: string } | null;
|
||||
}> = [];
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const { title, date, startTime, endTime, eventType, subjectId, classGroupId, notes, allDay } = body;
|
||||
|
||||
const existing = await db.calendarEvent.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Event not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const event = await db.calendarEvent.update({
|
||||
where: { id },
|
||||
data: {
|
||||
title: title ?? existing.title,
|
||||
date: date ? new Date(date) : existing.date,
|
||||
startTime: startTime !== undefined ? startTime : existing.startTime,
|
||||
endTime: endTime !== undefined ? endTime : existing.endTime,
|
||||
eventType: eventType ?? existing.eventType,
|
||||
subjectId: subjectId !== undefined ? subjectId : existing.subjectId,
|
||||
classGroupId: classGroupId !== undefined ? classGroupId : existing.classGroupId,
|
||||
notes: notes !== undefined ? notes : existing.notes,
|
||||
allDay: allDay !== undefined ? allDay : existing.allDay,
|
||||
},
|
||||
include: {
|
||||
subject: { select: { id: true, name: true } },
|
||||
classGroup: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(event);
|
||||
} catch (error) {
|
||||
console.error('CalendarEvent PUT error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const existing = await db.calendarEvent.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Event not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
await db.calendarEvent.delete({ where: { id } });
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('CalendarEvent DELETE error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
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 { title, date, startTime, endTime, eventType, subjectId, classGroupId, notes, allDay, schoolId } = body;
|
||||
|
||||
if (!title || !date || !schoolId) {
|
||||
return NextResponse.json({ error: 'title, date, and schoolId are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const event = await db.calendarEvent.create({
|
||||
data: {
|
||||
schoolId,
|
||||
teacherId: session.user?.id ?? '',
|
||||
title,
|
||||
date: new Date(date),
|
||||
startTime: startTime || null,
|
||||
endTime: endTime || null,
|
||||
eventType: eventType || 'reminder',
|
||||
subjectId: subjectId || null,
|
||||
classGroupId: classGroupId || null,
|
||||
notes: notes || null,
|
||||
allDay: allDay ?? false,
|
||||
},
|
||||
include: {
|
||||
subject: { select: { id: true, name: true } },
|
||||
classGroup: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(event, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('CalendarEvent POST error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const schoolId = searchParams.get('schoolId') ?? session.user?.schoolId ?? undefined;
|
||||
const monthParam = searchParams.get('month'); // YYYY-MM
|
||||
|
||||
if (!schoolId) {
|
||||
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
let year: number;
|
||||
let monthIndex: number;
|
||||
if (monthParam && /^\d{4}-\d{2}$/.test(monthParam)) {
|
||||
const [yStr, mStr] = monthParam.split('-');
|
||||
year = parseInt(yStr, 10);
|
||||
monthIndex = parseInt(mStr, 10) - 1;
|
||||
} else {
|
||||
const now = new Date();
|
||||
year = now.getUTCFullYear();
|
||||
monthIndex = now.getUTCMonth();
|
||||
}
|
||||
|
||||
const startOfMonth = new Date(Date.UTC(year, monthIndex, 1, 0, 0, 0, 0));
|
||||
const endOfMonth = new Date(Date.UTC(year, monthIndex + 1, 0, 23, 59, 59, 999));
|
||||
|
||||
const events = await db.calendarEvent.findMany({
|
||||
where: {
|
||||
schoolId,
|
||||
date: { gte: startOfMonth, lte: endOfMonth },
|
||||
},
|
||||
include: {
|
||||
subject: { select: { id: true, name: true } },
|
||||
classGroup: { select: { id: true, name: true } },
|
||||
},
|
||||
orderBy: { date: 'asc' },
|
||||
});
|
||||
|
||||
return NextResponse.json(events);
|
||||
} catch (error) {
|
||||
console.error('CalendarEvent GET error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -294,6 +294,54 @@ export async function GET(request: Request) {
|
||||
console.info('Calendar: LessonPlan not available yet, skipping lessons.');
|
||||
}
|
||||
|
||||
/* ── Custom Calendar Events ───────────────────────────────────── */
|
||||
try {
|
||||
const customEvents = await db.calendarEvent.findMany({
|
||||
where: {
|
||||
schoolId,
|
||||
date: dateRange,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
date: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
eventType: true,
|
||||
allDay: true,
|
||||
notes: true,
|
||||
subject: { select: { id: true, name: true } },
|
||||
classGroup: { select: { id: true, name: true } },
|
||||
},
|
||||
orderBy: { date: 'asc' },
|
||||
});
|
||||
|
||||
for (const ce of customEvents) {
|
||||
// Map custom event types to CalendarEventType
|
||||
const mappedType: EventType = ['assessment', 'lesson'].includes(ce.eventType)
|
||||
? (ce.eventType as EventType)
|
||||
: 'lesson';
|
||||
events.push({
|
||||
date: toDayKey(ce.date),
|
||||
type: mappedType,
|
||||
id: ce.id,
|
||||
title: ce.title,
|
||||
meta: {
|
||||
customEvent: true,
|
||||
eventType: ce.eventType,
|
||||
startTime: ce.startTime ?? null,
|
||||
endTime: ce.endTime ?? null,
|
||||
allDay: ce.allDay,
|
||||
notes: ce.notes ?? null,
|
||||
subject: ce.subject?.name ?? null,
|
||||
classGroup: ce.classGroup?.name ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Calendar: custom events query failed:', err);
|
||||
}
|
||||
|
||||
// Stable ordering: by date asc, then by type for deterministic display
|
||||
const typeOrder: Record<EventType, number> = {
|
||||
lesson: 0,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
// ── PUT: Reorder pages ──
|
||||
const reorderSchema = z.object({
|
||||
pageOrders: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
pageNumber: z.number().int(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const notebook = await db.notebook.findUnique({
|
||||
where: { id, deletedAt: null },
|
||||
});
|
||||
|
||||
if (!notebook) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Notebook not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (notebook.ownerId !== session.user.id) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = reorderSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Update each page's pageNumber in a transaction
|
||||
await db.$transaction(
|
||||
parsed.data.pageOrders.map((po) =>
|
||||
db.notebookPage.update({
|
||||
where: { id: po.id },
|
||||
data: { pageNumber: po.pageNumber },
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Return updated pages
|
||||
const pages = await db.notebookPage.findMany({
|
||||
where: { notebookId: id },
|
||||
orderBy: { pageNumber: 'asc' },
|
||||
});
|
||||
|
||||
return NextResponse.json(pages);
|
||||
} catch (error) {
|
||||
console.error('Notebook page reorder error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
+241
-116
@@ -1,11 +1,8 @@
|
||||
// CompetenceTrack — Notifications API
|
||||
// CompetenceTrack — Notifications API (DB-backed)
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
// In-memory store for read notification IDs (per server instance)
|
||||
const readNotificationIds = new Set<string>();
|
||||
|
||||
export async function GET() {
|
||||
const session = await getSession();
|
||||
if (!session?.user) {
|
||||
@@ -14,19 +11,31 @@ export async function GET() {
|
||||
|
||||
const userId = session.user.id;
|
||||
const schoolId = session.user.schoolId;
|
||||
const role = session.user.role;
|
||||
|
||||
if (!schoolId) {
|
||||
return NextResponse.json({
|
||||
upcomingAssessments: [],
|
||||
missingObservations: [],
|
||||
notifications: [],
|
||||
unreadCount: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch DB notifications for the current user
|
||||
const notifications = await db.notification.findMany({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
});
|
||||
|
||||
const unreadCount = notifications.filter((n) => !n.isRead).length;
|
||||
|
||||
// Also generate dynamic notifications from existing data
|
||||
const now = new Date();
|
||||
const sevenDaysFromNow = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
|
||||
const fourteenDaysAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000);
|
||||
const role = session.user.role;
|
||||
|
||||
// Get teacher's class IDs
|
||||
const teacherClasses = await db.classGroupTeacher.findMany({
|
||||
@@ -45,105 +54,138 @@ export async function GET() {
|
||||
targetClassIds = schoolClasses.map((c) => c.id);
|
||||
}
|
||||
|
||||
if (targetClassIds.length === 0) {
|
||||
return NextResponse.json({
|
||||
upcomingAssessments: [],
|
||||
missingObservations: [],
|
||||
unreadCount: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Upcoming assessments (next 7 days, not completed)
|
||||
const upcomingAssessments = await db.assessment.findMany({
|
||||
where: {
|
||||
classGroupId: { in: targetClassIds },
|
||||
date: {
|
||||
gte: now,
|
||||
lte: sevenDaysFromNow,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
classGroup: { select: { name: true } },
|
||||
subject: { select: { name: true } },
|
||||
assessmentResults: { select: { id: true } },
|
||||
},
|
||||
orderBy: { date: 'asc' },
|
||||
take: 10,
|
||||
});
|
||||
|
||||
const assessmentNotifications = upcomingAssessments
|
||||
.filter((a) => a.assessmentResults.length === 0)
|
||||
.map((a) => {
|
||||
const diffMs = new Date(a.date).getTime() - now.getTime();
|
||||
const daysUntil = Math.max(0, Math.ceil(diffMs / (1000 * 60 * 60 * 24)));
|
||||
const id = `assessment-${a.id}`;
|
||||
return {
|
||||
id,
|
||||
type: 'assessment' as const,
|
||||
title: a.title,
|
||||
description: `${a.classGroup.name} · ${a.subject.name}`,
|
||||
daysUntil,
|
||||
timestamp: t('notifications.in_days', { days: String(daysUntil) }),
|
||||
classGroupId: a.classGroupId,
|
||||
subjectId: a.subjectId,
|
||||
assessmentId: a.id,
|
||||
isRead: readNotificationIds.has(id),
|
||||
};
|
||||
});
|
||||
|
||||
// Students with no progress entries in last 14 days
|
||||
const activeEnrollments = await db.enrollment.findMany({
|
||||
where: {
|
||||
classGroupId: { in: targetClassIds },
|
||||
endDate: null,
|
||||
},
|
||||
include: {
|
||||
student: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
learningProgressEntries: {
|
||||
where: {
|
||||
date: { gte: fourteenDaysAgo },
|
||||
},
|
||||
select: { id: true },
|
||||
take: 1,
|
||||
},
|
||||
// For students, get their enrolled class IDs
|
||||
if (role === 'STUDENT') {
|
||||
const student = await db.student.findFirst({
|
||||
where: { schoolId },
|
||||
include: {
|
||||
enrollments: {
|
||||
where: { endDate: null },
|
||||
select: { classGroupId: true },
|
||||
},
|
||||
},
|
||||
classGroup: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
});
|
||||
if (student) {
|
||||
targetClassIds = student.enrollments.map((e) => e.classGroupId);
|
||||
}
|
||||
}
|
||||
|
||||
const missingObservations = activeEnrollments
|
||||
.filter((e) => e.student.learningProgressEntries.length === 0)
|
||||
.map((e) => {
|
||||
const id = `missing-${e.student.id}-${e.classGroupId}`;
|
||||
// Calculate days since last progress (use 14 as default since we filtered for 14 days)
|
||||
return {
|
||||
id,
|
||||
type: 'missing_observation' as const,
|
||||
title: `${e.student.firstName} ${e.student.lastName}`,
|
||||
description: e.classGroup.name,
|
||||
daysSince: 14,
|
||||
timestamp: t('notifications.days_ago', { days: '14' }),
|
||||
studentId: e.student.id,
|
||||
classGroupId: e.classGroupId,
|
||||
isRead: readNotificationIds.has(id),
|
||||
};
|
||||
// Upcoming assessments (next 7 days)
|
||||
const upcomingAssessments: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
message: string;
|
||||
isRead: boolean;
|
||||
actionUrl: string | null;
|
||||
relatedId: string | null;
|
||||
createdAt: string;
|
||||
}> = [];
|
||||
|
||||
if (targetClassIds.length > 0) {
|
||||
const assessments = await db.assessment.findMany({
|
||||
where: {
|
||||
classGroupId: { in: targetClassIds },
|
||||
date: {
|
||||
gte: now,
|
||||
lte: sevenDaysFromNow,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
classGroup: { select: { name: true } },
|
||||
subject: { select: { name: true } },
|
||||
assessmentResults: { select: { id: true } },
|
||||
},
|
||||
orderBy: { date: 'asc' },
|
||||
take: 10,
|
||||
});
|
||||
|
||||
// Limit to 10 most relevant
|
||||
const limitedMissing = missingObservations.slice(0, 10);
|
||||
for (const a of assessments) {
|
||||
const diffMs = new Date(a.date).getTime() - now.getTime();
|
||||
const daysUntil = Math.max(0, Math.ceil(diffMs / (1000 * 60 * 60 * 24)));
|
||||
// Check if we already have a DB notification for this assessment
|
||||
const existing = notifications.find(
|
||||
(n) => n.type === 'ASSESSMENT_DUE' && n.relatedId === a.id
|
||||
);
|
||||
if (!existing) {
|
||||
upcomingAssessments.push({
|
||||
id: `assessment-${a.id}`,
|
||||
type: 'ASSESSMENT_DUE',
|
||||
title: a.title,
|
||||
message: `${a.classGroup.name} - ${a.subject.name}`,
|
||||
isRead: false,
|
||||
actionUrl: 'assessments',
|
||||
relatedId: a.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const allItems = [...assessmentNotifications, ...limitedMissing];
|
||||
const unreadCount = allItems.filter((n) => !n.isRead).length;
|
||||
// Missing observations (students with no progress in 14 days) - only for teachers
|
||||
if (role !== 'STUDENT') {
|
||||
const activeEnrollments = await db.enrollment.findMany({
|
||||
where: {
|
||||
classGroupId: { in: targetClassIds },
|
||||
endDate: null,
|
||||
},
|
||||
include: {
|
||||
student: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
learningProgressEntries: {
|
||||
where: {
|
||||
date: { gte: fourteenDaysAgo },
|
||||
},
|
||||
select: { id: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
classGroup: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
for (const e of activeEnrollments) {
|
||||
if (e.student.learningProgressEntries.length === 0) {
|
||||
const existing = notifications.find(
|
||||
(n) => n.type === 'MISSING_OBSERVATION' && n.relatedId === e.student.id
|
||||
);
|
||||
if (!existing) {
|
||||
upcomingAssessments.push({
|
||||
id: `missing-${e.student.id}-${e.classGroupId}`,
|
||||
type: 'MISSING_OBSERVATION',
|
||||
title: `${e.student.firstName} ${e.student.lastName}`,
|
||||
message: e.classGroup.name,
|
||||
isRead: false,
|
||||
actionUrl: 'progress',
|
||||
relatedId: e.student.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Combine DB notifications with dynamic ones
|
||||
const dbNotifications = notifications.map((n) => ({
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
title: n.title,
|
||||
message: n.message,
|
||||
isRead: n.isRead,
|
||||
actionUrl: n.actionUrl,
|
||||
relatedId: n.relatedId,
|
||||
createdAt: n.createdAt.toISOString(),
|
||||
}));
|
||||
|
||||
const allNotifications = [...dbNotifications, ...upcomingAssessments];
|
||||
|
||||
return NextResponse.json({
|
||||
upcomingAssessments: assessmentNotifications,
|
||||
missingObservations: limitedMissing,
|
||||
unreadCount,
|
||||
notifications: allNotifications,
|
||||
unreadCount: allNotifications.filter((n) => !n.isRead).length,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -155,12 +197,110 @@ export async function PUT(request: Request) {
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { ids, markAll } = body as { ids?: string[]; markAll?: boolean };
|
||||
|
||||
if (markAll) {
|
||||
// Mark all notifications as read
|
||||
await db.notification.updateMany({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
isRead: false,
|
||||
deletedAt: null,
|
||||
},
|
||||
data: { isRead: true },
|
||||
});
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
if (ids && Array.isArray(ids)) {
|
||||
// Mark specific DB notifications as read
|
||||
const dbIds = ids.filter((id) => !id.startsWith('assessment-') && !id.startsWith('missing-'));
|
||||
if (dbIds.length > 0) {
|
||||
await db.notification.updateMany({
|
||||
where: {
|
||||
id: { in: dbIds },
|
||||
userId: session.user.id,
|
||||
},
|
||||
data: { isRead: true },
|
||||
});
|
||||
}
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await getSession();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { schoolId, userId, type, title, message, actionUrl, relatedId } = body as {
|
||||
schoolId: string;
|
||||
userId: string;
|
||||
type: string;
|
||||
title: string;
|
||||
message: string;
|
||||
actionUrl?: string;
|
||||
relatedId?: string;
|
||||
};
|
||||
|
||||
if (!schoolId || !userId || !type || !title || !message) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
const notification = await db.notification.create({
|
||||
data: {
|
||||
schoolId,
|
||||
userId,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
actionUrl: actionUrl ?? null,
|
||||
relatedId: relatedId ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ notification }, { status: 201 });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
const session = await getSession();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const { ids } = body as { ids?: string[] };
|
||||
|
||||
if (ids && Array.isArray(ids)) {
|
||||
for (const id of ids) {
|
||||
readNotificationIds.add(id);
|
||||
}
|
||||
// Soft-delete specific notifications
|
||||
await db.notification.updateMany({
|
||||
where: {
|
||||
id: { in: ids },
|
||||
userId: session.user.id,
|
||||
},
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
} else {
|
||||
// Soft-delete all notifications for the user
|
||||
await db.notification.updateMany({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
deletedAt: null,
|
||||
},
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
@@ -168,18 +308,3 @@ export async function PUT(request: Request) {
|
||||
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// Helper — minimal i18n for server-side timestamps
|
||||
function t(key: string, params?: Record<string, string>): string {
|
||||
const dict: Record<string, string> = {
|
||||
'notifications.in_days': 'in {days} Tagen',
|
||||
'notifications.days_ago': 'vor {days} Tagen',
|
||||
};
|
||||
let text = dict[key] ?? key;
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
text = text.replace(new RegExp(`\\{${k}\\}`, 'g'), v);
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
@@ -675,6 +675,125 @@
|
||||
.animate-chart-shimmer {
|
||||
animation: chart-shimmer 1.5s ease-out forwards;
|
||||
}
|
||||
|
||||
/* ─── Button Press Effect ────────────────────────────────────────── */
|
||||
.btn-press {
|
||||
transition: transform 0.1s ease-out, box-shadow 0.1s ease-out;
|
||||
}
|
||||
.btn-press:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
.btn-press:focus-visible {
|
||||
outline: 2px solid oklch(0.55 0.17 155);
|
||||
outline-offset: 2px;
|
||||
box-shadow: 0 0 0 4px oklch(0.55 0.17 155 / 0.15);
|
||||
}
|
||||
.dark .btn-press:focus-visible {
|
||||
outline: 2px solid oklch(0.65 0.17 155);
|
||||
box-shadow: 0 0 0 4px oklch(0.65 0.17 155 / 0.2);
|
||||
}
|
||||
|
||||
/* ─── Smooth Theme Switching ─────────────────────────────────────── */
|
||||
html {
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
html *,
|
||||
html *::before,
|
||||
html *::after {
|
||||
transition-property: background-color, border-color, color, fill, stroke, box-shadow;
|
||||
transition-duration: 0.2s;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html *,
|
||||
html *::before,
|
||||
html *::after {
|
||||
transition-duration: 0ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Skeleton Loading Shimmer ───────────────────────────────────── */
|
||||
.skeleton-shimmer {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: oklch(0.92 0.01 155 / 0.5);
|
||||
}
|
||||
.dark .skeleton-shimmer {
|
||||
background: oklch(0.25 0.01 260 / 0.5);
|
||||
}
|
||||
.skeleton-shimmer::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
oklch(0.97 0.01 155 / 0.4) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: skeleton-slide 1.5s ease-in-out infinite;
|
||||
}
|
||||
.dark .skeleton-shimmer::after {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
oklch(0.35 0.01 260 / 0.3) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
@keyframes skeleton-slide {
|
||||
from { transform: translateX(-100%); }
|
||||
to { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
/* ─── Interactive Card Hover Micro-Effect ─────────────────────────── */
|
||||
.card-interactive {
|
||||
transition: transform 0.2s ease-out, box-shadow 0.2s ease-out;
|
||||
}
|
||||
.card-interactive:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px -6px oklch(0.55 0.17 155 / 0.12);
|
||||
}
|
||||
.dark .card-interactive:hover {
|
||||
box-shadow: 0 8px 24px -6px oklch(0.35 0.17 155 / 0.2);
|
||||
}
|
||||
.card-interactive:active {
|
||||
transform: translateY(0px) scale(0.99);
|
||||
}
|
||||
|
||||
/* ─── Danger Zone Styling ────────────────────────────────────────── */
|
||||
.danger-zone {
|
||||
border: 2px dashed oklch(0.55 0.22 25 / 0.4);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
background: oklch(0.55 0.22 25 / 0.03);
|
||||
}
|
||||
.dark .danger-zone {
|
||||
border-color: oklch(0.55 0.22 25 / 0.3);
|
||||
background: oklch(0.55 0.22 25 / 0.05);
|
||||
}
|
||||
.danger-zone:hover {
|
||||
border-color: oklch(0.55 0.22 25 / 0.6);
|
||||
background: oklch(0.55 0.22 25 / 0.06);
|
||||
}
|
||||
|
||||
/* ─── Status Indicator Dot Pulse ─────────────────────────────────── */
|
||||
@keyframes status-pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.5; transform: scale(1.3); }
|
||||
}
|
||||
.status-dot-pulse {
|
||||
animation: status-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ─── Count-Up Animation for Numbers ─────────────────────────────── */
|
||||
.animate-count-up {
|
||||
animation: count-up 0.8s ease-out;
|
||||
}
|
||||
@keyframes count-up {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
}
|
||||
|
||||
/* Print-specific styling — expands to all reports when printing */
|
||||
|
||||
Reference in new issue
Block a user