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
+2837
-358
No files matched your search
@@ -37,6 +37,8 @@ model School {
|
||||
commentBankEntries CommentBankEntry[]
|
||||
notebooks Notebook[]
|
||||
drawings Drawing[]
|
||||
notifications Notification[]
|
||||
calendarEvents CalendarEvent[]
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -71,6 +73,8 @@ model User {
|
||||
commentBankEntries CommentBankEntry[]
|
||||
notebooks Notebook[]
|
||||
drawings Drawing[]
|
||||
notifications Notification[]
|
||||
calendarEvents CalendarEvent[]
|
||||
}
|
||||
|
||||
model SchoolYear {
|
||||
@@ -115,6 +119,7 @@ model ClassGroup {
|
||||
behaviorIncidents BehaviorIncident[]
|
||||
notebooks Notebook[]
|
||||
drawings Drawing[]
|
||||
calendarEvents CalendarEvent[]
|
||||
}
|
||||
|
||||
model ClassGroupTeacher {
|
||||
@@ -187,6 +192,7 @@ model Subject {
|
||||
commentBankEntries CommentBankEntry[]
|
||||
notebooks Notebook[]
|
||||
drawings Drawing[]
|
||||
calendarEvents CalendarEvent[]
|
||||
}
|
||||
|
||||
model CompetencyTemplate {
|
||||
@@ -800,3 +806,55 @@ model Drawing {
|
||||
@@index([ownerId])
|
||||
@@index([subjectId])
|
||||
}
|
||||
|
||||
model Notification {
|
||||
id String @id @default(cuid())
|
||||
schoolId String
|
||||
userId String // recipient
|
||||
type String // ASSESSMENT_DUE, MISSING_OBSERVATION, NOTEBOOK_SHARED, BEHAVIOR_ALERT, GRADE_COMPUTED, etc.
|
||||
title String
|
||||
message String
|
||||
isRead Boolean @default(false)
|
||||
actionUrl String? // optional deep link
|
||||
relatedId String? // optional related entity ID
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
|
||||
school School @relation(fields: [schoolId], references: [id])
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@index([userId, isRead])
|
||||
@@index([schoolId])
|
||||
@@map("notifications")
|
||||
}
|
||||
|
||||
// ─── Calendar Events ──────────────────────────────────────────────
|
||||
|
||||
model CalendarEvent {
|
||||
id String @id @default(cuid())
|
||||
schoolId String
|
||||
teacherId String
|
||||
title String
|
||||
date DateTime
|
||||
startTime String? // HH:mm format, null = all day
|
||||
endTime String? // HH:mm format, null = all day
|
||||
eventType String @default("reminder") // assessment | lesson | reminder
|
||||
subjectId String?
|
||||
classGroupId String?
|
||||
notes String?
|
||||
allDay Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
school School @relation(fields: [schoolId], references: [id])
|
||||
teacher User @relation(fields: [teacherId], references: [id])
|
||||
subject Subject? @relation(fields: [subjectId], references: [id])
|
||||
classGroup ClassGroup? @relation(fields: [classGroupId], references: [id])
|
||||
|
||||
@@index([schoolId])
|
||||
@@index([teacherId])
|
||||
@@index([date])
|
||||
@@index([subjectId])
|
||||
@@index([classGroupId])
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,16 +54,36 @@ export async function GET() {
|
||||
targetClassIds = schoolClasses.map((c) => c.id);
|
||||
}
|
||||
|
||||
if (targetClassIds.length === 0) {
|
||||
return NextResponse.json({
|
||||
upcomingAssessments: [],
|
||||
missingObservations: [],
|
||||
unreadCount: 0,
|
||||
// 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 },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (student) {
|
||||
targetClassIds = student.enrollments.map((e) => e.classGroupId);
|
||||
}
|
||||
}
|
||||
|
||||
// Upcoming assessments (next 7 days, not completed)
|
||||
const upcomingAssessments = await db.assessment.findMany({
|
||||
// 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: {
|
||||
@@ -71,27 +100,29 @@ export async function GET() {
|
||||
take: 10,
|
||||
});
|
||||
|
||||
const assessmentNotifications = upcomingAssessments
|
||||
.filter((a) => a.assessmentResults.length === 0)
|
||||
.map((a) => {
|
||||
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)));
|
||||
const id = `assessment-${a.id}`;
|
||||
return {
|
||||
id,
|
||||
type: 'assessment' as const,
|
||||
// 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,
|
||||
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),
|
||||
};
|
||||
message: `${a.classGroup.name} - ${a.subject.name}`,
|
||||
isRead: false,
|
||||
actionUrl: 'assessments',
|
||||
relatedId: a.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Students with no progress entries in last 14 days
|
||||
// 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 },
|
||||
@@ -116,34 +147,45 @@ export async function GET() {
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
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}`,
|
||||
description: e.classGroup.name,
|
||||
daysSince: 14,
|
||||
timestamp: t('notifications.days_ago', { days: '14' }),
|
||||
studentId: e.student.id,
|
||||
classGroupId: e.classGroupId,
|
||||
isRead: readNotificationIds.has(id),
|
||||
};
|
||||
message: e.classGroup.name,
|
||||
isRead: false,
|
||||
actionUrl: 'progress',
|
||||
relatedId: e.student.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Limit to 10 most relevant
|
||||
const limitedMissing = missingObservations.slice(0, 10);
|
||||
// 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 allItems = [...assessmentNotifications, ...limitedMissing];
|
||||
const unreadCount = allItems.filter((n) => !n.isRead).length;
|
||||
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 */
|
||||
|
||||
+160
-77
@@ -42,6 +42,16 @@ import {
|
||||
Plus,
|
||||
Palette,
|
||||
Leaf,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
BookMarked,
|
||||
StickyNote,
|
||||
Award,
|
||||
UserCheck,
|
||||
BarChart3,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Sidebar,
|
||||
@@ -90,7 +100,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { useAppStore, type ViewName } from '@/lib/store';
|
||||
import { t } from '@/lib/i18n';
|
||||
import { fetchSchoolYears, fetchStudents, fetchClasses, fetchNotifications, markServerNotificationsRead, type SchoolYear, type Student, type ClassGroup, type NotificationData, type AssessmentNotification, type MissingObservationNotification } from '@/lib/api';
|
||||
import { fetchSchoolYears, fetchStudents, fetchClasses, fetchDBNotifications, markAllNotificationsRead, markSingleNotificationRead, type SchoolYear, type Student, type ClassGroup, type DBNotification, type DBNotificationData } from '@/lib/api';
|
||||
import OnboardingTour, { isOnboardingCompleted } from '@/components/onboarding-tour';
|
||||
import KeyboardShortcutsDialog from '@/components/keyboard-shortcuts-dialog';
|
||||
import {
|
||||
@@ -104,7 +114,6 @@ import {
|
||||
CommandShortcut,
|
||||
} from '@/components/ui/command';
|
||||
import { toast } from 'sonner';
|
||||
import { AlertTriangle, Info, ArrowRight } from 'lucide-react';
|
||||
|
||||
import DashboardView from './dashboard-view';
|
||||
import ClassesView from './classes-view';
|
||||
@@ -174,6 +183,22 @@ const navSections: NavSection[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// Student-specific navigation (simplified)
|
||||
const studentNavSections: NavSection[] = [
|
||||
{
|
||||
id: 'student-main',
|
||||
labelKey: 'student.dashboard_title',
|
||||
items: [
|
||||
{ key: 'dashboard', icon: LayoutDashboard, labelKey: 'nav.dashboard' },
|
||||
{ key: 'notebooks', icon: BookOpen, labelKey: 'nav.student_notebooks' },
|
||||
{ key: 'flower', icon: Flower2, labelKey: 'nav.student_competencies' },
|
||||
{ key: 'grading', icon: Calculator, labelKey: 'nav.student_grades' },
|
||||
{ key: 'attendance', icon: CalendarCheck, labelKey: 'nav.student_attendance' },
|
||||
{ key: 'calendar', icon: CalendarIconNav, labelKey: 'nav.calendar' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function renderView(view: ViewName) {
|
||||
switch (view) {
|
||||
case 'dashboard': return <DashboardView />;
|
||||
@@ -225,7 +250,7 @@ export default function AppLayout() {
|
||||
const [searchLoading, setSearchLoading] = useState(false);
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const [notifOpen, setNotifOpen] = useState(false);
|
||||
const [notifData, setNotifData] = useState<NotificationData | null>(null);
|
||||
const [notifData, setNotifData] = useState<DBNotificationData | null>(null);
|
||||
const [notifLoading, setNotifLoading] = useState(false);
|
||||
const [onboardingOpen, setOnboardingOpen] = useState(false);
|
||||
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||||
@@ -239,7 +264,10 @@ export default function AppLayout() {
|
||||
? `${currentUser.firstName} ${currentUser.lastName}`
|
||||
: '';
|
||||
|
||||
const roleKey = currentUser?.role === 'TEACHER' ? 'role.teacher' : currentUser?.role === 'SCHOOL_ADMIN' ? 'role.school_admin' : 'role.super_admin';
|
||||
const roleKey = currentUser?.role === 'TEACHER' ? 'role.teacher' : currentUser?.role === 'SCHOOL_ADMIN' ? 'role.school_admin' : currentUser?.role === 'STUDENT' ? 'role.student' : 'role.super_admin';
|
||||
|
||||
const isStudent = currentUser?.role === 'STUDENT';
|
||||
const activeNavSections = isStudent ? studentNavSections : navSections;
|
||||
|
||||
const isDemoUser = currentUser?.isDemo === true || (currentUser?.email?.endsWith('@competencetrack.org') && currentUser?.email?.startsWith('demo'));
|
||||
|
||||
@@ -441,7 +469,7 @@ export default function AppLayout() {
|
||||
if (!currentUser?.schoolId) return;
|
||||
setNotifLoading(true);
|
||||
try {
|
||||
const data = await fetchNotifications();
|
||||
const data = await fetchDBNotifications();
|
||||
setNotifData(data);
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -452,21 +480,18 @@ export default function AppLayout() {
|
||||
|
||||
useEffect(() => {
|
||||
loadNotifications();
|
||||
// Refresh notifications every 60 seconds
|
||||
const interval = setInterval(loadNotifications, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadNotifications]);
|
||||
|
||||
const handleMarkAllRead = useCallback(async () => {
|
||||
if (!notifData) return;
|
||||
const allIds = [
|
||||
...notifData.upcomingAssessments.map((n) => n.id),
|
||||
...notifData.missingObservations.map((n) => n.id),
|
||||
];
|
||||
if (allIds.length === 0) return;
|
||||
if (!notifData || notifData.unreadCount === 0) return;
|
||||
try {
|
||||
await markServerNotificationsRead(allIds);
|
||||
await markAllNotificationsRead();
|
||||
setNotifData((prev) => prev ? {
|
||||
...prev,
|
||||
upcomingAssessments: prev.upcomingAssessments.map((n) => ({ ...n, isRead: true })),
|
||||
missingObservations: prev.missingObservations.map((n) => ({ ...n, isRead: true })),
|
||||
notifications: prev.notifications.map((n) => ({ ...n, isRead: true })),
|
||||
unreadCount: 0,
|
||||
} : prev);
|
||||
} catch {
|
||||
@@ -474,6 +499,87 @@ export default function AppLayout() {
|
||||
}
|
||||
}, [notifData]);
|
||||
|
||||
const handleMarkSingleRead = useCallback(async (id: string) => {
|
||||
try {
|
||||
await markSingleNotificationRead(id);
|
||||
setNotifData((prev) => {
|
||||
if (!prev) return prev;
|
||||
const updated = prev.notifications.map((n) =>
|
||||
n.id === id ? { ...n, isRead: true } : n
|
||||
);
|
||||
return {
|
||||
...prev,
|
||||
notifications: updated,
|
||||
unreadCount: updated.filter((n) => !n.isRead).length,
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleNotificationClick = useCallback((n: DBNotification) => {
|
||||
if (!n.isRead) {
|
||||
handleMarkSingleRead(n.id);
|
||||
}
|
||||
setNotifOpen(false);
|
||||
if (n.actionUrl) {
|
||||
setCurrentView(n.actionUrl as ViewName);
|
||||
}
|
||||
}, [handleMarkSingleRead, setCurrentView]);
|
||||
|
||||
// Get icon and color for notification type
|
||||
const getNotifTypeInfo = (type: string) => {
|
||||
switch (type) {
|
||||
case 'ASSESSMENT_DUE':
|
||||
return { icon: AlertTriangle, bg: 'bg-amber-100/80 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400', label: t('notifications.type_assessment_due') };
|
||||
case 'MISSING_OBSERVATION':
|
||||
return { icon: Info, bg: 'bg-teal-100/80 dark:bg-teal-900/20', text: 'text-teal-600 dark:text-teal-400', label: t('notifications.type_missing_observation') };
|
||||
case 'NOTEBOOK_SHARED':
|
||||
return { icon: BookMarked, bg: 'bg-emerald-100/80 dark:bg-emerald-900/20', text: 'text-emerald-600 dark:text-emerald-400', label: t('notifications.type_notebook_shared') };
|
||||
case 'BEHAVIOR_ALERT':
|
||||
return { icon: Shield, bg: 'bg-rose-100/80 dark:bg-rose-900/20', text: 'text-rose-600 dark:text-rose-400', label: t('notifications.type_behavior_alert') };
|
||||
case 'GRADE_COMPUTED':
|
||||
return { icon: Award, bg: 'bg-violet-100/80 dark:bg-violet-900/20', text: 'text-violet-600 dark:text-violet-400', label: t('notifications.type_grade_computed') };
|
||||
case 'ATTENDANCE_ALERT':
|
||||
return { icon: UserCheck, bg: 'bg-orange-100/80 dark:bg-orange-900/20', text: 'text-orange-600 dark:text-orange-400', label: t('notifications.type_attendance_alert') };
|
||||
case 'REPORT_READY':
|
||||
return { icon: FileText, bg: 'bg-cyan-100/80 dark:bg-cyan-900/20', text: 'text-cyan-600 dark:text-cyan-400', label: t('notifications.type_report_ready') };
|
||||
default:
|
||||
return { icon: Bell, bg: 'bg-gray-100/80 dark:bg-gray-900/20', text: 'text-gray-600 dark:text-gray-400', label: t('notifications.type_general') };
|
||||
}
|
||||
};
|
||||
|
||||
// Group notifications by date
|
||||
const groupedNotifications = useMemo(() => {
|
||||
if (!notifData?.notifications) return [];
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const yesterday = new Date(today.getTime() - 86400000);
|
||||
const weekAgo = new Date(today.getTime() - 7 * 86400000);
|
||||
|
||||
const groups: { label: string; notifications: DBNotification[] }[] = [];
|
||||
const todayItems: DBNotification[] = [];
|
||||
const yesterdayItems: DBNotification[] = [];
|
||||
const weekItems: DBNotification[] = [];
|
||||
const earlierItems: DBNotification[] = [];
|
||||
|
||||
for (const n of notifData.notifications) {
|
||||
const date = new Date(n.createdAt);
|
||||
if (date >= today) todayItems.push(n);
|
||||
else if (date >= yesterday) yesterdayItems.push(n);
|
||||
else if (date >= weekAgo) weekItems.push(n);
|
||||
else earlierItems.push(n);
|
||||
}
|
||||
|
||||
if (todayItems.length > 0) groups.push({ label: t('notifications.today'), notifications: todayItems });
|
||||
if (yesterdayItems.length > 0) groups.push({ label: t('notifications.yesterday'), notifications: yesterdayItems });
|
||||
if (weekItems.length > 0) groups.push({ label: t('notifications.this_week'), notifications: weekItems });
|
||||
if (earlierItems.length > 0) groups.push({ label: t('notifications.earlier'), notifications: earlierItems });
|
||||
|
||||
return groups;
|
||||
}, [notifData?.notifications]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadYears() {
|
||||
if (!currentUser?.schoolId) return;
|
||||
@@ -515,12 +621,13 @@ export default function AppLayout() {
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
{navSections.map((section) => (
|
||||
{activeNavSections.map((section) => (
|
||||
<SidebarGroup key={section.id}>
|
||||
<SidebarGroupLabel className="text-emerald-600/70 dark:text-emerald-400/50 text-xs font-semibold uppercase tracking-wider inline-flex items-center gap-1.5">
|
||||
{section.id === 'analysis' && <TrendingUp className="h-3 w-3" />}
|
||||
{section.id === 'teaching' && <BookOpen className="h-3 w-3" />}
|
||||
{section.id === 'setup' && <Settings className="h-3 w-3" />}
|
||||
{section.id === 'student-main' && <GraduationCap className="h-3 w-3" />}
|
||||
{t(section.labelKey)}
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
@@ -711,7 +818,8 @@ export default function AppLayout() {
|
||||
<motion.span
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="absolute -top-0.5 -right-0.5 w-4 h-4 rounded-full bg-gradient-to-br from-emerald-400 to-teal-500 text-white text-[9px] font-bold flex items-center justify-center shadow-sm shadow-emerald-300/30"
|
||||
transition={{ type: 'spring', stiffness: 500, damping: 15 }}
|
||||
className="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-0.5 rounded-full bg-gradient-to-br from-emerald-400 to-teal-500 text-white text-[9px] font-bold flex items-center justify-center shadow-sm shadow-emerald-300/30"
|
||||
>
|
||||
{notifData.unreadCount > 9 ? '9+' : notifData.unreadCount}
|
||||
</motion.span>
|
||||
@@ -720,13 +828,15 @@ export default function AppLayout() {
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="w-[360px] p-0 rounded-xl border-emerald-200/60 dark:border-emerald-900/40 shadow-xl shadow-emerald-900/10"
|
||||
className="w-[380px] p-0 rounded-xl border-emerald-200/60 dark:border-emerald-900/40 shadow-xl shadow-emerald-900/10"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-emerald-100/50 dark:border-emerald-900/30 bg-gradient-to-r from-emerald-50/50 to-teal-50/30 dark:from-emerald-950/20 dark:to-teal-950/10">
|
||||
<h3 className="text-sm font-semibold text-emerald-700 dark:text-emerald-300">
|
||||
<h3 className="text-sm font-semibold text-emerald-700 dark:text-emerald-300 flex items-center gap-1.5">
|
||||
<Bell className="h-3.5 w-3.5" />
|
||||
{t('notifications.title')}
|
||||
</h3>
|
||||
<div className="flex items-center gap-1">
|
||||
{notifData && notifData.unreadCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -734,100 +844,74 @@ export default function AppLayout() {
|
||||
onClick={handleMarkAllRead}
|
||||
className="h-7 text-xs text-emerald-600 dark:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-900/20"
|
||||
>
|
||||
<CheckCircle2 className="h-3 w-3 mr-0.5" />
|
||||
{t('notifications.mark_all_read')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<ScrollArea className="max-h-[400px]">
|
||||
<ScrollArea className="max-h-[420px]">
|
||||
{notifLoading && (
|
||||
<div className="px-4 py-8 text-center">
|
||||
<Bell className="h-6 w-6 animate-pulse mx-auto mb-2 text-emerald-400" />
|
||||
<p className="text-xs text-emerald-600/70 dark:text-emerald-400/60">Loading…</p>
|
||||
</div>
|
||||
)}
|
||||
{!notifLoading && notifData && notifData.upcomingAssessments.length === 0 && notifData.missingObservations.length === 0 && (
|
||||
{!notifLoading && notifData && notifData.notifications.length === 0 && (
|
||||
<div className="px-4 py-8 text-center">
|
||||
<Bell className="h-8 w-8 mx-auto mb-2 text-gray-300 dark:text-gray-600" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{t('notifications.empty')}</p>
|
||||
</div>
|
||||
)}
|
||||
{!notifLoading && notifData && notifData.upcomingAssessments.length > 0 && (
|
||||
<div className="px-2">
|
||||
<p className="px-2 py-1.5 text-[10px] uppercase tracking-wider font-semibold text-amber-600/70 dark:text-amber-400/60 flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
{t('notifications.upcoming_assessments')}
|
||||
{!notifLoading && groupedNotifications.map((group) => (
|
||||
<div key={group.label} className="px-2">
|
||||
<p className="px-2 py-1.5 text-[10px] uppercase tracking-wider font-semibold text-emerald-600/70 dark:text-emerald-400/60">
|
||||
{group.label}
|
||||
</p>
|
||||
{notifData.upcomingAssessments.map((n: AssessmentNotification, i: number) => (
|
||||
{group.notifications.map((n: DBNotification, i: number) => {
|
||||
const typeInfo = getNotifTypeInfo(n.type);
|
||||
const TypeIcon = typeInfo.icon;
|
||||
return (
|
||||
<motion.div
|
||||
key={n.id}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
className={`flex items-start gap-3 px-2 py-2 rounded-lg transition-colors hover:bg-amber-50/50 dark:hover:bg-amber-900/10 ${n.isRead ? 'opacity-60' : ''}`}
|
||||
transition={{ delay: i * 0.03 }}
|
||||
onClick={() => handleNotificationClick(n)}
|
||||
className={`flex items-start gap-3 px-2 py-2.5 rounded-lg transition-colors cursor-pointer min-h-[44px] hover:bg-emerald-50/50 dark:hover:bg-emerald-900/10 ${!n.isRead ? 'bg-emerald-50/30 dark:bg-emerald-900/5' : 'opacity-70'}`}
|
||||
>
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-amber-100/80 dark:bg-amber-900/20 shrink-0">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
||||
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${typeInfo.bg} shrink-0`}>
|
||||
<TypeIcon className={`h-4 w-4 ${typeInfo.text}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{n.title}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{n.description}</p>
|
||||
<Badge variant="outline" className="mt-1 text-[10px] px-1.5 py-0 bg-amber-50/50 dark:bg-amber-900/10 border-amber-200/50 dark:border-amber-900/30 text-amber-700 dark:text-amber-300">
|
||||
{n.timestamp}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{n.message}</p>
|
||||
<Badge variant="outline" className="mt-1 text-[10px] px-1.5 py-0 bg-emerald-50/50 dark:bg-emerald-900/10 border-emerald-200/50 dark:border-emerald-900/30 text-emerald-700 dark:text-emerald-300">
|
||||
{typeInfo.label}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||
{!n.isRead && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => { setNotifOpen(false); setCurrentView('assessments'); }}
|
||||
className="h-7 text-xs shrink-0 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-900/20 flex items-center gap-1"
|
||||
onClick={(e) => { e.stopPropagation(); handleMarkSingleRead(n.id); }}
|
||||
className="h-6 w-6 p-0 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-900/20"
|
||||
title={t('notifications.mark_read')}
|
||||
>
|
||||
{t('notifications.to_assessment')}
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
<Check className="h-3 w-3" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!notifLoading && notifData && notifData.missingObservations.length > 0 && (
|
||||
<div className="px-2 border-t border-emerald-100/30 dark:border-emerald-900/20">
|
||||
<p className="px-2 py-1.5 text-[10px] uppercase tracking-wider font-semibold text-teal-600/70 dark:text-teal-400/60 flex items-center gap-1">
|
||||
<Info className="h-3 w-3" />
|
||||
{t('notifications.missing_observations')}
|
||||
</p>
|
||||
{notifData.missingObservations.map((n: MissingObservationNotification, i: number) => (
|
||||
<motion.div
|
||||
key={n.id}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
className={`flex items-start gap-3 px-2 py-2 rounded-lg transition-colors hover:bg-teal-50/50 dark:hover:bg-teal-900/10 ${n.isRead ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-teal-100/80 dark:bg-teal-900/20 shrink-0">
|
||||
<Info className="h-4 w-4 text-teal-600 dark:text-teal-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{n.title}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{n.description}</p>
|
||||
<Badge variant="outline" className="mt-1 text-[10px] px-1.5 py-0 bg-teal-50/50 dark:bg-teal-900/10 border-teal-200/50 dark:border-teal-900/30 text-teal-700 dark:text-teal-300">
|
||||
{n.timestamp}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => { setNotifOpen(false); setCurrentView('progress'); }}
|
||||
className="h-7 text-xs shrink-0 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-900/20 flex items-center gap-1"
|
||||
>
|
||||
{t('notifications.record_now')}
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{/* Quick Actions footer */}
|
||||
{!notifLoading && notifData && (notifData.upcomingAssessments.length > 0 || notifData.missingObservations.length > 0) && (
|
||||
))}
|
||||
</ScrollArea>
|
||||
{/* Footer */}
|
||||
{!notifLoading && notifData && notifData.notifications.length > 0 && (
|
||||
<div className="px-3 py-2 border-t border-emerald-100/30 dark:border-emerald-900/20 flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -855,7 +939,6 @@ export default function AppLayout() {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{/* Language toggle (mobile: just flag) */}
|
||||
|
||||
+945
-107
File diff suppressed because it is too large.
Load diff
@@ -126,6 +126,7 @@ const masteryBarColor = (level: number) => {
|
||||
|
||||
export default function CompetenceFlowerView() {
|
||||
const currentUser = useAppStore((s) => s.currentUser);
|
||||
const locale = useAppStore((s) => s.locale);
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [classes, setClasses] = useState<ClassGroup[]>([]);
|
||||
@@ -496,7 +497,7 @@ export default function CompetenceFlowerView() {
|
||||
{t('flower.color_scheme')}
|
||||
{/* Preview swatch with current color */}
|
||||
<div className="flex items-center gap-1.5 ml-1">
|
||||
<div className="w-5 h-5 rounded-md ring-2 ring-offset-1 shadow-sm" style={{ backgroundColor: primaryColor, ringColor: primaryColor }} />
|
||||
<div className="w-5 h-5 rounded-md ring-2 ring-offset-1 shadow-sm" style={{ backgroundColor: primaryColor, outlineColor: primaryColor }} />
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-normal ml-auto">
|
||||
{colorPickerOpen ? '▼' : '▶'}
|
||||
|
||||
@@ -41,6 +41,11 @@ import {
|
||||
Palette,
|
||||
Notebook,
|
||||
CircleDot,
|
||||
Flower2,
|
||||
Calculator,
|
||||
CalendarCheck,
|
||||
Calendar as CalendarIconNav,
|
||||
BookMarked,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -55,7 +60,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useAppStore } from '@/lib/store';
|
||||
import { useAppStore, type ViewName } from '@/lib/store';
|
||||
import { t } from '@/lib/i18n';
|
||||
import { fetchDashboard, type DashboardData, getStoredNotifications, type AppNotification, addNotification, markNotificationsRead } from '@/lib/api';
|
||||
|
||||
@@ -271,6 +276,244 @@ export default function DashboardView() {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Student Dashboard ──────────────────────────────────────────────
|
||||
if (currentUser?.role === 'STUDENT') {
|
||||
return (
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="space-y-6 pb-8"
|
||||
>
|
||||
{/* Student Welcome Header */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3.5">
|
||||
<div className="flex items-center justify-center w-12 h-12 rounded-2xl bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-lg shadow-emerald-300/30 ring-2 ring-emerald-200/30 dark:ring-emerald-800/20">
|
||||
<GraduationCap className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold bg-gradient-to-r from-emerald-600 via-teal-600 to-emerald-500 dark:from-emerald-400 dark:via-teal-400 dark:to-emerald-300 bg-clip-text text-transparent">
|
||||
{t('student.welcome_back')}, {currentUser.firstName}!
|
||||
</h2>
|
||||
<p className="text-emerald-600/60 dark:text-emerald-400/40 mt-0.5 text-sm">{t('student.progress_overview')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-gradient-to-r from-emerald-50 to-teal-50/50 dark:from-emerald-900/20 dark:to-teal-900/10 border border-emerald-200/40 dark:border-emerald-900/30 text-xs font-medium text-emerald-700 dark:text-emerald-300 shadow-sm">
|
||||
<CalendarDays className="h-3.5 w-3.5" />
|
||||
<span className="font-semibold">
|
||||
{new Date().toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Student Quick Stats */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<div className="relative p-4 rounded-xl bg-mesh border border-emerald-200/40 dark:border-emerald-900/30 overflow-hidden backdrop-blur-sm">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-emerald-50/30 via-transparent to-teal-50/30 dark:from-emerald-900/10 dark:to-teal-900/5 pointer-events-none" />
|
||||
<div className="relative flex flex-wrap items-center gap-x-5 gap-y-2 text-sm">
|
||||
<span className="inline-flex items-center gap-1.5 font-medium text-gray-700 dark:text-gray-300">
|
||||
<Activity className="h-4 w-4 text-emerald-500" />
|
||||
{t('student.progress_overview')}:
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<PenLine className="h-3.5 w-3.5 text-amber-500" />
|
||||
<span className="text-amber-600 dark:text-amber-400 font-semibold">{data.stats.totalProgressEntries}</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">{t('dashboard.total_entries').toLowerCase()}</span>
|
||||
</span>
|
||||
{data.stats.totalAssessments > 0 && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ClipboardCheck className="h-3.5 w-3.5 text-rose-500" />
|
||||
<span className="text-rose-600 dark:text-rose-400 font-semibold">{data.stats.totalAssessments}</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">{t('dashboard.total_assessments').toLowerCase()}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Student Cards Grid */}
|
||||
<motion.div variants={itemVariants} className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{/* My Competencies Card */}
|
||||
<Card className="border-0 shadow-sm rounded-xl overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05] hover:ring-emerald-200/40 dark:hover:ring-emerald-700/30 transition-all duration-300 hover:shadow-lg hover:shadow-emerald-200/60 dark:hover:shadow-emerald-800/30 cursor-pointer min-h-[44px]" onClick={() => setCurrentView('flower')}>
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br from-emerald-400 to-emerald-500 text-white shadow-sm">
|
||||
<Flower2 className="h-4 w-4" />
|
||||
</div>
|
||||
<CardTitle className="text-sm font-semibold">{t('student.my_competencies')}</CardTitle>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-emerald-500" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-2">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">{t('student.my_competencies_desc')}</p>
|
||||
{/* Mini competence flower placeholder */}
|
||||
<div className="flex items-center justify-center w-full h-24 rounded-lg bg-gradient-to-br from-emerald-50 to-teal-50/50 dark:from-emerald-900/20 dark:to-teal-900/10 border border-emerald-200/30 dark:border-emerald-900/20">
|
||||
<Flower2 className="h-10 w-10 text-emerald-400/60 dark:text-emerald-600/40" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* My Recent Grades Card */}
|
||||
<Card className="border-0 shadow-sm rounded-xl overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05] hover:ring-amber-200/40 dark:hover:ring-amber-700/30 transition-all duration-300 hover:shadow-lg hover:shadow-amber-200/60 dark:hover:shadow-amber-800/30 cursor-pointer min-h-[44px]" onClick={() => setCurrentView('grading')}>
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<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 shadow-sm">
|
||||
<Calculator className="h-4 w-4" />
|
||||
</div>
|
||||
<CardTitle className="text-sm font-semibold">{t('student.my_grades')}</CardTitle>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-amber-500" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-2">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">{t('student.my_grades_desc')}</p>
|
||||
{data.recentAssessments && data.recentAssessments.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{data.recentAssessments.slice(0, 3).map((a) => (
|
||||
<div key={a.id} className="flex items-center justify-between px-3 py-2 rounded-lg bg-amber-50/50 dark:bg-amber-900/10 border border-amber-100/50 dark:border-amber-900/20">
|
||||
<span className="text-xs font-medium text-gray-700 dark:text-gray-300 truncate">{a.title}</span>
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 bg-amber-50/50 dark:bg-amber-900/10 border-amber-200/50 dark:border-amber-900/30 text-amber-700 dark:text-amber-300 shrink-0">
|
||||
{t('student.grade_in')} {a.title}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-24 rounded-lg bg-gray-50 dark:bg-gray-900/20 border border-gray-200/30 dark:border-gray-800/20">
|
||||
<Award className="h-8 w-8 text-gray-300 dark:text-gray-600 mb-1" />
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">{t('student.no_grades')}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* My Notebooks Card */}
|
||||
<Card className="border-0 shadow-sm rounded-xl overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05] hover:ring-teal-200/40 dark:hover:ring-teal-700/30 transition-all duration-300 hover:shadow-lg hover:shadow-teal-200/60 dark:hover:shadow-teal-800/30 cursor-pointer min-h-[44px]" onClick={() => setCurrentView('notebooks')}>
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br from-teal-400 to-teal-500 text-white shadow-sm">
|
||||
<BookOpen className="h-4 w-4" />
|
||||
</div>
|
||||
<CardTitle className="text-sm font-semibold">{t('student.my_notebooks')}</CardTitle>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-teal-500" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-2">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">{t('student.my_notebooks_desc')}</p>
|
||||
<div className="flex flex-col items-center justify-center h-24 rounded-lg bg-gradient-to-br from-teal-50 to-emerald-50/50 dark:from-teal-900/20 dark:to-emerald-900/10 border border-teal-200/30 dark:border-teal-900/20">
|
||||
<BookMarked className="h-8 w-8 text-teal-400/60 dark:text-teal-600/40 mb-1" />
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{t('student.view_all')}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* My Attendance Card */}
|
||||
<Card className="border-0 shadow-sm rounded-xl overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05] hover:ring-rose-200/40 dark:hover:ring-rose-700/30 transition-all duration-300 hover:shadow-lg hover:shadow-rose-200/60 dark:hover:shadow-rose-800/30 cursor-pointer min-h-[44px]" onClick={() => setCurrentView('attendance')}>
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br from-rose-400 to-rose-500 text-white shadow-sm">
|
||||
<CalendarCheck className="h-4 w-4" />
|
||||
</div>
|
||||
<CardTitle className="text-sm font-semibold">{t('student.my_attendance')}</CardTitle>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-rose-500" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-2">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">{t('student.my_attendance_desc')}</p>
|
||||
<div className="flex items-center justify-center h-24 rounded-lg bg-gradient-to-br from-rose-50 to-amber-50/50 dark:from-rose-900/20 dark:to-amber-900/10 border border-rose-200/30 dark:border-rose-900/20">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-emerald-600 dark:text-emerald-400">--</p>
|
||||
<p className="text-[10px] text-gray-500 dark:text-gray-400">{t('student.attendance_rate')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Upcoming Assessments Card */}
|
||||
<Card className="border-0 shadow-sm rounded-xl overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05] hover:ring-violet-200/40 dark:hover:ring-violet-700/30 transition-all duration-300 hover:shadow-lg hover:shadow-violet-200/60 dark:hover:shadow-violet-800/30 cursor-pointer min-h-[44px]" onClick={() => setCurrentView('assessments')}>
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br from-violet-400 to-violet-500 text-white shadow-sm">
|
||||
<ClipboardCheck className="h-4 w-4" />
|
||||
</div>
|
||||
<CardTitle className="text-sm font-semibold">{t('student.upcoming_assessments')}</CardTitle>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-violet-500" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-2">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">{t('student.upcoming_assessments_desc')}</p>
|
||||
{data.recentAssessments && data.recentAssessments.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{data.recentAssessments.slice(0, 3).map((a) => {
|
||||
const daysUntil = Math.max(0, Math.ceil((new Date(a.date).getTime() - Date.now()) / (1000 * 60 * 60 * 24)));
|
||||
return (
|
||||
<div key={a.id} className="flex items-center justify-between px-3 py-2 rounded-lg bg-violet-50/50 dark:bg-violet-900/10 border border-violet-100/50 dark:border-violet-900/20">
|
||||
<span className="text-xs font-medium text-gray-700 dark:text-gray-300 truncate">{a.title}</span>
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 bg-violet-50/50 dark:bg-violet-900/10 border-violet-200/50 dark:border-violet-900/30 text-violet-700 dark:text-violet-300 shrink-0">
|
||||
{daysUntil} {t('student.days_until')}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-24 rounded-lg bg-gray-50 dark:bg-gray-900/20 border border-gray-200/30 dark:border-gray-800/20">
|
||||
<ClipboardCheck className="h-8 w-8 text-gray-300 dark:text-gray-600 mb-1" />
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">{t('student.no_assessments')}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Calendar Card */}
|
||||
<Card className="border-0 shadow-sm rounded-xl overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05] hover:ring-emerald-200/40 dark:hover:ring-emerald-700/30 transition-all duration-300 hover:shadow-lg hover:shadow-emerald-200/60 dark:hover:shadow-emerald-800/30 cursor-pointer min-h-[44px]" onClick={() => setCurrentView('calendar')}>
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-sm">
|
||||
<CalendarIconNav className="h-4 w-4" />
|
||||
</div>
|
||||
<CardTitle className="text-sm font-semibold">{t('nav.calendar')}</CardTitle>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-emerald-500" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-2">
|
||||
<div className="flex items-center justify-center h-24 rounded-lg bg-gradient-to-br from-emerald-50 to-teal-50/50 dark:from-emerald-900/20 dark:to-teal-900/10 border border-emerald-200/30 dark:border-emerald-900/20">
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-bold text-emerald-600 dark:text-emerald-400">
|
||||
{new Date().toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', { day: 'numeric', month: 'short' })}
|
||||
</p>
|
||||
<p className="text-[10px] text-gray-500 dark:text-gray-400">
|
||||
{new Date().toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', { weekday: 'long' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
@@ -685,7 +928,7 @@ export default function DashboardView() {
|
||||
whileHover={{ scale: 1.01, x: 2 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
onClick={() => setCurrentView(item.view)}
|
||||
onClick={() => setCurrentView(item.view as ViewName)}
|
||||
className="w-full group flex items-center gap-3 p-3 rounded-xl bg-gradient-to-r from-gray-50/80 to-gray-50/0 dark:from-gray-800/50 dark:to-gray-800/0 border border-gray-100/60 dark:border-gray-800/40 hover:border-emerald-200/60 dark:hover:border-emerald-800/30 transition-all relative overflow-hidden"
|
||||
>
|
||||
<div className={`flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br ${item.color} text-white shadow-sm shrink-0 transition-transform duration-200 group-hover:scale-110`}>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Heading1, Heading2, Heading3, CheckCircle2,
|
||||
User as UserIcon, GraduationCap, FileDown,
|
||||
Copy, FlaskConical, Languages, Calculator, Paintbrush, Megaphone,
|
||||
Strikethrough, Type, Highlighter, GripVertical,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -27,6 +28,7 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { useAppStore } from '@/lib/store';
|
||||
import { t } from '@/lib/i18n';
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '@/lib/api';
|
||||
@@ -279,63 +281,73 @@ function getPageBackgroundCSS(type: string): React.CSSProperties {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Rich Text Toolbar ───────────────────────────────────────────────
|
||||
// ─── WYSIWYG Rich Text Toolbar ───────────────────────────────────────
|
||||
|
||||
function RichTextToolbar({ textareaRef }: { textareaRef: React.RefObject<HTMLTextAreaElement | null> }) {
|
||||
// These functions only access ref.current inside event handlers (onClick), never during render
|
||||
const applyFormat = (before: string, after: string) => () => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const text = textarea.value;
|
||||
const selectedText = text.substring(start, end);
|
||||
const newText = text.substring(0, start) + before + selectedText + after + text.substring(end);
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set;
|
||||
if (nativeInputValueSetter) {
|
||||
nativeInputValueSetter.call(textarea, newText);
|
||||
}
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
textarea.setSelectionRange(start + before.length + selectedText.length, start + before.length + selectedText.length);
|
||||
textarea.focus();
|
||||
};
|
||||
const TEXT_COLORS = [
|
||||
{ key: 'black', color: '#1f2937', labelKey: 'notebooks.toolbar_color_black' },
|
||||
{ key: 'red', color: '#dc2626', labelKey: 'notebooks.toolbar_color_red' },
|
||||
{ key: 'blue', color: '#2563eb', labelKey: 'notebooks.toolbar_color_blue' },
|
||||
{ key: 'green', color: '#16a34a', labelKey: 'notebooks.toolbar_color_green' },
|
||||
{ key: 'orange', color: '#ea580c', labelKey: 'notebooks.toolbar_color_orange' },
|
||||
];
|
||||
|
||||
const applyLinePrefix = (prefix: string) => () => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
const start = textarea.selectionStart;
|
||||
const text = textarea.value;
|
||||
const lineStart = text.lastIndexOf('\n', start - 1) + 1;
|
||||
const newText = text.substring(0, lineStart) + prefix + text.substring(lineStart);
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set;
|
||||
if (nativeInputValueSetter) {
|
||||
nativeInputValueSetter.call(textarea, newText);
|
||||
}
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
textarea.setSelectionRange(start + prefix.length, start + prefix.length);
|
||||
textarea.focus();
|
||||
};
|
||||
const HIGHLIGHT_COLORS = [
|
||||
{ key: 'yellow', color: '#fef08a', labelKey: 'notebooks.toolbar_highlight_yellow' },
|
||||
{ key: 'green', color: '#bbf7d0', labelKey: 'notebooks.toolbar_highlight_green' },
|
||||
{ key: 'blue', color: '#bfdbfe', labelKey: 'notebooks.toolbar_highlight_blue' },
|
||||
{ key: 'pink', color: '#fbcfe8', labelKey: 'notebooks.toolbar_highlight_pink' },
|
||||
{ key: 'none', color: 'transparent', labelKey: 'notebooks.toolbar_highlight_none' },
|
||||
];
|
||||
|
||||
const applyBlockPrefix = (prefix: string) => () => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const text = textarea.value;
|
||||
const lineStart = text.lastIndexOf('\n', start - 1) + 1;
|
||||
const lineEnd = text.indexOf('\n', end);
|
||||
const actualEnd = lineEnd === -1 ? text.length : lineEnd;
|
||||
const selectedLines = text.substring(lineStart, actualEnd);
|
||||
const newLines = selectedLines.split('\n').map(line => prefix + line).join('\n');
|
||||
const newText = text.substring(0, lineStart) + newLines + text.substring(actualEnd);
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set;
|
||||
if (nativeInputValueSetter) {
|
||||
nativeInputValueSetter.call(textarea, newText);
|
||||
function WysiwygToolbar({ editorRef, onFormatChange }: { editorRef: React.RefObject<HTMLDivElement | null>; onFormatChange: () => void }) {
|
||||
const [activeStates, setActiveStates] = useState<Record<string, boolean>>({});
|
||||
|
||||
const updateActiveStates = useCallback(() => {
|
||||
setActiveStates({
|
||||
bold: document.queryCommandState('bold'),
|
||||
italic: document.queryCommandState('italic'),
|
||||
underline: document.queryCommandState('underline'),
|
||||
strikethrough: document.queryCommandState('strikeThrough'),
|
||||
justifyLeft: document.queryCommandState('justifyLeft'),
|
||||
justifyCenter: document.queryCommandState('justifyCenter'),
|
||||
justifyRight: document.queryCommandState('justifyRight'),
|
||||
});
|
||||
}, []);
|
||||
|
||||
const execCommand = useCallback((command: string, value?: string) => {
|
||||
document.execCommand(command, false, value);
|
||||
editorRef.current?.focus();
|
||||
updateActiveStates();
|
||||
onFormatChange();
|
||||
}, [editorRef, updateActiveStates, onFormatChange]);
|
||||
|
||||
const applyHeading = useCallback((level: number) => {
|
||||
document.execCommand('formatBlock', false, `h${level}`);
|
||||
editorRef.current?.focus();
|
||||
updateActiveStates();
|
||||
onFormatChange();
|
||||
}, [editorRef, updateActiveStates, onFormatChange]);
|
||||
|
||||
const applyTextColor = useCallback((color: string) => {
|
||||
document.execCommand('foreColor', false, color);
|
||||
editorRef.current?.focus();
|
||||
updateActiveStates();
|
||||
onFormatChange();
|
||||
}, [editorRef, updateActiveStates, onFormatChange]);
|
||||
|
||||
const applyHighlight = useCallback((color: string) => {
|
||||
if (color === 'transparent') {
|
||||
document.execCommand('removeFormat', false);
|
||||
} else {
|
||||
document.execCommand('hiliteColor', false, color);
|
||||
}
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
textarea.setSelectionRange(start + prefix.length, start + prefix.length);
|
||||
textarea.focus();
|
||||
};
|
||||
editorRef.current?.focus();
|
||||
updateActiveStates();
|
||||
onFormatChange();
|
||||
}, [editorRef, updateActiveStates, onFormatChange]);
|
||||
|
||||
const fmtBtnClass = (active: boolean) =>
|
||||
`min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0 ${active ? 'bg-gray-200 dark:bg-gray-600 text-gray-900 dark:text-gray-100' : ''}`;
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
@@ -343,7 +355,7 @@ function RichTextToolbar({ textareaRef }: { textareaRef: React.RefObject<HTMLTex
|
||||
{/* Format group */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="sm" onClick={applyFormat('**', '**')} className="min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0">
|
||||
<Button variant="ghost" size="sm" onClick={() => execCommand('bold')} className={fmtBtnClass(activeStates.bold)}>
|
||||
<Bold className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
@@ -351,7 +363,7 @@ function RichTextToolbar({ textareaRef }: { textareaRef: React.RefObject<HTMLTex
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="sm" onClick={applyFormat('*', '*')} className="min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0">
|
||||
<Button variant="ghost" size="sm" onClick={() => execCommand('italic')} className={fmtBtnClass(activeStates.italic)}>
|
||||
<ItalicIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
@@ -359,19 +371,27 @@ function RichTextToolbar({ textareaRef }: { textareaRef: React.RefObject<HTMLTex
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="sm" onClick={applyFormat('_', '_')} className="min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0">
|
||||
<Button variant="ghost" size="sm" onClick={() => execCommand('underline')} className={fmtBtnClass(activeStates.underline)}>
|
||||
<UnderlineIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">{t('notebooks.toolbar_underline')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="sm" onClick={() => execCommand('strikeThrough')} className={fmtBtnClass(activeStates.strikethrough)}>
|
||||
<Strikethrough className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">{t('notebooks.toolbar_strikethrough')}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Separator orientation="vertical" className="h-5 mx-1" />
|
||||
|
||||
{/* Heading group */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="sm" onClick={applyLinePrefix('# ')} className="min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0">
|
||||
<Button variant="ghost" size="sm" onClick={() => applyHeading(1)} className="min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0">
|
||||
<Heading1 className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
@@ -379,7 +399,7 @@ function RichTextToolbar({ textareaRef }: { textareaRef: React.RefObject<HTMLTex
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="sm" onClick={applyLinePrefix('## ')} className="min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0">
|
||||
<Button variant="ghost" size="sm" onClick={() => applyHeading(2)} className="min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0">
|
||||
<Heading2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
@@ -387,7 +407,7 @@ function RichTextToolbar({ textareaRef }: { textareaRef: React.RefObject<HTMLTex
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="sm" onClick={applyLinePrefix('### ')} className="min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0">
|
||||
<Button variant="ghost" size="sm" onClick={() => applyHeading(3)} className="min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0">
|
||||
<Heading3 className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
@@ -399,7 +419,7 @@ function RichTextToolbar({ textareaRef }: { textareaRef: React.RefObject<HTMLTex
|
||||
{/* List group */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="sm" onClick={applyBlockPrefix('- ')} className="min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0">
|
||||
<Button variant="ghost" size="sm" onClick={() => execCommand('insertUnorderedList')} className="min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0">
|
||||
<List className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
@@ -407,7 +427,7 @@ function RichTextToolbar({ textareaRef }: { textareaRef: React.RefObject<HTMLTex
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="sm" onClick={applyBlockPrefix('1. ')} className="min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0">
|
||||
<Button variant="ghost" size="sm" onClick={() => execCommand('insertOrderedList')} className="min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0">
|
||||
<ListOrdered className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
@@ -419,7 +439,7 @@ function RichTextToolbar({ textareaRef }: { textareaRef: React.RefObject<HTMLTex
|
||||
{/* Alignment group */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="sm" onClick={applyLinePrefix('')} className="min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0">
|
||||
<Button variant="ghost" size="sm" onClick={() => execCommand('justifyLeft')} className={fmtBtnClass(activeStates.justifyLeft)}>
|
||||
<AlignLeftIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
@@ -427,7 +447,7 @@ function RichTextToolbar({ textareaRef }: { textareaRef: React.RefObject<HTMLTex
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="sm" onClick={applyLinePrefix('::center::\n')} className="min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0">
|
||||
<Button variant="ghost" size="sm" onClick={() => execCommand('justifyCenter')} className={fmtBtnClass(activeStates.justifyCenter)}>
|
||||
<AlignCenterIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
@@ -435,12 +455,74 @@ function RichTextToolbar({ textareaRef }: { textareaRef: React.RefObject<HTMLTex
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="sm" onClick={applyLinePrefix('::right::\n')} className="min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0">
|
||||
<Button variant="ghost" size="sm" onClick={() => execCommand('justifyRight')} className={fmtBtnClass(activeStates.justifyRight)}>
|
||||
<AlignRightIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">{t('notebooks.toolbar_align_right')}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Separator orientation="vertical" className="h-5 mx-1" />
|
||||
|
||||
{/* Text color */}
|
||||
<Popover>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0">
|
||||
<Type className="w-4 h-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">{t('notebooks.toolbar_text_color')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent className="w-auto p-2" align="start">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{TEXT_COLORS.map((tc) => (
|
||||
<button
|
||||
key={tc.key}
|
||||
onClick={() => applyTextColor(tc.color)}
|
||||
className="w-7 h-7 rounded-full border-2 border-gray-200 dark:border-gray-600 hover:scale-110 transition-transform"
|
||||
style={{ backgroundColor: tc.color }}
|
||||
title={t(tc.labelKey)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Highlight color */}
|
||||
<Popover>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0">
|
||||
<Highlighter className="w-4 h-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">{t('notebooks.toolbar_highlight')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent className="w-auto p-2" align="start">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{HIGHLIGHT_COLORS.map((hc) => (
|
||||
<button
|
||||
key={hc.key}
|
||||
onClick={() => applyHighlight(hc.color)}
|
||||
className={`w-7 h-7 rounded-full border-2 hover:scale-110 transition-transform ${
|
||||
hc.key === 'none'
|
||||
? 'border-gray-300 dark:border-gray-500 bg-white dark:bg-gray-800 flex items-center justify-center'
|
||||
: 'border-gray-200 dark:border-gray-600'
|
||||
}`}
|
||||
style={hc.key !== 'none' ? { backgroundColor: hc.color } : {}}
|
||||
title={t(hc.labelKey)}
|
||||
>
|
||||
{hc.key === 'none' && <X className="w-3.5 h-3.5 text-gray-400" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
@@ -897,6 +979,7 @@ function NotebookDetailView({
|
||||
onDeletePage,
|
||||
onToggleBookmark,
|
||||
onTogglePublic,
|
||||
onReorderPages,
|
||||
}: {
|
||||
notebook: Notebook;
|
||||
subjectName: string | null;
|
||||
@@ -906,6 +989,7 @@ function NotebookDetailView({
|
||||
onDeletePage: (pageId: string) => void;
|
||||
onToggleBookmark: (pageId: string) => void;
|
||||
onTogglePublic: () => void;
|
||||
onReorderPages: (pageOrders: Array<{ id: string; pageNumber: number }>) => Promise<void>;
|
||||
}) {
|
||||
const [currentPageId, setCurrentPageId] = useState<string | null>(null);
|
||||
const [pageContent, setPageContent] = useState('');
|
||||
@@ -914,39 +998,71 @@ function NotebookDetailView({
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [autoSaveStatus, setAutoSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
// Drag-and-drop state
|
||||
const [draggedPageId, setDraggedPageId] = useState<string | null>(null);
|
||||
const [dragOverPageId, setDragOverPageId] = useState<string | null>(null);
|
||||
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastSavedContentRef = useRef<string>('');
|
||||
const lastSavedTitleRef = useRef<string>('');
|
||||
const isInternalChange = useRef(false);
|
||||
|
||||
const pages = notebook.pages ?? [];
|
||||
const currentPage = pages.find(p => p.id === currentPageId) ?? pages[0] ?? null;
|
||||
|
||||
// Set content into editor ref without triggering auto-save
|
||||
const setEditorContent = useCallback((html: string) => {
|
||||
isInternalChange.current = true;
|
||||
if (editorRef.current) {
|
||||
editorRef.current.innerHTML = html;
|
||||
}
|
||||
setPageContent(html);
|
||||
// Reset flag after React processes the state update
|
||||
setTimeout(() => { isInternalChange.current = false; }, 50);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPage) {
|
||||
setCurrentPageId(currentPage.id);
|
||||
setPageContent(currentPage.textContent ?? '');
|
||||
setEditorContent(currentPage.textContent ?? '');
|
||||
setPageTitle(currentPage.title ?? '');
|
||||
lastSavedContentRef.current = currentPage.textContent ?? '';
|
||||
lastSavedTitleRef.current = currentPage.title ?? '';
|
||||
} else if (pages.length > 0) {
|
||||
setCurrentPageId(pages[0].id);
|
||||
setPageContent(pages[0].textContent ?? '');
|
||||
setEditorContent(pages[0].textContent ?? '');
|
||||
setPageTitle(pages[0].title ?? '');
|
||||
lastSavedContentRef.current = pages[0].textContent ?? '';
|
||||
lastSavedTitleRef.current = pages[0].title ?? '';
|
||||
}
|
||||
}, [notebook.id]);
|
||||
}, [notebook.id, setEditorContent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPage) {
|
||||
setPageContent(currentPage.textContent ?? '');
|
||||
setEditorContent(currentPage.textContent ?? '');
|
||||
setPageTitle(currentPage.title ?? '');
|
||||
lastSavedContentRef.current = currentPage.textContent ?? '';
|
||||
lastSavedTitleRef.current = currentPage.title ?? '';
|
||||
setAutoSaveStatus('idle');
|
||||
}
|
||||
}, [currentPageId]);
|
||||
}, [currentPageId, setEditorContent]);
|
||||
|
||||
// Handle content change from contentEditable div
|
||||
const handleEditorInput = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
const html = editorRef.current.innerHTML;
|
||||
setPageContent(html);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle format change from toolbar (for active state refresh)
|
||||
const handleFormatChange = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
const html = editorRef.current.innerHTML;
|
||||
setPageContent(html);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-save with 3 second debounce
|
||||
const triggerAutoSave = useCallback(() => {
|
||||
@@ -968,7 +1084,6 @@ function NotebookDetailView({
|
||||
lastSavedContentRef.current = pageContent;
|
||||
lastSavedTitleRef.current = pageTitle;
|
||||
setAutoSaveStatus('saved');
|
||||
// Reset status after 2 seconds
|
||||
setTimeout(() => setAutoSaveStatus('idle'), 2000);
|
||||
} catch {
|
||||
setAutoSaveStatus('idle');
|
||||
@@ -978,6 +1093,7 @@ function NotebookDetailView({
|
||||
|
||||
// Trigger auto-save on content or title change
|
||||
useEffect(() => {
|
||||
if (isInternalChange.current) return;
|
||||
triggerAutoSave();
|
||||
return () => {
|
||||
if (autoSaveTimerRef.current) {
|
||||
@@ -988,17 +1104,22 @@ function NotebookDetailView({
|
||||
|
||||
const handleSavePage = async () => {
|
||||
if (!currentPage) return;
|
||||
// Sync content from editor
|
||||
if (editorRef.current) {
|
||||
setPageContent(editorRef.current.innerHTML);
|
||||
}
|
||||
// Cancel pending auto-save
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current);
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const contentToSave = editorRef.current?.innerHTML ?? pageContent;
|
||||
await onUpdatePage(currentPage.id, {
|
||||
textContent: pageContent,
|
||||
textContent: contentToSave,
|
||||
title: pageTitle.trim() || null,
|
||||
});
|
||||
lastSavedContentRef.current = pageContent;
|
||||
lastSavedContentRef.current = contentToSave;
|
||||
lastSavedTitleRef.current = pageTitle;
|
||||
toast.success(t('notebooks.page_saved'));
|
||||
setAutoSaveStatus('idle');
|
||||
@@ -1009,6 +1130,94 @@ function NotebookDetailView({
|
||||
}
|
||||
};
|
||||
|
||||
// Drag-and-drop handlers for page reorder
|
||||
const handleDragStart = useCallback((e: React.DragEvent, pageId: string) => {
|
||||
setDraggedPageId(pageId);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
// Set a transparent drag image
|
||||
const ghost = document.createElement('div');
|
||||
ghost.style.opacity = '0';
|
||||
document.body.appendChild(ghost);
|
||||
e.dataTransfer.setDragImage(ghost, 0, 0);
|
||||
setTimeout(() => document.body.removeChild(ghost), 0);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent, pageId: string) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
if (draggedPageId && pageId !== draggedPageId) {
|
||||
setDragOverPageId(pageId);
|
||||
}
|
||||
}, [draggedPageId]);
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
setDragOverPageId(null);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(async (targetPageId: string) => {
|
||||
if (!draggedPageId || draggedPageId === targetPageId) {
|
||||
setDraggedPageId(null);
|
||||
setDragOverPageId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const draggedIdx = pages.findIndex(p => p.id === draggedPageId);
|
||||
const targetIdx = pages.findIndex(p => p.id === targetPageId);
|
||||
if (draggedIdx === -1 || targetIdx === -1) {
|
||||
setDraggedPageId(null);
|
||||
setDragOverPageId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Reorder pages locally
|
||||
const newPages = [...pages];
|
||||
const [removed] = newPages.splice(draggedIdx, 1);
|
||||
newPages.splice(targetIdx, 0, removed);
|
||||
|
||||
// Build new pageOrders array
|
||||
const pageOrders = newPages.map((p, i) => ({ id: p.id, pageNumber: i + 1 }));
|
||||
|
||||
setDraggedPageId(null);
|
||||
setDragOverPageId(null);
|
||||
|
||||
try {
|
||||
await onReorderPages(pageOrders);
|
||||
} catch {
|
||||
toast.error(t('notebooks.reorder_error'));
|
||||
}
|
||||
}, [draggedPageId, pages, onReorderPages]);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDraggedPageId(null);
|
||||
setDragOverPageId(null);
|
||||
}, []);
|
||||
|
||||
// Touch-based drag handlers for tablets
|
||||
const touchDragRef = useRef<{ pageId: string; startY: number; clone: HTMLElement | null } | null>(null);
|
||||
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent, pageId: string) => {
|
||||
const touch = e.touches[0];
|
||||
touchDragRef.current = { pageId, startY: touch.clientY, clone: null };
|
||||
}, []);
|
||||
|
||||
const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
||||
if (!touchDragRef.current) return;
|
||||
const touch = e.touches[0];
|
||||
const deltaY = Math.abs(touch.clientY - touchDragRef.current.startY);
|
||||
if (deltaY > 10 && !touchDragRef.current.clone) {
|
||||
setDraggedPageId(touchDragRef.current.pageId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
if (dragOverPageId && touchDragRef.current && dragOverPageId !== touchDragRef.current.pageId) {
|
||||
handleDrop(dragOverPageId);
|
||||
}
|
||||
touchDragRef.current = null;
|
||||
setDraggedPageId(null);
|
||||
setDragOverPageId(null);
|
||||
}, [dragOverPageId, handleDrop]);
|
||||
|
||||
const IconComponent = notebook.icon ? ICON_MAP[notebook.icon] ?? BookOpen : BookOpen;
|
||||
|
||||
return (
|
||||
@@ -1114,20 +1323,47 @@ function NotebookDetailView({
|
||||
|
||||
<ScrollArea className="flex-1 max-h-[calc(100vh-280px)]">
|
||||
<div className="p-2 space-y-2">
|
||||
{pages.map((page, idx) => (
|
||||
{pages.map((page, idx) => {
|
||||
const isDragged = draggedPageId === page.id;
|
||||
const isDragOver = dragOverPageId === page.id;
|
||||
return (
|
||||
<motion.div
|
||||
key={page.id}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
animate={{
|
||||
opacity: isDragged ? 0.5 : 1,
|
||||
x: 0,
|
||||
scale: isDragged ? 0.95 : 1,
|
||||
}}
|
||||
transition={{ delay: idx * 0.03 }}
|
||||
layout
|
||||
className={`rounded-lg transition-all ${
|
||||
currentPageId === page.id
|
||||
? 'ring-2 ring-emerald-500'
|
||||
: ''
|
||||
} ${isDragOver ? 'ring-2 ring-blue-400 shadow-lg' : ''} ${
|
||||
isDragged ? 'shadow-md' : ''
|
||||
}`}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e as unknown as React.DragEvent<HTMLDivElement>, page.id)}
|
||||
onDragOver={(e) => handleDragOver(e as unknown as React.DragEvent<HTMLDivElement>, page.id)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={() => handleDrop(page.id)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onTouchStart={(e) => handleTouchStart(e, page.id)}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div className="flex items-center">
|
||||
<div className="pl-1.5 py-1 cursor-grab active:cursor-grabbing touch-none">
|
||||
<GripVertical className="w-4 h-4 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
{/* Page thumbnail */}
|
||||
<PageThumbnail page={page} notebookType={notebook.notebookType} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Page info below thumbnail */}
|
||||
<button
|
||||
@@ -1149,7 +1385,8 @@ function NotebookDetailView({
|
||||
)}
|
||||
</button>
|
||||
</motion.div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -1220,10 +1457,10 @@ function NotebookDetailView({
|
||||
</motion.div>
|
||||
</div>
|
||||
) : (
|
||||
/* Text editing area with rich text toolbar and page background */
|
||||
/* Text editing area with WYSIWYG toolbar and page background */
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Rich text toolbar */}
|
||||
<RichTextToolbar textareaRef={textareaRef} />
|
||||
{/* WYSIWYG toolbar */}
|
||||
<WysiwygToolbar editorRef={editorRef} onFormatChange={handleFormatChange} />
|
||||
|
||||
{/* Page content area */}
|
||||
<div className="flex-1 overflow-hidden p-4">
|
||||
@@ -1236,12 +1473,13 @@ function NotebookDetailView({
|
||||
className="min-h-full p-6"
|
||||
style={getPageBackgroundCSS(currentPage.background ?? notebook.notebookType)}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={pageContent}
|
||||
onChange={(e) => setPageContent(e.target.value)}
|
||||
placeholder={t('notebooks.page_content') + '...'}
|
||||
className="w-full min-h-[500px] bg-transparent border-0 outline-none resize-none text-base leading-8 text-gray-800 dark:text-gray-200 placeholder:text-gray-300 dark:placeholder:text-gray-600 focus:ring-0"
|
||||
<div
|
||||
ref={editorRef}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
onInput={handleEditorInput}
|
||||
data-placeholder={t('notebooks.page_content') + '...'}
|
||||
className="w-full min-h-[500px] bg-transparent outline-none text-base text-gray-800 dark:text-gray-200 focus:ring-0 prose prose-sm max-w-none [&:empty]:before:content-[attr(data-placeholder)] [&:empty]:before:text-gray-300 [&:empty]:dark:before:text-gray-600 [&_h1]:text-2xl [&_h1]:font-bold [&_h1]:mb-2 [&_h1]:mt-4 [&_h2]:text-xl [&_h2]:font-bold [&_h2]:mb-1.5 [&_h2]:mt-3 [&_h3]:text-lg [&_h3]:font-semibold [&_h3]:mb-1 [&_h3]:mt-2 [&_ul]:list-disc [&_ul]:pl-6 [&_ul]:mb-2 [&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:mb-2 [&_li]:mb-0.5"
|
||||
style={{ lineHeight: notebook.notebookType === 'lined' || notebook.notebookType === 'calligraphy' ? '32px' : '1.5' }}
|
||||
/>
|
||||
</div>
|
||||
@@ -1507,6 +1745,21 @@ export default function NotebooksView() {
|
||||
setNotebooks(prev => prev.map(n => n.id === updated.id ? updated : n));
|
||||
}, [selectedNotebook]);
|
||||
|
||||
const handleReorderPages = useCallback(async (pageOrders: Array<{ id: string; pageNumber: number }>) => {
|
||||
if (!selectedNotebook) return;
|
||||
const updatedPages = await apiPut<NotebookPage[]>(`/api/notebooks/${selectedNotebook.id}/pages/reorder`, {
|
||||
pageOrders,
|
||||
});
|
||||
setSelectedNotebook(prev => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
pages: updatedPages,
|
||||
};
|
||||
});
|
||||
toast.success(t('notebooks.reorder_success'));
|
||||
}, [selectedNotebook]);
|
||||
|
||||
const handleShare = useCallback(async (notebook: Notebook) => {
|
||||
setShareConfirmNotebook(notebook);
|
||||
}, []);
|
||||
@@ -1646,6 +1899,7 @@ export default function NotebooksView() {
|
||||
onDeletePage={handleDeletePage}
|
||||
onToggleBookmark={handleToggleBookmark}
|
||||
onTogglePublic={handleTogglePublic}
|
||||
onReorderPages={handleReorderPages}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -121,6 +121,7 @@ const relativeDate = (dateStr: string) => {
|
||||
export default function ProgressEntriesView() {
|
||||
const currentUser = useAppStore((s) => s.currentUser);
|
||||
const currentClassId = useAppStore((s) => s.currentClassId);
|
||||
const locale = useAppStore((s) => s.locale);
|
||||
|
||||
const [classes, setClasses] = useState<ClassGroup[]>([]);
|
||||
const [selectedClass, setSelectedClass] = useState<ClassGroup | null>(null);
|
||||
@@ -915,7 +916,7 @@ export default function ProgressEntriesView() {
|
||||
style={{
|
||||
backgroundColor: `${catColor}20`,
|
||||
color: catColor,
|
||||
ringColor: `${catColor}30`,
|
||||
outlineColor: `${catColor}30`,
|
||||
}}
|
||||
>
|
||||
{entry.competency.category.name[0]}
|
||||
|
||||
@@ -1479,33 +1479,35 @@ export default function SettingsView() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* GDPR Data Erasure */}
|
||||
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-rose-500 overflow-hidden">
|
||||
<CardHeader className="bg-gradient-to-r from-rose-50/50 to-transparent dark:from-rose-900/10 dark:to-transparent">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{/* GDPR Data Erasure — Danger Zone */}
|
||||
<div className="danger-zone">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<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">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-rose-700 dark:text-rose-300">
|
||||
{t('settings.data_erasure')}
|
||||
</CardTitle>
|
||||
<CardDescription>{t('settings.data_erasure_desc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="p-4 rounded-xl bg-rose-50/50 dark:bg-rose-900/10 border border-rose-200/30 dark:border-rose-900/20">
|
||||
<p className="text-sm text-rose-700 dark:text-rose-300 font-medium">
|
||||
<AlertTriangle className="w-3.5 h-3.5 inline mr-1" /> {t('settings.data_erasure_desc')}
|
||||
</h3>
|
||||
<p className="text-xs text-rose-600/70 dark:text-rose-400/60">
|
||||
{t('settings.data_erasure_desc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-rose-50/50 dark:bg-rose-900/10 border border-rose-200/30 dark:border-rose-900/20 mb-3">
|
||||
<p className="text-xs text-rose-700 dark:text-rose-300 font-medium">
|
||||
<AlertTriangle className="w-3 h-3 inline mr-1" /> {t('settings.data_erasure_desc')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setShowErasureDialog(true)}
|
||||
variant="outline"
|
||||
className="border-rose-300 dark:border-rose-700 text-rose-700 dark:text-rose-300 hover:bg-rose-50 dark:hover:bg-rose-900/20 rounded-xl px-6"
|
||||
className="border-rose-300 dark:border-rose-700 text-rose-700 dark:text-rose-300 hover:bg-rose-50 dark:hover:bg-rose-900/20 rounded-xl px-6 min-h-[44px]"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
{t('settings.data_erasure_request')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Erasure Dialog */}
|
||||
<AlertDialog open={showErasureDialog} onOpenChange={setShowErasureDialog}>
|
||||
|
||||
+110
@@ -142,6 +142,7 @@ export interface ClassGroup {
|
||||
teachers: Array<{ id: string; userId: string; role: string; user: { id: string; firstName: string; lastName: string; email: string } }>;
|
||||
studentCount?: number;
|
||||
teacherList?: Array<{ id: string; firstName: string; lastName: string; teacherRole: string }>;
|
||||
_count?: { enrollments?: number; competencyAssignments?: number; [key: string]: unknown };
|
||||
}
|
||||
|
||||
export function fetchClasses(schoolId?: string, schoolYearId?: string): Promise<ClassGroup[]> {
|
||||
@@ -1339,6 +1340,69 @@ export function fetchCalendarEvents(schoolId: string, month: string): Promise<Ca
|
||||
return apiGet<CalendarResponse>(`/api/calendar?${sp.toString()}`);
|
||||
}
|
||||
|
||||
/* ── Calendar Events (Custom) ──────────────────────────────────── */
|
||||
|
||||
export type CalendarEventItemType = 'assessment' | 'lesson' | 'reminder';
|
||||
|
||||
export interface CalendarEventItem {
|
||||
id: string;
|
||||
schoolId: string;
|
||||
teacherId: string;
|
||||
title: string;
|
||||
date: string;
|
||||
startTime: string | null;
|
||||
endTime: string | null;
|
||||
eventType: CalendarEventItemType;
|
||||
subjectId: string | null;
|
||||
classGroupId: string | null;
|
||||
notes: string | null;
|
||||
allDay: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
subject: { id: string; name: string } | null;
|
||||
classGroup: { id: string; name: string } | null;
|
||||
}
|
||||
|
||||
export function fetchCalendarEventItems(schoolId: string, month: string): Promise<CalendarEventItem[]> {
|
||||
const sp = new URLSearchParams();
|
||||
sp.set('schoolId', schoolId);
|
||||
sp.set('month', month);
|
||||
return apiGet<CalendarEventItem[]>(`/api/calendar-events?${sp.toString()}`);
|
||||
}
|
||||
|
||||
export function createCalendarEventItem(data: {
|
||||
schoolId: string;
|
||||
title: string;
|
||||
date: string;
|
||||
startTime?: string | null;
|
||||
endTime?: string | null;
|
||||
eventType: CalendarEventItemType;
|
||||
subjectId?: string | null;
|
||||
classGroupId?: string | null;
|
||||
notes?: string | null;
|
||||
allDay?: boolean;
|
||||
}): Promise<CalendarEventItem> {
|
||||
return apiPost<CalendarEventItem>('/api/calendar-events', data);
|
||||
}
|
||||
|
||||
export function updateCalendarEventItem(id: string, data: {
|
||||
title?: string;
|
||||
date?: string;
|
||||
startTime?: string | null;
|
||||
endTime?: string | null;
|
||||
eventType?: CalendarEventItemType;
|
||||
subjectId?: string | null;
|
||||
classGroupId?: string | null;
|
||||
notes?: string | null;
|
||||
allDay?: boolean;
|
||||
}): Promise<CalendarEventItem> {
|
||||
return apiPut<CalendarEventItem>(`/api/calendar-events/${id}`, data);
|
||||
}
|
||||
|
||||
export function deleteCalendarEventItem(id: string): Promise<{ success: boolean }> {
|
||||
return apiDelete<{ success: boolean }>(`/api/calendar-events/${id}`);
|
||||
}
|
||||
|
||||
/* ── Lesson Plans ────────────────────────────────────────────────── */
|
||||
|
||||
export type LessonPlanStatus = 'draft' | 'scheduled' | 'completed' | 'cancelled';
|
||||
@@ -1974,20 +2038,66 @@ export interface MissingObservationNotification {
|
||||
isRead: boolean;
|
||||
}
|
||||
|
||||
/** @deprecated Use DBNotification instead */
|
||||
export interface NotificationData {
|
||||
upcomingAssessments: AssessmentNotification[];
|
||||
missingObservations: MissingObservationNotification[];
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
/** New DB-backed notification type */
|
||||
export interface DBNotification {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
message: string;
|
||||
isRead: boolean;
|
||||
actionUrl: string | null;
|
||||
relatedId: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface DBNotificationData {
|
||||
notifications: DBNotification[];
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
export function fetchNotifications(): Promise<NotificationData> {
|
||||
return apiGet<NotificationData>('/api/notifications');
|
||||
}
|
||||
|
||||
export function fetchDBNotifications(): Promise<DBNotificationData> {
|
||||
return apiGet<DBNotificationData>('/api/notifications');
|
||||
}
|
||||
|
||||
export function markServerNotificationsRead(ids: string[]): Promise<{ success: boolean }> {
|
||||
return apiPut<{ success: boolean }>('/api/notifications', { ids });
|
||||
}
|
||||
|
||||
export function markAllNotificationsRead(): Promise<{ success: boolean }> {
|
||||
return apiPut<{ success: boolean }>('/api/notifications', { markAll: true });
|
||||
}
|
||||
|
||||
export function markSingleNotificationRead(id: string): Promise<{ success: boolean }> {
|
||||
return apiPut<{ success: boolean }>('/api/notifications', { ids: [id] });
|
||||
}
|
||||
|
||||
export function createNotification(data: {
|
||||
schoolId: string;
|
||||
userId: string;
|
||||
type: string;
|
||||
title: string;
|
||||
message: string;
|
||||
actionUrl?: string;
|
||||
relatedId?: string;
|
||||
}): Promise<{ notification: DBNotification }> {
|
||||
return apiPost<{ notification: DBNotification }>('/api/notifications', data);
|
||||
}
|
||||
|
||||
export function deleteNotifications(ids?: string[]): Promise<{ success: boolean }> {
|
||||
return apiDelete<{ success: boolean }>(`/api/notifications${ids ? `?ids=${ids.join(',')}` : ''}`);
|
||||
}
|
||||
|
||||
/* ── Batch Delete & Reorder ─────────────────────────────────────────── */
|
||||
|
||||
export async function deleteBatchProgressEntries(ids: string[]): Promise<void> {
|
||||
|
||||
+200
@@ -1898,6 +1898,22 @@ const de: TranslationDict = {
|
||||
'notebooks.toolbar_align_left': 'Linksbuendig',
|
||||
'notebooks.toolbar_align_center': 'Zentriert',
|
||||
'notebooks.toolbar_align_right': 'Rechtsbuendig',
|
||||
'notebooks.toolbar_strikethrough': 'Durchgestrichen',
|
||||
'notebooks.toolbar_text_color': 'Textfarbe',
|
||||
'notebooks.toolbar_highlight': 'Hervorhebung',
|
||||
'notebooks.toolbar_highlight_yellow': 'Gelb',
|
||||
'notebooks.toolbar_highlight_green': 'Gruen',
|
||||
'notebooks.toolbar_highlight_blue': 'Blau',
|
||||
'notebooks.toolbar_highlight_pink': 'Rosa',
|
||||
'notebooks.toolbar_highlight_none': 'Keine',
|
||||
'notebooks.toolbar_color_black': 'Schwarz',
|
||||
'notebooks.toolbar_color_red': 'Rot',
|
||||
'notebooks.toolbar_color_blue': 'Blau',
|
||||
'notebooks.toolbar_color_green': 'Gruen',
|
||||
'notebooks.toolbar_color_orange': 'Orange',
|
||||
'notebooks.reorder_pages': 'Seiten neu anordnen',
|
||||
'notebooks.reorder_success': 'Reihenfolge gespeichert',
|
||||
'notebooks.reorder_error': 'Fehler beim Speichern der Reihenfolge',
|
||||
'notebooks.auto_saving': 'Automatisch speichern...',
|
||||
'notebooks.auto_saved': 'Automatisch gespeichert',
|
||||
'notebooks.page_thumbnail': 'Seitenvorschau',
|
||||
@@ -1957,6 +1973,90 @@ const de: TranslationDict = {
|
||||
'notebooks.template_science_desc': 'Kariertes Heft fuer Experimente',
|
||||
'notebooks.tab_all': 'Meine Hefte',
|
||||
'notebooks.owner': 'Von',
|
||||
|
||||
// ─── Enhanced Notifications ──────────────────────────────────────
|
||||
'notifications.today': 'Heute',
|
||||
'notifications.yesterday': 'Gestern',
|
||||
'notifications.this_week': 'Diese Woche',
|
||||
'notifications.earlier': 'Frueher',
|
||||
'notifications.mark_read': 'Als gelesen markieren',
|
||||
'notifications.clear_all': 'Alle loeschen',
|
||||
'notifications.type_assessment_due': 'Anstehende Pruefung',
|
||||
'notifications.type_missing_observation': 'Fehlende Beobachtung',
|
||||
'notifications.type_notebook_shared': 'Heft geteilt',
|
||||
'notifications.type_behavior_alert': 'Verhaltenswarnung',
|
||||
'notifications.type_grade_computed': 'Note berechnet',
|
||||
'notifications.type_attendance_alert': 'Anwesenheitswarnung',
|
||||
'notifications.type_report_ready': 'Bericht fertig',
|
||||
'notifications.type_general': 'Allgemein',
|
||||
|
||||
// ─── Student Dashboard ──────────────────────────────────────────────
|
||||
'student.dashboard_title': 'Mein Dashboard',
|
||||
'student.my_competencies': 'Meine Kompetenzen',
|
||||
'student.my_competencies_desc': 'Uebersicht deiner Kompetenzentwicklung',
|
||||
'student.my_grades': 'Meine Noten',
|
||||
'student.my_grades_desc': 'Aktuelle Bewertungsergebnisse',
|
||||
'student.my_notebooks': 'Meine Hefte',
|
||||
'student.my_notebooks_desc': 'Schnellzugriff auf deine Hefte',
|
||||
'student.my_attendance': 'Meine Anwesenheit',
|
||||
'student.my_attendance_desc': 'Anwesenheitsuebersicht',
|
||||
'student.upcoming_assessments': 'Anstehende Pruefungen',
|
||||
'student.upcoming_assessments_desc': 'Pruefungen in den naechsten Tagen',
|
||||
'student.no_competencies': 'Noch keine Kompetenzdaten vorhanden',
|
||||
'student.no_grades': 'Noch keine Noten vorhanden',
|
||||
'student.no_notebooks': 'Noch keine Hefte vorhanden',
|
||||
'student.no_attendance': 'Keine Anwesenheitsdaten vorhanden',
|
||||
'student.no_assessments': 'Keine anstehenden Pruefungen',
|
||||
'student.competency_level': 'Kompetenzniveau',
|
||||
'student.grade_in': 'Note in',
|
||||
'student.days_until': 'Tage bis',
|
||||
'student.attendance_rate': 'Anwesenheitsrate',
|
||||
'student.present': 'Anwesend',
|
||||
'student.absent': 'Abwesend',
|
||||
'student.late': 'Verspaetet',
|
||||
'student.view_all': 'Alle anzeigen',
|
||||
'student.welcome_back': 'Willkommen zurueck',
|
||||
'student.progress_overview': 'Dein Lernfortschritt',
|
||||
'nav.student_competencies': 'Meine Kompetenzen',
|
||||
'nav.student_grades': 'Meine Noten',
|
||||
'nav.student_notebooks': 'Meine Hefte',
|
||||
'nav.student_attendance': 'Meine Anwesenheit',
|
||||
'role.student': 'Schueler',
|
||||
|
||||
// Calendar (enhanced - Task 4)
|
||||
'calendar.new_event': 'Neues Ereignis',
|
||||
'calendar.edit_event': 'Ereignis bearbeiten',
|
||||
'calendar.week_view': 'Wochenansicht',
|
||||
'calendar.month_view': 'Monatsansicht',
|
||||
'calendar.all_day': 'Ganztägig',
|
||||
'calendar.event_type': 'Ereignistyp',
|
||||
'calendar.reminder': 'Erinnerung',
|
||||
'calendar.start_time': 'Startzeit',
|
||||
'calendar.end_time': 'Endzeit',
|
||||
'calendar.notes': 'Notizen',
|
||||
'calendar.notes_placeholder': 'Zusätzliche Notizen...',
|
||||
'calendar.delete_confirm': 'Ereignis wirklich löschen?',
|
||||
'calendar.delete_confirm_desc': 'Dieses Ereignis wird unwiderruflich gelöscht.',
|
||||
'calendar.event_created': 'Ereignis erstellt',
|
||||
'calendar.event_updated': 'Ereignis aktualisiert',
|
||||
'calendar.event_deleted': 'Ereignis gelöscht',
|
||||
'calendar.event_create_error': 'Fehler beim Erstellen des Ereignisses',
|
||||
'calendar.event_update_error': 'Fehler beim Aktualisieren des Ereignisses',
|
||||
'calendar.event_delete_error': 'Fehler beim Löschen des Ereignisses',
|
||||
|
||||
// Dashboard (enhanced - Task 4)
|
||||
'dashboard.new_assessment': 'Neue Bewertung',
|
||||
'dashboard.record_progress': 'Fortschritt erfassen',
|
||||
'dashboard.my_competencies': 'Meine Kompetenzen',
|
||||
'dashboard.my_grades': 'Meine Noten',
|
||||
'dashboard.paper_saved': 'Papier gespart',
|
||||
'dashboard.view_all': 'Alle anzeigen',
|
||||
|
||||
// Notifications (enhanced - Task 4)
|
||||
'notifications.no_notifications': 'Keine Benachrichtigungen',
|
||||
|
||||
// Student (enhanced - Task 4)
|
||||
'student.shared_with_me': 'Mit mir geteilt',
|
||||
};
|
||||
|
||||
const en: TranslationDict = {
|
||||
@@ -3854,6 +3954,22 @@ const en: TranslationDict = {
|
||||
'notebooks.toolbar_align_left': 'Align Left',
|
||||
'notebooks.toolbar_align_center': 'Align Center',
|
||||
'notebooks.toolbar_align_right': 'Align Right',
|
||||
'notebooks.toolbar_strikethrough': 'Strikethrough',
|
||||
'notebooks.toolbar_text_color': 'Text Color',
|
||||
'notebooks.toolbar_highlight': 'Highlight',
|
||||
'notebooks.toolbar_highlight_yellow': 'Yellow',
|
||||
'notebooks.toolbar_highlight_green': 'Green',
|
||||
'notebooks.toolbar_highlight_blue': 'Blue',
|
||||
'notebooks.toolbar_highlight_pink': 'Pink',
|
||||
'notebooks.toolbar_highlight_none': 'None',
|
||||
'notebooks.toolbar_color_black': 'Black',
|
||||
'notebooks.toolbar_color_red': 'Red',
|
||||
'notebooks.toolbar_color_blue': 'Blue',
|
||||
'notebooks.toolbar_color_green': 'Green',
|
||||
'notebooks.toolbar_color_orange': 'Orange',
|
||||
'notebooks.reorder_pages': 'Reorder Pages',
|
||||
'notebooks.reorder_success': 'Order saved',
|
||||
'notebooks.reorder_error': 'Error saving order',
|
||||
'notebooks.auto_saving': 'Auto-saving...',
|
||||
'notebooks.auto_saved': 'Auto-saved',
|
||||
'notebooks.page_thumbnail': 'Page Thumbnail',
|
||||
@@ -3913,6 +4029,90 @@ const en: TranslationDict = {
|
||||
'notebooks.template_science_desc': 'Grid notebook for experiments',
|
||||
'notebooks.tab_all': 'My Notebooks',
|
||||
'notebooks.owner': 'By',
|
||||
|
||||
// ─── Enhanced Notifications ──────────────────────────────────────
|
||||
'notifications.today': 'Today',
|
||||
'notifications.yesterday': 'Yesterday',
|
||||
'notifications.this_week': 'This Week',
|
||||
'notifications.earlier': 'Earlier',
|
||||
'notifications.mark_read': 'Mark as read',
|
||||
'notifications.clear_all': 'Clear all',
|
||||
'notifications.type_assessment_due': 'Assessment Due',
|
||||
'notifications.type_missing_observation': 'Missing Observation',
|
||||
'notifications.type_notebook_shared': 'Notebook Shared',
|
||||
'notifications.type_behavior_alert': 'Behavior Alert',
|
||||
'notifications.type_grade_computed': 'Grade Computed',
|
||||
'notifications.type_attendance_alert': 'Attendance Alert',
|
||||
'notifications.type_report_ready': 'Report Ready',
|
||||
'notifications.type_general': 'General',
|
||||
|
||||
// ─── Student Dashboard ──────────────────────────────────────────────
|
||||
'student.dashboard_title': 'My Dashboard',
|
||||
'student.my_competencies': 'My Competencies',
|
||||
'student.my_competencies_desc': 'Overview of your competency development',
|
||||
'student.my_grades': 'My Grades',
|
||||
'student.my_grades_desc': 'Latest assessment results',
|
||||
'student.my_notebooks': 'My Notebooks',
|
||||
'student.my_notebooks_desc': 'Quick access to your notebooks',
|
||||
'student.my_attendance': 'My Attendance',
|
||||
'student.my_attendance_desc': 'Attendance summary',
|
||||
'student.upcoming_assessments': 'Upcoming Assessments',
|
||||
'student.upcoming_assessments_desc': 'Assessments in the next days',
|
||||
'student.no_competencies': 'No competency data yet',
|
||||
'student.no_grades': 'No grades yet',
|
||||
'student.no_notebooks': 'No notebooks yet',
|
||||
'student.no_attendance': 'No attendance data yet',
|
||||
'student.no_assessments': 'No upcoming assessments',
|
||||
'student.competency_level': 'Competency Level',
|
||||
'student.grade_in': 'Grade in',
|
||||
'student.days_until': 'days until',
|
||||
'student.attendance_rate': 'Attendance Rate',
|
||||
'student.present': 'Present',
|
||||
'student.absent': 'Absent',
|
||||
'student.late': 'Late',
|
||||
'student.view_all': 'View all',
|
||||
'student.welcome_back': 'Welcome back',
|
||||
'student.progress_overview': 'Your Learning Progress',
|
||||
'nav.student_competencies': 'My Competencies',
|
||||
'nav.student_grades': 'My Grades',
|
||||
'nav.student_notebooks': 'My Notebooks',
|
||||
'nav.student_attendance': 'My Attendance',
|
||||
'role.student': 'Student',
|
||||
|
||||
// Calendar (enhanced - Task 4)
|
||||
'calendar.new_event': 'New Event',
|
||||
'calendar.edit_event': 'Edit Event',
|
||||
'calendar.week_view': 'Week View',
|
||||
'calendar.month_view': 'Month View',
|
||||
'calendar.all_day': 'All Day',
|
||||
'calendar.event_type': 'Event Type',
|
||||
'calendar.reminder': 'Reminder',
|
||||
'calendar.start_time': 'Start Time',
|
||||
'calendar.end_time': 'End Time',
|
||||
'calendar.notes': 'Notes',
|
||||
'calendar.notes_placeholder': 'Additional notes...',
|
||||
'calendar.delete_confirm': 'Delete this event?',
|
||||
'calendar.delete_confirm_desc': 'This event will be permanently deleted.',
|
||||
'calendar.event_created': 'Event created',
|
||||
'calendar.event_updated': 'Event updated',
|
||||
'calendar.event_deleted': 'Event deleted',
|
||||
'calendar.event_create_error': 'Error creating event',
|
||||
'calendar.event_update_error': 'Error updating event',
|
||||
'calendar.event_delete_error': 'Error deleting event',
|
||||
|
||||
// Dashboard (enhanced - Task 4)
|
||||
'dashboard.new_assessment': 'New Assessment',
|
||||
'dashboard.record_progress': 'Record Progress',
|
||||
'dashboard.my_competencies': 'My Competencies',
|
||||
'dashboard.my_grades': 'My Grades',
|
||||
'dashboard.paper_saved': 'Paper Saved',
|
||||
'dashboard.view_all': 'View All',
|
||||
|
||||
// Notifications (enhanced - Task 4)
|
||||
'notifications.no_notifications': 'No Notifications',
|
||||
|
||||
// Student (enhanced - Task 4)
|
||||
'student.shared_with_me': 'Shared with me',
|
||||
};
|
||||
|
||||
const translations: Record<string, TranslationDict> = { de, en };
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
// CompetenceTrack — Notification Helper
|
||||
// Server-side utility for creating notifications in the database
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export type NotificationType =
|
||||
| 'ASSESSMENT_DUE'
|
||||
| 'MISSING_OBSERVATION'
|
||||
| 'NOTEBOOK_SHARED'
|
||||
| 'BEHAVIOR_ALERT'
|
||||
| 'GRADE_COMPUTED'
|
||||
| 'ATTENDANCE_ALERT'
|
||||
| 'REPORT_READY'
|
||||
| 'GENERAL';
|
||||
|
||||
/**
|
||||
* Create a notification for a specific user.
|
||||
* This is a server-side function that writes directly to the database.
|
||||
*/
|
||||
export async function createNotification(params: {
|
||||
schoolId: string;
|
||||
userId: string;
|
||||
type: NotificationType | string;
|
||||
title: string;
|
||||
message: string;
|
||||
actionUrl?: string;
|
||||
relatedId?: string;
|
||||
}) {
|
||||
const { schoolId, userId, type, title, message, actionUrl, relatedId } = params;
|
||||
|
||||
return db.notification.create({
|
||||
data: {
|
||||
schoolId,
|
||||
userId,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
actionUrl: actionUrl ?? null,
|
||||
relatedId: relatedId ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a notification for multiple users (e.g., all students in a class).
|
||||
*/
|
||||
export async function createNotificationForUsers(params: {
|
||||
schoolId: string;
|
||||
userIds: string[];
|
||||
type: NotificationType | string;
|
||||
title: string;
|
||||
message: string;
|
||||
actionUrl?: string;
|
||||
relatedId?: string;
|
||||
}) {
|
||||
const { schoolId, userIds, type, title, message, actionUrl, relatedId } = params;
|
||||
|
||||
return db.notification.createMany({
|
||||
data: userIds.map((userId) => ({
|
||||
schoolId,
|
||||
userId,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
actionUrl: actionUrl ?? null,
|
||||
relatedId: relatedId ?? null,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all students in a class that a notebook has been shared.
|
||||
*/
|
||||
export async function notifyNotebookShared(params: {
|
||||
schoolId: string;
|
||||
classGroupId: string;
|
||||
notebookTitle: string;
|
||||
teacherName: string;
|
||||
notebookId: string;
|
||||
}) {
|
||||
const { schoolId, classGroupId, notebookTitle, teacherName, notebookId } = params;
|
||||
|
||||
// Get all student user IDs for the class
|
||||
const enrollments = await db.enrollment.findMany({
|
||||
where: {
|
||||
classGroupId,
|
||||
endDate: null,
|
||||
},
|
||||
include: {
|
||||
student: {
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Find User accounts linked to these students
|
||||
// Students may have a user account with the same email pattern
|
||||
const studentIds = enrollments.map((e) => e.student.id);
|
||||
|
||||
// For now, find users with STUDENT role in the school
|
||||
const studentUsers = await db.user.findMany({
|
||||
where: {
|
||||
schoolId,
|
||||
role: 'STUDENT',
|
||||
deletedAt: null,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (studentUsers.length === 0) return;
|
||||
|
||||
return createNotificationForUsers({
|
||||
schoolId,
|
||||
userIds: studentUsers.map((u) => u.id),
|
||||
type: 'NOTEBOOK_SHARED',
|
||||
title: notebookTitle,
|
||||
message: `${teacherName} has shared a notebook with you`,
|
||||
actionUrl: 'notebooks',
|
||||
relatedId: notebookId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify a teacher about a behavior alert.
|
||||
*/
|
||||
export async function notifyBehaviorAlert(params: {
|
||||
schoolId: string;
|
||||
userId: string;
|
||||
studentName: string;
|
||||
className: string;
|
||||
incidentId: string;
|
||||
}) {
|
||||
return createNotification({
|
||||
...params,
|
||||
type: 'BEHAVIOR_ALERT',
|
||||
title: `Behavior Alert: ${params.studentName}`,
|
||||
message: `New behavior incident in ${params.className}`,
|
||||
actionUrl: 'behavior',
|
||||
relatedId: params.incidentId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify a student/teacher that a grade has been computed.
|
||||
*/
|
||||
export async function notifyGradeComputed(params: {
|
||||
schoolId: string;
|
||||
userId: string;
|
||||
studentName: string;
|
||||
className: string;
|
||||
gradeId: string;
|
||||
}) {
|
||||
return createNotification({
|
||||
...params,
|
||||
type: 'GRADE_COMPUTED',
|
||||
title: `Grade Computed: ${params.studentName}`,
|
||||
message: `New grade available for ${params.className}`,
|
||||
actionUrl: 'grading',
|
||||
relatedId: params.gradeId,
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user