Architecture cleanup: Remove orphans from audit report (37 files)
Deleted orphan component files (4): - dashboard-customizer.tsx (never imported) - unified-communication.tsx (never imported) - data-table.tsx (never imported) - widget-container.tsx (only imported by orphan dashboard-customizer) Deleted unused shadcn/ui primitives (15): - accordion, aspect-ratio, calendar, carousel, chart, form, hover-card, input-otp, menubar, navigation-menu, pagination, radio-group, resizable, toaster, toggle-group (All verified as zero importers) Deleted orphan API route groups (6 directories, 13 route files): - ai-grading-audit (no caller) - demo-data (no caller) - exam-plans (no caller) - grade-analytics (no caller, dead renderView case also removed) - grading-annotations (no caller) - grading-reviews (no caller) Removed dead grade-analytics renderView case + ViewName type entry Removed 10 orphan database models from schema: - NotificationHub, NotificationArchive (superseded by Notification) - WidgetTemplate (dashboard customizer feature abandoned) - EmergencySignage, SignageMessage, SignageSchedule, SignageAuditLog (signage feature - no API routes implemented) - SchoolPolicy, EscalationPolicy, ClassMessagingLock (policy/messaging lock feature - never wired) Also removed 16 relation fields pointing to deleted models. Schema validated, db pushed, lint passes, all 10 tested modules work.
This commit is contained in:
1 parent
333f3e9466
commit
e5c761a179
37 files changed
-5279
No files matched your search
@@ -35,9 +35,6 @@ model School {
|
||||
phone String? // school phone number
|
||||
|
||||
users User[]
|
||||
schoolPolicies SchoolPolicy[]
|
||||
escalationPolicies EscalationPolicy[]
|
||||
classMessagingLocks ClassMessagingLock[]
|
||||
schoolYears SchoolYear[]
|
||||
classGroups ClassGroup[]
|
||||
students Student[]
|
||||
@@ -126,19 +123,12 @@ model School {
|
||||
complianceStatus SchoolComplianceStatus?
|
||||
deletionFlags DeletionFlag[]
|
||||
// Module B signage relations
|
||||
emergencySignages EmergencySignage[]
|
||||
signageMessages SignageMessage[]
|
||||
signageSchedules SignageSchedule[]
|
||||
signageAuditLogs SignageAuditLog[]
|
||||
// Module F exam mode relations
|
||||
examSessions ExamSession[]
|
||||
// Module H notifications relations
|
||||
notificationHubs NotificationHub[]
|
||||
notificationArchives NotificationArchive[]
|
||||
// Module J widgets relations
|
||||
dashboardWidgets DashboardWidget[]
|
||||
dashboardLayouts DashboardLayout[]
|
||||
widgetTemplates WidgetTemplate[]
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -240,15 +230,10 @@ model User {
|
||||
complianceConsultationsAcknowledged ComplianceConsultation[] @relation("ComplianceConsultationAcknowledger")
|
||||
deletionFlagsApproved DeletionFlag[] @relation("DeletionFlagApprover")
|
||||
// Module B signage relations
|
||||
signageMessagesCreated SignageMessage[]
|
||||
// Module H notifications relations
|
||||
notificationHubs NotificationHub[]
|
||||
notificationArchives NotificationArchive[]
|
||||
// Module J widgets relations
|
||||
dashboardWidgets DashboardWidget[]
|
||||
dashboardLayouts DashboardLayout[]
|
||||
escalationPoliciesOverridden EscalationPolicy[] @relation("EscalationOverriddenBy")
|
||||
classMessagingLocksLocked ClassMessagingLock[] @relation("ClassMessagingLockLockedBy")
|
||||
passwordResetTokens PasswordResetToken[]
|
||||
twoFactorRecoveryCodes TwoFactorRecoveryCode[]
|
||||
}
|
||||
@@ -338,7 +323,6 @@ model ClassGroup {
|
||||
examPlans ExamPlan[]
|
||||
seatingCharts SeatingChart[]
|
||||
substitutionAssignments SubstitutionAssignment[]
|
||||
classMessagingLocks ClassMessagingLock[]
|
||||
}
|
||||
|
||||
model ClassGroupTeacher {
|
||||
@@ -3439,99 +3423,6 @@ model DeletionFlag {
|
||||
|
||||
// ─── Module B: Emergency Signage System ─────────────────────────────
|
||||
|
||||
model EmergencySignage {
|
||||
id String @id @default(cuid())
|
||||
schoolId String
|
||||
displayName String // Room name or location (e.g., "Main Hallway", "Cafeteria TV 1")
|
||||
signageType String @default("digital_display") // digital_display, led_board, tv_monitor
|
||||
resolution String @default("1920x1080") // resolution info
|
||||
location String // Physical location in school
|
||||
isActive Boolean @default(true)
|
||||
currentMessageId String? // Current active message
|
||||
brightness Int @default(100) // 1-100
|
||||
volume Int @default(50) // 1-100 for audio
|
||||
ipAddress String? // For network monitoring
|
||||
lastHeartbeat DateTime? // Last connection check
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
school School @relation(fields: [schoolId], references: [id])
|
||||
messages SignageMessage[]
|
||||
schedules SignageSchedule[]
|
||||
|
||||
@@index([schoolId])
|
||||
@@index([isActive])
|
||||
@@map("emergency_signages")
|
||||
}
|
||||
|
||||
model SignageMessage {
|
||||
id String @id @default(cuid())
|
||||
schoolId String
|
||||
signageId String
|
||||
title String
|
||||
content String // HTML or plain text
|
||||
messageType String // emergency, notification, schedule, announcement, alert
|
||||
priority String @default("normal") // low, normal, high, critical
|
||||
backgroundColor String? // Hex color
|
||||
textColor String? // Hex color
|
||||
displayDuration Int @default(10) // seconds to display
|
||||
soundAlert Boolean @default(false) // Play sound on emergency
|
||||
soundFile String? // URL to audio file
|
||||
createdBy String // userId who created
|
||||
createdAt DateTime @default(now())
|
||||
startTime DateTime?
|
||||
endTime DateTime?
|
||||
displayOrder Int @default(0)
|
||||
|
||||
school School @relation(fields: [schoolId], references: [id])
|
||||
signage EmergencySignage @relation(fields: [signageId], references: [id], onDelete: Cascade)
|
||||
creator User @relation(fields: [createdBy], references: [id])
|
||||
audits SignageAuditLog[]
|
||||
|
||||
@@index([schoolId])
|
||||
@@index([signageId])
|
||||
@@index([messageType])
|
||||
@@index([priority])
|
||||
@@map("signage_messages")
|
||||
}
|
||||
|
||||
model SignageSchedule {
|
||||
id String @id @default(cuid())
|
||||
schoolId String
|
||||
signageId String
|
||||
messageId String? // Optional: specific message to display
|
||||
dayOfWeek Int // 0-6 (Sunday-Saturday)
|
||||
startTime String // HH:mm format
|
||||
endTime String // HH:mm format
|
||||
isRecurring Boolean @default(true)
|
||||
createdBy String // userId
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
school School @relation(fields: [schoolId], references: [id])
|
||||
signage EmergencySignage @relation(fields: [signageId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([schoolId])
|
||||
@@index([signageId])
|
||||
@@map("signage_schedules")
|
||||
}
|
||||
|
||||
model SignageAuditLog {
|
||||
id String @id @default(cuid())
|
||||
schoolId String
|
||||
messageId String
|
||||
action String // created, updated, displayed, error, offline
|
||||
details String? // JSON: { status, device, error, etc }
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
school School @relation(fields: [schoolId], references: [id])
|
||||
message SignageMessage @relation(fields: [messageId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([schoolId])
|
||||
@@index([messageId])
|
||||
@@index([action])
|
||||
@@map("signage_audit_logs")
|
||||
}
|
||||
|
||||
// ─── Module F: Exam Mode / Digital Test Environment ──────────────────
|
||||
|
||||
model ExamSession {
|
||||
@@ -3618,55 +3509,6 @@ model ExamWarning {
|
||||
|
||||
// ─── Module H: Centralized Notifications Hub ───────────────────────
|
||||
|
||||
model NotificationHub {
|
||||
id String @id @default(cuid())
|
||||
schoolId String
|
||||
userId String
|
||||
category String // assessment, behavior, communication, wellness, system, announcement
|
||||
priority String @default("normal") // low, normal, high, critical
|
||||
title String
|
||||
message String
|
||||
actionUrl String? // Link to related resource
|
||||
actionLabel String? // e.g. "View Grade", "Respond"
|
||||
isRead Boolean @default(false)
|
||||
readAt DateTime?
|
||||
isDismissed Boolean @default(false)
|
||||
dismissedAt DateTime?
|
||||
sourceType String? // assessment, incident, message, announcement, etc
|
||||
sourceId String? // ID of related entity
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime? // Auto-archive if set
|
||||
|
||||
school School @relation(fields: [schoolId], references: [id])
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@index([schoolId])
|
||||
@@index([userId])
|
||||
@@index([isRead])
|
||||
@@index([category])
|
||||
@@index([createdAt])
|
||||
@@map("notification_hubs")
|
||||
}
|
||||
|
||||
model NotificationArchive {
|
||||
id String @id @default(cuid())
|
||||
schoolId String
|
||||
userId String
|
||||
originalId String? // reference to NotificationHub.id if archived
|
||||
title String
|
||||
message String
|
||||
category String
|
||||
archivedAt DateTime @default(now())
|
||||
|
||||
school School @relation(fields: [schoolId], references: [id])
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@index([schoolId])
|
||||
@@index([userId])
|
||||
@@index([archivedAt])
|
||||
@@map("notification_archives")
|
||||
}
|
||||
|
||||
// ─── Module J: Customizable Dashboard Widgets ──────────────────────
|
||||
|
||||
model DashboardWidget {
|
||||
@@ -3716,76 +3558,3 @@ model DashboardLayout {
|
||||
@@map("dashboard_layouts")
|
||||
}
|
||||
|
||||
model WidgetTemplate {
|
||||
id String @id @default(cuid())
|
||||
schoolId String
|
||||
widgetType String // Same types as DashboardWidget
|
||||
name String
|
||||
description String?
|
||||
defaultConfig String // JSON: default configuration
|
||||
thumbnail String? // URL to preview image
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
school School @relation(fields: [schoolId], references: [id])
|
||||
|
||||
@@unique([schoolId, widgetType])
|
||||
@@index([schoolId])
|
||||
@@map("widget_templates")
|
||||
}
|
||||
|
||||
model SchoolPolicy {
|
||||
id String @id @default(cuid())
|
||||
schoolId String
|
||||
policyType String // ESCALATION, CLASS_LOCKING, etc.
|
||||
policyData String // JSON: policy configuration
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
school School @relation(fields: [schoolId], references: [id])
|
||||
|
||||
@@index([schoolId])
|
||||
@@map("school_policies")
|
||||
}
|
||||
|
||||
model EscalationPolicy {
|
||||
id String @id @default(cuid())
|
||||
schoolId String
|
||||
fromRole String // PARENT, TEACHER, ADMIN, etc.
|
||||
toRole String // ADMIN, TEACHER, etc.
|
||||
requiredPriorContactRole String // TEACHER, etc.
|
||||
waitingPeriodBusinessDays Int @default(3)
|
||||
isActive Boolean @default(true)
|
||||
overriddenByUserId String? // nullable, admin-configured exceptions
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
school School @relation(fields: [schoolId], references: [id])
|
||||
overriddenByUser User? @relation("EscalationOverriddenBy", fields: [overriddenByUserId], references: [id])
|
||||
|
||||
@@index([schoolId])
|
||||
@@index([fromRole, toRole])
|
||||
@@map("escalation_policies")
|
||||
}
|
||||
|
||||
model ClassMessagingLock {
|
||||
id String @id @default(cuid())
|
||||
classGroupId String
|
||||
schoolId String
|
||||
lockedByUserId String // teacher who locked
|
||||
lockedFrom DateTime
|
||||
lockedUntil DateTime? // nullable = "until class ends"
|
||||
reason String? // optional reason for lock
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
classGroup ClassGroup @relation(fields: [classGroupId], references: [id])
|
||||
school School @relation(fields: [schoolId], references: [id])
|
||||
lockedByUser User @relation("ClassMessagingLockLockedBy", fields: [lockedByUserId], references: [id])
|
||||
|
||||
@@index([classGroupId])
|
||||
@@index([schoolId])
|
||||
@@index([lockedByUserId])
|
||||
@@index([lockedFrom, lockedUntil])
|
||||
@@map("class_messaging_locks")
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const review = await db.teacherGradingReview.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
assessment: { select: { id: true, title: true } },
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
comments: {
|
||||
include: {
|
||||
user: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!review) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(review);
|
||||
} catch (error) {
|
||||
console.error('AI Grading Audit GET [id] error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
import ZAI from 'z-ai-web-dev-sdk';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const role = session.user?.role;
|
||||
if (role !== 'TEACHER' && role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN' && role !== 'VICE_PRINCIPAL') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { schoolId, assessmentId, studentId } = body;
|
||||
|
||||
if (!schoolId || !assessmentId || !studentId) {
|
||||
return NextResponse.json({ error: 'schoolId, assessmentId, and studentId are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get assessment and student data
|
||||
const assessment = await db.assessment.findUnique({
|
||||
where: { id: assessmentId },
|
||||
include: {
|
||||
assessmentResults: {
|
||||
where: { studentId },
|
||||
include: {
|
||||
student: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!assessment) {
|
||||
return NextResponse.json({ error: 'Assessment not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Get annotations for this student's submission
|
||||
const annotations = await db.gradingAnnotation.findMany({
|
||||
where: { assessmentId, studentId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
const studentResult = assessment.assessmentResults[0];
|
||||
const student = studentResult?.student;
|
||||
|
||||
// Generate AI grading audit
|
||||
const systemPrompt = `Du bist ein erfahrener Lehrer und Prüfer an einer deutschen Schule.
|
||||
Du überprüfst die Benotung eines Schülers und gibst detailliertes Feedback.
|
||||
Die deutsche Notenskala: 1 (sehr gut), 2 (gut), 3 (befriedigend), 4 (ausreichend), 5 (mangelhaft), 6 (ungenügend).
|
||||
Analysiere die Leistung und die gegebene Benotung. Wenn die Benotung nicht angemessen ist, schlage eine Korrektur vor mit Begründung.
|
||||
Antworte immer auf Deutsch und im JSON-Format.`;
|
||||
|
||||
const gradingInfo = {
|
||||
studentName: student ? `${student.firstName} ${student.lastName}` : 'Unknown',
|
||||
assessmentTitle: assessment.title,
|
||||
assessmentType: assessment.type,
|
||||
maxScore: assessment.maxScore,
|
||||
score: studentResult?.score ?? null,
|
||||
note: studentResult?.note ?? null,
|
||||
annotationsCount: annotations.length,
|
||||
annotationTypes: annotations.map((a: { type: string; content: string | null }) => ({ type: a.type, content: a.content })),
|
||||
teacherAnnotations: annotations.filter((a: { type: string }) => a.type === 'stamp' || a.type === 'text').map((a: { type: string; content: string | null; color: string }) => ({ type: a.type, content: a.content, color: a.color })),
|
||||
};
|
||||
|
||||
let reviewResult: string;
|
||||
let discrepanciesFound = 0;
|
||||
|
||||
try {
|
||||
const ai = await ZAI.create();
|
||||
const response = await ai.chat.completions.create({
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{
|
||||
role: 'user',
|
||||
content: `Überprüfe die Benotung für diesen Schüler und gib Feedback:\n\n${JSON.stringify(gradingInfo, null, 2)}\n\nGib deine Antwort als JSON mit folgenden Feldern:\n- currentGrade: die aktuelle Benotung\n- suggestedGrade: deine vorgeschlagene Note\n- reason: detaillierte Begründung auf Deutsch\n- discrepancies: Liste der gefundenen Unstimmigkeiten\n- overallAssessment: Gesamtbewertung der Benotung`,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
reviewResult = response?.choices?.[0]?.message?.content || 'Review could not be generated';
|
||||
} catch (aiError) {
|
||||
console.error('AI grading audit error:', aiError);
|
||||
reviewResult = JSON.stringify({
|
||||
currentGrade: studentResult?.score ?? 'N/A',
|
||||
suggestedGrade: 'N/A',
|
||||
reason: 'AI review could not be generated. Manual review recommended.',
|
||||
discrepancies: ['AI analysis unavailable — please review manually'],
|
||||
overallAssessment: 'AI audit could not be completed due to a technical error. Please review the grading manually.',
|
||||
});
|
||||
}
|
||||
|
||||
// Count discrepancies
|
||||
try {
|
||||
const parsed = typeof reviewResult === 'string' ? JSON.parse(reviewResult) : reviewResult;
|
||||
discrepanciesFound = Array.isArray(parsed.discrepancies) ? parsed.discrepancies.length : 0;
|
||||
} catch {
|
||||
discrepanciesFound = 0;
|
||||
}
|
||||
|
||||
// Create or update the grading review
|
||||
const review = await db.teacherGradingReview.create({
|
||||
data: {
|
||||
schoolId,
|
||||
assessmentId,
|
||||
teacherId: session.userId,
|
||||
aiProvider: 'pollination',
|
||||
status: 'completed',
|
||||
reviewResult,
|
||||
discrepanciesFound,
|
||||
},
|
||||
include: {
|
||||
assessment: { select: { id: true, title: true } },
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
comments: {
|
||||
include: {
|
||||
user: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(review, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('AI Grading Audit POST error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
// ── GET: Get demo data statistics ──
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Only SCHOOL_ADMIN or SUPER_ADMIN can view demo data stats
|
||||
if (!['SCHOOL_ADMIN', 'SUPER_ADMIN'].includes(session.user.role)) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Get demo user IDs for cascade counting
|
||||
const demoUsers = await db.user.findMany({
|
||||
where: { isDemo: true },
|
||||
select: { id: true },
|
||||
});
|
||||
const demoUserIds = demoUsers.map((u) => u.id);
|
||||
|
||||
const [
|
||||
demoUserCount,
|
||||
demoNotebookCount,
|
||||
demoDrawingCount,
|
||||
cascadeNotebookCount,
|
||||
cascadeDrawingCount,
|
||||
] = await Promise.all([
|
||||
db.user.count({ where: { isDemo: true } }),
|
||||
db.notebook.count({ where: { isDemo: true } }),
|
||||
db.drawing.count({ where: { isDemo: true } }),
|
||||
demoUserIds.length > 0
|
||||
? db.notebook.count({
|
||||
where: { ownerId: { in: demoUserIds }, isDemo: false },
|
||||
})
|
||||
: 0,
|
||||
demoUserIds.length > 0
|
||||
? db.drawing.count({
|
||||
where: { ownerId: { in: demoUserIds }, isDemo: false },
|
||||
})
|
||||
: 0,
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
User: demoUserCount,
|
||||
Notebook: demoNotebookCount,
|
||||
Drawing: demoDrawingCount,
|
||||
_cascadeNotebooks: cascadeNotebookCount,
|
||||
_cascadeDrawings: cascadeDrawingCount,
|
||||
_total:
|
||||
demoUserCount +
|
||||
demoNotebookCount +
|
||||
demoDrawingCount +
|
||||
cascadeNotebookCount +
|
||||
cascadeDrawingCount,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Demo data stats error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── DELETE: Delete ALL demo data ──
|
||||
export async function DELETE() {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Only SCHOOL_ADMIN or SUPER_ADMIN can delete demo data
|
||||
if (!['SCHOOL_ADMIN', 'SUPER_ADMIN'].includes(session.user.role)) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const summary: Record<string, number> = {};
|
||||
|
||||
// 1. Get all demo user IDs for cascade deletion
|
||||
const demoUsers = await db.user.findMany({
|
||||
where: { isDemo: true },
|
||||
select: { id: true },
|
||||
});
|
||||
const demoUserIds = demoUsers.map((u) => u.id);
|
||||
|
||||
// 2. Delete NotebookPages for notebooks owned by demo users
|
||||
// (NotebookPage has cascade delete on Notebook, but we need to handle
|
||||
// notebooks where isDemo=true OR ownerId is a demo user)
|
||||
const demoNotebookIds = await db.notebook.findMany({
|
||||
where: {
|
||||
OR: [{ isDemo: true }, { ownerId: { in: demoUserIds } }],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
const demoNotebookIdList = demoNotebookIds.map((n) => n.id);
|
||||
|
||||
let deletedPages = 0;
|
||||
if (demoNotebookIdList.length > 0) {
|
||||
deletedPages = (await db.notebookPage.deleteMany({
|
||||
where: { notebookId: { in: demoNotebookIdList } },
|
||||
})) as unknown as number;
|
||||
summary.NotebookPage = typeof deletedPages === 'number' ? deletedPages : (deletedPages as { count: number }).count ?? 0;
|
||||
} else {
|
||||
summary.NotebookPage = 0;
|
||||
}
|
||||
|
||||
// 3. Delete notebooks where isDemo=true OR ownerId is a demo user
|
||||
const deletedNotebooks = await db.notebook.deleteMany({
|
||||
where: {
|
||||
OR: [{ isDemo: true }, { ownerId: { in: demoUserIds } }],
|
||||
},
|
||||
});
|
||||
summary.Notebook =
|
||||
typeof deletedNotebooks === 'number'
|
||||
? deletedNotebooks
|
||||
: (deletedNotebooks as { count: number }).count ?? 0;
|
||||
|
||||
// 4. Delete drawings where isDemo=true OR ownerId is a demo user
|
||||
const deletedDrawings = await db.drawing.deleteMany({
|
||||
where: {
|
||||
OR: [{ isDemo: true }, { ownerId: { in: demoUserIds } }],
|
||||
},
|
||||
});
|
||||
summary.Drawing =
|
||||
typeof deletedDrawings === 'number'
|
||||
? deletedDrawings
|
||||
: (deletedDrawings as { count: number }).count ?? 0;
|
||||
|
||||
// 5. Delete demo users (soft-deleted users that are demo)
|
||||
const deletedUsers = await db.user.deleteMany({
|
||||
where: { isDemo: true },
|
||||
});
|
||||
summary.User =
|
||||
typeof deletedUsers === 'number'
|
||||
? deletedUsers
|
||||
: (deletedUsers as { count: number }).count ?? 0;
|
||||
|
||||
// 6. Log the cleanup in AuditLog
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
schoolId: session.user.schoolId,
|
||||
action: 'DEMO_DATA_CLEANUP',
|
||||
entityType: 'System',
|
||||
metadata: JSON.stringify(summary),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'Demo data cleanup completed',
|
||||
deleted: summary,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Demo data cleanup error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
// ── GET: Get single exam plan ──────────────────────────────────────────
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const examPlan = await db.examPlan.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
subject: { select: { id: true, name: true } },
|
||||
classGroup: { select: { id: true, name: true, gradeLevel: true } },
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
calendarEvent: { select: { id: true } },
|
||||
assessment: { select: { id: true, title: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!examPlan) {
|
||||
return NextResponse.json({ error: 'Exam plan not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Role-based access check
|
||||
if (
|
||||
session.user?.role === 'STUDENT' ||
|
||||
session.user?.role === 'PARENT'
|
||||
) {
|
||||
// Students and parents can view but not modify
|
||||
} else if (session.user?.role === 'TEACHER') {
|
||||
// Teachers can only view their own exams
|
||||
if (examPlan.teacherId !== session.userId) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(examPlan);
|
||||
} catch (error) {
|
||||
console.error('ExamPlan GET error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// ── PUT: Update exam plan ──────────────────────────────────────────────
|
||||
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 userRole = session.user?.role;
|
||||
|
||||
if (
|
||||
userRole !== 'TEACHER' &&
|
||||
userRole !== 'SCHOOL_ADMIN' &&
|
||||
userRole !== 'VICE_PRINCIPAL' &&
|
||||
userRole !== 'SUPER_ADMIN'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const existing = await db.examPlan.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Exam plan not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Teachers can only edit their own exams
|
||||
if (userRole === 'TEACHER' && existing.teacherId !== session.userId) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const {
|
||||
title,
|
||||
date,
|
||||
startTime,
|
||||
endTime,
|
||||
subjectId,
|
||||
classGroupId,
|
||||
room,
|
||||
topics,
|
||||
weight,
|
||||
status,
|
||||
notes,
|
||||
} = body;
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (title !== undefined) updateData.title = title;
|
||||
if (date !== undefined) updateData.date = new Date(date);
|
||||
if (startTime !== undefined) updateData.startTime = startTime || null;
|
||||
if (endTime !== undefined) updateData.endTime = endTime || null;
|
||||
if (subjectId !== undefined) updateData.subjectId = subjectId;
|
||||
if (classGroupId !== undefined) updateData.classGroupId = classGroupId;
|
||||
if (room !== undefined) updateData.room = room || null;
|
||||
if (topics !== undefined) updateData.topics = topics ? JSON.stringify(topics) : null;
|
||||
if (weight !== undefined) updateData.weight = weight;
|
||||
if (status !== undefined) updateData.status = status;
|
||||
if (notes !== undefined) updateData.notes = notes || null;
|
||||
|
||||
const updated = await db.examPlan.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: {
|
||||
subject: { select: { id: true, name: true } },
|
||||
classGroup: { select: { id: true, name: true, gradeLevel: true } },
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
calendarEvent: { select: { id: true } },
|
||||
assessment: { select: { id: true, title: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Also update the linked CalendarEvent
|
||||
if (existing.calendarEventId) {
|
||||
const calendarUpdateData: Record<string, unknown> = {};
|
||||
if (title !== undefined) calendarUpdateData.title = `[Exam] ${title}`;
|
||||
if (date !== undefined) calendarUpdateData.date = new Date(date);
|
||||
if (startTime !== undefined) calendarUpdateData.startTime = startTime || null;
|
||||
if (endTime !== undefined) calendarUpdateData.endTime = endTime || null;
|
||||
if (subjectId !== undefined) calendarUpdateData.subjectId = subjectId;
|
||||
if (classGroupId !== undefined) calendarUpdateData.classGroupId = classGroupId;
|
||||
if (notes !== undefined) calendarUpdateData.notes = notes || null;
|
||||
if (startTime !== undefined || endTime !== undefined) {
|
||||
calendarUpdateData.allDay = !startTime && !endTime;
|
||||
}
|
||||
|
||||
if (Object.keys(calendarUpdateData).length > 0) {
|
||||
await db.calendarEvent.update({
|
||||
where: { id: existing.calendarEventId },
|
||||
data: calendarUpdateData,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
console.error('ExamPlan PUT error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// ── DELETE: Delete exam plan ───────────────────────────────────────────
|
||||
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 userRole = session.user?.role;
|
||||
|
||||
if (
|
||||
userRole !== 'TEACHER' &&
|
||||
userRole !== 'SCHOOL_ADMIN' &&
|
||||
userRole !== 'VICE_PRINCIPAL' &&
|
||||
userRole !== 'SUPER_ADMIN'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const existing = await db.examPlan.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Exam plan not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Teachers can only delete their own exams
|
||||
if (userRole === 'TEACHER' && existing.teacherId !== session.userId) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Delete the linked CalendarEvent first
|
||||
if (existing.calendarEventId) {
|
||||
await db.calendarEvent.delete({ where: { id: existing.calendarEventId } }).catch(() => {});
|
||||
}
|
||||
|
||||
await db.examPlan.delete({ where: { id } });
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('ExamPlan DELETE error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
// @ts-nocheck
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
import { differenceInCalendarDays } from 'date-fns';
|
||||
|
||||
// ── GET: List exam plans with filters ──────────────────────────────────
|
||||
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 classId = searchParams.get('classId');
|
||||
const subjectId = searchParams.get('subjectId');
|
||||
const teacherId = searchParams.get('teacherId');
|
||||
const status = searchParams.get('status');
|
||||
const dateFrom = searchParams.get('dateFrom');
|
||||
const dateTo = searchParams.get('dateTo');
|
||||
const viewMode = searchParams.get('viewMode') ?? 'all'; // all, upcoming, past
|
||||
|
||||
if (!schoolId) {
|
||||
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const where: Record<string, unknown> = { schoolId };
|
||||
|
||||
if (classId) where.classGroupId = classId;
|
||||
if (subjectId) where.subjectId = subjectId;
|
||||
if (teacherId) where.teacherId = teacherId;
|
||||
if (status) where.status = status;
|
||||
|
||||
// Date filters
|
||||
const dateFilter: Record<string, Date> = {};
|
||||
if (dateFrom) dateFilter.gte = new Date(dateFrom);
|
||||
if (dateTo) dateFilter.lte = new Date(dateTo);
|
||||
if (Object.keys(dateFilter).length > 0) where.date = dateFilter;
|
||||
|
||||
// View mode filters
|
||||
const now = new Date();
|
||||
if (viewMode === 'upcoming') {
|
||||
where.date = { ...((where.date as Record<string, Date>) || {}), gte: now };
|
||||
} else if (viewMode === 'past') {
|
||||
where.date = { ...((where.date as Record<string, Date>) || {}), lt: now };
|
||||
}
|
||||
|
||||
// Role-based access
|
||||
if (session.user?.role === 'STUDENT') {
|
||||
const student = await db.student.findFirst({
|
||||
where: { userId: session.userId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (student) {
|
||||
const enrollments = await db.enrollment.findMany({
|
||||
where: { studentId: student.id },
|
||||
select: { classGroupId: true },
|
||||
});
|
||||
const classIds = enrollments.map((e) => e.classGroupId);
|
||||
where.classGroupId = { in: classIds };
|
||||
}
|
||||
} else if (session.user?.role === 'PARENT') {
|
||||
const parentLinks = await db.parentStudentLink.findMany({
|
||||
where: { parentId: session.userId },
|
||||
select: { studentId: true },
|
||||
});
|
||||
const studentIds = parentLinks.map((l) => l.studentId);
|
||||
const enrollments = await db.enrollment.findMany({
|
||||
where: { studentId: { in: studentIds } },
|
||||
select: { classGroupId: true },
|
||||
});
|
||||
const classIds = enrollments.map((e) => e.classGroupId);
|
||||
where.classGroupId = { in: classIds };
|
||||
} else if (session.user?.role === 'TEACHER') {
|
||||
// Teachers see their own exams by default, or all if explicitly requested
|
||||
if (!teacherId) {
|
||||
where.teacherId = session.userId;
|
||||
}
|
||||
}
|
||||
|
||||
const examPlans = await db.examPlan.findMany({
|
||||
where,
|
||||
include: {
|
||||
subject: { select: { id: true, name: true } },
|
||||
classGroup: { select: { id: true, name: true, gradeLevel: true } },
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
calendarEvent: { select: { id: true } },
|
||||
assessment: { select: { id: true, title: true } },
|
||||
},
|
||||
orderBy: { date: 'asc' },
|
||||
});
|
||||
|
||||
// Add computed fields
|
||||
const enriched = examPlans.map((plan) => {
|
||||
const daysUntil = differenceInCalendarDays(new Date(plan.date), now);
|
||||
const isWithinTwoWeeks = daysUntil >= 0 && daysUntil <= 14;
|
||||
return {
|
||||
...plan,
|
||||
daysUntil,
|
||||
isWithinTwoWeeks,
|
||||
countdownLabel:
|
||||
daysUntil === 0
|
||||
? 'today'
|
||||
: daysUntil === 1
|
||||
? 'tomorrow'
|
||||
: daysUntil < 7
|
||||
? `${daysUntil} days`
|
||||
: daysUntil < 14
|
||||
? `${Math.floor(daysUntil / 7)} week`
|
||||
: `${Math.floor(daysUntil / 7)} weeks`,
|
||||
};
|
||||
});
|
||||
|
||||
// Check for AI test availability for each exam
|
||||
const examIdsWithTests = await db.aITestGeneration.findMany({
|
||||
where: {
|
||||
classGroupId: { in: enriched.map((e) => e.classGroupId) },
|
||||
subjectId: { in: enriched.map((e) => e.subjectId) },
|
||||
isCompleted: true,
|
||||
},
|
||||
select: { classGroupId: true, subjectId: true },
|
||||
});
|
||||
|
||||
const testSet = new Set(
|
||||
examIdsWithTests.map((t) => `${t.classGroupId}-${t.subjectId}`)
|
||||
);
|
||||
|
||||
const result = enriched.map((plan) => ({
|
||||
...plan,
|
||||
hasAITest: testSet.has(`${plan.classGroupId}-${plan.subjectId}`),
|
||||
}));
|
||||
|
||||
// Statistics (for admin view)
|
||||
let stats = null;
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' ||
|
||||
session.user?.role === 'VICE_PRINCIPAL' ||
|
||||
session.user?.role === 'SUPER_ADMIN'
|
||||
) {
|
||||
const totalExams = await db.examPlan.count({ where: { schoolId } });
|
||||
const upcomingExams = await db.examPlan.count({
|
||||
where: { schoolId, date: { gte: now } },
|
||||
});
|
||||
const plannedExams = await db.examPlan.count({
|
||||
where: { schoolId, status: 'planned' },
|
||||
});
|
||||
const confirmedExams = await db.examPlan.count({
|
||||
where: { schoolId, status: 'confirmed' },
|
||||
});
|
||||
const completedExams = await db.examPlan.count({
|
||||
where: { schoolId, status: 'completed' },
|
||||
});
|
||||
const cancelledExams = await db.examPlan.count({
|
||||
where: { schoolId, status: 'cancelled' },
|
||||
});
|
||||
|
||||
// Exams per subject
|
||||
const examsPerSubject = await db.examPlan.groupBy({
|
||||
by: ['subjectId'],
|
||||
where: { schoolId },
|
||||
_count: { id: true },
|
||||
});
|
||||
|
||||
const subjectNames = await db.subject.findMany({
|
||||
where: {
|
||||
id: { in: examsPerSubject.map((e) => e.subjectId) },
|
||||
},
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
|
||||
const subjectMap = Object.fromEntries(
|
||||
subjectNames.map((s) => [s.id, s.name])
|
||||
);
|
||||
|
||||
// Exams per class
|
||||
const examsPerClass = await db.examPlan.groupBy({
|
||||
by: ['classGroupId'],
|
||||
where: { schoolId },
|
||||
_count: { id: true },
|
||||
});
|
||||
|
||||
const classNames = await db.classGroup.findMany({
|
||||
where: {
|
||||
id: { in: examsPerClass.map((e) => e.classGroupId) },
|
||||
},
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
|
||||
const classMap = Object.fromEntries(
|
||||
classNames.map((c) => [c.id, c.name])
|
||||
);
|
||||
|
||||
stats = {
|
||||
totalExams,
|
||||
upcomingExams,
|
||||
byStatus: { planned: plannedExams, confirmed: confirmedExams, completed: completedExams, cancelled: cancelledExams },
|
||||
bySubject: examsPerSubject.map((e) => ({
|
||||
subjectId: e.subjectId,
|
||||
subjectName: subjectMap[e.subjectId] || e.subjectId,
|
||||
count: e._count.id,
|
||||
})),
|
||||
byClass: examsPerClass.map((e) => ({
|
||||
classGroupId: e.classGroupId,
|
||||
className: classMap[e.classGroupId] || e.classGroupId,
|
||||
count: e._count.id,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return NextResponse.json({ exams: result, stats });
|
||||
} catch (error) {
|
||||
console.error('ExamPlans GET error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// ── POST: Create an exam plan ──────────────────────────────────────────
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const userRole = session.user?.role;
|
||||
if (
|
||||
userRole !== 'TEACHER' &&
|
||||
userRole !== 'SCHOOL_ADMIN' &&
|
||||
userRole !== 'VICE_PRINCIPAL' &&
|
||||
userRole !== 'SUPER_ADMIN'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const {
|
||||
title,
|
||||
date,
|
||||
startTime,
|
||||
endTime,
|
||||
subjectId,
|
||||
classGroupId,
|
||||
schoolId,
|
||||
room,
|
||||
topics,
|
||||
weight,
|
||||
status,
|
||||
notes,
|
||||
} = body;
|
||||
|
||||
if (!title || !date || !schoolId || !subjectId || !classGroupId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'title, date, schoolId, subjectId, and classGroupId are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Create the exam plan
|
||||
const examPlan = await db.examPlan.create({
|
||||
data: {
|
||||
schoolId,
|
||||
teacherId: session.userId,
|
||||
subjectId,
|
||||
classGroupId,
|
||||
title,
|
||||
date: new Date(date),
|
||||
startTime: startTime || null,
|
||||
endTime: endTime || null,
|
||||
room: room || null,
|
||||
topics: topics ? JSON.stringify(topics) : null,
|
||||
weight: weight ?? 1.0,
|
||||
status: status || 'planned',
|
||||
notes: notes || null,
|
||||
},
|
||||
include: {
|
||||
subject: { select: { id: true, name: true } },
|
||||
classGroup: { select: { id: true, name: true, gradeLevel: true } },
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Also create a CalendarEvent for the exam
|
||||
const calendarEvent = await db.calendarEvent.create({
|
||||
data: {
|
||||
schoolId,
|
||||
teacherId: session.userId,
|
||||
title: `[Exam] ${title}`,
|
||||
date: new Date(date),
|
||||
startTime: startTime || null,
|
||||
endTime: endTime || null,
|
||||
eventType: 'exam',
|
||||
subjectId,
|
||||
classGroupId,
|
||||
notes: notes || null,
|
||||
allDay: !startTime && !endTime,
|
||||
},
|
||||
});
|
||||
|
||||
// Link the calendar event to the exam plan
|
||||
await db.examPlan.update({
|
||||
where: { id: examPlan.id },
|
||||
data: { calendarEventId: calendarEvent.id },
|
||||
});
|
||||
|
||||
const result = {
|
||||
...examPlan,
|
||||
calendarEventId: calendarEvent.id,
|
||||
};
|
||||
|
||||
return NextResponse.json(result, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('ExamPlans POST error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
// @ts-nocheck
|
||||
@@ -1,358 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const schoolId = searchParams.get('schoolId') ?? session.user?.schoolId ?? undefined;
|
||||
const schoolYearId = searchParams.get('schoolYearId');
|
||||
const compareType = searchParams.get('compareType') ?? 'class'; // class, subject, teacher
|
||||
|
||||
const role = session.user?.role;
|
||||
const isAdmin = role === 'SUPER_ADMIN' || role === 'SCHOOL_ADMIN' || role === 'VICE_PRINCIPAL';
|
||||
|
||||
// Only admins can do teacher comparison
|
||||
if (compareType === 'teacher' && !isAdmin) {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Build where clause
|
||||
const gradeWhere: Record<string, unknown> = {};
|
||||
if (schoolYearId) gradeWhere.schoolYearId = schoolYearId;
|
||||
if (schoolId) {
|
||||
gradeWhere.classGroup = { schoolId };
|
||||
}
|
||||
|
||||
const grades = await db.computedGrade.findMany({
|
||||
where: gradeWhere,
|
||||
select: {
|
||||
computedValue: true,
|
||||
overriddenValue: true,
|
||||
subjectId: true,
|
||||
classGroupId: true,
|
||||
studentId: true,
|
||||
subject: { select: { id: true, name: true } },
|
||||
classGroup: { select: { id: true, name: true, gradeLevel: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (compareType === 'class') {
|
||||
// Class comparison
|
||||
const classMap: Record<string, {
|
||||
name: string;
|
||||
gradeLevel: number;
|
||||
grades: number[];
|
||||
average: number;
|
||||
median: number;
|
||||
stdDev: number;
|
||||
gradeCount: number;
|
||||
}> = {};
|
||||
|
||||
grades.forEach((g) => {
|
||||
const val = g.overriddenValue ?? g.computedValue;
|
||||
if (!classMap[g.classGroupId]) {
|
||||
classMap[g.classGroupId] = {
|
||||
name: g.classGroup.name,
|
||||
gradeLevel: g.classGroup.gradeLevel,
|
||||
grades: [],
|
||||
average: 0,
|
||||
median: 0,
|
||||
stdDev: 0,
|
||||
gradeCount: 0,
|
||||
};
|
||||
}
|
||||
classMap[g.classGroupId].grades.push(val);
|
||||
});
|
||||
|
||||
// Calculate statistics
|
||||
Object.values(classMap).forEach((c) => {
|
||||
c.grades.sort((a, b) => a - b);
|
||||
c.gradeCount = c.grades.length;
|
||||
c.average = c.grades.length > 0 ? Math.round((c.grades.reduce((s, v) => s + v, 0) / c.grades.length) * 100) / 100 : 0;
|
||||
|
||||
// Median
|
||||
const mid = Math.floor(c.grades.length / 2);
|
||||
c.median = c.grades.length > 0
|
||||
? c.grades.length % 2 !== 0
|
||||
? c.grades[mid]
|
||||
: Math.round(((c.grades[mid - 1] + c.grades[mid]) / 2) * 100) / 100
|
||||
: 0;
|
||||
|
||||
// Standard deviation
|
||||
const variance = c.grades.length > 0
|
||||
? c.grades.reduce((s, v) => s + Math.pow(v - c.average, 2), 0) / c.grades.length
|
||||
: 0;
|
||||
c.stdDev = Math.round(Math.sqrt(variance) * 100) / 100;
|
||||
});
|
||||
|
||||
// Class ranking (anonymized)
|
||||
const classRanking = Object.entries(classMap)
|
||||
.map(([id, data]) => ({ id, ...data }))
|
||||
.sort((a, b) => a.average - b.average)
|
||||
.map((c, idx) => ({
|
||||
...c,
|
||||
rank: idx + 1,
|
||||
anonymizedName: `Class ${String.fromCharCode(65 + idx)}`, // A, B, C, ...
|
||||
}));
|
||||
|
||||
// Subject comparison across classes
|
||||
const subjectByClass: Record<string, Record<string, { total: number; count: number; name: string }>> = {};
|
||||
grades.forEach((g) => {
|
||||
const val = g.overriddenValue ?? g.computedValue;
|
||||
if (!subjectByClass[g.subjectId]) {
|
||||
subjectByClass[g.subjectId] = {};
|
||||
}
|
||||
if (!subjectByClass[g.subjectId][g.classGroupId]) {
|
||||
subjectByClass[g.subjectId][g.classGroupId] = {
|
||||
total: 0,
|
||||
count: 0,
|
||||
name: g.classGroup.name,
|
||||
};
|
||||
}
|
||||
subjectByClass[g.subjectId][g.classGroupId].total += val;
|
||||
subjectByClass[g.subjectId][g.classGroupId].count += 1;
|
||||
});
|
||||
|
||||
// Get subject names
|
||||
const subjects = await db.subject.findMany({
|
||||
where: schoolId ? { schoolId } : {},
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
const subjectNameMap: Record<string, string> = {};
|
||||
subjects.forEach((s) => { subjectNameMap[s.id] = s.name; });
|
||||
|
||||
const subjectComparison = Object.entries(subjectByClass).map(([subjectId, classData]) => ({
|
||||
subjectId,
|
||||
subjectName: subjectNameMap[subjectId] || subjectId,
|
||||
classes: Object.entries(classData).map(([classId, data]) => ({
|
||||
classId,
|
||||
className: data.name,
|
||||
average: data.count > 0 ? Math.round((data.total / data.count) * 100) / 100 : 0,
|
||||
count: data.count,
|
||||
})),
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
type: 'class',
|
||||
classRanking,
|
||||
subjectComparison,
|
||||
total: grades.length,
|
||||
});
|
||||
}
|
||||
|
||||
if (compareType === 'subject') {
|
||||
// Subject comparison
|
||||
const subjectMap: Record<string, {
|
||||
name: string;
|
||||
grades: number[];
|
||||
average: number;
|
||||
variance: number;
|
||||
gradeCount: number;
|
||||
}> = {};
|
||||
|
||||
grades.forEach((g) => {
|
||||
const val = g.overriddenValue ?? g.computedValue;
|
||||
if (!subjectMap[g.subjectId]) {
|
||||
subjectMap[g.subjectId] = {
|
||||
name: g.subject.name,
|
||||
grades: [],
|
||||
average: 0,
|
||||
variance: 0,
|
||||
gradeCount: 0,
|
||||
};
|
||||
}
|
||||
subjectMap[g.subjectId].grades.push(val);
|
||||
});
|
||||
|
||||
Object.values(subjectMap).forEach((s) => {
|
||||
s.gradeCount = s.grades.length;
|
||||
s.average = s.grades.length > 0 ? Math.round((s.grades.reduce((sum, v) => sum + v, 0) / s.grades.length) * 100) / 100 : 0;
|
||||
const variance = s.grades.length > 0
|
||||
? s.grades.reduce((sum, v) => sum + Math.pow(v - s.average, 2), 0) / s.grades.length
|
||||
: 0;
|
||||
s.variance = Math.round(variance * 100) / 100;
|
||||
});
|
||||
|
||||
// Cross-subject correlation
|
||||
const subjectIds = Object.keys(subjectMap);
|
||||
const studentSubjectGrades: Record<string, Record<string, number[]>> = {};
|
||||
grades.forEach((g) => {
|
||||
const val = g.overriddenValue ?? g.computedValue;
|
||||
if (!studentSubjectGrades[g.studentId]) {
|
||||
studentSubjectGrades[g.studentId] = {};
|
||||
}
|
||||
if (!studentSubjectGrades[g.studentId][g.subjectId]) {
|
||||
studentSubjectGrades[g.studentId][g.subjectId] = [];
|
||||
}
|
||||
studentSubjectGrades[g.studentId][g.subjectId].push(val);
|
||||
});
|
||||
|
||||
// Calculate correlation matrix
|
||||
const correlationMatrix: Array<{
|
||||
subject1: string;
|
||||
subject2: string;
|
||||
name1: string;
|
||||
name2: string;
|
||||
correlation: number;
|
||||
}> = [];
|
||||
|
||||
for (let i = 0; i < subjectIds.length; i++) {
|
||||
for (let j = i; j < subjectIds.length; j++) {
|
||||
const s1 = subjectIds[i];
|
||||
const s2 = subjectIds[j];
|
||||
const pairs: Array<{ x: number; y: number }> = [];
|
||||
|
||||
Object.entries(studentSubjectGrades).forEach(([, subjects]) => {
|
||||
const g1 = subjects[s1];
|
||||
const g2 = subjects[s2];
|
||||
if (g1 && g2 && g1.length > 0 && g2.length > 0) {
|
||||
pairs.push({
|
||||
x: g1.reduce((a, b) => a + b, 0) / g1.length,
|
||||
y: g2.reduce((a, b) => a + b, 0) / g2.length,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (pairs.length >= 3) {
|
||||
const n = pairs.length;
|
||||
const sumX = pairs.reduce((s, p) => s + p.x, 0);
|
||||
const sumY = pairs.reduce((s, p) => s + p.y, 0);
|
||||
const sumXY = pairs.reduce((s, p) => s + p.x * p.y, 0);
|
||||
const sumX2 = pairs.reduce((s, p) => s + p.x * p.x, 0);
|
||||
const sumY2 = pairs.reduce((s, p) => s + p.y * p.y, 0);
|
||||
|
||||
const numerator = n * sumXY - sumX * sumY;
|
||||
const denominator = Math.sqrt((n * sumX2 - sumX * sumX) * (n * sumY2 - sumY * sumY));
|
||||
const correlation = denominator !== 0 ? Math.round((numerator / denominator) * 100) / 100 : 0;
|
||||
|
||||
correlationMatrix.push({
|
||||
subject1: s1,
|
||||
subject2: s2,
|
||||
name1: subjectMap[s1].name,
|
||||
name2: subjectMap[s2].name,
|
||||
correlation,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Subject difficulty ranking
|
||||
const subjectRanking = Object.entries(subjectMap)
|
||||
.map(([id, data]) => ({
|
||||
id,
|
||||
name: data.name,
|
||||
average: data.average,
|
||||
variance: data.variance,
|
||||
gradeCount: data.gradeCount,
|
||||
difficulty: data.average <= 2 ? 'easy' : data.average <= 3 ? 'moderate' : data.average <= 4 ? 'challenging' : 'hard',
|
||||
}))
|
||||
.sort((a, b) => b.average - a.average);
|
||||
|
||||
// Subject recommendations
|
||||
const recommendations = subjectRanking.map((s) => {
|
||||
const recs: string[] = [];
|
||||
if (s.average >= 4.5) {
|
||||
recs.push('Consider additional support materials and tutoring');
|
||||
recs.push('Review curriculum difficulty and pacing');
|
||||
} else if (s.average >= 3.5) {
|
||||
recs.push('Monitor student progress closely');
|
||||
recs.push('Consider differentiated instruction');
|
||||
} else {
|
||||
recs.push('Performance is on track');
|
||||
recs.push('Continue current teaching approach');
|
||||
}
|
||||
if (s.variance > 1.5) {
|
||||
recs.push('High variance suggests need for differentiated instruction');
|
||||
}
|
||||
return { subjectId: s.id, subjectName: s.name, recommendations: recs };
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
type: 'subject',
|
||||
subjectRanking,
|
||||
correlationMatrix,
|
||||
recommendations,
|
||||
total: grades.length,
|
||||
});
|
||||
}
|
||||
|
||||
if (compareType === 'teacher') {
|
||||
// Teacher comparison (admin only)
|
||||
if (!isAdmin) {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
|
||||
}
|
||||
|
||||
const teacherClasses = await db.classGroupTeacher.findMany({
|
||||
where: schoolId ? { classGroup: { schoolId } } : {},
|
||||
select: {
|
||||
userId: true,
|
||||
role: true,
|
||||
user: { select: { id: true, firstName: true, lastName: true } },
|
||||
classGroup: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const teacherMap: Record<string, {
|
||||
name: string;
|
||||
classIds: string[];
|
||||
grades: number[];
|
||||
average: number;
|
||||
classCount: number;
|
||||
gradeCount: number;
|
||||
}> = {};
|
||||
|
||||
teacherClasses.forEach((tc) => {
|
||||
if (!teacherMap[tc.userId]) {
|
||||
teacherMap[tc.userId] = {
|
||||
name: `${tc.user.firstName} ${tc.user.lastName}`,
|
||||
classIds: [],
|
||||
grades: [],
|
||||
average: 0,
|
||||
classCount: 0,
|
||||
gradeCount: 0,
|
||||
};
|
||||
}
|
||||
if (!teacherMap[tc.userId].classIds.includes(tc.classGroup.id)) {
|
||||
teacherMap[tc.userId].classIds.push(tc.classGroup.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Assign grades to teachers based on class
|
||||
grades.forEach((g) => {
|
||||
Object.entries(teacherMap).forEach(([, teacher]) => {
|
||||
if (teacher.classIds.includes(g.classGroupId)) {
|
||||
teacher.grades.push(g.overriddenValue ?? g.computedValue);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Calculate teacher stats
|
||||
Object.values(teacherMap).forEach((t) => {
|
||||
t.gradeCount = t.grades.length;
|
||||
t.classCount = t.classIds.length;
|
||||
t.average = t.grades.length > 0 ? Math.round((t.grades.reduce((s, v) => s + v, 0) / t.grades.length) * 100) / 100 : 0;
|
||||
});
|
||||
|
||||
const teacherRanking = Object.entries(teacherMap)
|
||||
.map(([id, data]) => ({ id, ...data }))
|
||||
.sort((a, b) => a.average - b.average);
|
||||
|
||||
return NextResponse.json({
|
||||
type: 'teacher',
|
||||
teacherRanking,
|
||||
total: grades.length,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Invalid compareType parameter' }, { status: 400 });
|
||||
} catch (error) {
|
||||
console.error('Grade comparison error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
// @ts-nocheck
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const schoolId = searchParams.get('schoolId') ?? session.user?.schoolId ?? undefined;
|
||||
const classGroupId = searchParams.get('classGroupId');
|
||||
const subjectId = searchParams.get('subjectId');
|
||||
const schoolYearId = searchParams.get('schoolYearId');
|
||||
const groupBy = searchParams.get('groupBy') ?? 'overall'; // overall, subject, class
|
||||
|
||||
const role = session.user?.role;
|
||||
const isStudent = role === 'STUDENT';
|
||||
const isParent = role === 'PARENT';
|
||||
|
||||
// Build where clause
|
||||
const gradeWhere: Record<string, unknown> = {};
|
||||
if (classGroupId) gradeWhere.classGroupId = classGroupId;
|
||||
if (subjectId) gradeWhere.subjectId = subjectId;
|
||||
if (schoolYearId) gradeWhere.schoolYearId = schoolYearId;
|
||||
|
||||
if (schoolId) {
|
||||
gradeWhere.classGroup = { schoolId };
|
||||
}
|
||||
|
||||
// Role-based scoping
|
||||
if (isStudent && session.user?.id) {
|
||||
const student = await db.student.findFirst({
|
||||
where: { userId: session.user.id, deletedAt: null },
|
||||
});
|
||||
if (student) gradeWhere.studentId = student.id;
|
||||
}
|
||||
|
||||
if (isParent && session.user?.id) {
|
||||
const parentLinks = await db.parentStudentLink.findMany({
|
||||
where: { parentId: session.user.id },
|
||||
select: { studentId: true },
|
||||
});
|
||||
const studentIds = parentLinks.map((l) => l.studentId);
|
||||
gradeWhere.studentId = { in: studentIds };
|
||||
}
|
||||
|
||||
// Get all grades with subject and class info
|
||||
const grades = await db.computedGrade.findMany({
|
||||
where: gradeWhere,
|
||||
select: {
|
||||
computedValue: true,
|
||||
overriddenValue: true,
|
||||
subjectId: true,
|
||||
classGroupId: true,
|
||||
studentId: true,
|
||||
subject: { select: { id: true, name: true } },
|
||||
classGroup: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Grade colors for German 1-6 scale
|
||||
const gradeColors: Record<number, string> = {
|
||||
1: '#10b981', // emerald
|
||||
2: '#22c55e', // green
|
||||
3: '#eab308', // yellow
|
||||
4: '#f59e0b', // amber
|
||||
5: '#f97316', // orange
|
||||
6: '#ef4444', // red
|
||||
};
|
||||
|
||||
if (groupBy === 'overall') {
|
||||
// Overall distribution
|
||||
const distribution: Record<number, { count: number; percentage: number; color: string }> = {};
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
distribution[i] = { count: 0, percentage: 0, color: gradeColors[i] };
|
||||
}
|
||||
grades.forEach((g) => {
|
||||
const val = g.overriddenValue ?? g.computedValue;
|
||||
const rounded = Math.round(val);
|
||||
if (rounded >= 1 && rounded <= 6) {
|
||||
distribution[rounded].count++;
|
||||
}
|
||||
});
|
||||
const total = grades.length || 1;
|
||||
Object.values(distribution).forEach((d) => {
|
||||
d.percentage = Math.round((d.count / total) * 10000) / 100;
|
||||
});
|
||||
|
||||
// Bell curve data (normal distribution overlay)
|
||||
const mean = grades.reduce((s, g) => s + (g.overriddenValue ?? g.computedValue), 0) / (grades.length || 1);
|
||||
const variance = grades.reduce((s, g) => {
|
||||
const val = g.overriddenValue ?? g.computedValue;
|
||||
return s + Math.pow(val - mean, 2);
|
||||
}, 0) / (grades.length || 1);
|
||||
const stdDev = Math.sqrt(variance);
|
||||
|
||||
const bellCurveData = [];
|
||||
for (let x = 0.5; x <= 6.5; x += 0.1) {
|
||||
const exponent = -Math.pow(x - mean, 2) / (2 * (stdDev * stdDev || 1));
|
||||
const y = (1 / (stdDev * Math.sqrt(2 * Math.PI) || 1)) * Math.exp(exponent);
|
||||
bellCurveData.push({ x: Math.round(x * 10) / 10, y: Math.round(y * 1000) / 1000 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
type: 'overall',
|
||||
distribution: Object.entries(distribution).map(([grade, data]) => ({
|
||||
grade: parseInt(grade),
|
||||
...data,
|
||||
label: grade === '1' ? 'Sehr gut' : grade === '2' ? 'Gut' : grade === '3' ? 'Befriedigend' : grade === '4' ? 'Ausreichend' : grade === '5' ? 'Mangelhaft' : 'Ungenügend',
|
||||
})),
|
||||
bellCurve: bellCurveData,
|
||||
mean: Math.round(mean * 100) / 100,
|
||||
stdDev: Math.round(stdDev * 100) / 100,
|
||||
total: grades.length,
|
||||
});
|
||||
}
|
||||
|
||||
if (groupBy === 'subject') {
|
||||
// Distribution by subject
|
||||
const subjectMap: Record<string, { name: string; distribution: Record<number, number> }> = {};
|
||||
grades.forEach((g) => {
|
||||
const val = g.overriddenValue ?? g.computedValue;
|
||||
const rounded = Math.round(val);
|
||||
if (!subjectMap[g.subjectId]) {
|
||||
subjectMap[g.subjectId] = { name: g.subject.name, distribution: { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0 } };
|
||||
}
|
||||
if (rounded >= 1 && rounded <= 6) {
|
||||
subjectMap[g.subjectId].distribution[rounded]++;
|
||||
}
|
||||
});
|
||||
|
||||
const subjectDistribution = Object.entries(subjectMap).map(([id, data]) => ({
|
||||
id,
|
||||
name: data.name,
|
||||
distribution: data.distribution,
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
type: 'subject',
|
||||
subjects: subjectDistribution,
|
||||
gradeColors,
|
||||
total: grades.length,
|
||||
});
|
||||
}
|
||||
|
||||
if (groupBy === 'class') {
|
||||
// Distribution by class
|
||||
const classMap: Record<string, { name: string; distribution: Record<number, number> }> = {};
|
||||
grades.forEach((g) => {
|
||||
const val = g.overriddenValue ?? g.computedValue;
|
||||
const rounded = Math.round(val);
|
||||
if (!classMap[g.classGroupId]) {
|
||||
classMap[g.classGroupId] = { name: g.classGroup.name, distribution: { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0 } };
|
||||
}
|
||||
if (rounded >= 1 && rounded <= 6) {
|
||||
classMap[g.classGroupId].distribution[rounded]++;
|
||||
}
|
||||
});
|
||||
|
||||
const classDistribution = Object.entries(classMap).map(([id, data]) => ({
|
||||
id,
|
||||
name: data.name,
|
||||
distribution: data.distribution,
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
type: 'class',
|
||||
classes: classDistribution,
|
||||
gradeColors,
|
||||
total: grades.length,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Invalid groupBy parameter' }, { status: 400 });
|
||||
} catch (error) {
|
||||
console.error('Grade distribution error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
// @ts-nocheck
|
||||
@@ -1,232 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
// GET: List grade reports
|
||||
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 type = searchParams.get('type');
|
||||
|
||||
const role = session.user?.role;
|
||||
const isAdmin = role === 'SUPER_ADMIN' || role === 'SCHOOL_ADMIN' || role === 'VICE_PRINCIPAL';
|
||||
const isTeacher = role === 'TEACHER';
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (schoolId) where.schoolId = schoolId;
|
||||
if (type) where.type = type;
|
||||
|
||||
// Non-admin users can only see their own reports
|
||||
if (!isAdmin && !isTeacher) {
|
||||
where.generatedBy = session.userId;
|
||||
}
|
||||
|
||||
const reports = await db.gradeReport.findMany({
|
||||
where,
|
||||
include: {
|
||||
generator: {
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
reports: reports.map((r) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
type: r.type,
|
||||
dateRange: r.dateRange,
|
||||
classIds: r.classIds,
|
||||
subjectIds: r.subjectIds,
|
||||
metrics: r.metrics,
|
||||
status: r.status,
|
||||
generatedBy: r.generator ? `${r.generator.firstName} ${r.generator.lastName}` : 'Unknown',
|
||||
createdAt: r.createdAt,
|
||||
hasFileData: !!r.fileData,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Grade reports list error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST: Generate a new grade report
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const role = session.user?.role;
|
||||
const isAdmin = role === 'SUPER_ADMIN' || role === 'SCHOOL_ADMIN' || role === 'VICE_PRINCIPAL';
|
||||
const isTeacher = role === 'TEACHER';
|
||||
|
||||
if (!isAdmin && !isTeacher) {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const {
|
||||
title,
|
||||
type,
|
||||
dateRange,
|
||||
classIds,
|
||||
subjectIds,
|
||||
metrics,
|
||||
} = body;
|
||||
|
||||
if (!title || !type || !dateRange) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
const schoolId = body.schoolId ?? session.user?.schoolId;
|
||||
if (!schoolId) {
|
||||
return NextResponse.json({ error: 'School ID required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Build the grade data for the report
|
||||
const gradeWhere: Record<string, unknown> = {
|
||||
classGroup: { schoolId },
|
||||
};
|
||||
|
||||
const parsedDateRange = typeof dateRange === 'string' ? JSON.parse(dateRange) : dateRange;
|
||||
if (parsedDateRange.from || parsedDateRange.to) {
|
||||
if (parsedDateRange.from) {
|
||||
gradeWhere.computedAt = { gte: new Date(parsedDateRange.from) };
|
||||
}
|
||||
if (parsedDateRange.to) {
|
||||
gradeWhere.computedAt = { ...(gradeWhere.computedAt as object || {}), lte: new Date(parsedDateRange.to) };
|
||||
}
|
||||
}
|
||||
|
||||
const parsedClassIds = typeof classIds === 'string' ? JSON.parse(classIds) : classIds;
|
||||
const parsedSubjectIds = typeof subjectIds === 'string' ? JSON.parse(subjectIds) : subjectIds;
|
||||
const parsedMetrics = typeof metrics === 'string' ? JSON.parse(metrics) : metrics;
|
||||
|
||||
if (parsedClassIds && Array.isArray(parsedClassIds) && parsedClassIds.length > 0) {
|
||||
gradeWhere.classGroupId = { in: parsedClassIds };
|
||||
}
|
||||
if (parsedSubjectIds && Array.isArray(parsedSubjectIds) && parsedSubjectIds.length > 0) {
|
||||
gradeWhere.subjectId = { in: parsedSubjectIds };
|
||||
}
|
||||
|
||||
// Fetch grades for report
|
||||
const grades = await db.computedGrade.findMany({
|
||||
where: gradeWhere,
|
||||
include: {
|
||||
student: { select: { id: true, firstName: true, lastName: true } },
|
||||
subject: { select: { id: true, name: true } },
|
||||
classGroup: { select: { id: true, name: true } },
|
||||
},
|
||||
orderBy: { computedAt: 'asc' },
|
||||
});
|
||||
|
||||
// Generate report data
|
||||
const reportData: Record<string, unknown> = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
generatedBy: `${session.user?.firstName} ${session.user?.lastName}`,
|
||||
title,
|
||||
type,
|
||||
dateRange: parsedDateRange,
|
||||
totalGrades: grades.length,
|
||||
};
|
||||
|
||||
// Include selected metrics
|
||||
if (!parsedMetrics || parsedMetrics.includes('distribution')) {
|
||||
const distribution: Record<number, number> = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0 };
|
||||
grades.forEach((g) => {
|
||||
const val = g.overriddenValue ?? g.computedValue;
|
||||
const rounded = Math.round(val);
|
||||
if (rounded >= 1 && rounded <= 6) distribution[rounded]++;
|
||||
});
|
||||
reportData.distribution = distribution;
|
||||
}
|
||||
|
||||
if (!parsedMetrics || parsedMetrics.includes('averages')) {
|
||||
const avg = grades.length > 0
|
||||
? Math.round((grades.reduce((s, g) => s + (g.overriddenValue ?? g.computedValue), 0) / grades.length) * 100) / 100
|
||||
: 0;
|
||||
reportData.averageGrade = avg;
|
||||
}
|
||||
|
||||
if (!parsedMetrics || parsedMetrics.includes('classComparison')) {
|
||||
const classAvg: Record<string, { name: string; average: number; count: number }> = {};
|
||||
grades.forEach((g) => {
|
||||
const val = g.overriddenValue ?? g.computedValue;
|
||||
if (!classAvg[g.classGroupId]) {
|
||||
classAvg[g.classGroupId] = { name: g.classGroup.name, average: 0, count: 0 };
|
||||
}
|
||||
classAvg[g.classGroupId].average += val;
|
||||
classAvg[g.classGroupId].count += 1;
|
||||
});
|
||||
Object.values(classAvg).forEach((c) => {
|
||||
c.average = c.count > 0 ? Math.round((c.average / c.count) * 100) / 100 : 0;
|
||||
});
|
||||
reportData.classComparison = classAvg;
|
||||
}
|
||||
|
||||
if (!parsedMetrics || parsedMetrics.includes('subjectAnalysis')) {
|
||||
const subjectAvg: Record<string, { name: string; average: number; count: number }> = {};
|
||||
grades.forEach((g) => {
|
||||
const val = g.overriddenValue ?? g.computedValue;
|
||||
if (!subjectAvg[g.subjectId]) {
|
||||
subjectAvg[g.subjectId] = { name: g.subject.name, average: 0, count: 0 };
|
||||
}
|
||||
subjectAvg[g.subjectId].average += val;
|
||||
subjectAvg[g.subjectId].count += 1;
|
||||
});
|
||||
Object.values(subjectAvg).forEach((s) => {
|
||||
s.average = s.count > 0 ? Math.round((s.average / s.count) * 100) / 100 : 0;
|
||||
});
|
||||
reportData.subjectAnalysis = subjectAvg;
|
||||
}
|
||||
|
||||
if (!parsedMetrics || parsedMetrics.includes('studentGrades')) {
|
||||
reportData.studentGrades = grades.map((g) => ({
|
||||
student: `${g.student.firstName} ${g.student.lastName}`,
|
||||
subject: g.subject.name,
|
||||
class: g.classGroup.name,
|
||||
grade: g.overriddenValue ?? g.computedValue,
|
||||
isFinalized: g.isFinalized,
|
||||
period: g.period,
|
||||
}));
|
||||
}
|
||||
|
||||
// Create the report record
|
||||
const report = await db.gradeReport.create({
|
||||
data: {
|
||||
schoolId,
|
||||
generatedBy: session.userId,
|
||||
title,
|
||||
type,
|
||||
dateRange: typeof dateRange === 'string' ? dateRange : JSON.stringify(dateRange),
|
||||
classIds: classIds ? (typeof classIds === 'string' ? classIds : JSON.stringify(classIds)) : null,
|
||||
subjectIds: subjectIds ? (typeof subjectIds === 'string' ? subjectIds : JSON.stringify(subjectIds)) : null,
|
||||
metrics: metrics ? (typeof metrics === 'string' ? metrics : JSON.stringify(metrics)) : null,
|
||||
status: 'completed',
|
||||
fileData: JSON.stringify(reportData),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
id: report.id,
|
||||
title: report.title,
|
||||
type: report.type,
|
||||
status: report.status,
|
||||
createdAt: report.createdAt,
|
||||
reportData,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Grade report generation error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const schoolId = searchParams.get('schoolId') ?? session.user?.schoolId ?? undefined;
|
||||
const classGroupId = searchParams.get('classGroupId');
|
||||
const subjectId = searchParams.get('subjectId');
|
||||
const schoolYearId = searchParams.get('schoolYearId');
|
||||
|
||||
const role = session.user?.role;
|
||||
const isStudent = role === 'STUDENT';
|
||||
const isParent = role === 'PARENT';
|
||||
|
||||
// Build where clause for ComputedGrade
|
||||
const gradeWhere: Record<string, unknown> = {};
|
||||
if (classGroupId) gradeWhere.classGroupId = classGroupId;
|
||||
if (subjectId) gradeWhere.subjectId = subjectId;
|
||||
if (schoolYearId) gradeWhere.schoolYearId = schoolYearId;
|
||||
|
||||
// Scope by school
|
||||
if (schoolId) {
|
||||
const classWhere: Record<string, unknown> = { schoolId };
|
||||
gradeWhere.classGroup = { schoolId };
|
||||
if (Object.keys(classWhere).length > 0) {
|
||||
gradeWhere.classGroup = classWhere;
|
||||
}
|
||||
}
|
||||
|
||||
// Role-based scoping
|
||||
if (isStudent && session.user?.id) {
|
||||
const student = await db.student.findFirst({
|
||||
where: { userId: session.user.id, deletedAt: null },
|
||||
});
|
||||
if (student) {
|
||||
gradeWhere.studentId = student.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (isParent && session.user?.id) {
|
||||
const parentLinks = await db.parentStudentLink.findMany({
|
||||
where: { parentId: session.user.id },
|
||||
select: { studentId: true },
|
||||
});
|
||||
const studentIds = parentLinks.map((l) => l.studentId);
|
||||
gradeWhere.studentId = { in: studentIds };
|
||||
}
|
||||
|
||||
// Get total grades count
|
||||
const totalGrades = await db.computedGrade.count({ where: gradeWhere });
|
||||
|
||||
// Get average grade
|
||||
const gradeAgg = await db.computedGrade.aggregate({
|
||||
where: gradeWhere,
|
||||
_avg: { computedValue: true },
|
||||
_min: { computedValue: true },
|
||||
_max: { computedValue: true },
|
||||
});
|
||||
|
||||
// Get grade distribution (1-6 scale)
|
||||
const allGrades = await db.computedGrade.findMany({
|
||||
where: gradeWhere,
|
||||
select: {
|
||||
computedValue: true,
|
||||
overriddenValue: true,
|
||||
subjectId: true,
|
||||
classGroupId: true,
|
||||
studentId: true,
|
||||
period: true,
|
||||
isFinalized: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Grade distribution
|
||||
const distribution: Record<number, number> = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0 };
|
||||
allGrades.forEach((g) => {
|
||||
const val = g.overriddenValue ?? g.computedValue;
|
||||
const rounded = Math.round(val);
|
||||
if (rounded >= 1 && rounded <= 6) {
|
||||
distribution[rounded]++;
|
||||
}
|
||||
});
|
||||
|
||||
// Finalized vs draft count
|
||||
const finalizedCount = allGrades.filter((g) => g.isFinalized).length;
|
||||
const draftCount = totalGrades - finalizedCount;
|
||||
|
||||
// Top performers (lowest grade = best on German scale)
|
||||
const studentGradeMap: Record<string, { total: number; count: number; name: string }> = {};
|
||||
for (const g of allGrades) {
|
||||
const val = g.overriddenValue ?? g.computedValue;
|
||||
if (!studentGradeMap[g.studentId]) {
|
||||
studentGradeMap[g.studentId] = { total: 0, count: 0, name: '' };
|
||||
}
|
||||
studentGradeMap[g.studentId].total += val;
|
||||
studentGradeMap[g.studentId].count += 1;
|
||||
}
|
||||
|
||||
// Get student names
|
||||
const studentIds = Object.keys(studentGradeMap);
|
||||
const students = await db.student.findMany({
|
||||
where: { id: { in: studentIds }, deletedAt: null },
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
});
|
||||
students.forEach((s) => {
|
||||
if (studentGradeMap[s.id]) {
|
||||
studentGradeMap[s.id].name = `${s.firstName} ${s.lastName}`;
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate averages and sort
|
||||
const studentAverages = studentIds
|
||||
.map((id) => ({
|
||||
id,
|
||||
name: studentGradeMap[id].name,
|
||||
average: studentGradeMap[id].count > 0 ? studentGradeMap[id].total / studentGradeMap[id].count : 0,
|
||||
gradeCount: studentGradeMap[id].count,
|
||||
}))
|
||||
.sort((a, b) => a.average - b.average); // Lower = better on German scale
|
||||
|
||||
const topPerformers = studentAverages.slice(0, 5);
|
||||
const bottomPerformers = studentAverages.slice(-5).reverse();
|
||||
|
||||
// Risk students (average >= 4.5, trending towards 5-6)
|
||||
const riskStudents = studentAverages.filter((s) => s.average >= 4.5 && s.gradeCount >= 2);
|
||||
|
||||
// Get classes for filter
|
||||
const classes = await db.classGroup.findMany({
|
||||
where: schoolId ? { schoolId } : {},
|
||||
select: { id: true, name: true, gradeLevel: true },
|
||||
orderBy: { gradeLevel: 'asc' },
|
||||
});
|
||||
|
||||
// Get subjects for filter
|
||||
const subjects = await db.subject.findMany({
|
||||
where: schoolId ? { schoolId } : {},
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
|
||||
// Subject difficulty ranking
|
||||
const subjectGradeMap: Record<string, { total: number; count: number; name: string }> = {};
|
||||
for (const g of allGrades) {
|
||||
const val = g.overriddenValue ?? g.computedValue;
|
||||
if (!subjectGradeMap[g.subjectId]) {
|
||||
subjectGradeMap[g.subjectId] = { total: 0, count: 0, name: '' };
|
||||
}
|
||||
subjectGradeMap[g.subjectId].total += val;
|
||||
subjectGradeMap[g.subjectId].count += 1;
|
||||
}
|
||||
|
||||
subjects.forEach((s) => {
|
||||
if (subjectGradeMap[s.id]) {
|
||||
subjectGradeMap[s.id].name = s.name;
|
||||
}
|
||||
});
|
||||
|
||||
const subjectDifficulty = Object.entries(subjectGradeMap)
|
||||
.map(([id, data]) => ({
|
||||
id,
|
||||
name: data.name,
|
||||
average: data.count > 0 ? data.total / data.count : 0,
|
||||
gradeCount: data.count,
|
||||
}))
|
||||
.sort((a, b) => b.average - a.average); // Higher average = harder
|
||||
|
||||
// Class average comparison
|
||||
const classGradeMap: Record<string, { total: number; count: number; name: string }> = {};
|
||||
for (const g of allGrades) {
|
||||
const val = g.overriddenValue ?? g.computedValue;
|
||||
if (!classGradeMap[g.classGroupId]) {
|
||||
classGradeMap[g.classGroupId] = { total: 0, count: 0, name: '' };
|
||||
}
|
||||
classGradeMap[g.classGroupId].total += val;
|
||||
classGradeMap[g.classGroupId].count += 1;
|
||||
}
|
||||
|
||||
classes.forEach((c) => {
|
||||
if (classGradeMap[c.id]) {
|
||||
classGradeMap[c.id].name = c.name;
|
||||
}
|
||||
});
|
||||
|
||||
const classAverages = Object.entries(classGradeMap)
|
||||
.map(([id, data]) => ({
|
||||
id,
|
||||
name: data.name,
|
||||
average: data.count > 0 ? data.total / data.count : 0,
|
||||
gradeCount: data.count,
|
||||
}))
|
||||
.sort((a, b) => a.average - b.average);
|
||||
|
||||
// Teacher comparison (admin only)
|
||||
let teacherComparison: Array<{ id: string; name: string; average: number; gradeCount: number }> = [];
|
||||
if (role === 'SUPER_ADMIN' || role === 'SCHOOL_ADMIN' || role === 'VICE_PRINCIPAL') {
|
||||
const teacherIds = await db.classGroupTeacher.findMany({
|
||||
where: schoolId ? { classGroup: { schoolId } } : {},
|
||||
select: { userId: true, user: { select: { id: true, firstName: true, lastName: true } } },
|
||||
distinct: ['userId'],
|
||||
});
|
||||
|
||||
for (const t of teacherIds) {
|
||||
const teacherClasses = await db.classGroupTeacher.findMany({
|
||||
where: { userId: t.userId },
|
||||
select: { classGroupId: true },
|
||||
});
|
||||
const tClassIds = teacherClasses.map((tc) => tc.classGroupId);
|
||||
const tGrades = allGrades.filter((g) => tClassIds.includes(g.classGroupId));
|
||||
if (tGrades.length > 0) {
|
||||
const avg = tGrades.reduce((sum, g) => sum + (g.overriddenValue ?? g.computedValue), 0) / tGrades.length;
|
||||
teacherComparison.push({
|
||||
id: t.user.id,
|
||||
name: `${t.user.firstName} ${t.user.lastName}`,
|
||||
average: Math.round(avg * 100) / 100,
|
||||
gradeCount: tGrades.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
teacherComparison.sort((a, b) => a.average - b.average);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
totalGrades,
|
||||
averageGrade: gradeAgg._avg.computedValue ? Math.round(gradeAgg._avg.computedValue * 100) / 100 : 0,
|
||||
minGrade: gradeAgg._min.computedValue ?? 0,
|
||||
maxGrade: gradeAgg._max.computedValue ?? 0,
|
||||
finalizedCount,
|
||||
draftCount,
|
||||
distribution,
|
||||
topPerformers,
|
||||
bottomPerformers,
|
||||
riskStudents,
|
||||
classes,
|
||||
subjects,
|
||||
subjectDifficulty,
|
||||
classAverages,
|
||||
teacherComparison,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Grade analytics error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const schoolId = searchParams.get('schoolId') ?? session.user?.schoolId ?? undefined;
|
||||
const classGroupId = searchParams.get('classGroupId');
|
||||
const subjectId = searchParams.get('subjectId');
|
||||
const schoolYearId = searchParams.get('schoolYearId');
|
||||
const studentId = searchParams.get('studentId');
|
||||
|
||||
const role = session.user?.role;
|
||||
const isStudent = role === 'STUDENT';
|
||||
const isParent = role === 'PARENT';
|
||||
|
||||
// Build where clause for assessments
|
||||
const assessmentWhere: Record<string, unknown> = {};
|
||||
if (classGroupId) assessmentWhere.classGroupId = classGroupId;
|
||||
if (subjectId) assessmentWhere.subjectId = subjectId;
|
||||
|
||||
if (schoolId) {
|
||||
assessmentWhere.classGroup = { schoolId };
|
||||
}
|
||||
|
||||
// Get assessments with results over time
|
||||
const assessments = await db.assessment.findMany({
|
||||
where: assessmentWhere,
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
date: true,
|
||||
type: true,
|
||||
subjectId: true,
|
||||
classGroupId: true,
|
||||
maxScore: true,
|
||||
subject: { select: { id: true, name: true } },
|
||||
classGroup: { select: { id: true, name: true } },
|
||||
assessmentResults: {
|
||||
select: {
|
||||
studentId: true,
|
||||
score: true,
|
||||
masteryLevelValue: true,
|
||||
student: {
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
...(studentId ? { where: { studentId } } : {}),
|
||||
},
|
||||
},
|
||||
orderBy: { date: 'asc' },
|
||||
});
|
||||
|
||||
// Role-based scoping for student/parent
|
||||
let filteredAssessments = assessments;
|
||||
if (isStudent && session.user?.id) {
|
||||
const student = await db.student.findFirst({
|
||||
where: { userId: session.user.id, deletedAt: null },
|
||||
});
|
||||
if (student) {
|
||||
filteredAssessments = assessments.map((a) => ({
|
||||
...a,
|
||||
assessmentResults: a.assessmentResults.filter((r) => r.studentId === student.id),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (isParent && session.user?.id) {
|
||||
const parentLinks = await db.parentStudentLink.findMany({
|
||||
where: { parentId: session.user.id },
|
||||
select: { studentId: true },
|
||||
});
|
||||
const childIds = parentLinks.map((l) => l.studentId);
|
||||
filteredAssessments = assessments.map((a) => ({
|
||||
...a,
|
||||
assessmentResults: a.assessmentResults.filter((r) => childIds.includes(r.studentId)),
|
||||
}));
|
||||
}
|
||||
|
||||
// Build trend data by month
|
||||
const monthMap: Record<string, { date: string; average: number; count: number; grade1: number; grade2: number; grade3: number; grade4: number; grade5: number; grade6: number }> = {};
|
||||
|
||||
filteredAssessments.forEach((a) => {
|
||||
const monthKey = a.date.toISOString().substring(0, 7); // YYYY-MM
|
||||
if (!monthMap[monthKey]) {
|
||||
monthMap[monthKey] = { date: monthKey, average: 0, count: 0, grade1: 0, grade2: 0, grade3: 0, grade4: 0, grade5: 0, grade6: 0 };
|
||||
}
|
||||
a.assessmentResults.forEach((r) => {
|
||||
if (r.score !== null && a.maxScore) {
|
||||
// Convert score to German grade (1-6 scale)
|
||||
const percentage = r.score / a.maxScore;
|
||||
let grade: number;
|
||||
if (percentage >= 0.92) grade = 1;
|
||||
else if (percentage >= 0.81) grade = 2;
|
||||
else if (percentage >= 0.67) grade = 3;
|
||||
else if (percentage >= 0.50) grade = 4;
|
||||
else if (percentage >= 0.30) grade = 5;
|
||||
else grade = 6;
|
||||
|
||||
monthMap[monthKey].count++;
|
||||
monthMap[monthKey].average += grade;
|
||||
if (grade === 1) monthMap[monthKey].grade1++;
|
||||
else if (grade === 2) monthMap[monthKey].grade2++;
|
||||
else if (grade === 3) monthMap[monthKey].grade3++;
|
||||
else if (grade === 4) monthMap[monthKey].grade4++;
|
||||
else if (grade === 5) monthMap[monthKey].grade5++;
|
||||
else monthMap[monthKey].grade6++;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Calculate averages
|
||||
Object.values(monthMap).forEach((m) => {
|
||||
m.average = m.count > 0 ? Math.round((m.average / m.count) * 100) / 100 : 0;
|
||||
});
|
||||
|
||||
const trendData = Object.values(monthMap).sort((a, b) => a.date.localeCompare(b.date));
|
||||
|
||||
// Student trajectory data
|
||||
const studentTrajectories: Record<string, { name: string; data: Array<{ date: string; grade: number }> }> = {};
|
||||
filteredAssessments.forEach((a) => {
|
||||
const monthKey = a.date.toISOString().substring(0, 7);
|
||||
a.assessmentResults.forEach((r) => {
|
||||
if (r.score !== null && a.maxScore) {
|
||||
const percentage = r.score / a.maxScore;
|
||||
let grade: number;
|
||||
if (percentage >= 0.92) grade = 1;
|
||||
else if (percentage >= 0.81) grade = 2;
|
||||
else if (percentage >= 0.67) grade = 3;
|
||||
else if (percentage >= 0.50) grade = 4;
|
||||
else if (percentage >= 0.30) grade = 5;
|
||||
else grade = 6;
|
||||
|
||||
if (!studentTrajectories[r.studentId]) {
|
||||
studentTrajectories[r.studentId] = {
|
||||
name: `${r.student.firstName} ${r.student.lastName}`,
|
||||
data: [],
|
||||
};
|
||||
}
|
||||
// Check if we already have data for this month
|
||||
const existing = studentTrajectories[r.studentId].data.find((d) => d.date === monthKey);
|
||||
if (!existing) {
|
||||
studentTrajectories[r.studentId].data.push({ date: monthKey, grade });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Sort trajectory data by date
|
||||
Object.values(studentTrajectories).forEach((t) => {
|
||||
t.data.sort((a, b) => a.date.localeCompare(b.date));
|
||||
});
|
||||
|
||||
// Improvement/regression detection
|
||||
const studentImprovements: Array<{
|
||||
studentId: string;
|
||||
name: string;
|
||||
trend: 'improving' | 'stable' | 'regressing';
|
||||
firstGrade: number;
|
||||
lastGrade: number;
|
||||
change: number;
|
||||
}> = [];
|
||||
|
||||
Object.entries(studentTrajectories).forEach(([id, data]) => {
|
||||
if (data.data.length >= 2) {
|
||||
const firstGrade = data.data[0].grade;
|
||||
const lastGrade = data.data[data.data.length - 1].grade;
|
||||
const change = firstGrade - lastGrade; // Negative = regressing (grade went up), Positive = improving
|
||||
let trend: 'improving' | 'stable' | 'regressing' = 'stable';
|
||||
if (change >= 0.5) trend = 'improving';
|
||||
else if (change <= -0.5) trend = 'regressing';
|
||||
|
||||
studentImprovements.push({
|
||||
studentId: id,
|
||||
name: data.name,
|
||||
trend,
|
||||
firstGrade,
|
||||
lastGrade,
|
||||
change: Math.round(change * 100) / 100,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Percentile calculation
|
||||
const allStudentAvgs = Object.entries(studentTrajectories).map(([id, data]) => {
|
||||
const avg = data.data.length > 0 ? data.data.reduce((s, d) => s + d.grade, 0) / data.data.length : 0;
|
||||
return { id, name: data.name, average: avg };
|
||||
}).sort((a, b) => a.average - b.average);
|
||||
|
||||
const percentileData = allStudentAvgs.map((s, idx) => ({
|
||||
...s,
|
||||
percentile: Math.round((idx / Math.max(allStudentAvgs.length - 1, 1)) * 100),
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
trendData,
|
||||
studentTrajectories: Object.entries(studentTrajectories).map(([id, data]) => ({
|
||||
id,
|
||||
...data,
|
||||
})),
|
||||
studentImprovements,
|
||||
percentileData,
|
||||
totalAssessments: filteredAssessments.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Grade trends error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const updateAnnotationSchema = z.object({
|
||||
type: z.enum(['drawing', 'text', 'highlight', 'stamp']).optional(),
|
||||
content: z.string().optional(),
|
||||
positionX: z.number().optional(),
|
||||
positionY: z.number().optional(),
|
||||
width: z.number().optional(),
|
||||
height: z.number().optional(),
|
||||
color: z.string().optional(),
|
||||
strokeWidth: z.number().optional(),
|
||||
page: z.number().optional(),
|
||||
pathData: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const annotation = await db.gradingAnnotation.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!annotation) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(annotation);
|
||||
} catch (error) {
|
||||
console.error('GradingAnnotation GET [id] error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const role = session.user?.role;
|
||||
if (role !== 'TEACHER' && role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN' && role !== 'VICE_PRINCIPAL') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const parsed = updateAnnotationSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await db.gradingAnnotation.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Only the creator or admin can update
|
||||
if (existing.teacherId !== session.userId && role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const annotation = await db.gradingAnnotation.update({
|
||||
where: { id },
|
||||
data: parsed.data,
|
||||
include: {
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(annotation);
|
||||
} catch (error) {
|
||||
console.error('GradingAnnotation PUT [id] error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const role = session.user?.role;
|
||||
if (role !== 'TEACHER' && role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN' && role !== 'VICE_PRINCIPAL') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const existing = await db.gradingAnnotation.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (existing.teacherId !== session.userId && role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
await db.gradingAnnotation.delete({ where: { id } });
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('GradingAnnotation DELETE [id] error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const createAnnotationSchema = z.object({
|
||||
schoolId: z.string().min(1),
|
||||
assessmentId: z.string().min(1),
|
||||
studentId: z.string().min(1),
|
||||
resultId: z.string().optional(),
|
||||
type: z.enum(['drawing', 'text', 'highlight', 'stamp']).default('drawing'),
|
||||
content: z.string().optional(),
|
||||
positionX: z.number().default(0),
|
||||
positionY: z.number().default(0),
|
||||
width: z.number().optional(),
|
||||
height: z.number().optional(),
|
||||
color: z.string().default('#ef4444'),
|
||||
strokeWidth: z.number().default(2),
|
||||
page: z.number().default(1),
|
||||
pathData: z.string().optional(),
|
||||
});
|
||||
|
||||
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 assessmentId = searchParams.get('assessmentId');
|
||||
const studentId = searchParams.get('studentId');
|
||||
const page = searchParams.get('page');
|
||||
|
||||
if (!assessmentId) {
|
||||
return NextResponse.json({ error: 'assessmentId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const where: Record<string, unknown> = { assessmentId };
|
||||
if (studentId) where.studentId = studentId;
|
||||
if (page) where.page = parseInt(page, 10);
|
||||
|
||||
const annotations = await db.gradingAnnotation.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: {
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(annotations);
|
||||
} catch (error) {
|
||||
console.error('GradingAnnotations GET error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const role = session.user?.role;
|
||||
if (role !== 'TEACHER' && role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN' && role !== 'VICE_PRINCIPAL') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = createAnnotationSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const data = parsed.data;
|
||||
const annotation = await db.gradingAnnotation.create({
|
||||
data: {
|
||||
schoolId: data.schoolId,
|
||||
assessmentId: data.assessmentId,
|
||||
studentId: data.studentId,
|
||||
resultId: data.resultId,
|
||||
teacherId: session.userId,
|
||||
type: data.type,
|
||||
content: data.content,
|
||||
positionX: data.positionX,
|
||||
positionY: data.positionY,
|
||||
width: data.width,
|
||||
height: data.height,
|
||||
color: data.color,
|
||||
strokeWidth: data.strokeWidth,
|
||||
page: data.page,
|
||||
pathData: data.pathData,
|
||||
},
|
||||
include: {
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(annotation, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('GradingAnnotations POST error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const comments = await db.gradingReviewComment.findMany({
|
||||
where: { reviewId: id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
user: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(comments);
|
||||
} catch (error) {
|
||||
console.error('GradingReviewComments GET error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const { comment, suggestedGrade, reason, resultId } = body;
|
||||
|
||||
if (!comment) {
|
||||
return NextResponse.json({ error: 'comment is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const existing = await db.teacherGradingReview.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Review not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const newComment = await db.gradingReviewComment.create({
|
||||
data: {
|
||||
reviewId: id,
|
||||
userId: session.userId,
|
||||
resultId: resultId || null,
|
||||
comment,
|
||||
suggestedGrade: suggestedGrade || null,
|
||||
reason: reason || null,
|
||||
},
|
||||
include: {
|
||||
user: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(newComment, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('GradingReviewComments POST error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const review = await db.teacherGradingReview.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
assessment: { select: { id: true, title: true } },
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
comments: {
|
||||
include: {
|
||||
user: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!review) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(review);
|
||||
} catch (error) {
|
||||
console.error('GradingReviews GET [id] error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const { status } = body;
|
||||
|
||||
const existing = await db.teacherGradingReview.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (status) updateData.status = status;
|
||||
|
||||
const review = await db.teacherGradingReview.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: {
|
||||
assessment: { select: { id: true, title: true } },
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
comments: {
|
||||
include: {
|
||||
user: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(review);
|
||||
} catch (error) {
|
||||
console.error('GradingReviews PUT [id] error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
// @ts-nocheck
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
import ZAI from 'z-ai-web-dev-sdk';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const schoolId = searchParams.get('schoolId') ?? session.user?.schoolId;
|
||||
const status = searchParams.get('status');
|
||||
|
||||
if (!schoolId) {
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
|
||||
const role = session.user?.role;
|
||||
if (role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN' && role !== 'VICE_PRINCIPAL' && role !== 'TEACHER') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const where: Record<string, unknown> = { schoolId };
|
||||
if (status) where.status = status;
|
||||
|
||||
if (role === 'TEACHER') {
|
||||
where.teacherId = session.userId;
|
||||
}
|
||||
|
||||
const reviews = await db.teacherGradingReview.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
assessment: { select: { id: true, title: true } },
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
comments: {
|
||||
include: {
|
||||
user: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(reviews);
|
||||
} catch (error) {
|
||||
console.error('GradingReviews GET error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const role = session.user?.role;
|
||||
if (role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN' && role !== 'VICE_PRINCIPAL') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { schoolId, assessmentId, teacherId } = body;
|
||||
|
||||
if (!schoolId || !assessmentId || !teacherId) {
|
||||
return NextResponse.json({ error: 'schoolId, assessmentId, and teacherId are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get assessment results for the teacher
|
||||
const assessment = await db.assessment.findUnique({
|
||||
where: { id: assessmentId },
|
||||
include: {
|
||||
assessmentResults: {
|
||||
where: { teacherId },
|
||||
include: {
|
||||
student: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!assessment) {
|
||||
return NextResponse.json({ error: 'Assessment not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Generate AI review
|
||||
const systemPrompt = 'Du bist ein erfahrener Lehrer. Überprüfe die Benotung und gib Feedback. Prüfe auf Fairness und Konsistenz.';
|
||||
|
||||
const gradingData = assessment.assessmentResults.map((r: { student: { firstName: string; lastName: string }; score: number | null; masteryLevelValue: number | null }) => ({
|
||||
student: `${r.student.firstName} ${r.student.lastName}`,
|
||||
score: r.score,
|
||||
maxScore: assessment.maxScore,
|
||||
grade: r.masteryLevelValue,
|
||||
}));
|
||||
|
||||
let reviewResult: string;
|
||||
let discrepanciesFound = 0;
|
||||
|
||||
try {
|
||||
const ai = await ZAI.create();
|
||||
const response = await ai.chat.completions.create({
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{
|
||||
role: 'user',
|
||||
content: `Überprüfe die folgende Benotung und gib Feedback zu Fairness und Konsistenz:\n\n${JSON.stringify(gradingData, null, 2)}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
reviewResult = response?.choices?.[0]?.message?.content || 'Review could not be generated';
|
||||
} catch (aiError) {
|
||||
console.error('AI review error:', aiError);
|
||||
reviewResult = JSON.stringify({
|
||||
overall: 'AI review could not be generated. Manual review recommended.',
|
||||
fairness: 'unknown',
|
||||
consistency: 'unknown',
|
||||
recommendations: ['Please review grading manually'],
|
||||
});
|
||||
}
|
||||
|
||||
// Count discrepancies
|
||||
const scores = assessment.assessmentResults
|
||||
.map((r: { score: number | null }) => r.score && assessment.maxScore ? (r.score / assessment.maxScore) * 100 : null)
|
||||
.filter((s: number | null): s is number => s !== null);
|
||||
if (scores.length > 0) {
|
||||
const avg = scores.reduce((a: number, b: number) => a + b, 0) / scores.length;
|
||||
discrepanciesFound = scores.filter((s: number) => Math.abs(s - avg) > 30).length;
|
||||
}
|
||||
|
||||
const review = await db.teacherGradingReview.create({
|
||||
data: {
|
||||
schoolId,
|
||||
assessmentId,
|
||||
teacherId,
|
||||
aiProvider: 'pollination',
|
||||
status: 'completed',
|
||||
reviewResult,
|
||||
discrepanciesFound,
|
||||
},
|
||||
include: {
|
||||
assessment: { select: { id: true, title: true } },
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(review, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('GradingReviews POST error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -488,7 +488,6 @@ function renderView(view: ViewName) {
|
||||
case 'notification-center': return <NotificationCenterView />;
|
||||
case 'announcements': return <SchoolAnnouncementsView />;
|
||||
case 'peer-assessment': return <PeerAssessmentView />;
|
||||
case 'grade-analytics': return <UnifiedGradingPanel mode="teacher" />;
|
||||
case 'student-achievements': return <StudentAchievementsView />;
|
||||
case 'student-study-planner': return <StudentStudyPlannerView />;
|
||||
case 'seating-chart': return <SeatingChartView />;
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { WidgetContainer } from './widget-container';
|
||||
import { WIDGET_TYPES, WIDGET_SIZES, getDefaultConfig } from '@/lib/widget-utils';
|
||||
import { Plus, Edit2, Save } from 'lucide-react';
|
||||
|
||||
interface DashboardCustomizerProps {
|
||||
schoolId: string;
|
||||
userId: string;
|
||||
widgets: any[];
|
||||
onWidgetAdd: (widgetType: string) => void;
|
||||
onWidgetRemove: (widgetId: string) => void;
|
||||
onLayoutSave: () => void;
|
||||
}
|
||||
|
||||
export function DashboardCustomizer({
|
||||
schoolId,
|
||||
userId,
|
||||
widgets,
|
||||
onWidgetAdd,
|
||||
onWidgetRemove,
|
||||
onLayoutSave,
|
||||
}: DashboardCustomizerProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [showWidgetGallery, setShowWidgetGallery] = useState(false);
|
||||
|
||||
const availableWidgets = Object.values(WIDGET_TYPES).filter(
|
||||
(type) => !widgets.some((w) => w.widgetType === type)
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-2 mb-4">
|
||||
<Button
|
||||
onClick={() => setIsEditing(!isEditing)}
|
||||
variant={isEditing ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
{isEditing ? 'Done Editing' : 'Customize'}
|
||||
</Button>
|
||||
|
||||
{isEditing && (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => setShowWidgetGallery(true)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Widget
|
||||
</Button>
|
||||
<Button onClick={onLayoutSave} size="sm" className="flex items-center gap-2">
|
||||
<Save className="h-4 w-4" />
|
||||
Save Layout
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Widget Gallery Dialog */}
|
||||
<Dialog open={showWidgetGallery} onOpenChange={setShowWidgetGallery}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Widget hinzufügen</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{availableWidgets.map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => {
|
||||
onWidgetAdd(type);
|
||||
setShowWidgetGallery(false);
|
||||
}}
|
||||
className="p-4 border rounded-lg hover:bg-gray-50 text-left"
|
||||
>
|
||||
<div className="font-semibold text-sm capitalize">
|
||||
{type.replace(/_/g, ' ')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Add this widget to your dashboard
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Dashboard Grid */}
|
||||
<div className="grid grid-cols-4 gap-4 auto-rows-max">
|
||||
{widgets.map((widget) => (
|
||||
<WidgetContainer
|
||||
key={widget.id}
|
||||
id={widget.id}
|
||||
title={widget.title || widget.widgetType.replace(/_/g, ' ')}
|
||||
widgetType={widget.widgetType}
|
||||
size={widget.size as any}
|
||||
isEditing={isEditing}
|
||||
onRemove={() => onWidgetRemove(widget.id)}
|
||||
>
|
||||
<div className="text-sm text-gray-500 text-center py-8">
|
||||
{widget.widgetType} widget content
|
||||
</div>
|
||||
</WidgetContainer>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ChevronDown, MoreHorizontal, Search } from 'lucide-react';
|
||||
|
||||
export interface DataTableColumn<T> {
|
||||
key: keyof T;
|
||||
label: string;
|
||||
sortable?: boolean;
|
||||
filterable?: boolean;
|
||||
render?: (value: any, row: T) => React.ReactNode;
|
||||
width?: string;
|
||||
}
|
||||
|
||||
export interface DataTableAction<T> {
|
||||
label: string;
|
||||
onClick: (row: T) => void;
|
||||
variant?: 'default' | 'destructive';
|
||||
}
|
||||
|
||||
export interface DataTableProps<T> {
|
||||
columns: DataTableColumn<T>[];
|
||||
data: T[];
|
||||
actions?: DataTableAction<T>[];
|
||||
onRowClick?: (row: T) => void;
|
||||
searchableFields?: (keyof T)[];
|
||||
isLoading?: boolean;
|
||||
rowKey?: keyof T;
|
||||
}
|
||||
|
||||
export function DataTable<T extends Record<string, any>>({
|
||||
columns,
|
||||
data,
|
||||
actions,
|
||||
onRowClick,
|
||||
searchableFields = [],
|
||||
isLoading = false,
|
||||
rowKey = 'id' as keyof T,
|
||||
}: DataTableProps<T>) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortConfig, setSortConfig] = useState<{
|
||||
key: keyof T;
|
||||
direction: 'asc' | 'desc';
|
||||
} | null>(null);
|
||||
|
||||
// Filter data based on search
|
||||
const filteredData = useMemo(() => {
|
||||
if (!searchTerm || searchableFields.length === 0) return data;
|
||||
|
||||
return data.filter((row) =>
|
||||
searchableFields.some((field) => {
|
||||
const value = row[field];
|
||||
return value?.toString().toLowerCase().includes(searchTerm.toLowerCase());
|
||||
})
|
||||
);
|
||||
}, [data, searchTerm, searchableFields]);
|
||||
|
||||
// Sort data
|
||||
const sortedData = useMemo(() => {
|
||||
if (!sortConfig) return filteredData;
|
||||
|
||||
return [...filteredData].sort((a, b) => {
|
||||
const aVal = a[sortConfig.key];
|
||||
const bVal = b[sortConfig.key];
|
||||
|
||||
if (aVal < bVal) return sortConfig.direction === 'asc' ? -1 : 1;
|
||||
if (aVal > bVal) return sortConfig.direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}, [filteredData, sortConfig]);
|
||||
|
||||
const handleSort = (key: keyof T) => {
|
||||
setSortConfig((prev) =>
|
||||
prev?.key === key && prev.direction === 'asc'
|
||||
? { key, direction: 'desc' }
|
||||
: { key, direction: 'asc' }
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-center py-8 text-gray-500">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{searchableFields.length > 0 && (
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-2.5 h-5 w-5 text-gray-400" />
|
||||
<Input
|
||||
placeholder="Suchen..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-200">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
{columns.map((col) => (
|
||||
<TableHead
|
||||
key={String(col.key)}
|
||||
style={{ width: col.width }}
|
||||
className="font-semibold text-gray-900"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{col.label}</span>
|
||||
{col.sortable && (
|
||||
<button
|
||||
onClick={() => handleSort(col.key)}
|
||||
className="hover:bg-gray-100 p-1 rounded"
|
||||
>
|
||||
<ChevronDown
|
||||
className="h-4 w-4 text-gray-400"
|
||||
style={{
|
||||
transform:
|
||||
sortConfig?.key === col.key && sortConfig.direction === 'desc'
|
||||
? 'rotate(180deg)'
|
||||
: 'rotate(0deg)',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</TableHead>
|
||||
))}
|
||||
{actions && <TableHead className="w-12"></TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedData.map((row, idx) => (
|
||||
<TableRow
|
||||
key={String(row[rowKey]) || idx}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
className="cursor-pointer hover:bg-gray-50"
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<TableCell key={String(col.key)} className="text-gray-700">
|
||||
{col.render ? col.render(row[col.key], row) : row[col.key]}
|
||||
</TableCell>
|
||||
))}
|
||||
{actions && (
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{actions.map((action) => (
|
||||
<DropdownMenuItem
|
||||
key={action.label}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
action.onClick(row);
|
||||
}}
|
||||
className={
|
||||
action.variant === 'destructive' ? 'text-red-600' : ''
|
||||
}
|
||||
>
|
||||
{action.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{sortedData.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">Keine Daten gefunden</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Accordion({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("border-b last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
)
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
@@ -1,11 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
|
||||
|
||||
function AspectRatio({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
|
||||
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
|
||||
}
|
||||
|
||||
export { AspectRatio }
|
||||
@@ -1,213 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react"
|
||||
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
buttonVariant = "ghost",
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString("default", { month: "short" }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn("w-fit", defaultClassNames.root),
|
||||
months: cn(
|
||||
"flex gap-4 flex-col md:flex-row relative",
|
||||
defaultClassNames.months
|
||||
),
|
||||
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
||||
nav: cn(
|
||||
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
|
||||
defaultClassNames.nav
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_previous
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_next
|
||||
),
|
||||
month_caption: cn(
|
||||
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
||||
defaultClassNames.month_caption
|
||||
),
|
||||
dropdowns: cn(
|
||||
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
||||
defaultClassNames.dropdowns
|
||||
),
|
||||
dropdown_root: cn(
|
||||
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
||||
defaultClassNames.dropdown_root
|
||||
),
|
||||
dropdown: cn(
|
||||
"absolute bg-popover inset-0 opacity-0",
|
||||
defaultClassNames.dropdown
|
||||
),
|
||||
caption_label: cn(
|
||||
"select-none font-medium",
|
||||
captionLayout === "label"
|
||||
? "text-sm"
|
||||
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
|
||||
defaultClassNames.caption_label
|
||||
),
|
||||
table: "w-full border-collapse",
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
|
||||
defaultClassNames.weekday
|
||||
),
|
||||
week: cn("flex w-full mt-2", defaultClassNames.week),
|
||||
week_number_header: cn(
|
||||
"select-none w-(--cell-size)",
|
||||
defaultClassNames.week_number_header
|
||||
),
|
||||
week_number: cn(
|
||||
"text-[0.8rem] select-none text-muted-foreground",
|
||||
defaultClassNames.week_number
|
||||
),
|
||||
day: cn(
|
||||
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
||||
defaultClassNames.day
|
||||
),
|
||||
range_start: cn(
|
||||
"rounded-l-md bg-accent",
|
||||
defaultClassNames.range_start
|
||||
),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
||||
today: cn(
|
||||
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||
defaultClassNames.today
|
||||
),
|
||||
outside: cn(
|
||||
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||
defaultClassNames.outside
|
||||
),
|
||||
disabled: cn(
|
||||
"text-muted-foreground opacity-50",
|
||||
defaultClassNames.disabled
|
||||
),
|
||||
hidden: cn("invisible", defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="calendar"
|
||||
ref={rootRef}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return (
|
||||
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return (
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null)
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus()
|
||||
}, [modifiers.focused])
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton }
|
||||
@@ -1,241 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import useEmblaCarousel, {
|
||||
type UseEmblaCarouselType,
|
||||
} from "embla-carousel-react"
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1]
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
||||
type CarouselOptions = UseCarouselParameters[0]
|
||||
type CarouselPlugin = UseCarouselParameters[1]
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions
|
||||
plugins?: CarouselPlugin
|
||||
orientation?: "horizontal" | "vertical"
|
||||
setApi?: (api: CarouselApi) => void
|
||||
}
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
||||
api: ReturnType<typeof useEmblaCarousel>[1]
|
||||
scrollPrev: () => void
|
||||
scrollNext: () => void
|
||||
canScrollPrev: boolean
|
||||
canScrollNext: boolean
|
||||
} & CarouselProps
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useCarousel must be used within a <Carousel />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function Carousel({
|
||||
orientation = "horizontal",
|
||||
opts,
|
||||
setApi,
|
||||
plugins,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & CarouselProps) {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{
|
||||
...opts,
|
||||
axis: orientation === "horizontal" ? "x" : "y",
|
||||
},
|
||||
plugins
|
||||
)
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
||||
|
||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||
if (!api) return
|
||||
setCanScrollPrev(api.canScrollPrev())
|
||||
setCanScrollNext(api.canScrollNext())
|
||||
}, [])
|
||||
|
||||
const scrollPrev = React.useCallback(() => {
|
||||
api?.scrollPrev()
|
||||
}, [api])
|
||||
|
||||
const scrollNext = React.useCallback(() => {
|
||||
api?.scrollNext()
|
||||
}, [api])
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault()
|
||||
scrollPrev()
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault()
|
||||
scrollNext()
|
||||
}
|
||||
},
|
||||
[scrollPrev, scrollNext]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !setApi) return
|
||||
setApi(api)
|
||||
}, [api, setApi])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) return
|
||||
onSelect(api)
|
||||
api.on("reInit", onSelect)
|
||||
api.on("select", onSelect)
|
||||
|
||||
return () => {
|
||||
api?.off("select", onSelect)
|
||||
}
|
||||
}, [api, onSelect])
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
carouselRef,
|
||||
api: api,
|
||||
opts,
|
||||
orientation:
|
||||
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn("relative", className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
data-slot="carousel"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { carouselRef, orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className="overflow-hidden"
|
||||
data-slot="carousel-content"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex",
|
||||
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
data-slot="carousel-item"
|
||||
className={cn(
|
||||
"min-w-0 shrink-0 grow-0 basis-full",
|
||||
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselPrevious({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-previous"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute size-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -left-12 -translate-y-1/2"
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft />
|
||||
<span className="sr-only">Vorherige Folie</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselNext({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-next"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute size-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -right-12 -translate-y-1/2"
|
||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight />
|
||||
<span className="sr-only">Nächste Folie</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
type CarouselApi,
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
}
|
||||
@@ -1,351 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as RechartsPrimitive from "recharts"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode
|
||||
icon?: React.ComponentType
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
)
|
||||
}
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig
|
||||
}
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
config,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
config: ChartConfig
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"]
|
||||
}) {
|
||||
const uniqueId = React.useId()
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme || config.color
|
||||
)
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const cssText = Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color
|
||||
return color ? ` --color-${key}: ${color};` : null
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`
|
||||
)
|
||||
.join("\n")
|
||||
|
||||
// Use ref to set textContent instead of dangerouslySetInnerHTML to avoid
|
||||
// React 19 "Encountered a script tag" warning for style elements
|
||||
return <style ref={(el) => { if (el) el.textContent = cssText }} />
|
||||
}
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean
|
||||
hideIndicator?: boolean
|
||||
indicator?: "line" | "dot" | "dashed"
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
}) {
|
||||
const { config } = useChart()
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [item] = payload
|
||||
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
])
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color || item.payload.fill || item.color
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
||||
indicator === "dot" && "items-center"
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
}
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = "bottom",
|
||||
nameKey,
|
||||
}: React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||
hideIcon?: boolean
|
||||
nameKey?: string
|
||||
}) {
|
||||
const { config } = useChart()
|
||||
|
||||
if (!payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{payload.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined
|
||||
|
||||
let configLabelKey: string = key
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config]
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState } = useFormContext()
|
||||
const formState = useFormState({ name: fieldContext.name })
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function FormLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={cn("data-[error=true]:text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
data-slot="form-control"
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message ?? "") : props.children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-message"
|
||||
id={formMessageId}
|
||||
className={cn("text-destructive text-sm", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function HoverCard({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
||||
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
|
||||
}
|
||||
|
||||
function HoverCardTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function HoverCardContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
||||
<HoverCardPrimitive.Content
|
||||
data-slot="hover-card-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</HoverCardPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
@@ -1,77 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { OTPInput, OTPInputContext } from "input-otp"
|
||||
import { MinusIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function InputOTP({
|
||||
className,
|
||||
containerClassName,
|
||||
...props
|
||||
}: React.ComponentProps<typeof OTPInput> & {
|
||||
containerClassName?: string
|
||||
}) {
|
||||
return (
|
||||
<OTPInput
|
||||
data-slot="input-otp"
|
||||
containerClassName={cn(
|
||||
"flex items-center gap-2 has-disabled:opacity-50",
|
||||
containerClassName
|
||||
)}
|
||||
className={cn("disabled:cursor-not-allowed", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-group"
|
||||
className={cn("flex items-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputOTPSlot({
|
||||
index,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
index: number
|
||||
}) {
|
||||
const inputOTPContext = React.useContext(OTPInputContext)
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-slot"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div data-slot="input-otp-separator" role="separator" {...props}>
|
||||
<MinusIcon />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
|
||||
@@ -1,276 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as MenubarPrimitive from "@radix-ui/react-menubar"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Menubar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
|
||||
return (
|
||||
<MenubarPrimitive.Root
|
||||
data-slot="menubar"
|
||||
className={cn(
|
||||
"bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
|
||||
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />
|
||||
}
|
||||
|
||||
function MenubarGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
|
||||
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />
|
||||
}
|
||||
|
||||
function MenubarPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
|
||||
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />
|
||||
}
|
||||
|
||||
function MenubarRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
|
||||
return (
|
||||
<MenubarPrimitive.Trigger
|
||||
data-slot="menubar-trigger"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarContent({
|
||||
className,
|
||||
align = "start",
|
||||
alignOffset = -4,
|
||||
sideOffset = 8,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
|
||||
return (
|
||||
<MenubarPortal>
|
||||
<MenubarPrimitive.Content
|
||||
data-slot="menubar-content"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</MenubarPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<MenubarPrimitive.Item
|
||||
data-slot="menubar-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<MenubarPrimitive.CheckboxItem
|
||||
data-slot="menubar-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
|
||||
return (
|
||||
<MenubarPrimitive.RadioItem
|
||||
data-slot="menubar-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenubarPrimitive.Label
|
||||
data-slot="menubar-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
|
||||
return (
|
||||
<MenubarPrimitive.Separator
|
||||
data-slot="menubar-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="menubar-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
|
||||
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />
|
||||
}
|
||||
|
||||
function MenubarSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenubarPrimitive.SubTrigger
|
||||
data-slot="menubar-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto h-4 w-4" />
|
||||
</MenubarPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
|
||||
return (
|
||||
<MenubarPrimitive.SubContent
|
||||
data-slot="menubar-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Menubar,
|
||||
MenubarPortal,
|
||||
MenubarMenu,
|
||||
MenubarTrigger,
|
||||
MenubarContent,
|
||||
MenubarGroup,
|
||||
MenubarSeparator,
|
||||
MenubarLabel,
|
||||
MenubarItem,
|
||||
MenubarShortcut,
|
||||
MenubarCheckboxItem,
|
||||
MenubarRadioGroup,
|
||||
MenubarRadioItem,
|
||||
MenubarSub,
|
||||
MenubarSubTrigger,
|
||||
MenubarSubContent,
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
import * as React from "react"
|
||||
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
|
||||
import { cva } from "class-variance-authority"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function NavigationMenu({
|
||||
className,
|
||||
children,
|
||||
viewport = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
|
||||
viewport?: boolean
|
||||
}) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Root
|
||||
data-slot="navigation-menu"
|
||||
data-viewport={viewport}
|
||||
className={cn(
|
||||
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{viewport && <NavigationMenuViewport />}
|
||||
</NavigationMenuPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.List
|
||||
data-slot="navigation-menu-list"
|
||||
className={cn(
|
||||
"group flex flex-1 list-none items-center justify-center gap-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Item
|
||||
data-slot="navigation-menu-item"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const navigationMenuTriggerStyle = cva(
|
||||
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
)
|
||||
|
||||
function NavigationMenuTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
data-slot="navigation-menu-trigger"
|
||||
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}{" "}
|
||||
<ChevronDownIcon
|
||||
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Content
|
||||
data-slot="navigation-menu-content"
|
||||
className={cn(
|
||||
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
|
||||
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuViewport({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-full left-0 isolate z-50 flex justify-center"
|
||||
)}
|
||||
>
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
data-slot="navigation-menu-viewport"
|
||||
className={cn(
|
||||
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuLink({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Link
|
||||
data-slot="navigation-menu-link"
|
||||
className={cn(
|
||||
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuIndicator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
data-slot="navigation-menu-indicator"
|
||||
className={cn(
|
||||
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuTrigger,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuViewport,
|
||||
navigationMenuTriggerStyle,
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import * as React from "react"
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
MoreHorizontalIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||
return (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
data-slot="pagination"
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="pagination-content"
|
||||
className={cn("flex flex-row items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||
return <li data-slot="pagination-item" {...props} />
|
||||
}
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean
|
||||
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
||||
React.ComponentProps<"a">
|
||||
|
||||
function PaginationLink({
|
||||
className,
|
||||
isActive,
|
||||
size = "icon",
|
||||
...props
|
||||
}: PaginationLinkProps) {
|
||||
return (
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-slot="pagination-link"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationPrevious({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
<span className="hidden sm:block">Zurück</span>
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationNext({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="hidden sm:block">Weiter</span>
|
||||
<ChevronRightIcon />
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
data-slot="pagination-ellipsis"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
<span className="sr-only">Weitere Seiten</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationLink,
|
||||
PaginationItem,
|
||||
PaginationPrevious,
|
||||
PaginationNext,
|
||||
PaginationEllipsis,
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import { CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function RadioGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
data-slot="radio-group"
|
||||
className={cn("grid gap-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioGroupItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator
|
||||
data-slot="radio-group-indicator"
|
||||
className="relative flex items-center justify-center"
|
||||
>
|
||||
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
@@ -1,56 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { GripVerticalIcon } from "lucide-react"
|
||||
import * as ResizablePrimitive from "react-resizable-panels"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ResizablePanelGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
|
||||
return (
|
||||
<ResizablePrimitive.PanelGroup
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ResizablePanel({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
|
||||
}
|
||||
|
||||
function ResizableHandle({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||
withHandle?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
data-slot="resizable-handle"
|
||||
className={cn(
|
||||
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
|
||||
<GripVerticalIcon className="size-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
)
|
||||
}
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||
@@ -1,35 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/components/ui/toast"
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
|
||||
import { type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toggleVariants } from "@/components/ui/toggle"
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants>
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
})
|
||||
|
||||
function ToggleGroup({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<ToggleGroupPrimitive.Root
|
||||
data-slot="toggle-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ToggleGroupItem({
|
||||
className,
|
||||
children,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
const context = React.useContext(ToggleGroupContext)
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
data-slot="toggle-group-item"
|
||||
data-variant={context.variant || variant}
|
||||
data-size={context.size || size}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
"min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem }
|
||||
@@ -1,58 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { ReactNode } from 'react';
|
||||
import { useAppStore } from '@/lib/store';
|
||||
import CommunicationView from './communication-view';
|
||||
import ParentCommunicationView from './parent-communication-view';
|
||||
|
||||
interface UnifiedCommunicationProps {
|
||||
variant?: 'auto' | 'teacher' | 'parent' | 'student';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified Communication Component
|
||||
* Consolidates teacher, parent, and student communication views.
|
||||
* Automatically selects the correct view based on user role.
|
||||
*/
|
||||
export function UnifiedCommunication({
|
||||
variant = 'auto',
|
||||
className
|
||||
}: UnifiedCommunicationProps) {
|
||||
const currentUser = useAppStore((s) => s.currentUser);
|
||||
|
||||
// Determine which view to show
|
||||
const getView = (): ReactNode => {
|
||||
// Override variant takes precedence
|
||||
if (variant === 'teacher') {
|
||||
return <CommunicationView />;
|
||||
}
|
||||
if (variant === 'parent') {
|
||||
return <ParentCommunicationView />;
|
||||
}
|
||||
if (variant === 'student') {
|
||||
return <CommunicationView />;
|
||||
}
|
||||
|
||||
// Auto-detect based on role
|
||||
switch (currentUser?.role) {
|
||||
case 'TEACHER':
|
||||
case 'SCHOOL_ADMIN':
|
||||
return <CommunicationView />;
|
||||
case 'PARENT':
|
||||
return <ParentCommunicationView />;
|
||||
case 'STUDENT':
|
||||
return <CommunicationView />;
|
||||
default:
|
||||
return <CommunicationView />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{getView()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UnifiedCommunication;
|
||||
@@ -1,84 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { MoreVertical, X, RotateCw } from 'lucide-react';
|
||||
import { needsRefresh, updateWidgetCache } from '@/lib/widget-utils';
|
||||
|
||||
interface WidgetContainerProps {
|
||||
id: string;
|
||||
title: string;
|
||||
widgetType: string;
|
||||
size: 'small' | 'medium' | 'large' | 'full_width';
|
||||
isEditing: boolean;
|
||||
onRemove: () => void;
|
||||
onRefresh?: () => Promise<void>;
|
||||
refreshInterval?: number;
|
||||
lastRefresh?: Date | null;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function WidgetContainer({
|
||||
id,
|
||||
title,
|
||||
size,
|
||||
isEditing,
|
||||
onRemove,
|
||||
onRefresh,
|
||||
refreshInterval = 300,
|
||||
lastRefresh,
|
||||
children,
|
||||
}: WidgetContainerProps) {
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
|
||||
const sizeClasses = {
|
||||
small: 'col-span-1',
|
||||
medium: 'col-span-2',
|
||||
large: 'col-span-3',
|
||||
full_width: 'col-span-full',
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
if (!onRefresh) return;
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await onRefresh();
|
||||
} catch (error) {
|
||||
console.error('[v0] Widget refresh failed:', error);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={`${sizeClasses[size]} h-full shadow-sm hover:shadow-md transition-shadow`}>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-3">
|
||||
<CardTitle className="text-base font-semibold">{title}</CardTitle>
|
||||
{isEditing && (
|
||||
<div className="flex gap-1">
|
||||
{onRefresh && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<RotateCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onRemove}
|
||||
className="h-6 w-6 p-0 hover:bg-red-50 hover:text-red-600"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="pb-4">{children}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -53,7 +53,6 @@ export type ViewName =
|
||||
| 'school-newsletter'
|
||||
| 'school-transport'
|
||||
| 'peer-assessment'
|
||||
| 'grade-analytics'
|
||||
| 'ai-studio';
|
||||
|
||||
export interface CurrentUser {
|
||||
|
||||
Reference in new issue
Block a user