Initial commit: ExaMetra v1.0.0 — Free & open-source competence & grading platform

Features:
- 15+ views: Dashboard, Classes, Progress, Flower, Matrix, Assessments, Grading, Reports, Attendance, Calendar, Lesson Plans, Analytics, Parent Communication, Behavior Tracking, Settings
- 34+ API endpoints
- 30+ Prisma models
- 700+ i18n keys (German + English)
- Keyboard shortcuts (Cmd+K, Cmd+1-9, Cmd+/)
- Batch deletion, drag-and-drop seating, custom color palette
- Demo accounts (admin, teacher, student, parent)
- Dark mode, responsive design, print styles
- CSV/PDF export, notification system, onboarding tour
- Zero emojis — all replaced with Lucide icons
- MIT License
This commit is contained in:
Mustafa Cetin committed 2026-07-29 05:46:10 +00:00
commit 0c1f739989
156 files changed
+51090

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
# CompetenceTrack / ExaMetra — Environment Configuration
# Copy this file to .env and adjust values as needed
# Database URL (SQLite via Prisma)
DATABASE_URL=file:./db/custom.db
+82
View File
@@ -0,0 +1,82 @@
# dependencies
node_modules/
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files
.env
.env*.local
.env.production
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# database
db/*.db
db/*.db-journal
prisma/*.db
prisma/*.db-journal
# dev artifacts
dev.log
dev.out.log
dev.pid
server.log
*.log
# internal development files
.claude
.z-ai-config
.zscripts/
agent-ctx/
tool-results/
worklog.md
qa-*.png
*-screenshot.png
*-qa.png
*-polished.png
current-state.png
dashboard-working.png
# skills and examples
skills/
examples/
mini-services/
tests/
download/
upload/
# gateway
Caddyfile
# lock files (optional - some projects include them)
bun.lock
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Mustafa Cetin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+155
View File
@@ -0,0 +1,155 @@
# ExaMetra
**Free & open-source competence & grading platform for schools**
_Kompetenzorientierte Beurteilung fur alle_
---
## What is ExaMetra?
ExaMetra is a comprehensive, free, and open-source web application designed for schools to track student competencies, manage assessments, compute grades, and generate reports. Built with modern web technologies, it provides a rich, professional interface for teachers, school administrators, students, and parents.
## Features
### Core Modules
- **Dashboard** — Overview with KPIs, trends, recent activity, and quick actions
- **Classes & Students** — Manage classes, enrollments, seating order, and student profiles
- **Competency Grids** — Assign and track competency templates per class and subject
- **Progress Entries** — Record and visualize student learning progress over time
- **Competence Flower** — Radar chart visualization of student competencies per subject
- **Mastery Matrix** — Grid view of student mastery levels across all competencies
- **Assessments** — Create and manage tests, oral exams, projects, and homework
- **Grading** — Compute grades with configurable weight rules, overrides, and breakdowns
- **Reports** — Generate narrative report cards with phrase templates
- **Attendance** — Track student attendance with sessions, stats, and calendar heatmap
- **Calendar** — Unified calendar view of assessments, attendance, and deadlines
- **Lesson Plans** — Plan and manage lessons with objectives, materials, and reflections
- **Analytics** — Insights with mastery trends, risk detection, and competency analysis
### Additional Features
- **Parent Communication** — Message system for parent-teacher communication
- **Behavior Tracking** — Incident logging with categories, severity, and follow-up
- **Rubric Library** — Create and share analytic/holistic rubrics
- **Comment Bank** — Reusable comment templates for report cards
- **Curriculum Coverage** — Track which competencies have been assessed
- **Settings** — Admin panel with user management, demo accounts, audit logs, and data export
- **Student Detail** — Comprehensive student profile with journey timeline, grades, and export
### Technical Features
- **Keyboard Shortcuts** — Cmd+K command palette, Cmd+1-9 view switching, Cmd+/ shortcuts dialog
- **Batch Operations** — Batch deletion of progress entries
- **Drag-and-Drop** — Student seating order reordering
- **Custom Color Palette** — 7 preset colors + custom picker for competence flower
- **Notification System** — Deadline reminders and missing observation alerts
- **Onboarding Tour** — Guided tour for new users
- **CSV Export** — Export student data as CSV
- **Print Support** — Print-optimized CSS for report generation
- **Dark Mode** — Full light/dark mode support with next-themes
- **i18n** — 700+ translation keys in German and English
- **Responsive Design** — Mobile-first responsive layout
## Tech Stack
- **Framework**: Next.js 16 (App Router)
- **Language**: TypeScript 5
- **Styling**: Tailwind CSS 4 + shadcn/ui
- **Database**: Prisma ORM + SQLite
- **State**: Zustand + React Query
- **Charts**: Recharts (Radar, Bar, Line)
- **Auth**: Cookie-based sessions + bcryptjs
- **Icons**: Lucide React (no emojis)
- **Animations**: Framer Motion
## Getting Started
### Prerequisites
- Node.js 18+ or Bun
- npm, yarn, or bun
### Installation
```bash
# Clone the repository
git clone https://github.com/cetinmustafa83/exametra.git
cd exametra
# Install dependencies
npm install
# or: bun install
# Set up environment
cp .env.example .env
# Push database schema
npm run db:push
# Generate Prisma client
npm run db:generate
# Seed demo data
npm run db:seed
# Start development server
npm run dev
```
### Demo Accounts
After seeding, you can log in with these demo accounts:
| Role | Email | Password |
|------|-------|----------|
| Admin | `demo@competencetrack.org` | `Demo2025!` |
| Teacher | `demo.teacher@competencetrack.org` | `Demo2025!` |
| Student | `demo.student@competencetrack.org` | `Demo2025!` |
| Parent | `demo.parent@competencetrack.org` | `Demo2025!` |
## Project Structure
```
exametra/
src/
app/ # Next.js App Router pages & API routes
api/ # 34+ API endpoint directories
globals.css # Global styles & animations
layout.tsx # Root layout
page.tsx # Entry point (SPA)
components/ # 26 React components
auth-view.tsx
dashboard-view.tsx
classes-view.tsx
competence-flower-view.tsx
...
hooks/ # Custom React hooks
lib/ # Utilities
api.ts # API client functions
auth.ts # Auth helpers
db.ts # Prisma client
i18n.ts # Internationalization (700+ keys)
store.ts # Zustand state management
utils.ts # Utility functions
prisma/
schema.prisma # 30+ database models
seed.ts # Demo data seed script
public/
logo.svg # App logo
```
## License
MIT License — see [LICENSE](./LICENSE) for details.
## Contributing
Contributions are welcome! This is a free and open-source project aimed at making competence-oriented assessment accessible to all schools.
1. Fork the repository
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
---
**ExaMetra** — Kompetenzorientierte Beurteilung fur alle
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+50
View File
@@ -0,0 +1,50 @@
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
import nextTypescript from "eslint-config-next/typescript";
import { dirname } from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const eslintConfig = [...nextCoreWebVitals, ...nextTypescript, {
rules: {
// TypeScript rules
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/prefer-as-const": "off",
"@typescript-eslint/no-unused-disable-directive": "off",
// React rules
"react-hooks/exhaustive-deps": "off",
"react-hooks/purity": "off",
"react/no-unescaped-entities": "off",
"react/display-name": "off",
"react/prop-types": "off",
"react-compiler/react-compiler": "off",
// Next.js rules
"@next/next/no-img-element": "off",
"@next/next/no-html-link-for-pages": "off",
// General JavaScript rules
"prefer-const": "off",
"no-unused-vars": "off",
"no-console": "off",
"no-debugger": "off",
"no-empty": "off",
"no-irregular-whitespace": "off",
"no-case-declarations": "off",
"no-fallthrough": "off",
"no-mixed-spaces-and-tabs": "off",
"no-redeclare": "off",
"no-undef": "off",
"no-unreachable": "off",
"no-useless-escape": "off",
},
}, {
ignores: ["node_modules/**", ".next/**", "out/**", "build/**", "next-env.d.ts", "examples/**", "skills"]
}];
export default eslintConfig;
+12
View File
@@ -0,0 +1,12 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
/* config options here */
typescript: {
ignoreBuildErrors: true,
},
reactStrictMode: false,
};
export default nextConfig;
+105
View File
@@ -0,0 +1,105 @@
{
"name": "exametra",
"version": "1.0.0",
"private": false,
"description": "Free & open-source competence & grading platform for schools — Kompetenzorientierte Beurteilung für alle",
"license": "MIT",
"author": "Mustafa Cetin",
"repository": {
"type": "git",
"url": "https://github.com/cetinmustafa83/exametra"
},
"keywords": ["education", "school", "competence", "grading", "assessment", "open-source", "nextjs"],
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint .",
"db:push": "prisma db push --accept-data-loss",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:reset": "prisma migrate reset",
"db:seed": "bun prisma/seed.ts"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.1.1",
"@mdxeditor/editor": "^3.39.1",
"@prisma/client": "^6.11.1",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-aspect-ratio": "^1.1.7",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-context-menu": "^2.2.15",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-hover-card": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-menubar": "^1.1.15",
"@radix-ui/react-navigation-menu": "^1.2.13",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toast": "^1.2.14",
"@radix-ui/react-toggle": "^1.1.9",
"@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.7",
"@reactuses/core": "^6.0.5",
"@tanstack/react-query": "^5.82.0",
"@tanstack/react-table": "^8.21.3",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.23.2",
"input-otp": "^1.4.2",
"lucide-react": "^0.525.0",
"next": "^16.1.1",
"next-auth": "^4.24.11",
"next-intl": "^4.3.4",
"next-themes": "^0.4.6",
"prisma": "^6.11.1",
"react": "^19.0.0",
"react-day-picker": "^9.8.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.60.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^3.0.3",
"react-syntax-highlighter": "^15.6.1",
"recharts": "^2.15.4",
"sharp": "^0.34.3",
"sonner": "^2.0.6",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"uuid": "^11.1.0",
"vaul": "^1.1.2",
"z-ai-web-dev-sdk": "^0.0.18",
"zod": "^4.0.2",
"zustand": "^5.0.6"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^3.0.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"bun-types": "^1.3.4",
"eslint": "^9",
"eslint-config-next": "^16.1.1",
"tailwindcss": "^4",
"tw-animate-css": "^1.3.5",
"typescript": "^5"
}
}
+5
View File
@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;
+713
View File
@@ -0,0 +1,713 @@
// CompetenceTrack — Full Database Schema
// SQLite via Prisma ORM | UUIDs for portability | UTC timestamps
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
// ─── Core Organizational Entities ───────────────────────────────────
model School {
id String @id @default(cuid())
name String
schoolType String // ELEMENTARY, MIDDLE, GYMNASIUM, OTHER
country String @default("DE")
timezone String @default("Europe/Berlin")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
schoolYears SchoolYear[]
classGroups ClassGroup[]
students Student[]
subjects Subject[]
competencyTemplates CompetencyTemplate[]
gradingSchemes GradingScheme[]
auditLogs AuditLog[]
dataExportRequests DataExportRequest[]
behaviorCategories BehaviorCategory[]
behaviorIncidents BehaviorIncident[]
rubrics Rubric[]
commentCategories CommentCategory[]
commentBankEntries CommentBankEntry[]
}
model User {
id String @id @default(cuid())
schoolId String?
email String @unique
passwordHash String
firstName String
lastName String
role String @default("TEACHER") // TEACHER, SCHOOL_ADMIN, SUPER_ADMIN, STUDENT, PARENT
locale String @default("de")
twoFactorSecret String?
isDemo Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
school School? @relation(fields: [schoolId], references: [id])
classGroupTeachers ClassGroupTeacher[]
learningProgressEntries LearningProgressEntry[]
assessments Assessment[]
generatedReports Report[]
auditLogs AuditLog[]
dataExportRequests DataExportRequest[]
teacherNotes TeacherNote[]
attendanceSessions AttendanceSession[]
lessonPlans LessonPlan[]
parentMessagesSent ParentMessage[]
behaviorIncidentsLogged BehaviorIncident[] @relation("BehaviorLogger")
behaviorIncidentsResolved BehaviorIncident[] @relation("BehaviorResolver")
rubrics Rubric[]
commentBankEntries CommentBankEntry[]
}
model SchoolYear {
id String @id @default(cuid())
schoolId String
label String // e.g. "2025/2026"
startDate DateTime
endDate DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
school School @relation(fields: [schoolId], references: [id])
classGroups ClassGroup[]
enrollments Enrollment[]
computedGrades ComputedGrade[]
reports Report[]
}
model ClassGroup {
id String @id @default(cuid())
schoolId String
schoolYearId String
name String // e.g. "3a"
gradeLevel Int
schoolType String @default("ELEMENTARY")
seatingOrder String? // JSON array of student IDs
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
school School @relation(fields: [schoolId], references: [id])
schoolYear SchoolYear @relation(fields: [schoolYearId], references: [id])
teachers ClassGroupTeacher[]
enrollments Enrollment[]
classCompetencyAssignments ClassCompetencyAssignment[]
assessments Assessment[]
learningProgressEntries LearningProgressEntry[]
gradingSchemes GradingScheme[]
computedGrades ComputedGrade[]
reports Report[]
attendanceSessions AttendanceSession[]
lessonPlans LessonPlan[]
behaviorIncidents BehaviorIncident[]
}
model ClassGroupTeacher {
id String @id @default(cuid())
classGroupId String
userId String
role String @default("SUBJECT_TEACHER") // HOMEROOM_TEACHER, SUBJECT_TEACHER
classGroup ClassGroup @relation(fields: [classGroupId], references: [id])
user User @relation(fields: [userId], references: [id])
@@unique([classGroupId, userId])
}
model Student {
id String @id @default(cuid())
schoolId String
firstName String
lastName String
dateOfBirth DateTime?
externalId String? // for import matching
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
school School @relation(fields: [schoolId], references: [id])
enrollments Enrollment[]
learningProgressEntries LearningProgressEntry[]
assessmentResults AssessmentResult[]
computedGrades ComputedGrade[]
reports Report[]
teacherNotes TeacherNote[]
attendanceRecords AttendanceRecord[]
parentContacts ParentContact[]
parentMessages ParentMessage[]
behaviorIncidents BehaviorIncident[]
}
model Enrollment {
id String @id @default(cuid())
studentId String
classGroupId String
schoolYearId String
startDate DateTime
endDate DateTime? // null = still enrolled
student Student @relation(fields: [studentId], references: [id])
classGroup ClassGroup @relation(fields: [classGroupId], references: [id])
schoolYear SchoolYear @relation(fields: [schoolYearId], references: [id])
}
// ─── Subject & Competency Structure ────────────────────────────────
model Subject {
id String @id @default(cuid())
schoolId String? // null = global/template
name String
gradeLevelMin Int @default(1)
gradeLevelMax Int @default(13)
school School? @relation(fields: [schoolId], references: [id])
competencyTemplates CompetencyTemplate[]
assessments Assessment[]
classCompetencyAssignments ClassCompetencyAssignment[]
gradingSchemes GradingScheme[]
computedGrades ComputedGrade[]
attendanceSessions AttendanceSession[]
lessonPlans LessonPlan[]
rubrics Rubric[]
commentBankEntries CommentBankEntry[]
}
model CompetencyTemplate {
id String @id @default(cuid())
name String
description String?
subjectId String?
schoolType String @default("ELEMENTARY")
gradeLevelMin Int @default(1)
gradeLevelMax Int @default(4)
isGlobalTemplate Boolean @default(false)
createdByUserId String?
version Int @default(1)
schoolId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
subject Subject? @relation(fields: [subjectId], references: [id])
school School? @relation(fields: [schoolId], references: [id])
categories CompetencyCategory[]
classCompetencyAssignments ClassCompetencyAssignment[]
}
model CompetencyCategory {
id String @id @default(cuid())
competencyTemplateId String
name String
order Int @default(0)
color String? // hex color for chart petals
competencyTemplate CompetencyTemplate @relation(fields: [competencyTemplateId], references: [id])
competencies Competency[]
reportSections ReportSection[]
}
model Competency {
id String @id @default(cuid())
categoryId String
code String // e.g. "D.3.LES.01"
title String
description String?
order Int @default(0)
category CompetencyCategory @relation(fields: [categoryId], references: [id])
masteryLevelDefinitions MasteryLevelDefinition[]
learningProgressEntries LearningProgressEntry[]
assessmentCompetencyLinks AssessmentCompetencyLink[]
}
model MasteryLevelDefinition {
id String @id @default(cuid())
competencyId String
levelValue Int // e.g. 1-4
label String // e.g. "noch nicht erreicht", "entwickelt sich", "kompetent", "weit entwickelt"
description String?
competency Competency @relation(fields: [competencyId], references: [id])
}
model ClassCompetencyAssignment {
id String @id @default(cuid())
classGroupId String
subjectId String
competencyTemplateId String
schoolYearId String
clonedTemplateId String? // if customized, points to the school's cloned copy
classGroup ClassGroup @relation(fields: [classGroupId], references: [id])
subject Subject @relation(fields: [subjectId], references: [id])
competencyTemplate CompetencyTemplate @relation(fields: [competencyTemplateId], references: [id])
@@unique([classGroupId, subjectId, schoolYearId])
}
// ─── Progress Tracking & Assessment ────────────────────────────────
model LearningProgressEntry {
id String @id @default(cuid())
studentId String
competencyId String
teacherId String
classGroupId String
date DateTime
masteryLevelValue Int
note String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
student Student @relation(fields: [studentId], references: [id])
competency Competency @relation(fields: [competencyId], references: [id])
teacher User @relation(fields: [teacherId], references: [id])
classGroup ClassGroup @relation(fields: [classGroupId], references: [id])
}
model Assessment {
id String @id @default(cuid())
classGroupId String
subjectId String
teacherId String
title String
date DateTime
type String @default("TEST") // TEST, ORAL, PROJECT, HOMEWORK, OTHER
maxScore Float?
weight Float @default(1.0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
classGroup ClassGroup @relation(fields: [classGroupId], references: [id])
subject Subject @relation(fields: [subjectId], references: [id])
teacher User @relation(fields: [teacherId], references: [id])
assessmentCompetencyLinks AssessmentCompetencyLink[]
assessmentResults AssessmentResult[]
}
model AssessmentCompetencyLink {
id String @id @default(cuid())
assessmentId String
competencyId String
weight Float @default(1.0)
assessment Assessment @relation(fields: [assessmentId], references: [id])
competency Competency @relation(fields: [competencyId], references: [id])
@@unique([assessmentId, competencyId])
}
model AssessmentResult {
id String @id @default(cuid())
assessmentId String
studentId String
score Float?
masteryLevelValue Int?
note String?
assessment Assessment @relation(fields: [assessmentId], references: [id])
student Student @relation(fields: [studentId], references: [id])
@@unique([assessmentId, studentId])
}
// ─── Grading ───────────────────────────────────────────────────────
model GradingScheme {
id String @id @default(cuid())
classGroupId String?
subjectId String?
schoolId String?
name String
type String @default("NUMERIC_GRADE") // NUMERIC_GRADE, VERBAL_FEEDBACK, COMBINED
scaleDefinition String // JSON: e.g. {"min":1,"max":6,"labels":{"1":"sehr gut","6":"ungenügend"}}
classGroup ClassGroup? @relation(fields: [classGroupId], references: [id])
subject Subject? @relation(fields: [subjectId], references: [id])
school School? @relation(fields: [schoolId], references: [id])
gradingWeightRules GradingWeightRule[]
}
model GradingWeightRule {
id String @id @default(cuid())
gradingSchemeId String
sourceType String // LEARNING_PROGRESS, ASSESSMENT
targetRef String? // competencyId, assessmentId, or categoryId
weightPercent Float @default(50.0)
gradingScheme GradingScheme @relation(fields: [gradingSchemeId], references: [id])
}
model ComputedGrade {
id String @id @default(cuid())
studentId String
subjectId String
classGroupId String
schoolYearId String
period String @default("Semester 1")
computedValue Float
computedAt DateTime @default(now())
isFinalized Boolean @default(false)
overriddenValue Float?
overrideReason String?
student Student @relation(fields: [studentId], references: [id])
subject Subject @relation(fields: [subjectId], references: [id])
classGroup ClassGroup @relation(fields: [classGroupId], references: [id])
schoolYear SchoolYear @relation(fields: [schoolYearId], references: [id])
}
// ─── Reporting ─────────────────────────────────────────────────────
model Report {
id String @id @default(cuid())
studentId String
classGroupId String
schoolYearId String
period String
generatedByUserId String
generatedAt DateTime @default(now())
status String @default("DRAFT") // DRAFT, FINAL
pdfFilePath String?
includesGrades Boolean @default(false)
student Student @relation(fields: [studentId], references: [id])
classGroup ClassGroup @relation(fields: [classGroupId], references: [id])
schoolYear SchoolYear @relation(fields: [schoolYearId], references: [id])
generatedByUser User @relation(fields: [generatedByUserId], references: [id])
sections ReportSection[]
}
model ReportSection {
id String @id @default(cuid())
reportId String
competencyCategoryId String?
generatedText String
order Int @default(0)
report Report @relation(fields: [reportId], references: [id])
competencyCategory CompetencyCategory? @relation(fields: [competencyCategoryId], references: [id])
}
// ─── Auditing & Compliance ─────────────────────────────────────────
model AuditLog {
id String @id @default(cuid())
userId String?
schoolId String?
action String
entityType String
entityId String?
timestamp DateTime @default(now())
metadata String? // JSON
user User? @relation(fields: [userId], references: [id])
school School? @relation(fields: [schoolId], references: [id])
}
model DataExportRequest {
id String @id @default(cuid())
requestedByUserId String
schoolId String?
scope String // STUDENT, CLASS, SCHOOL
scopeId String? // studentId, classGroupId, or schoolId
status String @default("PENDING") // PENDING, PROCESSING, COMPLETED, FAILED
filePath String?
requestedAt DateTime @default(now())
completedAt DateTime?
requestedByUser User @relation(fields: [requestedByUserId], references: [id])
school School? @relation(fields: [schoolId], references: [id])
}
// ─── Attendance Tracking ────────────────────────────────────────────
model AttendanceSession {
id String @id @default(cuid())
classGroupId String
teacherId String
date DateTime
subjectId String?
period String? // e.g. "1. Stunde", "Morning"
status String @default("OPEN") // OPEN, COMPLETED
note String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
classGroup ClassGroup @relation(fields: [classGroupId], references: [id])
teacher User @relation(fields: [teacherId], references: [id])
subject Subject? @relation(fields: [subjectId], references: [id])
records AttendanceRecord[]
@@index([classGroupId, date])
}
model AttendanceRecord {
id String @id @default(cuid())
sessionId String
studentId String
status String @default("PRESENT") // PRESENT, ABSENT, EXCUSED, LATE
arrivalTime String? // for LATE status, e.g. "08:25"
comment String?
session AttendanceSession @relation(fields: [sessionId], references: [id])
student Student @relation(fields: [studentId], references: [id])
@@unique([sessionId, studentId])
@@index([studentId])
}
// ─── Teacher Notes ─────────────────────────────────────────────────
model TeacherNote {
id String @id @default(cuid())
studentId String
teacherId String
category String // GENERAL, BEHAVIOR, ACADEMIC, INTERVENTION, PARENT_CONTACT
content String
isPrivate Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
student Student @relation(fields: [studentId], references: [id])
teacher User @relation(fields: [teacherId], references: [id])
@@index([studentId])
@@index([teacherId])
}
// ─── Lesson Planning ────────────────────────────────────────────────
model LessonPlan {
id String @id @default(cuid())
teacherId String
classGroupId String
subjectId String?
title String
description String?
date DateTime
durationMin Int @default(45)
status String @default("draft") // draft|scheduled|completed|cancelled
objectives String? // JSON array of strings
materials String? // JSON array of strings
homework String?
reflection String?
linkedCompetencyIds String? // JSON array of competency IDs
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
teacher User @relation(fields: [teacherId], references: [id])
classGroup ClassGroup @relation(fields: [classGroupId], references: [id])
subject Subject? @relation(fields: [subjectId], references: [id])
@@index([teacherId])
@@index([classGroupId])
@@index([date])
}
// ─── Parent Communication ──────────────────────────────────────────
model ParentContact {
id String @id @default(cuid())
studentId String
firstName String
lastName String
email String?
phone String?
relationship String @default("parent") // parent|guardian|emergency
preferredContact String @default("email") // email|phone|both
preferredLanguage String @default("de")
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
student Student @relation(fields: [studentId], references: [id])
messages ParentMessage[]
@@index([studentId])
@@unique([studentId, email])
}
model ParentMessage {
id String @id @default(cuid())
parentId String
teacherId String
studentId String
subject String
body String
category String @default("general") // general|progress|assessment|behavior|attendance|event
priority String @default("normal") // low|normal|high|urgent
status String @default("sent") // draft|sent|delivered|read|replied
readAt DateTime?
reply String?
replyAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
parent ParentContact @relation(fields: [parentId], references: [id])
teacher User @relation(fields: [teacherId], references: [id])
student Student @relation(fields: [studentId], references: [id])
@@index([teacherId])
@@index([parentId])
@@index([studentId])
@@index([createdAt])
}
// ─── Behavior Tracking ─────────────────────────────────────────────
model BehaviorCategory {
id String @id @default(cuid())
schoolId String
school School @relation(fields: [schoolId], references: [id])
name String
color String // hex color, e.g. "#10b981"
valence String // "positive" | "negative" | "neutral"
icon String? // emoji
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
incidents BehaviorIncident[]
@@unique([schoolId, name])
@@index([schoolId])
}
model BehaviorIncident {
id String @id @default(cuid())
studentId String
student Student @relation(fields: [studentId], references: [id])
teacherId String
teacher User @relation("BehaviorLogger", fields: [teacherId], references: [id])
classGroupId String?
classGroup ClassGroup? @relation(fields: [classGroupId], references: [id])
schoolId String
school School @relation(fields: [schoolId], references: [id])
categoryId String
category BehaviorCategory @relation(fields: [categoryId], references: [id])
date DateTime
severity String // "minor" | "moderate" | "major"
description String
location String? // e.g., "Klassenzimmer", "Pausenhof", "Flur"
followUpAction String? // e.g., "Elterngespräch", "Nachsitzen", "Lob im Kreis"
resolved Boolean @default(false)
resolvedAt DateTime?
resolvedById String?
resolvedBy User? @relation("BehaviorResolver", fields: [resolvedById], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([studentId, date])
@@index([classGroupId, date])
@@index([schoolId, date])
@@index([teacherId])
}
// ─── Rubric Library ────────────────────────────────────────────────
model Rubric {
id String @id @default(cuid())
schoolId String
school School @relation(fields: [schoolId], references: [id])
teacherId String
teacher User @relation(fields: [teacherId], references: [id])
subjectId String?
subject Subject? @relation(fields: [subjectId], references: [id])
title String
description String?
type String @default("ANALYTIC") // ANALYTIC | HOLISTIC
maxPoints Int @default(100)
isPublic Boolean @default(false) // shared across school
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
criteria RubricCriterion[]
@@index([schoolId])
@@index([teacherId])
@@index([subjectId])
}
model RubricCriterion {
id String @id @default(cuid())
rubricId String
rubric Rubric @relation(fields: [rubricId], references: [id], onDelete: Cascade)
name String // e.g., "Inhalt", "Sprache", "Darstellung"
description String?
weight Float @default(1.0) // relative weight for this criterion
maxPoints Int // max points for this criterion
order Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
levels RubricLevel[]
@@index([rubricId])
}
model RubricLevel {
id String @id @default(cuid())
criterionId String
criterion RubricCriterion @relation(fields: [criterionId], references: [id], onDelete: Cascade)
label String // e.g., "Sehr gut", "Gut", "Befriedigend", "Ausreichend"
description String // descriptor for this level
points Int // points for this level
order Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([criterionId])
}
// ─── Report Card Comments Bank ────────────────────────────────────
model CommentCategory {
id String @id @default(cuid())
schoolId String
school School @relation(fields: [schoolId], references: [id])
name String
color String // hex color
icon String? // emoji
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
comments CommentBankEntry[]
@@unique([schoolId, name])
@@index([schoolId])
}
model CommentBankEntry {
id String @id @default(cuid())
schoolId String
school School @relation(fields: [schoolId], references: [id])
teacherId String
teacher User @relation(fields: [teacherId], references: [id])
categoryId String
category CommentCategory @relation(fields: [categoryId], references: [id])
subjectId String?
subject Subject? @relation(fields: [subjectId], references: [id])
title String // short title
text String // the full comment text
gradeLevel String? // e.g., "3", "3-4", "all" — which grade levels this applies to
schoolType String? // e.g., "ELEMENTARY", "SECONDARY"
isPublic Boolean @default(false) // shared across school
usageCount Int @default(0) // how many times used
tags String? // comma-separated tags for search
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([schoolId])
@@index([teacherId])
@@index([categoryId])
@@index([subjectId])
}
+1545
View File
File diff suppressed because it is too large. Load diff
+29
View File
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve">
<defs>
<style type="text/css">
.st194{fill:#2D2D2D;stroke:#FFFFFF;stroke-width:0.6317;stroke-miterlimit:10;}
.st23{fill:#FFFFFF;}
.z-breathe {
animation: breathe 2.5s ease-in-out infinite;
}
@keyframes breathe {
0%, 100% { opacity: 0.7; }
50% { opacity: 1; }
}
</style>
</defs>
<g>
<path class="st194" d="M24.51,28.51H5.49c-2.21,0-4-1.79-4-4V5.49c0-2.21,1.79-4,4-4h19.03c2.21,0,4,1.79,4,4v19.03
C28.51,26.72,26.72,28.51,24.51,28.51z"/>
<g class="z-breathe">
<path class="st23" d="M15.47,7.1l-1.3,1.85c-0.2,0.29-0.54,0.47-0.9,0.47h-7.1V7.09C6.16,7.1,15.47,7.1,15.47,7.1z"/>
<polygon class="st23" points="24.3,7.1 13.14,22.91 5.7,22.91 16.86,7.1"/>
<path class="st23" d="M14.53,22.91l1.31-1.86c0.2-0.29,0.54-0.47,0.9-0.47h7.09v2.33H14.53z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+14
View File
@@ -0,0 +1,14 @@
User-agent: Googlebot
Allow: /
User-agent: Bingbot
Allow: /
User-agent: Twitterbot
Allow: /
User-agent: facebookexternalhit
Allow: /
User-agent: *
Allow: /
+439
View File
@@ -0,0 +1,439 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
// Helper: get ISO week key (YYYY-Www) for a date (Monday-based, ISO-8601)
function isoWeekKey(date: Date): string {
const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
const day = d.getUTCDay() || 7; // make Sunday = 7
d.setUTCDate(d.getUTCDate() + 4 - day); // Thursday of this week
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
const weekNo = Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`;
}
function startOfDate(d: Date): Date {
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
}
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 classGroupId = searchParams.get('classGroupId');
const subjectId = searchParams.get('subjectId');
const schoolYearId = searchParams.get('schoolYearId');
const schoolId = searchParams.get('schoolId') ?? session.user?.schoolId ?? undefined;
const startDate = searchParams.get('startDate');
const endDate = searchParams.get('endDate');
// Build base where clause for LearningProgressEntry
const where: Record<string, unknown> = {};
if (classGroupId) {
where.classGroupId = classGroupId;
} else {
// Scope by school via classGroup relation
const classWhere: Record<string, unknown> = {};
if (schoolId) classWhere.schoolId = schoolId;
if (schoolYearId) classWhere.schoolYearId = schoolYearId;
where.classGroup = classWhere;
}
// Subject filter — via competency.category.competencyTemplate.subjectId
if (subjectId) {
where.competency = {
category: {
competencyTemplate: { subjectId },
},
};
}
// Date range filter
if (startDate || endDate) {
const dateFilter: Record<string, unknown> = {};
if (startDate) dateFilter.gte = new Date(startDate);
if (endDate) dateFilter.lte = new Date(endDate);
where.date = dateFilter;
}
// ── Fetch all matching entries with light relations ──
const entries = await db.learningProgressEntry.findMany({
where,
orderBy: { date: 'asc' },
select: {
id: true,
date: true,
masteryLevelValue: true,
studentId: true,
competencyId: true,
classGroupId: true,
competency: {
select: {
id: true,
code: true,
title: true,
category: { select: { id: true, name: true, color: true } },
},
},
classGroup: { select: { id: true, name: true, schoolId: true, schoolYearId: true } },
},
});
// ── Mastery Trend (weekly aggregation) ──
const weekMap = new Map<string, { total: number; count: number; dateMs: number }>();
for (const e of entries) {
const wk = isoWeekKey(new Date(e.date));
const cur = weekMap.get(wk) ?? { total: 0, count: 0, dateMs: 0 };
cur.total += e.masteryLevelValue;
cur.count += 1;
const d = new Date(e.date);
const ms = startOfDate(d).getTime();
if (cur.dateMs === 0 || ms < cur.dateMs) cur.dateMs = ms;
weekMap.set(wk, cur);
}
const masteryTrend = Array.from(weekMap.entries())
.map(([week, v]) => ({
week,
date: new Date(v.dateMs).toISOString(),
avgMastery: v.count > 0 ? Math.round((v.total / v.count) * 100) / 100 : 0,
count: v.count,
}))
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
// ── Class Comparison (avg per class for the subject) ──
const classAgg = new Map<string, { className: string; total: number; count: number; students: Set<string> }>();
for (const e of entries) {
const cid = e.classGroupId;
const className = e.classGroup?.name ?? cid;
const cur = classAgg.get(cid) ?? { className, total: 0, count: 0, students: new Set<string>() };
cur.total += e.masteryLevelValue;
cur.count += 1;
cur.students.add(e.studentId);
classAgg.set(cid, cur);
}
const classComparison = Array.from(classAgg.entries())
.map(([classId, v]) => ({
classId,
className: v.className,
avgMastery: v.count > 0 ? Math.round((v.total / v.count) * 100) / 100 : 0,
studentCount: v.students.size,
}))
.sort((a, b) => b.avgMastery - a.avgMastery);
// ── Mastery Distribution (levels 1-4) ──
const distMap = new Map<number, number>([[1, 0], [2, 0], [3, 0], [4, 0]]);
for (const e of entries) {
const lvl = Math.min(4, Math.max(1, Math.floor(e.masteryLevelValue)));
distMap.set(lvl, (distMap.get(lvl) ?? 0) + 1);
}
const totalEntries = entries.length;
const masteryDistribution = Array.from(distMap.entries())
.map(([level, count]) => ({
level,
count,
percentage: totalEntries > 0 ? Math.round((count / totalEntries) * 1000) / 10 : 0,
}))
.sort((a, b) => a.level - b.level);
// ── Top/Bottom Competencies ──
const compAgg = new Map<string, { code: string; title: string; total: number; count: number }>();
for (const e of entries) {
const cid = e.competencyId;
const cur = compAgg.get(cid) ?? {
code: e.competency?.code ?? cid,
title: e.competency?.title ?? '',
total: 0,
count: 0,
};
cur.total += e.masteryLevelValue;
cur.count += 1;
compAgg.set(cid, cur);
}
const competencyArr = Array.from(compAgg.entries())
.map(([competencyId, v]) => ({
competencyId,
code: v.code,
title: v.title,
avgMastery: v.count > 0 ? Math.round((v.total / v.count) * 100) / 100 : 0,
entryCount: v.count,
}))
.sort((a, b) => b.avgMastery - a.avgMastery);
const topCompetencies = competencyArr.slice(0, 5);
const bottomCompetencies = competencyArr
.slice()
.sort((a, b) => a.avgMastery - b.avgMastery)
.slice(0, 5);
// ── Activity Heatmap (last 90 days) ──
const now = new Date();
const ninetyAgo = new Date(now);
ninetyAgo.setDate(ninetyAgo.getDate() - 89);
const heatStart = startOfDate(ninetyAgo);
const heatEnd = startOfDate(now);
const heatMap = new Map<string, number>();
for (let d = new Date(heatStart); d.getTime() <= heatEnd.getTime(); d.setDate(d.getDate() + 1)) {
heatMap.set(d.toISOString().slice(0, 10), 0);
}
for (const e of entries) {
const ed = startOfDate(new Date(e.date));
if (ed.getTime() < heatStart.getTime() || ed.getTime() > heatEnd.getTime()) continue;
const key = ed.toISOString().slice(0, 10);
heatMap.set(key, (heatMap.get(key) ?? 0) + 1);
}
const activityHeatmap = Array.from(heatMap.entries())
.map(([date, count]) => ({ date, count }))
.sort((a, b) => a.date.localeCompare(b.date));
// ── Grade Trend by Week (with class-average line) ──
// For each ISO week: school avg mastery (entry-weighted), entry count,
// unique students, class-average (simple mean of per-class averages).
type TrendWeekAgg = {
total: number;
count: number;
students: Set<string>;
dateMs: number;
classAgg: Map<string, { total: number; count: number }>;
};
const trendWeekMap = new Map<string, TrendWeekAgg>();
for (const e of entries) {
const wk = isoWeekKey(new Date(e.date));
const cur = trendWeekMap.get(wk) ?? {
total: 0,
count: 0,
students: new Set<string>(),
dateMs: 0,
classAgg: new Map<string, { total: number; count: number }>(),
};
cur.total += e.masteryLevelValue;
cur.count += 1;
cur.students.add(e.studentId);
const ms = startOfDate(new Date(e.date)).getTime();
if (cur.dateMs === 0 || ms < cur.dateMs) cur.dateMs = ms;
const cid = e.classGroupId;
const ca = cur.classAgg.get(cid) ?? { total: 0, count: 0 };
ca.total += e.masteryLevelValue;
ca.count += 1;
cur.classAgg.set(cid, ca);
trendWeekMap.set(wk, cur);
}
const gradeTrend = Array.from(trendWeekMap.entries())
.map(([week, v]) => {
const classMeans: number[] = [];
for (const ca of v.classAgg.values()) {
if (ca.count > 0) classMeans.push(ca.total / ca.count);
}
const classAvg =
classMeans.length > 0
? Math.round((classMeans.reduce((s, m) => s + m, 0) / classMeans.length) * 100) / 100
: 0;
const weekNo = parseInt(week.split('-W')[1] ?? '0', 10);
return {
week,
weekLabel: `KW${weekNo}`,
avgMastery: v.count > 0 ? Math.round((v.total / v.count) * 100) / 100 : 0,
entryCount: v.count,
uniqueStudents: v.students.size,
classAvg,
};
})
.sort((a, b) => a.week.localeCompare(b.week));
// ── At-Risk Students (Risk Analysis) ──
// Step 1: identify students in scope (via Enrollment → ClassGroup filter)
const enrollmentWhere: Record<string, unknown> = { endDate: null };
const enrClassWhere: Record<string, unknown> = {};
if (schoolId) enrClassWhere.schoolId = schoolId;
if (schoolYearId) enrClassWhere.schoolYearId = schoolYearId;
if (classGroupId) enrClassWhere.id = classGroupId;
if (Object.keys(enrClassWhere).length > 0) {
enrollmentWhere.classGroup = enrClassWhere;
}
let enrollments: Array<{
studentId: string;
classGroupId: string;
student: { id: string; firstName: string; lastName: string; deletedAt: string | null } | null;
classGroup: { id: string; name: string } | null;
}> = [];
try {
enrollments = await db.enrollment.findMany({
where: enrollmentWhere,
select: {
studentId: true,
classGroupId: true,
student: { select: { id: true, firstName: true, lastName: true, deletedAt: true } },
classGroup: { select: { id: true, name: true } },
},
});
} catch {
enrollments = [];
}
// Build studentId -> { name, className } (latest enrollment wins)
const studentMeta = new Map<string, { studentName: string; className: string }>();
for (const enr of enrollments) {
if (!enr.student || enr.student.deletedAt) continue;
const name = `${enr.student.firstName ?? ''} ${enr.student.lastName ?? ''}`.trim();
studentMeta.set(enr.studentId, {
studentName: name || enr.studentId,
className: enr.classGroup?.name ?? '',
});
}
// Step 2: group entries by studentId (only entries in scope are already filtered)
const studentEntries = new Map<string, Array<{ date: Date; masteryLevelValue: number }>>();
for (const e of entries) {
const arr = studentEntries.get(e.studentId) ?? [];
arr.push({ date: new Date(e.date), masteryLevelValue: e.masteryLevelValue });
studentEntries.set(e.studentId, arr);
}
// Step 3: batch-fetch attendance records (wrapped in try/catch)
const attendanceMap = new Map<string, { present: number; total: number }>();
const studentIds = Array.from(studentMeta.keys());
if (studentIds.length > 0) {
try {
const attendanceRecords = await db.attendanceRecord.findMany({
where: { studentId: { in: studentIds } },
select: { studentId: true, status: true },
});
for (const r of attendanceRecords) {
const cur = attendanceMap.get(r.studentId) ?? { present: 0, total: 0 };
cur.total += 1;
if (r.status === 'PRESENT' || r.status === 'LATE') cur.present += 1;
attendanceMap.set(r.studentId, cur);
}
} catch {
// Attendance data may not exist — ignore
}
}
// Step 4: compute risk score & signals per student
const nowMs = Date.now();
const fourteenDaysMs = 14 * 86400000;
type RiskLevel = 'low' | 'medium' | 'high' | 'critical';
type RiskSignal = 'low_mastery' | 'no_recent_progress' | 'low_attendance' | 'declining';
type AtRiskEntry = {
studentId: string;
studentName: string;
className: string;
riskScore: number;
riskLevel: RiskLevel;
signals: RiskSignal[];
latestMastery: number;
latestEntryDate: string;
};
const atRiskList: AtRiskEntry[] = [];
for (const [studentId, meta] of studentMeta) {
const eArr = studentEntries.get(studentId) ?? [];
const signals: RiskSignal[] = [];
let riskScore = 0;
let avgMastery = 0;
let latestMastery = 0;
let latestEntryDate: string | null = null;
if (eArr.length > 0) {
const sorted = eArr.sort((a, b) => a.date.getTime() - b.date.getTime());
avgMastery = sorted.reduce((s, e) => s + e.masteryLevelValue, 0) / sorted.length;
latestMastery = sorted[sorted.length - 1].masteryLevelValue;
latestEntryDate = sorted[sorted.length - 1].date.toISOString().slice(0, 10);
if (avgMastery < 2.0) {
signals.push('low_mastery');
riskScore += 30;
if (avgMastery < 1.5) {
riskScore += 20;
}
}
const latestMs = sorted[sorted.length - 1].date.getTime();
if (nowMs - latestMs > fourteenDaysMs) {
signals.push('no_recent_progress');
riskScore += 20;
}
// Declining trend: compare last 4 entries to previous 4
if (sorted.length >= 8) {
const prev4 = sorted.slice(-8, -4);
const last4 = sorted.slice(-4);
const prevAvg = prev4.reduce((s, e) => s + e.masteryLevelValue, 0) / 4;
const lastAvg = last4.reduce((s, e) => s + e.masteryLevelValue, 0) / 4;
if (lastAvg < prevAvg) {
signals.push('declining');
riskScore += 15;
}
}
} else {
// No progress entries at all → counts as no recent progress
signals.push('no_recent_progress');
riskScore += 20;
}
// Attendance (data may be missing for some students)
const att = attendanceMap.get(studentId);
if (att && att.total > 0) {
const rate = (att.present / att.total) * 100;
if (rate < 80) {
signals.push('low_attendance');
riskScore += 15;
}
}
if (riskScore === 0) continue;
let riskLevel: RiskLevel;
if (riskScore >= 65) riskLevel = 'critical';
else if (riskScore >= 45) riskLevel = 'high';
else if (riskScore >= 25) riskLevel = 'medium';
else riskLevel = 'low';
atRiskList.push({
studentId,
studentName: meta.studentName,
className: meta.className,
riskScore: Math.min(100, riskScore),
riskLevel,
signals,
latestMastery: Math.round(latestMastery * 100) / 100,
latestEntryDate: latestEntryDate ?? new Date().toISOString().slice(0, 10),
});
}
atRiskList.sort((a, b) => b.riskScore - a.riskScore);
const atRiskStudents = atRiskList.slice(0, 10);
// ── Summary stats ──
const totalStudents = new Set(entries.map((e) => e.studentId)).size;
const overallAvgMastery =
totalEntries > 0
? Math.round(
(entries.reduce((sum, e) => sum + e.masteryLevelValue, 0) / totalEntries) * 100
) / 100
: 0;
return NextResponse.json({
masteryTrend,
classComparison,
masteryDistribution,
topCompetencies,
bottomCompetencies,
activityHeatmap,
gradeTrend,
atRiskStudents,
totalEntries,
totalStudents,
overallAvgMastery,
});
} catch (error) {
console.error('Analytics GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
@@ -0,0 +1,151 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const resultSchema = z.object({
studentId: z.string().min(1),
score: z.number().nullable().optional(),
masteryLevelValue: z.number().int().nullable().optional(),
note: z.string().optional(),
});
const bulkResultSchema = z.array(resultSchema);
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: assessmentId } = await params;
const results = await db.assessmentResult.findMany({
where: { assessmentId },
include: {
student: {
select: { id: true, firstName: true, lastName: true },
},
assessment: {
select: { id: true, title: true, maxScore: true },
},
},
orderBy: { student: { lastName: 'asc' } },
});
return NextResponse.json(results);
} catch (error) {
console.error('Assessment results 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 });
}
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN' &&
session.user?.role !== 'TEACHER'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id: assessmentId } = await params;
const body = await request.json();
const isBulk = Array.isArray(body);
if (isBulk) {
const parsed = bulkResultSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
// Upsert each result (assessmentId + studentId is unique)
const results: Array<{ id: string; studentId: string; score: number | null; masteryLevelValue: number | null; note: string | null; assessmentId: string }> = [];
for (const item of parsed.data) {
const result = await db.assessmentResult.upsert({
where: {
assessmentId_studentId: {
assessmentId,
studentId: item.studentId,
},
},
update: {
score: item.score ?? null,
masteryLevelValue: item.masteryLevelValue ?? null,
note: item.note ?? null,
},
create: {
assessmentId,
studentId: item.studentId,
score: item.score ?? null,
masteryLevelValue: item.masteryLevelValue ?? null,
note: item.note ?? null,
},
});
results.push(result);
}
return NextResponse.json(
{ created: results.length },
{ status: 201 }
);
} else {
const parsed = resultSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const result = await db.assessmentResult.upsert({
where: {
assessmentId_studentId: {
assessmentId,
studentId: parsed.data.studentId,
},
},
update: {
score: parsed.data.score ?? null,
masteryLevelValue: parsed.data.masteryLevelValue ?? null,
note: parsed.data.note ?? null,
},
create: {
assessmentId,
studentId: parsed.data.studentId,
score: parsed.data.score ?? null,
masteryLevelValue: parsed.data.masteryLevelValue ?? null,
note: parsed.data.note ?? null,
},
});
return NextResponse.json(result, { status: 201 });
}
} catch (error) {
console.error('Assessment results POST error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
+133
View File
@@ -0,0 +1,133 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const competencyLinkSchema = z.object({
competencyId: z.string().min(1),
weight: z.number().default(1.0),
});
const createAssessmentSchema = z.object({
classGroupId: z.string().min(1),
subjectId: z.string().min(1),
teacherId: z.string().min(1),
title: z.string().min(1),
date: z.string().transform((v) => new Date(v)),
type: z.enum(['TEST', 'ORAL', 'PROJECT', 'HOMEWORK', 'OTHER']).default('TEST'),
maxScore: z.number().nullable().optional(),
weight: z.number().default(1.0),
competencyLinks: z.array(competencyLinkSchema).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 classGroupId = searchParams.get('classGroupId');
const subjectId = searchParams.get('subjectId');
const where: Record<string, unknown> = {};
if (classGroupId) where.classGroupId = classGroupId;
if (subjectId) where.subjectId = subjectId;
const assessments = await db.assessment.findMany({
where,
orderBy: { date: 'desc' },
include: {
classGroup: { select: { id: true, name: true } },
subject: { select: { id: true, name: true } },
teacher: { select: { id: true, firstName: true, lastName: true } },
assessmentCompetencyLinks: {
include: {
competency: {
select: { id: true, code: true, title: true },
},
},
},
_count: {
select: { assessmentResults: true },
},
},
});
return NextResponse.json(assessments);
} catch (error) {
console.error('Assessments GET error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN' &&
session.user?.role !== 'TEACHER'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const parsed = createAssessmentSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const { competencyLinks, ...assessmentData } = parsed.data;
const assessment = await db.assessment.create({
data: {
classGroupId: assessmentData.classGroupId,
subjectId: assessmentData.subjectId,
teacherId: assessmentData.teacherId,
title: assessmentData.title,
date: assessmentData.date,
type: assessmentData.type,
maxScore: assessmentData.maxScore ?? null,
weight: assessmentData.weight,
assessmentCompetencyLinks: competencyLinks
? {
create: competencyLinks.map((link) => ({
competencyId: link.competencyId,
weight: link.weight,
})),
}
: undefined,
},
include: {
classGroup: { select: { id: true, name: true } },
subject: { select: { id: true, name: true } },
teacher: { select: { id: true, firstName: true, lastName: true } },
assessmentCompetencyLinks: {
include: {
competency: { select: { id: true, code: true, title: true } },
},
},
},
});
return NextResponse.json(assessment, { status: 201 });
} catch (error) {
console.error('Assessments POST error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
+146
View File
@@ -0,0 +1,146 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const statusEnum = z.enum(['PRESENT', 'ABSENT', 'EXCUSED', 'LATE']);
const updateRecordSchema = z.object({
status: statusEnum.optional(),
arrivalTime: z.string().optional(),
comment: z.string().optional(),
});
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (session.user?.role !== 'TEACHER' && session.user?.role !== 'SCHOOL_ADMIN' && session.user?.role !== 'SUPER_ADMIN') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const body = await request.json();
const parsed = updateRecordSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const existing = await db.attendanceRecord.findUnique({
where: { id },
include: {
session: {
include: { classGroup: { select: { schoolId: true } } },
},
},
});
if (!existing) {
return NextResponse.json({ error: 'Record not found' }, { status: 404 });
}
const isTeacher = existing.session.teacherId === session.userId;
const isSchoolAdmin =
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
existing.session.classGroup.schoolId === session.user.schoolId;
const isSuperAdmin = session.user?.role === 'SUPER_ADMIN';
if (!isTeacher && !isSchoolAdmin && !isSuperAdmin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const data: Record<string, unknown> = {};
if (parsed.data.status !== undefined) data.status = parsed.data.status;
if (parsed.data.arrivalTime !== undefined) data.arrivalTime = parsed.data.arrivalTime;
if (parsed.data.comment !== undefined) data.comment = parsed.data.comment;
const record = await db.attendanceRecord.update({
where: { id },
data,
include: {
student: { select: { id: true, firstName: true, lastName: true } },
},
});
return NextResponse.json(record);
} catch (error) {
console.error('AttendanceRecord PUT error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (session.user?.role !== 'TEACHER' && session.user?.role !== 'SCHOOL_ADMIN' && session.user?.role !== 'SUPER_ADMIN') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const existing = await db.attendanceSession.findUnique({
where: { id },
include: { classGroup: { select: { schoolId: true } } },
});
if (!existing) {
return NextResponse.json({ error: 'Session not found' }, { status: 404 });
}
const isTeacher = existing.teacherId === session.userId;
const isSchoolAdmin =
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
existing.classGroup.schoolId === session.user.schoolId;
const isSuperAdmin = session.user?.role === 'SUPER_ADMIN';
if (!isTeacher && !isSchoolAdmin && !isSuperAdmin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Delete records first, then session
await db.attendanceRecord.deleteMany({
where: { sessionId: id },
});
await db.attendanceSession.delete({
where: { id },
});
await db.auditLog.create({
data: {
userId: session.userId,
schoolId: existing.classGroup.schoolId,
action: 'DELETE',
entityType: 'AttendanceSession',
entityId: id,
metadata: JSON.stringify({
classGroupId: existing.classGroupId,
date: existing.date.toISOString(),
}),
},
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('AttendanceSession DELETE error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+298
View File
@@ -0,0 +1,298 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const statusEnum = z.enum(['PRESENT', 'ABSENT', 'EXCUSED', 'LATE']);
const createSessionSchema = z.object({
classGroupId: z.string().min(1),
date: z.string().min(1),
subjectId: z.string().optional(),
period: z.string().optional(),
});
const updateSessionSchema = z.object({
id: z.string().min(1),
status: z.enum(['OPEN', 'COMPLETED']).optional(),
records: z.array(z.object({
id: z.string().min(1),
status: statusEnum.optional(),
arrivalTime: z.string().optional(),
comment: z.string().optional(),
})).optional(),
});
export async function GET(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (session.user?.role !== 'TEACHER' && session.user?.role !== 'SCHOOL_ADMIN' && session.user?.role !== 'SUPER_ADMIN') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { searchParams } = new URL(request.url);
const classGroupId = searchParams.get('classGroupId');
const dateFrom = searchParams.get('dateFrom');
const dateTo = searchParams.get('dateTo');
const status = searchParams.get('status');
if (!classGroupId) {
return NextResponse.json({ error: 'classGroupId is required' }, { status: 400 });
}
// Verify class belongs to user's school
const classGroup = await db.classGroup.findUnique({
where: { id: classGroupId },
select: { id: true, schoolId: true },
});
if (!classGroup) {
return NextResponse.json({ error: 'Class not found' }, { status: 404 });
}
if (
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
classGroup.schoolId !== session.user.schoolId
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const where: Record<string, unknown> = { classGroupId };
if (dateFrom || dateTo) {
const dateFilter: Record<string, unknown> = {};
if (dateFrom) dateFilter.gte = new Date(dateFrom);
if (dateTo) dateFilter.lte = new Date(dateTo);
where.date = dateFilter;
}
if (status) {
where.status = status;
}
const sessions = await db.attendanceSession.findMany({
where,
orderBy: { date: 'desc' },
include: {
subject: { select: { id: true, name: true } },
teacher: { select: { id: true, firstName: true, lastName: true } },
records: {
include: {
student: { select: { id: true, firstName: true, lastName: true } },
},
orderBy: { student: { lastName: 'asc' } },
},
},
});
return NextResponse.json(sessions);
} catch (error) {
console.error('Attendance GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (session.user?.role !== 'TEACHER' && session.user?.role !== 'SCHOOL_ADMIN' && session.user?.role !== 'SUPER_ADMIN') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const parsed = createSessionSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const { classGroupId, date, subjectId, period } = parsed.data;
// Verify class exists
const classGroup = await db.classGroup.findUnique({
where: { id: classGroupId },
select: { id: true, schoolId: true },
});
if (!classGroup) {
return NextResponse.json({ error: 'Class not found' }, { status: 404 });
}
if (
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
classGroup.schoolId !== session.user.schoolId
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Get all enrolled students for the class
const enrollments = await db.enrollment.findMany({
where: {
classGroupId,
endDate: null,
},
select: { studentId: true },
});
// Create the session with records for all students
const attendanceSession = await db.attendanceSession.create({
data: {
classGroupId,
teacherId: session.userId,
date: new Date(date),
subjectId: subjectId || null,
period: period || null,
status: 'OPEN',
records: {
create: enrollments.map((e) => ({
studentId: e.studentId,
status: 'PRESENT',
})),
},
},
include: {
subject: { select: { id: true, name: true } },
teacher: { select: { id: true, firstName: true, lastName: true } },
records: {
include: {
student: { select: { id: true, firstName: true, lastName: true } },
},
orderBy: { student: { lastName: 'asc' } },
},
},
});
await db.auditLog.create({
data: {
userId: session.userId,
schoolId: classGroup.schoolId,
action: 'CREATE',
entityType: 'AttendanceSession',
entityId: attendanceSession.id,
metadata: JSON.stringify({
classGroupId,
date,
studentCount: enrollments.length,
}),
},
});
return NextResponse.json(attendanceSession, { status: 201 });
} catch (error) {
console.error('Attendance POST error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function PUT(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (session.user?.role !== 'TEACHER' && session.user?.role !== 'SCHOOL_ADMIN' && session.user?.role !== 'SUPER_ADMIN') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const parsed = updateSessionSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const { id } = parsed.data;
const existing = await db.attendanceSession.findUnique({
where: { id },
include: { classGroup: { select: { schoolId: true } } },
});
if (!existing) {
return NextResponse.json({ error: 'Session not found' }, { status: 404 });
}
const isTeacher = existing.teacherId === session.userId;
const isSchoolAdmin =
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
existing.classGroup.schoolId === session.user.schoolId;
const isSuperAdmin = session.user?.role === 'SUPER_ADMIN';
if (!isTeacher && !isSchoolAdmin && !isSuperAdmin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Update session status if provided
if (parsed.data.status !== undefined) {
await db.attendanceSession.update({
where: { id },
data: { status: parsed.data.status },
});
}
// Update records if provided
if (parsed.data.records && parsed.data.records.length > 0) {
for (const record of parsed.data.records) {
const updateData: Record<string, unknown> = {};
if (record.status !== undefined) updateData.status = record.status;
if (record.arrivalTime !== undefined) updateData.arrivalTime = record.arrivalTime;
if (record.comment !== undefined) updateData.comment = record.comment;
if (Object.keys(updateData).length > 0) {
await db.attendanceRecord.update({
where: { id: record.id },
data: updateData,
});
}
}
}
// Return updated session
const updated = await db.attendanceSession.findUnique({
where: { id },
include: {
subject: { select: { id: true, name: true } },
teacher: { select: { id: true, firstName: true, lastName: true } },
records: {
include: {
student: { select: { id: true, firstName: true, lastName: true } },
},
orderBy: { student: { lastName: 'asc' } },
},
},
});
await db.auditLog.create({
data: {
userId: session.userId,
schoolId: existing.classGroup.schoolId,
action: 'UPDATE',
entityType: 'AttendanceSession',
entityId: id,
metadata: JSON.stringify({
status: parsed.data.status,
recordsUpdated: parsed.data.records?.length ?? 0,
}),
},
});
return NextResponse.json(updated);
} catch (error) {
console.error('Attendance PUT error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+52
View File
@@ -0,0 +1,52 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
export async function GET(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const schoolId = searchParams.get('schoolId');
const action = searchParams.get('action');
const startDate = searchParams.get('startDate');
const endDate = searchParams.get('endDate');
const where: Record<string, unknown> = {};
// If not super admin, restrict to user's school
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId) {
where.schoolId = session.user.schoolId;
} else if (schoolId) {
where.schoolId = schoolId;
}
if (action) where.action = action;
if (startDate || endDate) {
where.timestamp = {
...(startDate ? { gte: new Date(startDate) } : {}),
...(endDate ? { lte: new Date(endDate) } : {}),
};
}
const entries = await db.auditLog.findMany({
where,
orderBy: { timestamp: 'desc' },
take: 200,
include: {
user: {
select: { id: true, firstName: true, lastName: true },
},
},
});
return NextResponse.json(entries);
} catch (error) {
console.error('AuditLog GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+157
View File
@@ -0,0 +1,157 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import {
hashPassword,
verifyPassword,
createSession,
getSession,
clearSession,
} from '@/lib/auth';
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(6),
});
const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(6),
firstName: z.string().min(1),
lastName: z.string().min(1),
schoolId: z.string().optional(),
role: z.enum(['TEACHER', 'SCHOOL_ADMIN', 'SUPER_ADMIN']).default('TEACHER'),
});
export async function POST(request: Request) {
try {
const body = await request.json();
const { action } = body;
// ── Login ──
if (action === 'login') {
const parsed = loginSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const { email, password } = parsed.data;
const user = await db.user.findUnique({
where: { email, deletedAt: null },
});
if (!user) {
return NextResponse.json(
{ error: 'Invalid credentials' },
{ status: 401 }
);
}
const valid = await verifyPassword(password, user.passwordHash);
if (!valid) {
return NextResponse.json(
{ error: 'Invalid credentials' },
{ status: 401 }
);
}
await createSession(user.id);
return NextResponse.json({
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
schoolId: user.schoolId,
locale: user.locale,
isDemo: user.isDemo,
});
}
// ── Register ──
if (action === 'register') {
const parsed = registerSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const { email, password, firstName, lastName, schoolId, role } =
parsed.data;
const existing = await db.user.findUnique({ where: { email } });
if (existing) {
return NextResponse.json(
{ error: 'Email already registered' },
{ status: 409 }
);
}
const passwordHash = await hashPassword(password);
const user = await db.user.create({
data: {
email,
passwordHash,
firstName,
lastName,
schoolId: schoolId ?? null,
role,
},
});
await createSession(user.id);
return NextResponse.json(
{
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
schoolId: user.schoolId,
locale: user.locale,
isDemo: user.isDemo,
},
{ status: 201 }
);
}
// ── Logout ──
if (action === 'logout') {
await clearSession();
return NextResponse.json({ message: 'Logged out' });
}
return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
} catch (error) {
console.error('Auth error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
export async function GET() {
try {
const session = await getSession();
if (!session || !session.user) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
return NextResponse.json(session.user);
} catch (error) {
console.error('Auth me error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
@@ -0,0 +1,201 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const valenceEnum = z.enum(['positive', 'negative', 'neutral']);
const updateCategorySchema = z.object({
name: z.string().min(1).max(100).optional(),
color: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/, 'color must be a hex like #10b981')
.optional(),
valence: valenceEnum.optional(),
icon: z.string().max(20).optional().nullable(),
});
function isTeacherOrAdmin(role: string | undefined): boolean {
return (
role === 'TEACHER' ||
role === 'SCHOOL_ADMIN' ||
role === 'SUPER_ADMIN'
);
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (!isTeacherOrAdmin(session.user?.role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const category = await db.behaviorCategory.findUnique({
where: { id },
include: { _count: { select: { incidents: true } } },
});
if (!category) {
return NextResponse.json({ error: 'Category not found' }, { status: 404 });
}
if (
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
category.schoolId !== session.user.schoolId
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
return NextResponse.json(category);
} catch (error) {
console.error('BehaviorCategory GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (!isTeacherOrAdmin(session.user?.role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const body = await request.json();
const parsed = updateCategorySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const existing = await db.behaviorCategory.findUnique({ where: { id } });
if (!existing) {
return NextResponse.json({ error: 'Category not found' }, { status: 404 });
}
if (
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
existing.schoolId !== session.user.schoolId
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Check name uniqueness if name is being changed
if (parsed.data.name && parsed.data.name !== existing.name) {
const dupe = await db.behaviorCategory.findUnique({
where: { schoolId_name: { schoolId: existing.schoolId, name: parsed.data.name } },
});
if (dupe) {
return NextResponse.json(
{ error: 'A category with this name already exists in this school' },
{ status: 409 }
);
}
}
const data: Record<string, unknown> = {};
if (parsed.data.name !== undefined) data.name = parsed.data.name;
if (parsed.data.color !== undefined) data.color = parsed.data.color;
if (parsed.data.valence !== undefined) data.valence = parsed.data.valence;
if (parsed.data.icon !== undefined) data.icon = parsed.data.icon ?? null;
const updated = await db.behaviorCategory.update({
where: { id },
data,
});
await db.auditLog.create({
data: {
userId: session.userId,
schoolId: existing.schoolId,
action: 'UPDATE',
entityType: 'BehaviorCategory',
entityId: id,
metadata: JSON.stringify({ updatedFields: Object.keys(data) }),
},
});
return NextResponse.json(updated);
} catch (error) {
console.error('BehaviorCategory PUT error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (!isTeacherOrAdmin(session.user?.role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const existing = await db.behaviorCategory.findUnique({
where: { id },
include: { _count: { select: { incidents: true } } },
});
if (!existing) {
return NextResponse.json({ error: 'Category not found' }, { status: 404 });
}
if (
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
existing.schoolId !== session.user.schoolId
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Block deletion if incidents exist
if (existing._count.incidents > 0) {
return NextResponse.json(
{
error: 'Cannot delete: category is referenced by existing incidents',
incidentCount: existing._count.incidents,
},
{ status: 409 }
);
}
await db.behaviorCategory.delete({ where: { id } });
await db.auditLog.create({
data: {
userId: session.userId,
schoolId: existing.schoolId,
action: 'DELETE',
entityType: 'BehaviorCategory',
entityId: id,
metadata: JSON.stringify({ name: existing.name, valence: existing.valence }),
},
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('BehaviorCategory DELETE error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+137
View File
@@ -0,0 +1,137 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const valenceEnum = z.enum(['positive', 'negative', 'neutral']);
const createCategorySchema = z.object({
schoolId: z.string().min(1),
name: z.string().min(1).max(100),
color: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/, 'color must be a hex like #10b981'),
valence: valenceEnum,
icon: z.string().max(20).optional().nullable(),
});
function isTeacherOrAdmin(role: string | undefined): boolean {
return (
role === 'TEACHER' ||
role === 'SCHOOL_ADMIN' ||
role === 'SUPER_ADMIN'
);
}
export async function GET(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (!isTeacherOrAdmin(session.user?.role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { searchParams } = new URL(request.url);
const schoolIdParam = searchParams.get('schoolId');
// SCHOOL_ADMIN: forced to own school. SUPER_ADMIN/TEACHER: respect query, fallback to own.
let schoolId: string | undefined;
if (session.user?.role === 'SCHOOL_ADMIN') {
schoolId = session.user.schoolId ?? undefined;
} else {
schoolId = schoolIdParam ?? session.user?.schoolId ?? undefined;
}
if (!schoolId) {
return NextResponse.json([]);
}
const categories = await db.behaviorCategory.findMany({
where: { schoolId },
orderBy: [{ valence: 'asc' }, { name: 'asc' }],
include: {
_count: { select: { incidents: true } },
},
});
return NextResponse.json(categories);
} catch (error) {
console.error('BehaviorCategories GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (!isTeacherOrAdmin(session.user?.role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const parsed = createCategorySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const { schoolId, name } = parsed.data;
// Verify school exists; enforce SCHOOL_ADMIN scope
const school = await db.school.findUnique({ where: { id: schoolId } });
if (!school) {
return NextResponse.json({ error: 'School not found' }, { status: 404 });
}
if (
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
school.id !== session.user.schoolId
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Check for duplicate name within school
const existing = await db.behaviorCategory.findUnique({
where: { schoolId_name: { schoolId, name } },
});
if (existing) {
return NextResponse.json(
{ error: 'A category with this name already exists in this school' },
{ status: 409 }
);
}
const category = await db.behaviorCategory.create({
data: {
schoolId,
name,
color: parsed.data.color,
valence: parsed.data.valence,
icon: parsed.data.icon ?? null,
},
});
await db.auditLog.create({
data: {
userId: session.userId,
schoolId,
action: 'CREATE',
entityType: 'BehaviorCategory',
entityId: category.id,
metadata: JSON.stringify({ name, valence: parsed.data.valence, color: parsed.data.color }),
},
});
return NextResponse.json(category, { status: 201 });
} catch (error) {
console.error('BehaviorCategories POST error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
@@ -0,0 +1,241 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const severityEnum = z.enum(['minor', 'moderate', 'major']);
const updateIncidentSchema = z.object({
studentId: z.string().min(1).optional(),
classGroupId: z.string().min(1).optional().nullable(),
categoryId: z.string().min(1).optional(),
date: z.string().min(1).optional(),
severity: severityEnum.optional(),
description: z.string().min(1).max(2000).optional(),
location: z.string().max(200).optional().nullable(),
followUpAction: z.string().max(200).optional().nullable(),
resolved: z.boolean().optional(),
});
function isTeacherOrAdmin(role: string | undefined): boolean {
return (
role === 'TEACHER' ||
role === 'SCHOOL_ADMIN' ||
role === 'SUPER_ADMIN'
);
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (!isTeacherOrAdmin(session.user?.role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const incident = await db.behaviorIncident.findUnique({
where: { id },
include: {
student: { select: { id: true, firstName: true, lastName: true, deletedAt: true } },
teacher: { select: { id: true, firstName: true, lastName: true } },
classGroup: { select: { id: true, name: true, gradeLevel: true } },
category: { select: { id: true, name: true, color: true, valence: true, icon: true } },
resolvedBy: { select: { id: true, firstName: true, lastName: true } },
},
});
if (!incident) {
return NextResponse.json({ error: 'Incident not found' }, { status: 404 });
}
if (
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
incident.schoolId !== session.user.schoolId
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
return NextResponse.json(incident);
} catch (error) {
console.error('BehaviorIncident GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (!isTeacherOrAdmin(session.user?.role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const body = await request.json();
const parsed = updateIncidentSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const existing = await db.behaviorIncident.findUnique({ where: { id } });
if (!existing) {
return NextResponse.json({ error: 'Incident not found' }, { status: 404 });
}
if (
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
existing.schoolId !== session.user.schoolId
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Validate references when they're being changed
if (parsed.data.studentId && parsed.data.studentId !== existing.studentId) {
const student = await db.student.findUnique({
where: { id: parsed.data.studentId },
select: { id: true, schoolId: true, deletedAt: true },
});
if (!student || student.schoolId !== existing.schoolId) {
return NextResponse.json({ error: 'Student not found in this school' }, { status: 404 });
}
if (student.deletedAt) {
return NextResponse.json({ error: 'Student is deleted' }, { status: 400 });
}
}
if (parsed.data.categoryId && parsed.data.categoryId !== existing.categoryId) {
const category = await db.behaviorCategory.findUnique({
where: { id: parsed.data.categoryId },
select: { id: true, schoolId: true },
});
if (!category || category.schoolId !== existing.schoolId) {
return NextResponse.json({ error: 'Category not found in this school' }, { status: 404 });
}
}
if (parsed.data.classGroupId) {
const classGroup = await db.classGroup.findUnique({
where: { id: parsed.data.classGroupId },
select: { id: true, schoolId: true },
});
if (!classGroup || classGroup.schoolId !== existing.schoolId) {
return NextResponse.json({ error: 'Class group not found in this school' }, { status: 404 });
}
}
const data: Record<string, unknown> = {};
if (parsed.data.studentId !== undefined) data.studentId = parsed.data.studentId;
if (parsed.data.classGroupId !== undefined) data.classGroupId = parsed.data.classGroupId || null;
if (parsed.data.categoryId !== undefined) data.categoryId = parsed.data.categoryId;
if (parsed.data.date !== undefined) data.date = new Date(parsed.data.date);
if (parsed.data.severity !== undefined) data.severity = parsed.data.severity;
if (parsed.data.description !== undefined) data.description = parsed.data.description;
if (parsed.data.location !== undefined) data.location = parsed.data.location ?? null;
if (parsed.data.followUpAction !== undefined) data.followUpAction = parsed.data.followUpAction ?? null;
// Handle resolved transitions
if (parsed.data.resolved !== undefined && parsed.data.resolved !== existing.resolved) {
data.resolved = parsed.data.resolved;
if (parsed.data.resolved) {
data.resolvedAt = new Date();
data.resolvedById = session.userId;
} else {
data.resolvedAt = null;
data.resolvedById = null;
}
}
const updated = await db.behaviorIncident.update({
where: { id },
data,
include: {
student: { select: { id: true, firstName: true, lastName: true, deletedAt: true } },
teacher: { select: { id: true, firstName: true, lastName: true } },
classGroup: { select: { id: true, name: true, gradeLevel: true } },
category: { select: { id: true, name: true, color: true, valence: true, icon: true } },
resolvedBy: { select: { id: true, firstName: true, lastName: true } },
},
});
await db.auditLog.create({
data: {
userId: session.userId,
schoolId: existing.schoolId,
action: 'UPDATE',
entityType: 'BehaviorIncident',
entityId: id,
metadata: JSON.stringify({ updatedFields: Object.keys(data) }),
},
});
return NextResponse.json(updated);
} catch (error) {
console.error('BehaviorIncident PUT error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (!isTeacherOrAdmin(session.user?.role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const existing = await db.behaviorIncident.findUnique({ where: { id } });
if (!existing) {
return NextResponse.json({ error: 'Incident not found' }, { status: 404 });
}
if (
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
existing.schoolId !== session.user.schoolId
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
await db.behaviorIncident.delete({ where: { id } });
await db.auditLog.create({
data: {
userId: session.userId,
schoolId: existing.schoolId,
action: 'DELETE',
entityType: 'BehaviorIncident',
entityId: id,
metadata: JSON.stringify({
studentId: existing.studentId,
categoryId: existing.categoryId,
date: existing.date.toISOString(),
}),
},
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('BehaviorIncident DELETE error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+224
View File
@@ -0,0 +1,224 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const severityEnum = z.enum(['minor', 'moderate', 'major']);
const createIncidentSchema = z.object({
studentId: z.string().min(1),
classGroupId: z.string().min(1).optional().nullable(),
schoolId: z.string().min(1),
categoryId: z.string().min(1),
date: z.string().min(1),
severity: severityEnum,
description: z.string().min(1).max(2000),
location: z.string().max(200).optional().nullable(),
followUpAction: z.string().max(200).optional().nullable(),
resolved: z.boolean().optional(),
});
function isTeacherOrAdmin(role: string | undefined): boolean {
return (
role === 'TEACHER' ||
role === 'SCHOOL_ADMIN' ||
role === 'SUPER_ADMIN'
);
}
export async function GET(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (!isTeacherOrAdmin(session.user?.role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { searchParams } = new URL(request.url);
const studentId = searchParams.get('studentId');
const classGroupId = searchParams.get('classGroupId');
const schoolIdParam = searchParams.get('schoolId');
const dateFrom = searchParams.get('dateFrom');
const dateTo = searchParams.get('dateTo');
const categoryId = searchParams.get('categoryId');
const severity = searchParams.get('severity');
const resolved = searchParams.get('resolved');
const teacherId = searchParams.get('teacherId');
// Determine effective schoolId
let schoolId: string | undefined;
if (session.user?.role === 'SCHOOL_ADMIN') {
schoolId = session.user.schoolId ?? undefined;
} else {
schoolId = schoolIdParam ?? session.user?.schoolId ?? undefined;
}
if (!schoolId) {
return NextResponse.json([]);
}
const where: Record<string, unknown> = { schoolId };
if (studentId) where.studentId = studentId;
if (classGroupId && classGroupId !== 'all') where.classGroupId = classGroupId;
if (categoryId && categoryId !== 'all') where.categoryId = categoryId;
if (severity && severity !== 'all') where.severity = severity;
if (teacherId) where.teacherId = teacherId;
if (resolved === 'true') where.resolved = true;
if (resolved === 'false') where.resolved = false;
if (dateFrom || dateTo) {
const dateFilter: Record<string, unknown> = {};
if (dateFrom) dateFilter.gte = new Date(dateFrom);
if (dateTo) dateFilter.lte = new Date(dateTo);
where.date = dateFilter;
}
const incidents = await db.behaviorIncident.findMany({
where,
orderBy: { date: 'desc' },
include: {
student: {
select: {
id: true,
firstName: true,
lastName: true,
deletedAt: true,
},
},
teacher: {
select: { id: true, firstName: true, lastName: true },
},
classGroup: {
select: { id: true, name: true, gradeLevel: true },
},
category: {
select: { id: true, name: true, color: true, valence: true, icon: true },
},
resolvedBy: {
select: { id: true, firstName: true, lastName: true },
},
},
});
return NextResponse.json(incidents);
} catch (error) {
console.error('BehaviorIncidents GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (!isTeacherOrAdmin(session.user?.role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const parsed = createIncidentSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const { schoolId, studentId, categoryId, classGroupId, date, ...rest } = parsed.data;
// Verify school
const school = await db.school.findUnique({ where: { id: schoolId } });
if (!school) {
return NextResponse.json({ error: 'School not found' }, { status: 404 });
}
if (
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
school.id !== session.user.schoolId
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Verify student belongs to same school and is not deleted
const student = await db.student.findUnique({
where: { id: studentId },
select: { id: true, schoolId: true, deletedAt: true },
});
if (!student || student.schoolId !== schoolId) {
return NextResponse.json({ error: 'Student not found in this school' }, { status: 404 });
}
if (student.deletedAt) {
return NextResponse.json({ error: 'Student is deleted' }, { status: 400 });
}
// Verify category belongs to same school
const category = await db.behaviorCategory.findUnique({
where: { id: categoryId },
select: { id: true, schoolId: true },
});
if (!category || category.schoolId !== schoolId) {
return NextResponse.json({ error: 'Category not found in this school' }, { status: 404 });
}
// Verify classGroup belongs to same school (if provided)
if (classGroupId) {
const classGroup = await db.classGroup.findUnique({
where: { id: classGroupId },
select: { id: true, schoolId: true },
});
if (!classGroup || classGroup.schoolId !== schoolId) {
return NextResponse.json({ error: 'Class group not found in this school' }, { status: 404 });
}
}
const resolved = parsed.data.resolved ?? false;
const incident = await db.behaviorIncident.create({
data: {
studentId,
teacherId: session.userId,
classGroupId: classGroupId || null,
schoolId,
categoryId,
date: new Date(date),
severity: parsed.data.severity,
description: parsed.data.description,
location: parsed.data.location ?? null,
followUpAction: parsed.data.followUpAction ?? null,
resolved,
resolvedAt: resolved ? new Date() : null,
resolvedById: resolved ? session.userId : null,
},
include: {
student: { select: { id: true, firstName: true, lastName: true, deletedAt: true } },
teacher: { select: { id: true, firstName: true, lastName: true } },
classGroup: { select: { id: true, name: true, gradeLevel: true } },
category: { select: { id: true, name: true, color: true, valence: true, icon: true } },
resolvedBy: { select: { id: true, firstName: true, lastName: true } },
},
});
await db.auditLog.create({
data: {
userId: session.userId,
schoolId,
action: 'CREATE',
entityType: 'BehaviorIncident',
entityId: incident.id,
metadata: JSON.stringify({
studentId,
categoryId,
severity: parsed.data.severity,
date,
}),
},
});
return NextResponse.json(incident, { status: 201 });
} catch (error) {
console.error('BehaviorIncidents POST error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+326
View File
@@ -0,0 +1,326 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
/* ── helpers ──────────────────────────────────────────────────────── */
type EventType = 'assessment' | 'attendance' | 'progress' | 'report' | 'lesson';
interface CalendarEvent {
date: string; // YYYY-MM-DD
type: EventType;
id: string;
title: string;
meta: Record<string, unknown>;
}
/** Format a Date (or ISO string) as YYYY-MM-DD in UTC to avoid TZ drift. */
function toDayKey(value: Date | string): string {
const d = typeof value === 'string' ? new Date(value) : value;
if (Number.isNaN(d.getTime())) return '';
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
const day = String(d.getUTCDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
export async function GET(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const schoolId = searchParams.get('schoolId') ?? session.user?.schoolId ?? undefined;
const monthParam = searchParams.get('month'); // YYYY-MM
if (!schoolId) {
return NextResponse.json(
{ error: 'schoolId is required (pass as query param or have a school assigned to user)' },
{ status: 400 }
);
}
// Parse month, default to current month
let year: number;
let monthIndex: number; // 0-based
if (monthParam && /^\d{4}-\d{2}$/.test(monthParam)) {
const [yStr, mStr] = monthParam.split('-');
year = parseInt(yStr, 10);
monthIndex = parseInt(mStr, 10) - 1;
} else {
const now = new Date();
year = now.getUTCFullYear();
monthIndex = now.getUTCMonth();
}
// Build a UTC-based month range so it's timezone-stable
const startOfMonth = new Date(Date.UTC(year, monthIndex, 1, 0, 0, 0, 0));
const endOfMonth = new Date(Date.UTC(year, monthIndex + 1, 0, 23, 59, 59, 999));
const dateRange = { gte: startOfMonth, lte: endOfMonth };
const events: CalendarEvent[] = [];
/* ── Assessments ──────────────────────────────────────────────── */
try {
const assessments = await db.assessment.findMany({
where: {
date: dateRange,
classGroup: { schoolId },
},
select: {
id: true,
title: true,
date: true,
type: true,
maxScore: true,
weight: true,
classGroup: { select: { id: true, name: true } },
subject: { select: { id: true, name: true } },
},
orderBy: { date: 'asc' },
});
for (const a of assessments) {
events.push({
date: toDayKey(a.date),
type: 'assessment',
id: a.id,
title: a.title,
meta: {
assessmentType: a.type,
classGroup: a.classGroup?.name ?? null,
subject: a.subject?.name ?? null,
maxScore: a.maxScore ?? null,
weight: a.weight,
},
});
}
} catch (err) {
console.error('Calendar: assessments query failed:', err);
}
/* ── Attendance Sessions ──────────────────────────────────────── */
try {
const sessions = await db.attendanceSession.findMany({
where: {
date: dateRange,
classGroup: { schoolId },
},
select: {
id: true,
date: true,
status: true,
period: true,
classGroup: { select: { id: true, name: true } },
subject: { select: { id: true, name: true } },
records: {
select: { status: true },
},
},
orderBy: { date: 'asc' },
});
for (const s of sessions) {
const counts: Record<string, number> = {
PRESENT: 0,
ABSENT: 0,
EXCUSED: 0,
LATE: 0,
};
for (const r of s.records) {
counts[r.status] = (counts[r.status] ?? 0) + 1;
}
const total = s.records.length;
const present = counts.PRESENT ?? 0;
const rate = total > 0 ? Math.round((present / total) * 100) : null;
events.push({
date: toDayKey(s.date),
type: 'attendance',
id: s.id,
title: `${s.classGroup?.name ?? ''} ${t_attendance(s)}`.trim(),
meta: {
classGroup: s.classGroup?.name ?? null,
subject: s.subject?.name ?? null,
period: s.period ?? null,
sessionStatus: s.status,
counts,
total,
present,
rate,
},
});
}
} catch (err) {
console.error('Calendar: attendance query failed:', err);
}
/* ── Learning Progress Entries (aggregated by day) ───────────── */
try {
const progressEntries = await db.learningProgressEntry.findMany({
where: {
createdAt: dateRange,
classGroup: { schoolId },
},
select: {
id: true,
createdAt: true,
masteryLevelValue: true,
classGroup: { select: { id: true, name: true } },
},
orderBy: { createdAt: 'asc' },
});
// Aggregate by day + class group for nicer chips
const progressByDay = new Map<string, { count: number; classGroups: Set<string> }>();
for (const e of progressEntries) {
const key = toDayKey(e.createdAt);
if (!key) continue;
const slot = progressByDay.get(key) ?? { count: 0, classGroups: new Set<string>() };
slot.count += 1;
if (e.classGroup?.name) slot.classGroups.add(e.classGroup.name);
progressByDay.set(key, slot);
}
for (const [key, slot] of progressByDay.entries()) {
events.push({
date: key,
type: 'progress',
id: `progress-${key}`,
title: `${slot.count} ${slot.count === 1 ? 'Eintrag' : 'Einträge'}`,
meta: {
count: slot.count,
classGroups: Array.from(slot.classGroups),
},
});
}
} catch (err) {
console.error('Calendar: progress query failed:', err);
}
/* ── Reports ──────────────────────────────────────────────────── */
try {
const reports = await db.report.findMany({
where: {
generatedAt: dateRange,
classGroup: { schoolId },
},
select: {
id: true,
generatedAt: true,
period: true,
status: true,
includesGrades: true,
student: { select: { id: true, firstName: true, lastName: true } },
classGroup: { select: { id: true, name: true } },
},
orderBy: { generatedAt: 'asc' },
});
for (const r of reports) {
const studentName = r.student
? `${r.student.firstName} ${r.student.lastName}`
: '—';
events.push({
date: toDayKey(r.generatedAt),
type: 'report',
id: r.id,
title: `${studentName} · ${r.period}`,
meta: {
studentName,
classGroup: r.classGroup?.name ?? null,
period: r.period,
status: r.status,
includesGrades: r.includesGrades,
},
});
}
} catch (err) {
console.error('Calendar: reports query failed:', err);
}
/* ── Lessons (LessonPlan) — try/catch, model may not exist yet ─ */
try {
// The LessonPlan model is added by another agent concurrently.
// Use runtime access so this route still compiles & runs even if the
// Prisma Client hasn't been regenerated yet.
const lessonPlanClient = (db as unknown as {
lessonPlan?: {
findMany: (args: Record<string, unknown>) => Promise<Array<Record<string, unknown>>>;
};
}).lessonPlan;
if (lessonPlanClient && typeof lessonPlanClient.findMany === 'function') {
const lessons = await lessonPlanClient.findMany({
where: {
date: dateRange,
classGroup: { schoolId },
},
select: {
id: true,
title: true,
date: true,
status: true,
classGroup: { select: { id: true, name: true } },
subject: { select: { id: true, name: true } },
},
orderBy: { date: 'asc' },
});
for (const l of lessons) {
const dateVal = l.date as Date | string;
const classGroupName =
(l.classGroup as { name?: string } | null | undefined)?.name ?? null;
const subjectName =
(l.subject as { name?: string } | null | undefined)?.name ?? null;
events.push({
date: toDayKey(dateVal),
type: 'lesson',
id: String(l.id),
title: String(l.title ?? 'Unterricht'),
meta: {
classGroup: classGroupName,
subject: subjectName,
status: l.status ?? null,
},
});
}
}
} catch (err) {
// LessonPlan model doesn't exist yet — gracefully return empty.
console.info('Calendar: LessonPlan not available yet, skipping lessons.');
}
// Stable ordering: by date asc, then by type for deterministic display
const typeOrder: Record<EventType, number> = {
lesson: 0,
assessment: 1,
attendance: 2,
progress: 3,
report: 4,
};
events.sort((a, b) => {
if (a.date !== b.date) return a.date.localeCompare(b.date);
return typeOrder[a.type] - typeOrder[b.type];
});
return NextResponse.json({ events, month: `${year}-${String(monthIndex + 1).padStart(2, '0')}` });
} catch (error) {
console.error('Calendar GET error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
/** Tiny helper so we don't import i18n on the server. */
function t_attendance(s: { period?: string | null; subject?: { name?: string } | null }): string {
const parts: string[] = [];
if (s.subject?.name) parts.push(s.subject.name);
if (s.period) parts.push(s.period);
return parts.length ? `· ${parts.join(' ')}` : 'Anwesenheit';
}
@@ -0,0 +1,153 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const createAssignmentSchema = z.object({
classGroupId: z.string().min(1),
subjectId: z.string().min(1),
competencyTemplateId: z.string().min(1),
schoolYearId: z.string().min(1),
clonedTemplateId: 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 classGroupId = searchParams.get('classGroupId');
const subjectId = searchParams.get('subjectId');
const schoolYearId = searchParams.get('schoolYearId');
const where: Record<string, unknown> = {};
if (classGroupId) where.classGroupId = classGroupId;
if (subjectId) where.subjectId = subjectId;
if (schoolYearId) where.schoolYearId = schoolYearId;
const assignments = await db.classCompetencyAssignment.findMany({
where,
include: {
classGroup: { select: { id: true, name: true, gradeLevel: true } },
subject: { select: { id: true, name: true } },
competencyTemplate: {
select: {
id: true,
name: true,
schoolType: true,
gradeLevelMin: true,
gradeLevelMax: true,
},
},
},
});
return NextResponse.json(assignments);
} catch (error) {
console.error('ClassCompetencyAssignments GET error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN' &&
session.user?.role !== 'TEACHER'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const parsed = createAssignmentSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
// Check uniqueness constraint (classGroupId + subjectId + schoolYearId)
const existing = await db.classCompetencyAssignment.findFirst({
where: {
classGroupId: parsed.data.classGroupId,
subjectId: parsed.data.subjectId,
schoolYearId: parsed.data.schoolYearId,
},
});
if (existing) {
return NextResponse.json(
{ error: 'A competency template is already assigned for this class/subject/year combination' },
{ status: 409 }
);
}
const assignment = await db.classCompetencyAssignment.create({
data: parsed.data,
include: {
classGroup: { select: { id: true, name: true } },
subject: { select: { id: true, name: true } },
competencyTemplate: {
select: { id: true, name: true },
},
},
});
return NextResponse.json(assignment, { status: 201 });
} catch (error) {
console.error('ClassCompetencyAssignments POST error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
export async function DELETE(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) {
return NextResponse.json(
{ error: 'id query parameter is required' },
{ status: 400 }
);
}
await db.classCompetencyAssignment.delete({ where: { id } });
return NextResponse.json({ message: 'Assignment deleted' });
} catch (error) {
console.error('ClassCompetencyAssignments DELETE error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
+127
View File
@@ -0,0 +1,127 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
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: classGroupId } = await params;
const enrollments = await db.enrollment.findMany({
where: {
classGroupId,
endDate: null,
student: { deletedAt: null },
},
include: {
student: true,
schoolYear: { select: { id: true, label: true } },
},
orderBy: { student: { lastName: 'asc' } },
});
const students = enrollments.map((e) => ({
...e.student,
enrollmentId: e.id,
schoolYearId: e.schoolYearId,
schoolYear: e.schoolYear,
startDate: e.startDate,
}));
return NextResponse.json(students);
} catch (error) {
console.error('Class students 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 });
}
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN' &&
session.user?.role !== 'TEACHER'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id: classGroupId } = await params;
const body = await request.json();
const schema = z.object({
studentId: z.string().min(1),
schoolYearId: z.string().min(1),
startDate: z
.string()
.optional()
.transform((v) => (v ? new Date(v) : new Date())),
});
const parsed = schema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const { studentId, schoolYearId, startDate } = parsed.data;
// Check if already enrolled
const existing = await db.enrollment.findFirst({
where: {
studentId,
classGroupId,
endDate: null,
},
});
if (existing) {
return NextResponse.json(
{ error: 'Student already enrolled in this class' },
{ status: 409 }
);
}
const enrollment = await db.enrollment.create({
data: {
studentId,
classGroupId,
schoolYearId,
startDate,
},
include: {
student: true,
classGroup: true,
schoolYear: { select: { id: true, label: true } },
},
});
return NextResponse.json(enrollment, { status: 201 });
} catch (error) {
console.error('Class students POST error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
+204
View File
@@ -0,0 +1,204 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const createClassSchema = z.object({
schoolId: z.string().min(1),
schoolYearId: z.string().min(1),
name: z.string().min(1),
gradeLevel: z.number().int().min(1).max(13),
schoolType: z
.enum(['ELEMENTARY', 'MIDDLE', 'GYMNASIUM', 'OTHER'])
.default('ELEMENTARY'),
teacherIds: z.array(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 schoolId = searchParams.get('schoolId');
const schoolYearId = searchParams.get('schoolYearId');
const where: Record<string, unknown> = {};
if (schoolId) where.schoolId = schoolId;
if (schoolYearId) where.schoolYearId = schoolYearId;
// If teacher role, only show their assigned classes
if (session.user?.role === 'TEACHER' && session.userId) {
where.teachers = { some: { userId: session.userId } };
}
const classes = await db.classGroup.findMany({
where,
orderBy: { name: 'asc' },
include: {
school: { select: { id: true, name: true } },
schoolYear: { select: { id: true, label: true } },
teachers: {
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
},
},
},
},
_count: {
select: {
enrollments: {
where: { endDate: null },
},
},
},
},
});
const result = classes.map((cls) => ({
...cls,
studentCount: cls._count.enrollments,
teacherList: cls.teachers.map((t) => ({
...t.user,
teacherRole: t.role,
})),
}));
return NextResponse.json(result);
} catch (error) {
console.error('Classes GET error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN' &&
session.user?.role !== 'TEACHER'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const parsed = createClassSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const { teacherIds, ...classData } = parsed.data;
const classGroup = await db.classGroup.create({
data: {
...classData,
teachers: teacherIds
? {
create: teacherIds.map((userId) => ({
userId,
role: 'SUBJECT_TEACHER' as const,
})),
}
: undefined,
},
include: {
school: { select: { id: true, name: true } },
schoolYear: { select: { id: true, label: true } },
teachers: {
include: {
user: {
select: { id: true, firstName: true, lastName: true },
},
},
},
},
});
return NextResponse.json(classGroup, { status: 201 });
} catch (error) {
console.error('Classes POST error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
export async function PUT(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN' &&
session.user?.role !== 'TEACHER'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const updateSchema = z.object({
id: z.string().min(1),
name: z.string().min(1).optional(),
gradeLevel: z.number().int().min(1).max(13).optional(),
schoolType: z
.enum(['ELEMENTARY', 'MIDDLE', 'GYMNASIUM', 'OTHER'])
.optional(),
});
const parsed = updateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const { id, ...updateData } = parsed.data;
const classGroup = await db.classGroup.update({
where: { id },
data: updateData,
include: {
school: { select: { id: true, name: true } },
schoolYear: { select: { id: true, label: true } },
teachers: {
include: {
user: {
select: { id: true, firstName: true, lastName: true },
},
},
},
},
});
return NextResponse.json(classGroup);
} catch (error) {
console.error('Classes PUT error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
+212
View File
@@ -0,0 +1,212 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const updateEntrySchema = z.object({
categoryId: z.string().min(1).optional(),
subjectId: z.string().min(1).optional().nullable(),
title: z.string().min(1).max(200).optional(),
text: z.string().min(1).max(5000).optional(),
gradeLevel: z.string().max(50).optional().nullable(),
schoolType: z.string().max(50).optional().nullable(),
isPublic: z.boolean().optional(),
tags: z.string().max(500).optional().nullable(),
incrementUsage: z.boolean().optional(),
});
function isTeacherOrAdmin(role: string | undefined): boolean {
return (
role === 'TEACHER' ||
role === 'SCHOOL_ADMIN' ||
role === 'SUPER_ADMIN'
);
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (!isTeacherOrAdmin(session.user?.role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const entry = await db.commentBankEntry.findUnique({
where: { id },
include: {
teacher: { select: { id: true, firstName: true, lastName: true } },
category: { select: { id: true, name: true, color: true, icon: true } },
subject: { select: { id: true, name: true } },
},
});
if (!entry) {
return NextResponse.json({ error: 'Entry not found' }, { status: 404 });
}
if (
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
entry.schoolId !== session.user.schoolId
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
return NextResponse.json(entry);
} catch (error) {
console.error('CommentBankEntry GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (!isTeacherOrAdmin(session.user?.role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const body = await request.json();
const parsed = updateEntrySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const existing = await db.commentBankEntry.findUnique({ where: { id } });
if (!existing) {
return NextResponse.json({ error: 'Entry not found' }, { status: 404 });
}
if (
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
existing.schoolId !== session.user.schoolId
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
if (parsed.data.categoryId && parsed.data.categoryId !== existing.categoryId) {
const category = await db.commentCategory.findUnique({
where: { id: parsed.data.categoryId },
select: { id: true, schoolId: true },
});
if (!category || category.schoolId !== existing.schoolId) {
return NextResponse.json({ error: 'Category not found in this school' }, { status: 404 });
}
}
if (parsed.data.subjectId) {
const subject = await db.subject.findUnique({
where: { id: parsed.data.subjectId },
select: { id: true, schoolId: true },
});
if (!subject || (subject.schoolId && subject.schoolId !== existing.schoolId)) {
return NextResponse.json({ error: 'Subject not found in this school' }, { status: 404 });
}
}
const data: Record<string, unknown> = {};
if (parsed.data.categoryId !== undefined) data.categoryId = parsed.data.categoryId;
if (parsed.data.subjectId !== undefined) data.subjectId = parsed.data.subjectId || null;
if (parsed.data.title !== undefined) data.title = parsed.data.title;
if (parsed.data.text !== undefined) data.text = parsed.data.text;
if (parsed.data.gradeLevel !== undefined) data.gradeLevel = parsed.data.gradeLevel ?? null;
if (parsed.data.schoolType !== undefined) data.schoolType = parsed.data.schoolType ?? null;
if (parsed.data.isPublic !== undefined) data.isPublic = parsed.data.isPublic;
if (parsed.data.tags !== undefined) data.tags = parsed.data.tags ?? null;
// Increment usageCount if requested
if (parsed.data.incrementUsage) {
data.usageCount = existing.usageCount + 1;
}
const updated = await db.commentBankEntry.update({
where: { id },
data,
include: {
teacher: { select: { id: true, firstName: true, lastName: true } },
category: { select: { id: true, name: true, color: true, icon: true } },
subject: { select: { id: true, name: true } },
},
});
await db.auditLog.create({
data: {
userId: session.userId,
schoolId: existing.schoolId,
action: 'UPDATE',
entityType: 'CommentBankEntry',
entityId: id,
metadata: JSON.stringify({ updatedFields: Object.keys(data) }),
},
});
return NextResponse.json(updated);
} catch (error) {
console.error('CommentBankEntry PUT error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (!isTeacherOrAdmin(session.user?.role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const existing = await db.commentBankEntry.findUnique({ where: { id } });
if (!existing) {
return NextResponse.json({ error: 'Entry not found' }, { status: 404 });
}
if (
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
existing.schoolId !== session.user.schoolId
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
await db.commentBankEntry.delete({ where: { id } });
await db.auditLog.create({
data: {
userId: session.userId,
schoolId: existing.schoolId,
action: 'DELETE',
entityType: 'CommentBankEntry',
entityId: id,
metadata: JSON.stringify({ title: existing.title }),
},
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('CommentBankEntry DELETE error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+183
View File
@@ -0,0 +1,183 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const createEntrySchema = z.object({
schoolId: z.string().min(1),
categoryId: z.string().min(1),
subjectId: z.string().min(1).optional().nullable(),
title: z.string().min(1).max(200),
text: z.string().min(1).max(5000),
gradeLevel: z.string().max(50).optional().nullable(),
schoolType: z.string().max(50).optional().nullable(),
isPublic: z.boolean().optional(),
tags: z.string().max(500).optional().nullable(),
});
function isTeacherOrAdmin(role: string | undefined): boolean {
return (
role === 'TEACHER' ||
role === 'SCHOOL_ADMIN' ||
role === 'SUPER_ADMIN'
);
}
export async function GET(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (!isTeacherOrAdmin(session.user?.role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { searchParams } = new URL(request.url);
const schoolIdParam = searchParams.get('schoolId');
const teacherId = searchParams.get('teacherId');
const subjectId = searchParams.get('subjectId');
const categoryId = searchParams.get('categoryId');
const search = searchParams.get('search');
const gradeLevel = searchParams.get('gradeLevel');
const isPublic = searchParams.get('isPublic');
let schoolId: string | undefined;
if (session.user?.role === 'SCHOOL_ADMIN') {
schoolId = session.user.schoolId ?? undefined;
} else {
schoolId = schoolIdParam ?? session.user?.schoolId ?? undefined;
}
if (!schoolId) {
return NextResponse.json([]);
}
const where: Record<string, unknown> = { schoolId };
// Show public comments + own comments
if (teacherId) {
where.teacherId = teacherId;
}
if (subjectId && subjectId !== 'all') where.subjectId = subjectId;
if (categoryId && categoryId !== 'all') where.categoryId = categoryId;
if (gradeLevel && gradeLevel !== 'all') where.gradeLevel = gradeLevel;
if (isPublic === 'true') where.isPublic = true;
if (isPublic === 'false') where.isPublic = false;
// Full-text search on title, text, tags
if (search && search.trim()) {
const q = search.trim();
where.OR = [
{ title: { contains: q } },
{ text: { contains: q } },
{ tags: { contains: q } },
];
}
const entries = await db.commentBankEntry.findMany({
where,
orderBy: { updatedAt: 'desc' },
include: {
teacher: { select: { id: true, firstName: true, lastName: true } },
category: { select: { id: true, name: true, color: true, icon: true } },
subject: { select: { id: true, name: true } },
},
});
return NextResponse.json(entries);
} catch (error) {
console.error('CommentBank GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (!isTeacherOrAdmin(session.user?.role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const parsed = createEntrySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const { schoolId, categoryId, subjectId } = parsed.data;
const school = await db.school.findUnique({ where: { id: schoolId } });
if (!school) {
return NextResponse.json({ error: 'School not found' }, { status: 404 });
}
if (
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
school.id !== session.user.schoolId
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const category = await db.commentCategory.findUnique({
where: { id: categoryId },
select: { id: true, schoolId: true },
});
if (!category || category.schoolId !== schoolId) {
return NextResponse.json({ error: 'Category not found in this school' }, { status: 404 });
}
if (subjectId) {
const subject = await db.subject.findUnique({
where: { id: subjectId },
select: { id: true, schoolId: true },
});
if (!subject || (subject.schoolId && subject.schoolId !== schoolId)) {
return NextResponse.json({ error: 'Subject not found in this school' }, { status: 404 });
}
}
const entry = await db.commentBankEntry.create({
data: {
schoolId,
teacherId: session.userId,
categoryId,
subjectId: subjectId || null,
title: parsed.data.title,
text: parsed.data.text,
gradeLevel: parsed.data.gradeLevel ?? null,
schoolType: parsed.data.schoolType ?? null,
isPublic: parsed.data.isPublic ?? false,
tags: parsed.data.tags ?? null,
},
include: {
teacher: { select: { id: true, firstName: true, lastName: true } },
category: { select: { id: true, name: true, color: true, icon: true } },
subject: { select: { id: true, name: true } },
},
});
await db.auditLog.create({
data: {
userId: session.userId,
schoolId,
action: 'CREATE',
entityType: 'CommentBankEntry',
entityId: entry.id,
metadata: JSON.stringify({ title: parsed.data.title, categoryId }),
},
});
return NextResponse.json(entry, { status: 201 });
} catch (error) {
console.error('CommentBank POST error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
@@ -0,0 +1,195 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const updateCategorySchema = z.object({
name: z.string().min(1).max(100).optional(),
color: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/, 'color must be a hex like #10b981')
.optional(),
icon: z.string().max(20).optional().nullable(),
});
function isTeacherOrAdmin(role: string | undefined): boolean {
return (
role === 'TEACHER' ||
role === 'SCHOOL_ADMIN' ||
role === 'SUPER_ADMIN'
);
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (!isTeacherOrAdmin(session.user?.role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const category = await db.commentCategory.findUnique({
where: { id },
include: { _count: { select: { comments: true } } },
});
if (!category) {
return NextResponse.json({ error: 'Category not found' }, { status: 404 });
}
if (
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
category.schoolId !== session.user.schoolId
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
return NextResponse.json(category);
} catch (error) {
console.error('CommentCategory GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (!isTeacherOrAdmin(session.user?.role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const body = await request.json();
const parsed = updateCategorySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const existing = await db.commentCategory.findUnique({ where: { id } });
if (!existing) {
return NextResponse.json({ error: 'Category not found' }, { status: 404 });
}
if (
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
existing.schoolId !== session.user.schoolId
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
if (parsed.data.name && parsed.data.name !== existing.name) {
const dupe = await db.commentCategory.findUnique({
where: { schoolId_name: { schoolId: existing.schoolId, name: parsed.data.name } },
});
if (dupe) {
return NextResponse.json(
{ error: 'A category with this name already exists in this school' },
{ status: 409 }
);
}
}
const data: Record<string, unknown> = {};
if (parsed.data.name !== undefined) data.name = parsed.data.name;
if (parsed.data.color !== undefined) data.color = parsed.data.color;
if (parsed.data.icon !== undefined) data.icon = parsed.data.icon ?? null;
const updated = await db.commentCategory.update({
where: { id },
data,
});
await db.auditLog.create({
data: {
userId: session.userId,
schoolId: existing.schoolId,
action: 'UPDATE',
entityType: 'CommentCategory',
entityId: id,
metadata: JSON.stringify({ updatedFields: Object.keys(data) }),
},
});
return NextResponse.json(updated);
} catch (error) {
console.error('CommentCategory PUT error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (!isTeacherOrAdmin(session.user?.role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const existing = await db.commentCategory.findUnique({
where: { id },
include: { _count: { select: { comments: true } } },
});
if (!existing) {
return NextResponse.json({ error: 'Category not found' }, { status: 404 });
}
if (
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
existing.schoolId !== session.user.schoolId
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
if (existing._count.comments > 0) {
return NextResponse.json(
{
error: 'Cannot delete: category is referenced by existing comments',
commentCount: existing._count.comments,
},
{ status: 409 }
);
}
await db.commentCategory.delete({ where: { id } });
await db.auditLog.create({
data: {
userId: session.userId,
schoolId: existing.schoolId,
action: 'DELETE',
entityType: 'CommentCategory',
entityId: id,
metadata: JSON.stringify({ name: existing.name, color: existing.color }),
},
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('CommentCategory DELETE error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+130
View File
@@ -0,0 +1,130 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const createCategorySchema = z.object({
schoolId: z.string().min(1),
name: z.string().min(1).max(100),
color: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/, 'color must be a hex like #10b981'),
icon: z.string().max(20).optional().nullable(),
});
function isTeacherOrAdmin(role: string | undefined): boolean {
return (
role === 'TEACHER' ||
role === 'SCHOOL_ADMIN' ||
role === 'SUPER_ADMIN'
);
}
export async function GET(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (!isTeacherOrAdmin(session.user?.role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { searchParams } = new URL(request.url);
const schoolIdParam = searchParams.get('schoolId');
let schoolId: string | undefined;
if (session.user?.role === 'SCHOOL_ADMIN') {
schoolId = session.user.schoolId ?? undefined;
} else {
schoolId = schoolIdParam ?? session.user?.schoolId ?? undefined;
}
if (!schoolId) {
return NextResponse.json([]);
}
const categories = await db.commentCategory.findMany({
where: { schoolId },
orderBy: { name: 'asc' },
include: {
_count: { select: { comments: true } },
},
});
return NextResponse.json(categories);
} catch (error) {
console.error('CommentCategories GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (!isTeacherOrAdmin(session.user?.role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const parsed = createCategorySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const { schoolId, name } = parsed.data;
const school = await db.school.findUnique({ where: { id: schoolId } });
if (!school) {
return NextResponse.json({ error: 'School not found' }, { status: 404 });
}
if (
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
school.id !== session.user.schoolId
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const existing = await db.commentCategory.findUnique({
where: { schoolId_name: { schoolId, name } },
});
if (existing) {
return NextResponse.json(
{ error: 'A category with this name already exists in this school' },
{ status: 409 }
);
}
const category = await db.commentCategory.create({
data: {
schoolId,
name,
color: parsed.data.color,
icon: parsed.data.icon ?? null,
},
});
await db.auditLog.create({
data: {
userId: session.userId,
schoolId,
action: 'CREATE',
entityType: 'CommentCategory',
entityId: category.id,
metadata: JSON.stringify({ name, color: parsed.data.color }),
},
});
return NextResponse.json(category, { status: 201 });
} catch (error) {
console.error('CommentCategories POST error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+229
View File
@@ -0,0 +1,229 @@
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 studentId = searchParams.get('studentId');
const classGroupId = searchParams.get('classGroupId');
const subjectId = searchParams.get('subjectId');
if (!studentId && !classGroupId) {
return NextResponse.json(
{ error: 'studentId or classGroupId is required' },
{ status: 400 }
);
}
// Find the competency template assigned to this class + subject
if (!classGroupId || !subjectId) {
return NextResponse.json(
{ error: 'classGroupId and subjectId are required' },
{ status: 400 }
);
}
const assignment = await db.classCompetencyAssignment.findFirst({
where: { classGroupId, subjectId },
include: {
competencyTemplate: {
include: {
categories: {
orderBy: { order: 'asc' },
include: {
competencies: {
orderBy: { order: 'asc' },
},
},
},
},
},
},
});
if (!assignment) {
return NextResponse.json(
{ error: 'No competency template assigned for this class/subject' },
{ status: 404 }
);
}
const template = assignment.competencyTemplate;
const templateId = assignment.clonedTemplateId ?? assignment.competencyTemplateId;
// Re-fetch with full template if cloned
let categories: Array<{
id: string;
name: string;
color: string | null;
order: number;
competencies: Array<{ id: string; code: string; title: string; order: number }>;
}>;
if (assignment.clonedTemplateId) {
const clonedTemplate = await db.competencyTemplate.findUnique({
where: { id: assignment.clonedTemplateId },
include: {
categories: {
orderBy: { order: 'asc' },
include: {
competencies: {
orderBy: { order: 'asc' },
select: { id: true, code: true, title: true, order: true },
},
},
},
},
});
categories = clonedTemplate?.categories ?? template.categories;
} else {
categories = template.categories;
}
// Get student IDs to query
let studentIds: string[];
if (studentId) {
studentIds = [studentId];
} else {
// Get all students in the class
const enrollments = await db.enrollment.findMany({
where: { classGroupId, endDate: null },
select: { studentId: true },
});
studentIds = enrollments.map((e) => e.studentId);
}
// Fetch latest progress entries for each competency for each student
const progressEntries = await db.learningProgressEntry.findMany({
where: {
studentId: { in: studentIds },
classGroupId,
competency: {
category: {
competencyTemplateId: templateId,
},
},
},
orderBy: { date: 'desc' },
include: {
competency: {
select: {
id: true,
categoryId: true,
code: true,
title: true,
},
},
student: {
select: { id: true, firstName: true, lastName: true },
},
},
});
// Group by student, then compute per-category averages
// For each student, find the latest mastery level per competency, then average per category
const studentMap = new Map<
string,
Map<string, number>
>(); // studentId -> (competencyId -> latest masteryLevelValue)
for (const entry of progressEntries) {
const sid = entry.studentId;
if (!studentMap.has(sid)) {
studentMap.set(sid, new Map());
}
const compMap = studentMap.get(sid)!;
// Only keep the latest entry per competency (they are ordered by date desc)
if (!compMap.has(entry.competencyId)) {
compMap.set(entry.competencyId, entry.masteryLevelValue);
}
}
// Build the result
const result: Array<{
studentId: string;
studentName: string;
categories: Array<{
categoryId: string;
categoryName: string;
color: string | null;
averageMasteryLevel: number;
competencyCount: number;
assessedCompetencyCount: number;
}>;
}> = [];
// Get student names
const students = await db.student.findMany({
where: { id: { in: studentIds }, deletedAt: null },
select: { id: true, firstName: true, lastName: true },
});
const studentNameMap = new Map(
students.map((s) => [s.id, `${s.firstName} ${s.lastName}`])
);
for (const [sid, compMap] of studentMap) {
const categoryResults = categories.map((cat) => {
const catCompetencies = cat.competencies;
const assessedValues = catCompetencies
.filter((c) => compMap.has(c.id))
.map((c) => compMap.get(c.id)!);
const avg =
assessedValues.length > 0
? assessedValues.reduce((a, b) => a + b, 0) / assessedValues.length
: 0;
return {
categoryId: cat.id,
categoryName: cat.name,
color: cat.color,
averageMasteryLevel: Math.round(avg * 100) / 100,
competencyCount: catCompetencies.length,
assessedCompetencyCount: assessedValues.length,
};
});
result.push({
studentId: sid,
studentName: studentNameMap.get(sid) ?? 'Unknown',
categories: categoryResults,
});
}
// Also include students with no entries yet
for (const sid of studentIds) {
if (!studentMap.has(sid)) {
const categoryResults = categories.map((cat) => ({
categoryId: cat.id,
categoryName: cat.name,
color: cat.color,
averageMasteryLevel: 0,
competencyCount: cat.competencies.length,
assessedCompetencyCount: 0,
}));
result.push({
studentId: sid,
studentName: studentNameMap.get(sid) ?? 'Unknown',
categories: categoryResults,
});
}
}
return NextResponse.json(result);
} catch (error) {
console.error('CompetenceFlower GET error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
@@ -0,0 +1,169 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const updateTemplateSchema = z.object({
name: z.string().min(1).optional(),
description: z.string().optional(),
subjectId: z.string().optional(),
schoolType: z.enum(['ELEMENTARY', 'MIDDLE', 'GYMNASIUM', 'OTHER']).optional(),
gradeLevelMin: z.number().int().optional(),
gradeLevelMax: z.number().int().optional(),
isGlobalTemplate: z.boolean().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 template = await db.competencyTemplate.findUnique({
where: { id },
include: {
subject: { select: { id: true, name: true } },
school: { select: { id: true, name: true } },
categories: {
orderBy: { order: 'asc' },
include: {
competencies: {
orderBy: { order: 'asc' },
include: {
masteryLevelDefinitions: { orderBy: { levelValue: 'asc' } },
},
},
},
},
classCompetencyAssignments: {
include: {
classGroup: { select: { id: true, name: true } },
subject: { select: { id: true, name: true } },
},
},
},
});
if (!template) {
return NextResponse.json(
{ error: 'Template not found' },
{ status: 404 }
);
}
return NextResponse.json(template);
} catch (error) {
console.error('CompetencyTemplate GET error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN' &&
session.user?.role !== 'TEACHER'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const body = await request.json();
const parsed = updateTemplateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const template = await db.competencyTemplate.update({
where: { id },
data: parsed.data,
include: {
subject: { select: { id: true, name: true } },
categories: {
orderBy: { order: 'asc' },
include: {
competencies: {
orderBy: { order: 'asc' },
include: {
masteryLevelDefinitions: { orderBy: { levelValue: 'asc' } },
},
},
},
},
},
});
return NextResponse.json(template);
} catch (error) {
console.error('CompetencyTemplate PUT error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
// Check if template is assigned to any class
const assignments = await db.classCompetencyAssignment.findFirst({
where: { competencyTemplateId: id },
});
if (assignments) {
return NextResponse.json(
{ error: 'Template is currently assigned to a class and cannot be deleted' },
{ status: 409 }
);
}
await db.competencyTemplate.delete({ where: { id } });
return NextResponse.json({ message: 'Template deleted' });
} catch (error) {
console.error('CompetencyTemplate DELETE error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
+197
View File
@@ -0,0 +1,197 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const masteryLevelSchema = z.object({
levelValue: z.number().int(),
label: z.string().min(1),
description: z.string().optional(),
});
const competencySchema = z.object({
code: z.string().min(1),
title: z.string().min(1),
description: z.string().optional(),
order: z.number().int().default(0),
masteryLevels: z.array(masteryLevelSchema).optional(),
});
const categorySchema = z.object({
name: z.string().min(1),
order: z.number().int().default(0),
color: z.string().optional(),
competencies: z.array(competencySchema).optional(),
});
const createTemplateSchema = z.object({
name: z.string().min(1),
description: z.string().optional(),
subjectId: z.string().optional(),
schoolType: z.enum(['ELEMENTARY', 'MIDDLE', 'GYMNASIUM', 'OTHER']).default('ELEMENTARY'),
gradeLevelMin: z.number().int().default(1),
gradeLevelMax: z.number().int().default(4),
isGlobalTemplate: z.boolean().default(false),
schoolId: z.string().optional(),
categories: z.array(categorySchema).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 schoolType = searchParams.get('schoolType');
const subjectId = searchParams.get('subjectId');
const gradeLevel = searchParams.get('gradeLevel');
const schoolId = searchParams.get('schoolId');
const where: Record<string, unknown> = {};
if (schoolType) where.schoolType = schoolType;
if (subjectId) where.subjectId = subjectId;
if (gradeLevel) {
const gl = parseInt(gradeLevel, 10);
if (!isNaN(gl)) {
where.AND = [
{ gradeLevelMin: { lte: gl } },
{ gradeLevelMax: { gte: gl } },
];
}
}
// Show global templates + school-specific ones
if (schoolId) {
where.OR = [
{ isGlobalTemplate: true },
{ schoolId },
];
}
const templates = await db.competencyTemplate.findMany({
where,
orderBy: { createdAt: 'desc' },
include: {
subject: { select: { id: true, name: true } },
school: { select: { id: true, name: true } },
categories: {
orderBy: { order: 'asc' },
include: {
competencies: {
orderBy: { order: 'asc' },
include: {
masteryLevelDefinitions: { orderBy: { levelValue: 'asc' } },
},
},
},
},
_count: {
select: { classCompetencyAssignments: true },
},
},
});
return NextResponse.json(templates);
} catch (error) {
console.error('CompetencyTemplates GET error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN' &&
session.user?.role !== 'TEACHER'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const parsed = createTemplateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const { categories, ...templateData } = parsed.data;
const template = await db.competencyTemplate.create({
data: {
name: templateData.name,
description: templateData.description,
subjectId: templateData.subjectId ?? null,
schoolType: templateData.schoolType,
gradeLevelMin: templateData.gradeLevelMin,
gradeLevelMax: templateData.gradeLevelMax,
isGlobalTemplate: templateData.isGlobalTemplate,
schoolId: templateData.schoolId ?? null,
createdByUserId: session.userId,
categories: categories
? {
create: categories.map((cat) => ({
name: cat.name,
order: cat.order,
color: cat.color,
competencies: cat.competencies
? {
create: cat.competencies.map((comp) => ({
code: comp.code,
title: comp.title,
description: comp.description,
order: comp.order,
masteryLevelDefinitions: comp.masteryLevels
? {
create: comp.masteryLevels.map((ml) => ({
levelValue: ml.levelValue,
label: ml.label,
description: ml.description,
})),
}
: undefined,
})),
}
: undefined,
})),
}
: undefined,
},
include: {
subject: { select: { id: true, name: true } },
categories: {
orderBy: { order: 'asc' },
include: {
competencies: {
orderBy: { order: 'asc' },
include: {
masteryLevelDefinitions: { orderBy: { levelValue: 'asc' } },
},
},
},
},
},
});
return NextResponse.json(template, { status: 201 });
} catch (error) {
console.error('CompetencyTemplates POST error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
+332
View File
@@ -0,0 +1,332 @@
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 classGroupId = searchParams.get('classGroupId');
const subjectId = searchParams.get('subjectId');
if (!classGroupId) {
return NextResponse.json(
{ error: 'classGroupId is required' },
{ status: 400 }
);
}
// Verify access: must be admin of the school OR a teacher of the class
const classGroup = await db.classGroup.findUnique({
where: { id: classGroupId },
select: {
id: true,
name: true,
gradeLevel: true,
schoolType: true,
schoolId: true,
schoolYearId: true,
},
});
if (!classGroup) {
return NextResponse.json({ error: 'Class not found' }, { status: 404 });
}
if (session.user?.role !== 'SCHOOL_ADMIN' || session.user.schoolId !== classGroup.schoolId) {
if (session.user?.role === 'TEACHER') {
const teacherLink = await db.classGroupTeacher.findFirst({
where: { classGroupId, userId: session.userId },
});
if (!teacherLink && session.user.schoolId !== classGroup.schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
}
}
// Get all assignments for this class (optionally filtered by subject)
const assignmentWhere: { classGroupId: string; subjectId?: string } = { classGroupId };
if (subjectId) assignmentWhere.subjectId = subjectId;
const assignments = await db.classCompetencyAssignment.findMany({
where: assignmentWhere,
include: {
subject: { select: { id: true, name: true } },
competencyTemplate: {
include: {
categories: {
orderBy: { order: 'asc' },
include: {
competencies: {
orderBy: { order: 'asc' },
select: {
id: true,
code: true,
title: true,
categoryId: true,
},
},
},
},
},
},
},
});
if (assignments.length === 0) {
return NextResponse.json({
classGroup: { id: classGroup.id, name: classGroup.name, gradeLevel: classGroup.gradeLevel, schoolType: classGroup.schoolType },
subject: null,
totals: {
competencies: 0,
assessed: 0,
notAssessed: 0,
coveragePercent: 0,
studentsCount: 0,
assessmentsCount: 0,
progressEntriesCount: 0,
},
byCategory: [],
byCompetency: [],
notAssessedList: [],
recentAssessed: [],
hasAssignments: false,
});
}
// Aggregate all competencies across all assignments
const competencySet = new Map<string, { id: string; code: string; title: string; categoryId: string; categoryName: string; categoryColor: string | null; subjectName: string }>();
const categoryMap = new Map<string, { id: string; name: string; color: string | null }>();
for (const assignment of assignments) {
const template = assignment.competencyTemplate;
for (const category of template.categories) {
if (!categoryMap.has(category.id)) {
categoryMap.set(category.id, { id: category.id, name: category.name, color: category.color });
}
for (const comp of category.competencies) {
if (!competencySet.has(comp.id)) {
competencySet.set(comp.id, {
id: comp.id,
code: comp.code,
title: comp.title,
categoryId: category.id,
categoryName: category.name,
categoryColor: category.color,
subjectName: assignment.subject?.name ?? '',
});
}
}
}
}
const competencyIds = Array.from(competencySet.keys());
// Count students enrolled in this class
const studentsCount = await db.enrollment.count({
where: { classGroupId, endDate: null },
});
if (competencyIds.length === 0) {
return NextResponse.json({
classGroup: { id: classGroup.id, name: classGroup.name, gradeLevel: classGroup.gradeLevel, schoolType: classGroup.schoolType },
subject: subjectId ? assignments[0]?.subject : null,
totals: {
competencies: 0,
assessed: 0,
notAssessed: 0,
coveragePercent: 0,
studentsCount,
assessmentsCount: 0,
progressEntriesCount: 0,
},
byCategory: [],
byCompetency: [],
notAssessedList: [],
recentAssessed: [],
hasAssignments: true,
});
}
// Fetch all LearningProgressEntries for this class + these competencies (efficient single query)
const progressEntries = await db.learningProgressEntry.findMany({
where: {
classGroupId,
competencyId: { in: competencyIds },
},
select: {
id: true,
competencyId: true,
studentId: true,
date: true,
masteryLevelValue: true,
},
});
// Fetch all AssessmentCompetencyLinks for these competencies (with assessment + results for this class)
const assessmentLinks = await db.assessmentCompetencyLink.findMany({
where: { competencyId: { in: competencyIds } },
select: {
competencyId: true,
assessmentId: true,
assessment: {
select: {
id: true,
classGroupId: true,
date: true,
assessmentResults: { select: { id: true, studentId: true } },
},
},
},
});
// Filter assessment links to only those assessments for this class
const classAssessmentLinks = assessmentLinks.filter((l) => l.assessment.classGroupId === classGroupId);
const assessmentsCount = new Set(classAssessmentLinks.map((l) => l.assessmentId)).size;
// Aggregate per competency
const progressByCompetency = new Map<string, { count: number; students: Set<string>; lastDate: Date | null; lastMastery: number | null }>();
for (const entry of progressEntries) {
const cur = progressByCompetency.get(entry.competencyId) ?? { count: 0, students: new Set<string>(), lastDate: null, lastMastery: null };
cur.count += 1;
cur.students.add(entry.studentId);
if (!cur.lastDate || entry.date > cur.lastDate) {
cur.lastDate = entry.date;
cur.lastMastery = entry.masteryLevelValue;
}
progressByCompetency.set(entry.competencyId, cur);
}
const assessmentByCompetency = new Map<string, { count: number; students: Set<string> }>();
for (const link of classAssessmentLinks) {
const cur = assessmentByCompetency.get(link.competencyId) ?? { count: 0, students: new Set<string>() };
cur.count += link.assessment.assessmentResults.length;
for (const r of link.assessment.assessmentResults) cur.students.add(r.studentId);
assessmentByCompetency.set(link.competencyId, cur);
}
// Build byCompetency list
const byCompetency = Array.from(competencySet.values()).map((c) => {
const prog = progressByCompetency.get(c.id);
const assess = assessmentByCompetency.get(c.id);
const progressCount = prog?.count ?? 0;
const assessmentCount = assess?.count ?? 0;
// Deduplicate students (in case same student is in both)
const studentSet = new Set<string>();
prog?.students.forEach((s) => studentSet.add(s));
assess?.students.forEach((s) => studentSet.add(s));
const uniqueStudentsAssessed = studentSet.size;
const lastAssessedDate = prog?.lastDate ?? null;
let status: 'covered' | 'partial' | 'untouched' = 'untouched';
if (studentsCount > 0) {
const ratio = uniqueStudentsAssessed / studentsCount;
if (ratio >= 0.8) status = 'covered';
else if (ratio > 0) status = 'partial';
} else if (uniqueStudentsAssessed > 0) {
status = 'covered';
}
return {
competencyId: c.id,
code: c.code,
title: c.title,
categoryName: c.categoryName,
categoryColor: c.categoryColor ?? '#10b981',
assessmentCount,
progressCount,
studentsAssessed: uniqueStudentsAssessed,
lastAssessedDate: lastAssessedDate ? lastAssessedDate.toISOString() : null,
lastMasteryLevel: prog?.lastMastery ?? null,
status,
};
});
// byCategory aggregation (assessed = covered + partial, notAssessed = untouched)
const byCategoryMap = new Map<string, { categoryId: string; categoryName: string; categoryColor: string; total: number; assessed: number; notAssessed: number }>();
for (const c of byCompetency) {
const cur = byCategoryMap.get(c.categoryName) ?? {
categoryId: c.categoryName,
categoryName: c.categoryName,
categoryColor: c.categoryColor,
total: 0,
assessed: 0,
notAssessed: 0,
};
cur.total += 1;
if (c.status === 'covered' || c.status === 'partial') cur.assessed += 1;
else cur.notAssessed += 1;
byCategoryMap.set(c.categoryName, cur);
}
const byCategory = Array.from(byCategoryMap.values()).map((b) => ({
...b,
coveragePercent: b.total > 0 ? Math.round((b.assessed / b.total) * 100) : 0,
}));
// Totals
const assessed = byCompetency.filter((c) => c.status !== 'untouched').length;
const notAssessed = byCompetency.length - assessed;
const coveragePercent = byCompetency.length > 0 ? Math.round((assessed / byCompetency.length) * 100) : 0;
// notAssessedList (prioritize untouched, then partial; sort by category name)
const notAssessedList = byCompetency
.filter((c) => c.status !== 'covered')
.sort((a, b) => {
const orderA = a.status === 'untouched' ? 0 : 1;
const orderB = b.status === 'untouched' ? 0 : 1;
if (orderA !== orderB) return orderA - orderB;
return a.categoryName.localeCompare(b.categoryName);
})
.slice(0, 20)
.map((c) => ({
competencyId: c.competencyId,
code: c.code,
title: c.title,
categoryName: c.categoryName,
categoryColor: c.categoryColor,
status: c.status,
studentsAssessed: c.studentsAssessed,
studentsCount,
}));
// recentAssessed — 5 most recent
const recentAssessed = byCompetency
.filter((c) => c.lastAssessedDate)
.sort((a, b) => new Date(b.lastAssessedDate!).getTime() - new Date(a.lastAssessedDate!).getTime())
.slice(0, 5)
.map((c) => ({
competencyId: c.competencyId,
code: c.code,
title: c.title,
categoryName: c.categoryName,
date: c.lastAssessedDate,
masteryLevelValue: c.lastMasteryLevel,
}));
return NextResponse.json({
classGroup: { id: classGroup.id, name: classGroup.name, gradeLevel: classGroup.gradeLevel, schoolType: classGroup.schoolType },
subject: subjectId ? assignments[0]?.subject ?? null : null,
totals: {
competencies: byCompetency.length,
assessed,
notAssessed,
coveragePercent,
studentsCount,
assessmentsCount,
progressEntriesCount: progressEntries.length,
},
byCategory,
byCompetency,
notAssessedList,
recentAssessed,
hasAssignments: true,
});
} catch (error) {
console.error('Curriculum coverage API error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+209
View File
@@ -0,0 +1,209 @@
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');
if (!schoolId) {
return NextResponse.json(
{ error: 'schoolId is required (pass as query param or have a school assigned to user)' },
{ status: 400 }
);
}
// ── Classes Overview ──
const classWhere: Record<string, unknown> = { schoolId };
if (schoolYearId) classWhere.schoolYearId = schoolYearId;
// For teachers, only show assigned classes
if (session.user?.role === 'TEACHER' && session.userId) {
classWhere.teachers = { some: { userId: session.userId } };
}
const classes = await db.classGroup.findMany({
where: classWhere,
orderBy: { name: 'asc' },
include: {
schoolYear: { select: { id: true, label: true } },
teachers: {
include: {
user: { select: { id: true, firstName: true, lastName: true } },
},
},
_count: {
select: {
enrollments: { where: { endDate: null } },
assessments: true,
},
},
},
});
const classesOverview = classes.map((cls) => ({
id: cls.id,
name: cls.name,
gradeLevel: cls.gradeLevel,
schoolType: cls.schoolType,
schoolYear: cls.schoolYear,
studentCount: cls._count.enrollments,
assessmentCount: cls._count.assessments,
teachers: cls.teachers.map((t) => ({
...t.user,
teacherRole: t.role,
})),
}));
// ── Recent Progress Entries ──
const recentEntries = await db.learningProgressEntry.findMany({
where: {
classGroup: { schoolId },
...(schoolYearId ? { classGroup: { schoolYearId } } : {}),
},
orderBy: { createdAt: 'desc' },
take: 20,
include: {
student: { select: { id: true, firstName: true, lastName: true } },
competency: {
select: {
id: true,
code: true,
title: true,
category: { select: { id: true, name: true, color: true } },
},
},
teacher: { select: { id: true, firstName: true, lastName: true } },
classGroup: { select: { id: true, name: true } },
},
});
// ── Students needing attention ──
// Find students with low average mastery (< 2.0 out of 4.0)
const studentsWithProgress = await db.learningProgressEntry.findMany({
where: {
classGroup: { schoolId },
...(schoolYearId ? { classGroup: { schoolYearId } } : {}),
},
select: {
studentId: true,
masteryLevelValue: true,
competencyId: true,
},
});
// Calculate average mastery per student
const studentMasteryMap = new Map<string, { total: number; count: number }>();
for (const entry of studentsWithProgress) {
const current = studentMasteryMap.get(entry.studentId) ?? { total: 0, count: 0 };
current.total += entry.masteryLevelValue;
current.count += 1;
studentMasteryMap.set(entry.studentId, current);
}
const lowMasteryStudentIds = Array.from(studentMasteryMap.entries())
.filter(([, data]) => data.count > 0 && data.total / data.count < 2.0)
.map(([id]) => id);
const studentsNeedingAttention = await db.student.findMany({
where: {
id: { in: lowMasteryStudentIds },
deletedAt: null,
},
select: {
id: true,
firstName: true,
lastName: true,
enrollments: {
where: { endDate: null },
select: {
classGroup: { select: { id: true, name: true, gradeLevel: true } },
},
},
},
});
// Add average mastery to each student
const studentsWithAvg = studentsNeedingAttention.map((s) => {
const data = studentMasteryMap.get(s.id);
const avgMastery = data ? Math.round(data.total / data.count * 100) / 100 : 0;
return {
...s,
averageMastery: avgMastery,
};
});
// ── Stats ──
const totalStudents = await db.student.count({
where: { schoolId, deletedAt: null },
});
const totalClasses = await db.classGroup.count({
where: classWhere,
});
const totalAssessments = await db.assessment.count({
where: {
classGroup: { schoolId },
...(schoolYearId ? { classGroup: { schoolYearId } } : {}),
},
});
const totalProgressEntries = await db.learningProgressEntry.count({
where: {
classGroup: { schoolId },
...(schoolYearId ? { classGroup: { schoolYearId } } : {}),
},
});
const totalReports = await db.report.count({
where: {
classGroup: { schoolId },
...(schoolYearId ? { schoolYearId } : {}),
},
});
const recentAssessments = await db.assessment.findMany({
where: {
classGroup: { schoolId },
...(schoolYearId ? { classGroup: { schoolYearId } } : {}),
},
orderBy: { date: 'desc' },
take: 5,
include: {
classGroup: { select: { id: true, name: true } },
subject: { select: { id: true, name: true } },
_count: {
select: { assessmentResults: true },
},
},
});
return NextResponse.json({
classesOverview,
recentEntries,
recentAssessments,
studentsNeedingAttention: studentsWithAvg,
stats: {
totalStudents,
totalClasses,
totalAssessments,
totalProgressEntries,
totalReports,
},
});
} catch (error) {
console.error('Dashboard GET error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
+280
View File
@@ -0,0 +1,280 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
function escapeCsvField(field: string | number | null | undefined): string {
if (field === null || field === undefined) return '';
const str = String(field);
// Escape fields containing quotes, commas, or newlines
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
}
function toCsvRow(fields: (string | number | null | undefined)[]): string {
return fields.map(escapeCsvField).join(',');
}
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 type = searchParams.get('type') || 'students';
const classGroupId = searchParams.get('classGroupId');
const schoolYearId = searchParams.get('schoolYearId');
const schoolId = searchParams.get('schoolId') ?? session.user?.schoolId ?? undefined;
let csvContent = '';
let filename = `${type}_export.csv`;
if (type === 'students') {
// Students CSV
const where: Record<string, unknown> = { deletedAt: null };
if (schoolId) where.schoolId = schoolId;
let students = await db.student.findMany({
where,
orderBy: { lastName: 'asc' },
include: {
school: { select: { name: true } },
enrollments: {
where: classGroupId ? { classGroupId } : {},
include: {
classGroup: { select: { name: true, gradeLevel: true } },
},
},
_count: {
select: { learningProgressEntries: true, assessmentResults: true },
},
},
});
// Filter by school year if specified
if (schoolYearId && !classGroupId) {
students = students.filter((s) =>
s.enrollments.some((e) => e.schoolYearId === schoolYearId)
);
} else if (classGroupId) {
students = students.filter((s) =>
s.enrollments.some((e) => e.classGroupId === classGroupId)
);
}
const header = ['ID', 'First Name', 'Last Name', 'Date of Birth', 'External ID', 'School', 'Class', 'Grade Level', 'Progress Entries', 'Assessment Results'];
csvContent = header.map(escapeCsvField).join(',') + '\n';
for (const s of students) {
const enrollment = s.enrollments[0];
csvContent += toCsvRow([
s.id,
s.firstName,
s.lastName,
s.dateOfBirth ? new Date(s.dateOfBirth).toLocaleDateString() : '',
s.externalId ?? '',
s.school.name,
enrollment?.classGroup?.name ?? '',
enrollment?.classGroup?.gradeLevel ?? '',
s._count.learningProgressEntries,
s._count.assessmentResults,
]) + '\n';
}
filename = `students_export.csv`;
} else if (type === 'progress') {
// Progress entries CSV
const where: Record<string, unknown> = {};
if (classGroupId) where.classGroupId = classGroupId;
let entries = await db.learningProgressEntry.findMany({
where,
orderBy: { date: 'desc' },
include: {
student: { select: { id: true, firstName: true, lastName: true } },
competency: {
select: {
id: true,
code: true,
title: true,
category: { select: { id: true, name: true } },
},
},
teacher: { select: { id: true, firstName: true, lastName: true } },
classGroup: { select: { id: true, name: true } },
},
});
// Filter by school year through classGroup
if (schoolYearId) {
const classGroups = await db.classGroup.findMany({
where: { schoolYearId },
select: { id: true },
});
const classIds = classGroups.map((c) => c.id);
entries = entries.filter((e) => classIds.includes(e.classGroupId));
}
// Filter by school
if (schoolId) {
const classGroups = await db.classGroup.findMany({
where: { schoolId },
select: { id: true },
});
const classIds = classGroups.map((c) => c.id);
entries = entries.filter((e) => classIds.includes(e.classGroupId));
}
const header = ['ID', 'Date', 'Student First Name', 'Student Last Name', 'Student ID', 'Competency Code', 'Competency Title', 'Category', 'Mastery Level', 'Note', 'Teacher', 'Class'];
csvContent = header.map(escapeCsvField).join(',') + '\n';
for (const e of entries) {
csvContent += toCsvRow([
e.id,
new Date(e.date).toLocaleDateString(),
e.student.firstName,
e.student.lastName,
e.student.id,
e.competency.code,
e.competency.title,
e.competency.category.name,
e.masteryLevelValue,
e.note ?? '',
`${e.teacher.firstName} ${e.teacher.lastName}`,
e.classGroup.name,
]) + '\n';
}
filename = `progress_export.csv`;
} else if (type === 'assessments') {
// Assessments CSV
const where: Record<string, unknown> = {};
if (classGroupId) where.classGroupId = classGroupId;
let assessments = await db.assessment.findMany({
where,
orderBy: { date: 'desc' },
include: {
classGroup: { select: { id: true, name: true } },
subject: { select: { id: true, name: true } },
teacher: { select: { id: true, firstName: true, lastName: true } },
assessmentResults: {
include: {
student: { select: { id: true, firstName: true, lastName: true } },
},
},
},
});
if (schoolYearId) {
const classGroups = await db.classGroup.findMany({
where: { schoolYearId },
select: { id: true },
});
const classIds = classGroups.map((c) => c.id);
assessments = assessments.filter((a) => classIds.includes(a.classGroupId));
}
if (schoolId) {
const classGroups = await db.classGroup.findMany({
where: { schoolId },
select: { id: true },
});
const classIds = classGroups.map((c) => c.id);
assessments = assessments.filter((a) => classIds.includes(a.classGroupId));
}
const header = ['Assessment ID', 'Title', 'Date', 'Type', 'Subject', 'Class', 'Teacher', 'Max Score', 'Weight', 'Student ID', 'Student Name', 'Score', 'Mastery Level', 'Note'];
csvContent = header.map(escapeCsvField).join(',') + '\n';
for (const a of assessments) {
if (a.assessmentResults.length === 0) {
csvContent += toCsvRow([
a.id, a.title, new Date(a.date).toLocaleDateString(), a.type,
a.subject.name, a.classGroup.name,
`${a.teacher.firstName} ${a.teacher.lastName}`,
a.maxScore ?? '', a.weight, '', '', '', '', '',
]) + '\n';
} else {
for (const r of a.assessmentResults) {
csvContent += toCsvRow([
a.id, a.title, new Date(a.date).toLocaleDateString(), a.type,
a.subject.name, a.classGroup.name,
`${a.teacher.firstName} ${a.teacher.lastName}`,
a.maxScore ?? '', a.weight,
r.studentId, `${r.student.firstName} ${r.student.lastName}`,
r.score ?? '', r.masteryLevelValue ?? '', r.note ?? '',
]) + '\n';
}
}
}
filename = `assessments_export.csv`;
} else if (type === 'grades') {
// Grades CSV
const where: Record<string, unknown> = {};
if (classGroupId) where.classGroupId = classGroupId;
if (schoolYearId) where.schoolYearId = schoolYearId;
let grades = await db.computedGrade.findMany({
where,
orderBy: [{ student: { lastName: 'asc' } }],
include: {
student: { select: { id: true, firstName: true, lastName: true } },
subject: { select: { id: true, name: true } },
classGroup: { select: { id: true, name: true } },
schoolYear: { select: { id: true, label: true } },
},
});
if (schoolId && !classGroupId) {
const classGroups = await db.classGroup.findMany({
where: { schoolId },
select: { id: true },
});
const classIds = classGroups.map((c) => c.id);
grades = grades.filter((g) => classIds.includes(g.classGroupId));
}
const header = ['Student ID', 'Student First Name', 'Student Last Name', 'Subject', 'Class', 'School Year', 'Period', 'Computed Value', 'Overridden Value', 'Override Reason', 'Finalized'];
csvContent = header.map(escapeCsvField).join(',') + '\n';
for (const g of grades) {
csvContent += toCsvRow([
g.studentId, g.student.firstName, g.student.lastName,
g.subject.name, g.classGroup.name, g.schoolYear.label,
g.period, g.computedValue.toFixed(2),
g.overriddenValue?.toFixed(2) ?? '', g.overrideReason ?? '',
g.isFinalized ? 'Yes' : 'No',
]) + '\n';
}
filename = `grades_export.csv`;
}
// Create audit log for export
await db.auditLog.create({
data: {
userId: session.userId,
schoolId: session.user?.schoolId ?? null,
action: 'EXPORT',
entityType: 'DataExport',
entityId: null,
metadata: JSON.stringify({ type, classGroupId, schoolYearId }),
},
});
return new NextResponse(csvContent, {
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="${filename}"`,
},
});
} catch (error) {
console.error('CSV Export error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+184
View File
@@ -0,0 +1,184 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const createExportSchema = z.object({
scope: z.enum(['STUDENT', 'CLASS', 'SCHOOL']),
scopeId: z.string().optional(),
format: z.enum(['CSV', 'JSON', 'PDF']).default('JSON'),
});
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 status = searchParams.get('status');
const requestedByUserId = searchParams.get('requestedByUserId') ?? session.userId;
const where: Record<string, unknown> = { requestedByUserId };
if (status) where.status = status;
const exports = await db.dataExportRequest.findMany({
where,
orderBy: { requestedAt: 'desc' },
include: {
requestedByUser: {
select: { id: true, firstName: true, lastName: true },
},
school: { select: { id: true, name: true } },
},
});
return NextResponse.json(exports);
} catch (error) {
console.error('DataExport GET error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN' &&
session.user?.role !== 'TEACHER'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const parsed = createExportSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const exportRequest = await db.dataExportRequest.create({
data: {
requestedByUserId: session.userId,
schoolId: session.user?.schoolId ?? parsed.data.scopeId ?? null,
scope: parsed.data.scope,
scopeId: parsed.data.scopeId ?? null,
status: 'PENDING',
},
});
// Process the export request based on scope
// For now, we create the request and return it
// In a real app, this would be processed asynchronously
let exportData: Record<string, unknown> = {};
if (parsed.data.scope === 'STUDENT' && parsed.data.scopeId) {
const student = await db.student.findUnique({
where: { id: parsed.data.scopeId, deletedAt: null },
include: {
school: { select: { name: true } },
enrollments: {
include: {
classGroup: { select: { name: true, gradeLevel: true } },
},
},
learningProgressEntries: {
include: {
competency: { select: { code: true, title: true } },
teacher: { select: { firstName: true, lastName: true } },
},
},
assessmentResults: {
include: {
assessment: { select: { title: true, type: true } },
},
},
},
});
exportData = { student };
} else if (parsed.data.scope === 'CLASS' && parsed.data.scopeId) {
const classGroup = await db.classGroup.findUnique({
where: { id: parsed.data.scopeId },
include: {
school: { select: { name: true } },
schoolYear: { select: { label: true } },
teachers: {
include: {
user: { select: { firstName: true, lastName: true } },
},
},
enrollments: {
where: { endDate: null },
include: {
student: {
select: { id: true, firstName: true, lastName: true },
},
},
},
assessments: {
include: {
subject: { select: { name: true } },
_count: { select: { assessmentResults: true } },
},
},
},
});
exportData = { classGroup };
} else if (parsed.data.scope === 'SCHOOL') {
const schoolId = parsed.data.scopeId ?? session.user?.schoolId;
if (schoolId) {
const school = await db.school.findUnique({
where: { id: schoolId },
include: {
classGroups: {
include: {
_count: { select: { enrollments: true } },
},
},
subjects: true,
_count: {
select: {
users: { where: { deletedAt: null } },
students: { where: { deletedAt: null } },
},
},
},
});
exportData = { school };
}
}
// Update status to completed
await db.dataExportRequest.update({
where: { id: exportRequest.id },
data: {
status: 'COMPLETED',
completedAt: new Date(),
},
});
return NextResponse.json({
...exportRequest,
status: 'COMPLETED',
exportData,
}, { status: 201 });
} catch (error) {
console.error('DataExport POST error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
+96
View File
@@ -0,0 +1,96 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
// PUT /api/demo-accounts/[id] — Toggle a single demo user's active status
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session?.user) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const role = session.user.role;
if (role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const body = await request.json();
const { action } = body; // 'enable' or 'disable'
const user = await db.user.findUnique({ where: { id } });
if (!user || !user.isDemo) {
return NextResponse.json({ error: 'Demo account not found' }, { status: 404 });
}
if (action === 'disable') {
await db.user.update({
where: { id },
data: { deletedAt: new Date() },
});
} else if (action === 'enable') {
await db.user.update({
where: { id },
data: { deletedAt: null },
});
} else {
return NextResponse.json({ error: 'Invalid action. Use "enable" or "disable"' }, { status: 400 });
}
const updated = await db.user.findUnique({
where: { id },
select: {
id: true,
email: true,
firstName: true,
lastName: true,
role: true,
isDemo: true,
deletedAt: true,
createdAt: true,
updatedAt: true,
},
});
return NextResponse.json({ success: true, account: updated });
} catch (error) {
console.error('Demo account PUT error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
// DELETE /api/demo-accounts/[id] — Permanently delete a single demo user
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session?.user) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const role = session.user.role;
if (role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const user = await db.user.findUnique({ where: { id } });
if (!user || !user.isDemo) {
return NextResponse.json({ error: 'Demo account not found' }, { status: 404 });
}
await db.user.delete({ where: { id } });
return NextResponse.json({ success: true });
} catch (error) {
console.error('Demo account DELETE error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+116
View File
@@ -0,0 +1,116 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
// GET /api/demo-accounts — List all demo users
export async function GET() {
try {
const session = await getSession();
if (!session?.user) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const role = session.user.role;
if (role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const demoUsers = await db.user.findMany({
where: { isDemo: true },
select: {
id: true,
email: true,
firstName: true,
lastName: true,
role: true,
isDemo: true,
deletedAt: true,
createdAt: true,
updatedAt: true,
},
orderBy: { createdAt: 'asc' },
});
return NextResponse.json(demoUsers);
} catch (error) {
console.error('Demo accounts GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
// PUT /api/demo-accounts — Toggle all demo accounts (enable/disable)
export async function PUT(request: Request) {
try {
const session = await getSession();
if (!session?.user) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const role = session.user.role;
if (role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const { action } = body; // 'enable' or 'disable'
if (action === 'disable') {
await db.user.updateMany({
where: { isDemo: true, deletedAt: null },
data: { deletedAt: new Date() },
});
} else if (action === 'enable') {
await db.user.updateMany({
where: { isDemo: true, deletedAt: { not: null } },
data: { deletedAt: null },
});
} else {
return NextResponse.json({ error: 'Invalid action. Use "enable" or "disable"' }, { status: 400 });
}
const demoUsers = await db.user.findMany({
where: { isDemo: true },
select: {
id: true,
email: true,
firstName: true,
lastName: true,
role: true,
isDemo: true,
deletedAt: true,
createdAt: true,
updatedAt: true,
},
orderBy: { createdAt: 'asc' },
});
return NextResponse.json({ success: true, accounts: demoUsers });
} catch (error) {
console.error('Demo accounts PUT error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
// DELETE /api/demo-accounts — Permanently delete all demo users
export async function DELETE() {
try {
const session = await getSession();
if (!session?.user) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const role = session.user.role;
if (role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const result = await db.user.deleteMany({
where: { isDemo: true },
});
return NextResponse.json({ success: true, deleted: result.count });
} catch (error) {
console.error('Demo accounts DELETE error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+371
View File
@@ -0,0 +1,371 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const weightRuleSchema = z.object({
sourceType: z.enum(['LEARNING_PROGRESS', 'ASSESSMENT']),
targetRef: z.string().optional(),
weightPercent: z.number().min(0).max(100).default(50.0),
});
const createSchemeSchema = z.object({
classGroupId: z.string().optional(),
subjectId: z.string().optional(),
schoolId: z.string().optional(),
name: z.string().min(1),
type: z.enum(['NUMERIC_GRADE', 'VERBAL_FEEDBACK', 'COMBINED']).default('NUMERIC_GRADE'),
scaleDefinition: z.string(), // JSON string
weightRules: z.array(weightRuleSchema).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 classGroupId = searchParams.get('classGroupId');
const subjectId = searchParams.get('subjectId');
const schoolId = searchParams.get('schoolId');
const computeGrades = searchParams.get('computeGrades') === 'true';
const where: Record<string, unknown> = {};
if (classGroupId) where.classGroupId = classGroupId;
if (subjectId) where.subjectId = subjectId;
if (schoolId) where.schoolId = schoolId;
const schemes = await db.gradingScheme.findMany({
where,
include: {
gradingWeightRules: { orderBy: { weightPercent: 'desc' } },
classGroup: { select: { id: true, name: true } },
subject: { select: { id: true, name: true } },
school: { select: { id: true, name: true } },
},
});
if (computeGrades && classGroupId && subjectId) {
// Compute grades based on weight rules
const schoolYearId = searchParams.get('schoolYearId');
if (!schoolYearId) {
return NextResponse.json(
{ error: 'schoolYearId is required for grade computation' },
{ status: 400 }
);
}
// Get enrolled students
const enrollments = await db.enrollment.findMany({
where: { classGroupId, endDate: null },
select: { studentId: true },
});
const studentIds = enrollments.map((e) => e.studentId);
const computedGrades: Array<{
studentId: string;
computedValue: number;
breakdown: Array<{ source: string; weight: number; value: number }>;
}> = [];
for (const scheme of schemes) {
const rules = scheme.gradingWeightRules;
if (rules.length === 0) continue;
const scale = JSON.parse(scheme.scaleDefinition);
const maxLevel = scale.max ?? 4;
for (const studentId of studentIds) {
let totalWeightedValue = 0;
let totalWeight = 0;
const breakdown: Array<{ source: string; weight: number; value: number }> = [];
for (const rule of rules) {
let value = 0;
if (rule.sourceType === 'LEARNING_PROGRESS') {
// Average mastery level from progress entries
const whereEntry: Record<string, unknown> = {
studentId,
classGroupId,
};
if (rule.targetRef) {
// targetRef could be a categoryId or competencyId
whereEntry.competency = {
OR: [
{ id: rule.targetRef },
{ categoryId: rule.targetRef },
],
};
}
const entries = await db.learningProgressEntry.findMany({
where: whereEntry,
orderBy: { date: 'desc' },
select: { masteryLevelValue: true, competencyId: true },
});
// Get latest per competency
const latestMap = new Map<string, number>();
for (const entry of entries) {
if (!latestMap.has(entry.competencyId)) {
latestMap.set(entry.competencyId, entry.masteryLevelValue);
}
}
const avg =
latestMap.size > 0
? Array.from(latestMap.values()).reduce((a, b) => a + b, 0) /
latestMap.size
: 0;
value = avg / maxLevel; // Normalize to 0-1
} else if (rule.sourceType === 'ASSESSMENT') {
const whereResult: Record<string, unknown> = {
studentId,
};
if (rule.targetRef) {
whereResult.assessmentId = rule.targetRef;
}
const results = await db.assessmentResult.findMany({
where: whereResult,
include: {
assessment: { select: { maxScore: true, weight: true } },
},
});
if (results.length > 0) {
const totalScore = results.reduce((sum, r) => {
if (r.score !== null && r.assessment.maxScore) {
return sum + (r.score / r.assessment.maxScore) * r.assessment.weight;
}
return sum;
}, 0);
const totalWeight = results.reduce(
(sum, r) => sum + (r.assessment.weight ?? 1),
0
);
value = totalWeight > 0 ? totalScore / totalWeight : 0;
}
}
totalWeightedValue += value * (rule.weightPercent / 100);
totalWeight += rule.weightPercent / 100;
breakdown.push({
source: rule.sourceType,
weight: rule.weightPercent,
value: Math.round(value * 1000) / 1000,
});
}
const computedValue =
totalWeight > 0
? Math.round((totalWeightedValue / totalWeight) * maxLevel * 100) / 100
: 0;
computedGrades.push({
studentId,
computedValue,
breakdown,
});
// Store computed grade in DB
await db.computedGrade.upsert({
where: {
id: `${studentId}_${subjectId}_${classGroupId}_${schoolYearId}_${scheme.id}`,
},
update: {
computedValue,
computedAt: new Date(),
},
create: {
id: `${studentId}_${subjectId}_${classGroupId}_${schoolYearId}_${scheme.id}`,
studentId,
subjectId: subjectId!,
classGroupId: classGroupId!,
schoolYearId: schoolYearId!,
period: 'Semester 1',
computedValue,
},
});
}
}
return NextResponse.json({ schemes, computedGrades });
}
return NextResponse.json(schemes);
} catch (error) {
console.error('Grading GET error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const parsed = createSchemeSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const { weightRules, ...schemeData } = parsed.data;
const scheme = await db.gradingScheme.create({
data: {
classGroupId: schemeData.classGroupId ?? null,
subjectId: schemeData.subjectId ?? null,
schoolId: schemeData.schoolId ?? null,
name: schemeData.name,
type: schemeData.type,
scaleDefinition: schemeData.scaleDefinition,
gradingWeightRules: weightRules
? {
create: weightRules.map((rule) => ({
sourceType: rule.sourceType,
targetRef: rule.targetRef ?? null,
weightPercent: rule.weightPercent,
})),
}
: undefined,
},
include: {
gradingWeightRules: true,
classGroup: { select: { id: true, name: true } },
subject: { select: { id: true, name: true } },
},
});
return NextResponse.json(scheme, { status: 201 });
} catch (error) {
console.error('Grading POST error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
export async function PUT(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN' &&
session.user?.role !== 'TEACHER'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
// Grade override path
if (body.studentId && body.overriddenValue !== undefined) {
const overrideSchema = z.object({
studentId: z.string().min(1),
subjectId: z.string().min(1),
classGroupId: z.string().min(1),
schoolYearId: z.string().min(1),
overriddenValue: z.number(),
overrideReason: z.string().min(1),
});
const parsed = overrideSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const { studentId, subjectId, classGroupId, schoolYearId, overriddenValue, overrideReason } = parsed.data;
// Find existing computed grade
const existing = await db.computedGrade.findFirst({
where: { studentId, subjectId, classGroupId, schoolYearId },
});
if (existing) {
await db.computedGrade.update({
where: { id: existing.id },
data: { overriddenValue, overrideReason, isFinalized: true },
});
} else {
await db.computedGrade.create({
data: {
id: `${studentId}_${subjectId}_${classGroupId}_${schoolYearId}_override`,
studentId,
subjectId,
classGroupId,
schoolYearId,
period: 'Semester 1',
computedValue: overriddenValue,
overriddenValue,
overrideReason,
isFinalized: true,
},
});
}
return NextResponse.json({ success: true });
}
// Scheme update path
const updateSchema = z.object({
id: z.string().min(1),
name: z.string().min(1).optional(),
type: z.enum(['NUMERIC_GRADE', 'VERBAL_FEEDBACK', 'COMBINED']).optional(),
scaleDefinition: z.string().optional(),
});
const parsed = updateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const { id, ...updateData } = parsed.data;
const scheme = await db.gradingScheme.update({
where: { id },
data: updateData,
include: {
gradingWeightRules: true,
},
});
return NextResponse.json(scheme);
} catch (error) {
console.error('Grading PUT error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const batchDeleteSchema = z.object({
ids: z.array(z.string()).min(1).max(200),
});
export async function DELETE(req: NextRequest) {
const session = await getSession();
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (session.user.role !== 'TEACHER' && session.user.role !== 'SCHOOL_ADMIN' && session.user.role !== 'SUPER_ADMIN') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
const parsed = batchDeleteSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues.map((i) => i.message).join(', ') }, { status: 400 });
}
const { ids } = parsed.data;
try {
const result = await db.learningProgressEntry.deleteMany({
where: {
id: { in: ids },
teacherId: session.userId,
},
});
// Audit log
await db.auditLog.create({
data: {
userId: session.userId,
schoolId: session.user.schoolId,
action: 'BATCH_DELETE',
entityType: 'LearningProgressEntry',
metadata: JSON.stringify({ count: result.count, ids }),
},
});
return NextResponse.json({ deleted: result.count });
} catch (error) {
console.error('Batch delete error:', error);
return NextResponse.json({ error: 'Failed to delete entries' }, { status: 500 });
}
}
+130
View File
@@ -0,0 +1,130 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const entrySchema = z.object({
studentId: z.string().min(1),
competencyId: z.string().min(1),
teacherId: z.string().min(1),
classGroupId: z.string().min(1),
date: z.string().transform((v) => new Date(v)),
masteryLevelValue: z.number().int().min(1),
note: z.string().optional(),
});
const bulkEntrySchema = z.array(entrySchema);
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 studentId = searchParams.get('studentId');
const classGroupId = searchParams.get('classGroupId');
const competencyId = searchParams.get('competencyId');
const startDate = searchParams.get('startDate');
const endDate = searchParams.get('endDate');
const where: Record<string, unknown> = {};
if (studentId) where.studentId = studentId;
if (classGroupId) where.classGroupId = classGroupId;
if (competencyId) where.competencyId = competencyId;
if (startDate || endDate) {
const dateFilter: Record<string, unknown> = {};
if (startDate) dateFilter.gte = new Date(startDate);
if (endDate) dateFilter.lte = new Date(endDate);
where.date = dateFilter;
}
const entries = await db.learningProgressEntry.findMany({
where,
orderBy: { date: 'desc' },
include: {
student: { select: { id: true, firstName: true, lastName: true } },
competency: {
select: {
id: true,
code: true,
title: true,
category: { select: { id: true, name: true, color: true } },
},
},
teacher: { select: { id: true, firstName: true, lastName: true } },
classGroup: { select: { id: true, name: true } },
},
});
return NextResponse.json(entries);
} catch (error) {
console.error('LearningProgress GET error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const body = await request.json();
// Support both single entry and bulk (array) entries
const isBulk = Array.isArray(body);
if (isBulk) {
const parsed = bulkEntrySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const entries = await db.learningProgressEntry.createMany({
data: parsed.data,
});
return NextResponse.json(
{ created: entries.count },
{ status: 201 }
);
} else {
const parsed = entrySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const entry = await db.learningProgressEntry.create({
data: parsed.data,
include: {
student: { select: { id: true, firstName: true, lastName: true } },
competency: {
select: { id: true, code: true, title: true },
},
teacher: { select: { id: true, firstName: true, lastName: true } },
},
});
return NextResponse.json(entry, { status: 201 });
}
} catch (error) {
console.error('LearningProgress POST error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
+230
View File
@@ -0,0 +1,230 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const statusEnum = z.enum(['draft', 'scheduled', 'completed', 'cancelled']);
const updateLessonPlanSchema = z.object({
title: z.string().min(1).optional(),
description: z.string().optional().nullable(),
classGroupId: z.string().min(1).optional(),
subjectId: z.string().optional().nullable(),
date: z.string().min(1).optional(),
durationMin: z.number().int().min(1).max(480).optional(),
status: statusEnum.optional(),
objectives: z.array(z.string()).optional().nullable(),
materials: z.array(z.string()).optional().nullable(),
homework: z.string().optional().nullable(),
reflection: z.string().optional().nullable(),
linkedCompetencyIds: z.array(z.string()).optional().nullable(),
});
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 });
}
if (
session.user?.role !== 'TEACHER' &&
session.user?.role !== 'SCHOOL_ADMIN' &&
session.user?.role !== 'SUPER_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const lessonPlan = await db.lessonPlan.findUnique({
where: { id },
include: {
teacher: { select: { id: true, firstName: true, lastName: true } },
classGroup: { select: { id: true, name: true, gradeLevel: true, schoolId: true } },
subject: { select: { id: true, name: true } },
},
});
if (!lessonPlan) {
return NextResponse.json({ error: 'Lesson plan not found' }, { status: 404 });
}
const isTeacher = lessonPlan.teacherId === session.userId;
const isSchoolAdmin =
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
lessonPlan.classGroup.schoolId === session.user.schoolId;
const isSuperAdmin = session.user?.role === 'SUPER_ADMIN';
if (!isTeacher && !isSchoolAdmin && !isSuperAdmin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
return NextResponse.json(lessonPlan);
} catch (error) {
console.error('LessonPlan GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'TEACHER' &&
session.user?.role !== 'SCHOOL_ADMIN' &&
session.user?.role !== 'SUPER_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const body = await request.json();
const parsed = updateLessonPlanSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const existing = await db.lessonPlan.findUnique({
where: { id },
include: { classGroup: { select: { schoolId: true } } },
});
if (!existing) {
return NextResponse.json({ error: 'Lesson plan not found' }, { status: 404 });
}
const isTeacher = existing.teacherId === session.userId;
const isSchoolAdmin =
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
existing.classGroup.schoolId === session.user.schoolId;
const isSuperAdmin = session.user?.role === 'SUPER_ADMIN';
if (!isTeacher && !isSchoolAdmin && !isSuperAdmin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const data: Record<string, unknown> = {};
if (parsed.data.title !== undefined) data.title = parsed.data.title;
if (parsed.data.description !== undefined) data.description = parsed.data.description;
if (parsed.data.classGroupId !== undefined) data.classGroupId = parsed.data.classGroupId;
if (parsed.data.subjectId !== undefined) data.subjectId = parsed.data.subjectId || null;
if (parsed.data.date !== undefined) data.date = new Date(parsed.data.date);
if (parsed.data.durationMin !== undefined) data.durationMin = parsed.data.durationMin;
if (parsed.data.status !== undefined) data.status = parsed.data.status;
if (parsed.data.homework !== undefined) data.homework = parsed.data.homework;
if (parsed.data.reflection !== undefined) data.reflection = parsed.data.reflection;
if (parsed.data.objectives !== undefined) {
data.objectives = parsed.data.objectives ? JSON.stringify(parsed.data.objectives) : null;
}
if (parsed.data.materials !== undefined) {
data.materials = parsed.data.materials ? JSON.stringify(parsed.data.materials) : null;
}
if (parsed.data.linkedCompetencyIds !== undefined) {
data.linkedCompetencyIds = parsed.data.linkedCompetencyIds
? JSON.stringify(parsed.data.linkedCompetencyIds)
: null;
}
const updated = await db.lessonPlan.update({
where: { id },
data,
include: {
teacher: { select: { id: true, firstName: true, lastName: true } },
classGroup: { select: { id: true, name: true, gradeLevel: true } },
subject: { select: { id: true, name: true } },
},
});
await db.auditLog.create({
data: {
userId: session.userId,
schoolId: existing.classGroup.schoolId,
action: 'UPDATE',
entityType: 'LessonPlan',
entityId: id,
metadata: JSON.stringify({ updatedFields: Object.keys(data) }),
},
});
return NextResponse.json(updated);
} catch (error) {
console.error('LessonPlan PUT error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'TEACHER' &&
session.user?.role !== 'SCHOOL_ADMIN' &&
session.user?.role !== 'SUPER_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const existing = await db.lessonPlan.findUnique({
where: { id },
include: { classGroup: { select: { schoolId: true } } },
});
if (!existing) {
return NextResponse.json({ error: 'Lesson plan not found' }, { status: 404 });
}
const isTeacher = existing.teacherId === session.userId;
const isSchoolAdmin =
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
existing.classGroup.schoolId === session.user.schoolId;
const isSuperAdmin = session.user?.role === 'SUPER_ADMIN';
if (!isTeacher && !isSchoolAdmin && !isSuperAdmin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
await db.lessonPlan.delete({ where: { id } });
await db.auditLog.create({
data: {
userId: session.userId,
schoolId: existing.classGroup.schoolId,
action: 'DELETE',
entityType: 'LessonPlan',
entityId: id,
metadata: JSON.stringify({ title: existing.title, date: existing.date.toISOString() }),
},
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('LessonPlan DELETE error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+192
View File
@@ -0,0 +1,192 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const statusEnum = z.enum(['draft', 'scheduled', 'completed', 'cancelled']);
const createLessonPlanSchema = z.object({
title: z.string().min(1),
description: z.string().optional(),
classGroupId: z.string().min(1),
subjectId: z.string().optional().nullable(),
date: z.string().min(1),
durationMin: z.number().int().min(1).max(480).optional(),
status: statusEnum.optional(),
objectives: z.array(z.string()).optional(),
materials: z.array(z.string()).optional(),
homework: z.string().optional().nullable(),
reflection: z.string().optional().nullable(),
linkedCompetencyIds: z.array(z.string()).optional(),
});
export async function GET(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'TEACHER' &&
session.user?.role !== 'SCHOOL_ADMIN' &&
session.user?.role !== 'SUPER_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { searchParams } = new URL(request.url);
const classGroupId = searchParams.get('classGroupId');
const dateFrom = searchParams.get('dateFrom');
const dateTo = searchParams.get('dateTo');
const status = searchParams.get('status');
// Find all classes that belong to the user's school
const schoolId = session.user?.schoolId ?? undefined;
const classGroups = await db.classGroup.findMany({
where: schoolId ? { schoolId } : {},
select: { id: true },
});
const schoolClassIds = classGroups.map((c) => c.id);
if (schoolClassIds.length === 0) {
return NextResponse.json([]);
}
const where: Record<string, unknown> = {};
if (classGroupId && classGroupId !== 'all') {
where.classGroupId = classGroupId;
} else {
where.classGroupId = { in: schoolClassIds };
}
if (dateFrom || dateTo) {
const dateFilter: Record<string, unknown> = {};
if (dateFrom) dateFilter.gte = new Date(dateFrom);
if (dateTo) dateFilter.lte = new Date(dateTo);
where.date = dateFilter;
}
if (status && status !== 'all') {
where.status = status;
}
const lessonPlans = await db.lessonPlan.findMany({
where,
orderBy: { date: 'asc' },
include: {
teacher: { select: { id: true, firstName: true, lastName: true } },
classGroup: { select: { id: true, name: true, gradeLevel: true } },
subject: { select: { id: true, name: true } },
},
});
return NextResponse.json(lessonPlans);
} catch (error) {
console.error('LessonPlans GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'TEACHER' &&
session.user?.role !== 'SCHOOL_ADMIN' &&
session.user?.role !== 'SUPER_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const parsed = createLessonPlanSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const { classGroupId, subjectId, date, objectives, materials, linkedCompetencyIds, ...rest } = parsed.data;
// Verify class exists and belongs to user's school
const classGroup = await db.classGroup.findUnique({
where: { id: classGroupId },
select: { id: true, schoolId: true },
});
if (!classGroup) {
return NextResponse.json({ error: 'Class not found' }, { status: 404 });
}
if (
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
classGroup.schoolId !== session.user.schoolId
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Verify subject (if provided) belongs to same school
if (subjectId) {
const subject = await db.subject.findUnique({
where: { id: subjectId },
select: { id: true, schoolId: true },
});
if (!subject) {
return NextResponse.json({ error: 'Subject not found' }, { status: 404 });
}
if (
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
subject.schoolId &&
subject.schoolId !== session.user.schoolId
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
}
const lessonPlan = await db.lessonPlan.create({
data: {
...rest,
teacherId: session.userId,
classGroupId,
subjectId: subjectId || null,
date: new Date(date),
objectives: objectives ? JSON.stringify(objectives) : null,
materials: materials ? JSON.stringify(materials) : null,
linkedCompetencyIds: linkedCompetencyIds ? JSON.stringify(linkedCompetencyIds) : null,
homework: parsed.data.homework ?? null,
reflection: parsed.data.reflection ?? null,
},
include: {
teacher: { select: { id: true, firstName: true, lastName: true } },
classGroup: { select: { id: true, name: true, gradeLevel: true } },
subject: { select: { id: true, name: true } },
},
});
await db.auditLog.create({
data: {
userId: session.userId,
schoolId: classGroup.schoolId,
action: 'CREATE',
entityType: 'LessonPlan',
entityId: lessonPlan.id,
metadata: JSON.stringify({
title: parsed.data.title,
classGroupId,
date,
}),
},
});
return NextResponse.json(lessonPlan, { status: 201 });
} catch (error) {
console.error('LessonPlans POST error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+187
View File
@@ -0,0 +1,187 @@
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 classGroupId = searchParams.get('classGroupId');
const subjectId = searchParams.get('subjectId');
if (!classGroupId || !subjectId) {
return NextResponse.json(
{ error: 'classGroupId and subjectId are required' },
{ status: 400 }
);
}
// Find the competency template assigned to this class + subject
const assignment = await db.classCompetencyAssignment.findFirst({
where: { classGroupId, subjectId },
include: {
competencyTemplate: {
include: {
categories: {
orderBy: { order: 'asc' },
include: {
competencies: {
orderBy: { order: 'asc' },
select: {
id: true,
code: true,
title: true,
categoryId: true,
},
},
},
},
},
},
},
});
if (!assignment) {
return NextResponse.json(
{ error: 'No competency template assigned for this class/subject' },
{ status: 404 }
);
}
const templateId = assignment.clonedTemplateId ?? assignment.competencyTemplateId;
// Re-fetch with full template if cloned (so the categories / competencies match the cloned tree)
let categories: Array<{
id: string;
name: string;
color: string | null;
order: number;
competencies: Array<{ id: string; code: string; title: string; categoryId: string }>;
}>;
if (assignment.clonedTemplateId) {
const clonedTemplate = await db.competencyTemplate.findUnique({
where: { id: assignment.clonedTemplateId },
include: {
categories: {
orderBy: { order: 'asc' },
include: {
competencies: {
orderBy: { order: 'asc' },
select: { id: true, code: true, title: true, categoryId: true },
},
},
},
},
});
categories = clonedTemplate?.categories ?? assignment.competencyTemplate.categories;
} else {
categories = assignment.competencyTemplate.categories;
}
// Flatten competencies with category info
const competencies = categories.flatMap((cat) =>
cat.competencies.map((c) => ({
id: c.id,
code: c.code,
title: c.title,
category: {
id: cat.id,
name: cat.name,
color: cat.color,
},
}))
);
if (competencies.length === 0) {
return NextResponse.json({ students: [], competencies: [], matrix: [] });
}
// Get students enrolled in this class (active enrollments)
const enrollments = await db.enrollment.findMany({
where: { classGroupId, endDate: null },
include: {
student: {
select: { id: true, firstName: true, lastName: true, deletedAt: true },
},
},
});
const students = enrollments
.filter((e) => e.student.deletedAt === null)
.map((e) => ({
id: e.student.id,
firstName: e.student.firstName,
lastName: e.student.lastName,
}))
.sort((a, b) => a.lastName.localeCompare(b.lastName) || a.firstName.localeCompare(b.firstName));
if (students.length === 0) {
return NextResponse.json({ students: [], competencies, matrix: [] });
}
const studentIds = students.map((s) => s.id);
const competencyIds = competencies.map((c) => c.id);
// Fetch all progress entries for these students + competencies in this class
// (scope by competency.category.competencyTemplateId = templateId to avoid cross-template noise)
const progressEntries = await db.learningProgressEntry.findMany({
where: {
studentId: { in: studentIds },
classGroupId,
competencyId: { in: competencyIds },
competency: {
category: { competencyTemplateId: templateId },
},
},
orderBy: { date: 'desc' },
select: {
id: true,
studentId: true,
competencyId: true,
date: true,
masteryLevelValue: true,
},
});
// For each (student, competency) keep the latest entry (entries are sorted desc by date)
const latestMap = new Map<string, { level: number; count: number; lastDate: string | null }>();
for (const e of progressEntries) {
const key = `${e.studentId}::${e.competencyId}`;
const cur = latestMap.get(key);
if (cur) {
cur.count += 1;
// Keep earliest "latest" date if already set, else update if newer
if (!cur.lastDate || new Date(e.date).getTime() > new Date(cur.lastDate).getTime()) {
cur.lastDate = e.date.toISOString();
}
} else {
latestMap.set(key, {
level: e.masteryLevelValue,
count: 1,
lastDate: e.date.toISOString(),
});
}
}
const matrix = Array.from(latestMap.entries()).map(([key, v]) => {
const [studentId, competencyId] = key.split('::');
return {
studentId,
competencyId,
latestMasteryLevel: v.level,
entryCount: v.count,
lastEntryDate: v.lastDate,
};
});
return NextResponse.json({ students, competencies, matrix });
} catch (error) {
console.error('MasteryMatrix GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+185
View File
@@ -0,0 +1,185 @@
// CompetenceTrack — Notifications API
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
// In-memory store for read notification IDs (per server instance)
const readNotificationIds = new Set<string>();
export async function GET() {
const session = await getSession();
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const userId = session.user.id;
const schoolId = session.user.schoolId;
const role = session.user.role;
if (!schoolId) {
return NextResponse.json({
upcomingAssessments: [],
missingObservations: [],
unreadCount: 0,
});
}
const now = new Date();
const sevenDaysFromNow = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
const fourteenDaysAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000);
// Get teacher's class IDs
const teacherClasses = await db.classGroupTeacher.findMany({
where: { userId },
select: { classGroupId: true },
});
const classGroupIds = teacherClasses.map((tc) => tc.classGroupId);
// For admin, get all classes in the school
let targetClassIds = classGroupIds;
if (role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN') {
const schoolClasses = await db.classGroup.findMany({
where: { schoolId },
select: { id: true },
});
targetClassIds = schoolClasses.map((c) => c.id);
}
if (targetClassIds.length === 0) {
return NextResponse.json({
upcomingAssessments: [],
missingObservations: [],
unreadCount: 0,
});
}
// Upcoming assessments (next 7 days, not completed)
const upcomingAssessments = await db.assessment.findMany({
where: {
classGroupId: { in: targetClassIds },
date: {
gte: now,
lte: sevenDaysFromNow,
},
},
include: {
classGroup: { select: { name: true } },
subject: { select: { name: true } },
assessmentResults: { select: { id: true } },
},
orderBy: { date: 'asc' },
take: 10,
});
const assessmentNotifications = upcomingAssessments
.filter((a) => a.assessmentResults.length === 0)
.map((a) => {
const diffMs = new Date(a.date).getTime() - now.getTime();
const daysUntil = Math.max(0, Math.ceil(diffMs / (1000 * 60 * 60 * 24)));
const id = `assessment-${a.id}`;
return {
id,
type: 'assessment' as const,
title: a.title,
description: `${a.classGroup.name} · ${a.subject.name}`,
daysUntil,
timestamp: t('notifications.in_days', { days: String(daysUntil) }),
classGroupId: a.classGroupId,
subjectId: a.subjectId,
assessmentId: a.id,
isRead: readNotificationIds.has(id),
};
});
// Students with no progress entries in last 14 days
const activeEnrollments = await db.enrollment.findMany({
where: {
classGroupId: { in: targetClassIds },
endDate: null,
},
include: {
student: {
select: {
id: true,
firstName: true,
lastName: true,
learningProgressEntries: {
where: {
date: { gte: fourteenDaysAgo },
},
select: { id: true },
take: 1,
},
},
},
classGroup: { select: { id: true, name: true } },
},
});
const missingObservations = activeEnrollments
.filter((e) => e.student.learningProgressEntries.length === 0)
.map((e) => {
const id = `missing-${e.student.id}-${e.classGroupId}`;
// Calculate days since last progress (use 14 as default since we filtered for 14 days)
return {
id,
type: 'missing_observation' as const,
title: `${e.student.firstName} ${e.student.lastName}`,
description: e.classGroup.name,
daysSince: 14,
timestamp: t('notifications.days_ago', { days: '14' }),
studentId: e.student.id,
classGroupId: e.classGroupId,
isRead: readNotificationIds.has(id),
};
});
// Limit to 10 most relevant
const limitedMissing = missingObservations.slice(0, 10);
const allItems = [...assessmentNotifications, ...limitedMissing];
const unreadCount = allItems.filter((n) => !n.isRead).length;
return NextResponse.json({
upcomingAssessments: assessmentNotifications,
missingObservations: limitedMissing,
unreadCount,
});
}
export async function PUT(request: Request) {
const session = await getSession();
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const body = await request.json();
const { ids } = body as { ids?: string[] };
if (ids && Array.isArray(ids)) {
for (const id of ids) {
readNotificationIds.add(id);
}
}
return NextResponse.json({ success: true });
} catch {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
}
}
// Helper — minimal i18n for server-side timestamps
function t(key: string, params?: Record<string, string>): string {
const dict: Record<string, string> = {
'notifications.in_days': 'in {days} Tagen',
'notifications.days_ago': 'vor {days} Tagen',
};
let text = dict[key] ?? key;
if (params) {
for (const [k, v] of Object.entries(params)) {
text = text.replace(new RegExp(`\\{${k}\\}`, 'g'), v);
}
}
return text;
}
+293
View File
@@ -0,0 +1,293 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const categoryEnum = z.enum([
'general',
'progress',
'assessment',
'behavior',
'attendance',
'event',
]);
const priorityEnum = z.enum(['low', 'normal', 'high', 'urgent']);
const statusEnum = z.enum(['draft', 'sent', 'delivered', 'read', 'replied']);
const updateParentMessageSchema = z.object({
subject: z.string().min(1).optional(),
body: z.string().min(1).optional(),
category: categoryEnum.optional(),
priority: priorityEnum.optional(),
status: statusEnum.optional(),
readAt: z.string().datetime().optional().nullable(),
reply: z.string().optional().nullable(),
replyAt: z.string().datetime().optional().nullable(),
});
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 });
}
if (
session.user?.role !== 'TEACHER' &&
session.user?.role !== 'SCHOOL_ADMIN' &&
session.user?.role !== 'SUPER_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const message = await db.parentMessage.findUnique({
where: { id },
include: {
parent: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
phone: true,
relationship: true,
preferredContact: true,
preferredLanguage: true,
notes: true,
},
},
teacher: { select: { id: true, firstName: true, lastName: true } },
student: {
select: {
id: true,
firstName: true,
lastName: true,
enrollments: {
where: { endDate: null },
select: {
classGroup: { select: { id: true, name: true, gradeLevel: true } },
},
take: 1,
},
},
},
},
});
if (!message) {
return NextResponse.json({ error: 'Message not found' }, { status: 404 });
}
// School-scoped access check
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId) {
const student = await db.student.findUnique({
where: { id: message.studentId },
select: { schoolId: true },
});
if (student && student.schoolId !== session.user.schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
}
return NextResponse.json(message);
} catch (error) {
console.error('ParentMessage GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'TEACHER' &&
session.user?.role !== 'SCHOOL_ADMIN' &&
session.user?.role !== 'SUPER_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const body = await request.json();
const parsed = updateParentMessageSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const existing = await db.parentMessage.findUnique({
where: { id },
include: { student: { select: { id: true, schoolId: true } } },
});
if (!existing) {
return NextResponse.json({ error: 'Message not found' }, { status: 404 });
}
const isTeacher = existing.teacherId === session.userId;
const isSchoolAdmin =
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
existing.student.schoolId === session.user.schoolId;
const isSuperAdmin = session.user?.role === 'SUPER_ADMIN';
if (!isTeacher && !isSchoolAdmin && !isSuperAdmin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const data: Record<string, unknown> = {};
if (parsed.data.subject !== undefined) data.subject = parsed.data.subject;
if (parsed.data.body !== undefined) data.body = parsed.data.body;
if (parsed.data.category !== undefined) data.category = parsed.data.category;
if (parsed.data.priority !== undefined) data.priority = parsed.data.priority;
if (parsed.data.status !== undefined) data.status = parsed.data.status;
if (parsed.data.readAt !== undefined) {
data.readAt = parsed.data.readAt ? new Date(parsed.data.readAt) : null;
}
if (parsed.data.reply !== undefined) {
data.reply = parsed.data.reply || null;
}
if (parsed.data.replyAt !== undefined) {
data.replyAt = parsed.data.replyAt ? new Date(parsed.data.replyAt) : null;
}
// If reply is set and status wasn't provided, mark as replied
if (parsed.data.reply !== undefined && parsed.data.reply && parsed.data.status === undefined) {
data.status = 'replied';
if (!existing.replyAt && parsed.data.replyAt === undefined) {
data.replyAt = new Date();
}
}
// If marking as read and readAt not set
if (parsed.data.status === 'read' && !existing.readAt && parsed.data.readAt === undefined) {
data.readAt = new Date();
}
const updated = await db.parentMessage.update({
where: { id },
data,
include: {
parent: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
phone: true,
relationship: true,
preferredContact: true,
preferredLanguage: true,
notes: true,
},
},
teacher: { select: { id: true, firstName: true, lastName: true } },
student: {
select: {
id: true,
firstName: true,
lastName: true,
enrollments: {
where: { endDate: null },
select: {
classGroup: { select: { id: true, name: true, gradeLevel: true } },
},
take: 1,
},
},
},
},
});
await db.auditLog.create({
data: {
userId: session.userId,
schoolId: existing.student.schoolId,
action: 'UPDATE',
entityType: 'ParentMessage',
entityId: id,
metadata: JSON.stringify({ updatedFields: Object.keys(data) }),
},
});
return NextResponse.json(updated);
} catch (error) {
console.error('ParentMessage PUT error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'TEACHER' &&
session.user?.role !== 'SCHOOL_ADMIN' &&
session.user?.role !== 'SUPER_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const existing = await db.parentMessage.findUnique({
where: { id },
include: { student: { select: { id: true, schoolId: true } } },
});
if (!existing) {
return NextResponse.json({ error: 'Message not found' }, { status: 404 });
}
const isTeacher = existing.teacherId === session.userId;
const isSchoolAdmin =
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
existing.student.schoolId === session.user.schoolId;
const isSuperAdmin = session.user?.role === 'SUPER_ADMIN';
if (!isTeacher && !isSchoolAdmin && !isSuperAdmin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
await db.parentMessage.delete({ where: { id } });
await db.auditLog.create({
data: {
userId: session.userId,
schoolId: existing.student.schoolId,
action: 'DELETE',
entityType: 'ParentMessage',
entityId: id,
metadata: JSON.stringify({
subject: existing.subject,
}),
},
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('ParentMessage DELETE error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+252
View File
@@ -0,0 +1,252 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const categoryEnum = z.enum([
'general',
'progress',
'assessment',
'behavior',
'attendance',
'event',
]);
const priorityEnum = z.enum(['low', 'normal', 'high', 'urgent']);
const statusEnum = z.enum(['draft', 'sent', 'delivered', 'read', 'replied']);
const createParentMessageSchema = z.object({
parentId: z.string().min(1),
studentId: z.string().min(1),
subject: z.string().min(1),
body: z.string().min(1),
category: categoryEnum.optional(),
priority: priorityEnum.optional(),
status: statusEnum.optional(),
});
const listWhereSchema = z.object({
teacherId: z.string().optional(),
parentId: z.string().optional(),
studentId: z.string().optional(),
category: z.string().optional(),
status: z.string().optional(),
});
export async function GET(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'TEACHER' &&
session.user?.role !== 'SCHOOL_ADMIN' &&
session.user?.role !== 'SUPER_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { searchParams } = new URL(request.url);
const rawParams = {
teacherId: searchParams.get('teacherId') ?? undefined,
parentId: searchParams.get('parentId') ?? undefined,
studentId: searchParams.get('studentId') ?? undefined,
category: searchParams.get('category') ?? undefined,
status: searchParams.get('status') ?? undefined,
};
const parsed = listWhereSchema.safeParse(rawParams);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const cursor = searchParams.get('cursor');
const limitParam = searchParams.get('limit');
const limit = limitParam ? Math.min(parseInt(limitParam, 10) || 50, 200) : 50;
// Restrict to messages whose student belongs to user's school if SCHOOL_ADMIN
const where: Record<string, unknown> = {};
if (parsed.data.teacherId) where.teacherId = parsed.data.teacherId;
if (parsed.data.parentId) where.parentId = parsed.data.parentId;
if (parsed.data.studentId) where.studentId = parsed.data.studentId;
if (parsed.data.category && parsed.data.category !== 'all') where.category = parsed.data.category;
if (parsed.data.status && parsed.data.status !== 'all') where.status = parsed.data.status;
if (session.user?.schoolId && session.user.role !== 'SUPER_ADMIN') {
where.student = { schoolId: session.user.schoolId };
}
const messages = await db.parentMessage.findMany({
where,
orderBy: { createdAt: 'desc' },
take: limit + 1,
...(cursor ? { skip: 1, cursor: { id: cursor } } : {}),
include: {
parent: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
phone: true,
relationship: true,
preferredContact: true,
preferredLanguage: true,
notes: true,
},
},
teacher: { select: { id: true, firstName: true, lastName: true } },
student: {
select: {
id: true,
firstName: true,
lastName: true,
enrollments: {
where: { endDate: null },
select: {
classGroup: { select: { id: true, name: true, gradeLevel: true } },
},
take: 1,
},
},
},
},
});
const hasMore = messages.length > limit;
const items = hasMore ? messages.slice(0, limit) : messages;
const nextCursor = hasMore && items.length > 0 ? items[items.length - 1].id : null;
return NextResponse.json({
items,
nextCursor,
hasMore,
});
} catch (error) {
console.error('ParentMessages GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'TEACHER' &&
session.user?.role !== 'SCHOOL_ADMIN' &&
session.user?.role !== 'SUPER_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const parsed = createParentMessageSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const { parentId, studentId, ...rest } = parsed.data;
// Verify parent contact exists and matches student
const parent = await db.parentContact.findUnique({
where: { id: parentId },
include: { student: { select: { id: true, schoolId: true, deletedAt: true } } },
});
if (!parent) {
return NextResponse.json({ error: 'Parent contact not found' }, { status: 404 });
}
if (parent.studentId !== studentId) {
return NextResponse.json(
{ error: 'Parent contact does not belong to this student' },
{ status: 400 }
);
}
if (
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
parent.student.schoolId !== session.user.schoolId
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const message = await db.parentMessage.create({
data: {
parentId,
teacherId: session.userId,
studentId,
subject: rest.subject,
body: rest.body,
category: rest.category ?? 'general',
priority: rest.priority ?? 'normal',
status: rest.status ?? 'sent',
},
include: {
parent: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
phone: true,
relationship: true,
preferredContact: true,
preferredLanguage: true,
notes: true,
},
},
teacher: { select: { id: true, firstName: true, lastName: true } },
student: {
select: {
id: true,
firstName: true,
lastName: true,
enrollments: {
where: { endDate: null },
select: {
classGroup: { select: { id: true, name: true, gradeLevel: true } },
},
take: 1,
},
},
},
},
});
await db.auditLog.create({
data: {
userId: session.userId,
schoolId: parent.student.schoolId,
action: 'CREATE',
entityType: 'ParentMessage',
entityId: message.id,
metadata: JSON.stringify({
parentId,
studentId,
subject: rest.subject,
category: rest.category ?? 'general',
priority: rest.priority ?? 'normal',
}),
},
});
return NextResponse.json(message, { status: 201 });
} catch (error) {
console.error('ParentMessages POST error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+205
View File
@@ -0,0 +1,205 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const relationshipEnum = z.enum(['parent', 'guardian', 'emergency']);
const preferredContactEnum = z.enum(['email', 'phone', 'both']);
const updateParentContactSchema = z.object({
firstName: z.string().min(1).optional(),
lastName: z.string().min(1).optional(),
email: z.string().email().optional().nullable(),
phone: z.string().optional().nullable(),
relationship: relationshipEnum.optional(),
preferredContact: preferredContactEnum.optional(),
preferredLanguage: z.string().optional(),
notes: z.string().optional().nullable(),
});
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'TEACHER' &&
session.user?.role !== 'SCHOOL_ADMIN' &&
session.user?.role !== 'SUPER_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const body = await request.json();
const parsed = updateParentContactSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const existing = await db.parentContact.findUnique({
where: { id },
include: { student: { select: { id: true, schoolId: true, deletedAt: true } } },
});
if (!existing) {
return NextResponse.json({ error: 'Parent contact not found' }, { status: 404 });
}
const isSchoolAdmin =
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
existing.student.schoolId === session.user.schoolId;
const isSuperAdmin = session.user?.role === 'SUPER_ADMIN';
// Teachers can edit any parent contact of their school
const isTeacherInSchool =
session.user?.role === 'TEACHER' &&
session.user.schoolId &&
existing.student.schoolId === session.user.schoolId;
if (!isSchoolAdmin && !isSuperAdmin && !isTeacherInSchool) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Check uniqueness when email changes
if (parsed.data.email !== undefined && parsed.data.email) {
const dup = await db.parentContact.findUnique({
where: {
studentId_email: {
studentId: existing.studentId,
email: parsed.data.email,
},
},
});
if (dup && dup.id !== id) {
return NextResponse.json(
{ error: 'A contact with this email already exists for this student' },
{ status: 409 }
);
}
}
const data: Record<string, unknown> = {};
if (parsed.data.firstName !== undefined) data.firstName = parsed.data.firstName;
if (parsed.data.lastName !== undefined) data.lastName = parsed.data.lastName;
if (parsed.data.email !== undefined) data.email = parsed.data.email || null;
if (parsed.data.phone !== undefined) data.phone = parsed.data.phone || null;
if (parsed.data.relationship !== undefined) data.relationship = parsed.data.relationship;
if (parsed.data.preferredContact !== undefined) data.preferredContact = parsed.data.preferredContact;
if (parsed.data.preferredLanguage !== undefined) data.preferredLanguage = parsed.data.preferredLanguage;
if (parsed.data.notes !== undefined) data.notes = parsed.data.notes || null;
const updated = await db.parentContact.update({
where: { id },
data,
include: {
student: {
select: {
id: true,
firstName: true,
lastName: true,
enrollments: {
where: { endDate: null },
select: {
classGroup: { select: { id: true, name: true, gradeLevel: true } },
},
take: 1,
},
},
},
},
});
await db.auditLog.create({
data: {
userId: session.userId,
schoolId: existing.student.schoolId,
action: 'UPDATE',
entityType: 'ParentContact',
entityId: id,
metadata: JSON.stringify({ updatedFields: Object.keys(data) }),
},
});
return NextResponse.json(updated);
} catch (error) {
console.error('ParentContact PUT error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'TEACHER' &&
session.user?.role !== 'SCHOOL_ADMIN' &&
session.user?.role !== 'SUPER_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const existing = await db.parentContact.findUnique({
where: { id },
include: { student: { select: { id: true, schoolId: true } } },
});
if (!existing) {
return NextResponse.json({ error: 'Parent contact not found' }, { status: 404 });
}
const isSchoolAdmin =
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
existing.student.schoolId === session.user.schoolId;
const isSuperAdmin = session.user?.role === 'SUPER_ADMIN';
const isTeacherInSchool =
session.user?.role === 'TEACHER' &&
session.user.schoolId &&
existing.student.schoolId === session.user.schoolId;
if (!isSchoolAdmin && !isSuperAdmin && !isTeacherInSchool) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Cascade delete messages
await db.parentMessage.deleteMany({ where: { parentId: id } });
await db.parentContact.delete({ where: { id } });
await db.auditLog.create({
data: {
userId: session.userId,
schoolId: existing.student.schoolId,
action: 'DELETE',
entityType: 'ParentContact',
entityId: id,
metadata: JSON.stringify({
firstName: existing.firstName,
lastName: existing.lastName,
}),
},
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('ParentContact DELETE error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+196
View File
@@ -0,0 +1,196 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const relationshipEnum = z.enum(['parent', 'guardian', 'emergency']);
const preferredContactEnum = z.enum(['email', 'phone', 'both']);
const createParentContactSchema = z.object({
studentId: z.string().min(1),
firstName: z.string().min(1),
lastName: z.string().min(1),
email: z.string().email().optional().nullable(),
phone: z.string().optional().nullable(),
relationship: relationshipEnum.optional(),
preferredContact: preferredContactEnum.optional(),
preferredLanguage: z.string().optional(),
notes: z.string().optional().nullable(),
});
export async function GET(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'TEACHER' &&
session.user?.role !== 'SCHOOL_ADMIN' &&
session.user?.role !== 'SUPER_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { searchParams } = new URL(request.url);
const studentId = searchParams.get('studentId');
const search = searchParams.get('search');
// Build where clause
const where: Record<string, unknown> = {};
if (studentId) {
where.studentId = studentId;
} else if (session.user?.schoolId) {
// Restrict to students in this school
where.student = { schoolId: session.user.schoolId };
}
if (search) {
const s = search.toLowerCase();
where.OR = [
{ firstName: { contains: s } },
{ lastName: { contains: s } },
{ email: { contains: s } },
{ student: { firstName: { contains: s } } },
{ student: { lastName: { contains: s } } },
];
}
const contacts = await db.parentContact.findMany({
where,
orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }],
include: {
student: {
select: {
id: true,
firstName: true,
lastName: true,
enrollments: {
where: { endDate: null },
select: {
classGroup: { select: { id: true, name: true, gradeLevel: true } },
},
take: 1,
},
},
},
},
});
return NextResponse.json(contacts);
} catch (error) {
console.error('ParentContacts GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'TEACHER' &&
session.user?.role !== 'SCHOOL_ADMIN' &&
session.user?.role !== 'SUPER_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const parsed = createParentContactSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const { studentId, ...rest } = parsed.data;
// Verify student exists and belongs to user's school
const student = await db.student.findUnique({
where: { id: studentId, deletedAt: null },
select: { id: true, schoolId: true },
});
if (!student) {
return NextResponse.json({ error: 'Student not found' }, { status: 404 });
}
if (
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
student.schoolId !== session.user.schoolId
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Check uniqueness for (studentId, email) if email provided
if (rest.email) {
const existing = await db.parentContact.findUnique({
where: { studentId_email: { studentId, email: rest.email } },
});
if (existing) {
return NextResponse.json(
{ error: 'A contact with this email already exists for this student' },
{ status: 409 }
);
}
}
const contact = await db.parentContact.create({
data: {
studentId,
firstName: rest.firstName,
lastName: rest.lastName,
email: rest.email ?? null,
phone: rest.phone ?? null,
relationship: rest.relationship ?? 'parent',
preferredContact: rest.preferredContact ?? 'email',
preferredLanguage: rest.preferredLanguage ?? 'de',
notes: rest.notes ?? null,
},
include: {
student: {
select: {
id: true,
firstName: true,
lastName: true,
enrollments: {
where: { endDate: null },
select: {
classGroup: { select: { id: true, name: true, gradeLevel: true } },
},
take: 1,
},
},
},
},
});
await db.auditLog.create({
data: {
userId: session.userId,
schoolId: student.schoolId,
action: 'CREATE',
entityType: 'ParentContact',
entityId: contact.id,
metadata: JSON.stringify({
studentId,
firstName: rest.firstName,
lastName: rest.lastName,
}),
},
});
return NextResponse.json(contact, { status: 201 });
} catch (error) {
console.error('ParentContacts POST error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+204
View File
@@ -0,0 +1,204 @@
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 reportId = searchParams.get('reportId');
if (!reportId) {
return NextResponse.json({ error: 'reportId is required' }, { status: 400 });
}
const report = await db.report.findUnique({
where: { id: reportId },
include: {
student: { select: { id: true, firstName: true, lastName: true } },
classGroup: { select: { id: true, name: true, gradeLevel: true } },
schoolYear: { select: { id: true, label: true } },
generatedByUser: { select: { id: true, firstName: true, lastName: true } },
sections: {
orderBy: { order: 'asc' },
include: {
competencyCategory: { select: { id: true, name: true, color: true } },
},
},
},
});
if (!report) {
return NextResponse.json({ error: 'Report not found' }, { status: 404 });
}
// Generate a print-friendly HTML page
const htmlContent = generatePrintHtml(report);
return new NextResponse(htmlContent, {
headers: {
'Content-Type': 'text/html; charset=utf-8',
},
});
} catch (error) {
console.error('Report PDF error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
function generatePrintHtml(report: Record<string, unknown>): string {
const student = report.student as { firstName: string; lastName: string };
const classGroup = report.classGroup as { name: string; gradeLevel: number };
const schoolYear = report.schoolYear as { label: string };
const generatedByUser = report.generatedByUser as { firstName: string; lastName: string };
const sections = (report.sections as Array<{
order: number;
generatedText: string;
competencyCategory: { name: string; color: string | null } | null;
}>).sort((a, b) => a.order - b.order);
const sectionsHtml = sections.map((s) => {
const catName = s.competencyCategory?.name ?? '';
const catColor = s.competencyCategory?.color ?? '#10b981';
return `
<div class="section">
${catName ? `<h3 class="section-title" style="color: ${catColor}">${catName}</h3>` : ''}
<p class="section-text">${s.generatedText}</p>
</div>
`;
}).join('\n');
return `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Kompetenzbericht — ${student.firstName} ${student.lastName}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 800px;
margin: 40px auto;
padding: 20px;
}
.header {
text-align: center;
border-bottom: 3px solid #10b981;
padding-bottom: 20px;
margin-bottom: 30px;
}
.header h1 {
color: #10b981;
font-size: 28px;
margin-bottom: 8px;
}
.header h2 {
color: #666;
font-size: 18px;
margin-bottom: 6px;
}
.meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
font-size: 14px;
color: #555;
margin-bottom: 24px;
}
.meta dt {
font-weight: 600;
color: #333;
}
.section {
margin-bottom: 24px;
padding: 16px;
background: #f9fafb;
border-radius: 8px;
border-left: 4px solid #10b981;
}
.section-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 8px;
}
.section-text {
font-size: 14px;
color: #444;
}
.footer {
text-align: center;
font-size: 12px;
color: #999;
border-top: 1px solid #eee;
padding-top: 16px;
margin-top: 40px;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
}
.badge-draft {
background: #fef3c7;
color: #92400e;
}
.badge-final {
background: #d1fae5;
color: #065f46;
}
@media print {
body { margin: 0; padding: 20px; }
.no-print { display: none; }
.section { break-inside: avoid; }
}
.print-btn {
position: fixed;
top: 20px;
right: 20px;
background: #10b981;
color: white;
border: none;
padding: 12px 24px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3);
}
.print-btn:hover { background: #059669; }
</style>
</head>
<body>
<button class="print-btn no-print" onclick="window.print()">Drucken / Print</button>
<div class="header">
<h1>Kompetenzbericht</h1>
<h2>${student.firstName} ${student.lastName}</h2>
<span class="badge ${String(report.status) === 'FINAL' ? 'badge-final' : 'badge-draft'}">${String(report.status)}</span>
</div>
<div class="meta">
<div><dt>Klasse:</dt> <dd>${classGroup.name} (Jahrgang ${classGroup.gradeLevel})</dd></div>
<div><dt>Schuljahr:</dt> <dd>${schoolYear.label}</dd></div>
<div><dt>Zeitraum:</dt> <dd>${String(report.period)}</dd></div>
<div><dt>Erstellt von:</dt> <dd>${generatedByUser.firstName} ${generatedByUser.lastName}</dd></div>
<div><dt>Datum:</dt> <dd>${new Date(String(report.generatedAt)).toLocaleDateString()}</dd></div>
<div><dt>Noten inklusive:</dt> <dd>${String(report.includesGrades) === 'true' ? 'Ja' : 'Nein'}</dd></div>
</div>
${sectionsHtml}
<div class="footer">
<p>CompetenceTrack — Kompetenzbericht · ${new Date().toLocaleDateString()}</p>
</div>
</body>
</html>`;
}
+214
View File
@@ -0,0 +1,214 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const createReportSchema = z.object({
studentId: z.string().min(1),
classGroupId: z.string().min(1),
schoolYearId: z.string().min(1),
period: z.string().min(1),
includesGrades: z.boolean().default(false),
sections: z
.array(
z.object({
competencyCategoryId: z.string().optional(),
generatedText: z.string().min(1),
order: z.number().int().default(0),
})
)
.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 studentId = searchParams.get('studentId');
const classGroupId = searchParams.get('classGroupId');
const schoolYearId = searchParams.get('schoolYearId');
const status = searchParams.get('status');
const where: Record<string, unknown> = {};
if (studentId) where.studentId = studentId;
if (classGroupId) where.classGroupId = classGroupId;
if (schoolYearId) where.schoolYearId = schoolYearId;
if (status) where.status = status;
const reports = await db.report.findMany({
where,
orderBy: { generatedAt: 'desc' },
include: {
student: { select: { id: true, firstName: true, lastName: true } },
classGroup: { select: { id: true, name: true, gradeLevel: true } },
schoolYear: { select: { id: true, label: true } },
generatedByUser: {
select: { id: true, firstName: true, lastName: true },
},
sections: {
orderBy: { order: 'asc' },
include: {
competencyCategory: {
select: { id: true, name: true, color: true },
},
},
},
},
});
return NextResponse.json(reports);
} catch (error) {
console.error('Reports GET error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN' &&
session.user?.role !== 'TEACHER'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const parsed = createReportSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const { sections, ...reportData } = parsed.data;
const report = await db.report.create({
data: {
studentId: reportData.studentId,
classGroupId: reportData.classGroupId,
schoolYearId: reportData.schoolYearId,
period: reportData.period,
generatedByUserId: session.userId,
includesGrades: reportData.includesGrades,
status: 'DRAFT',
sections: sections
? {
create: sections.map((s) => ({
competencyCategoryId: s.competencyCategoryId ?? null,
generatedText: s.generatedText,
order: s.order,
})),
}
: undefined,
},
include: {
student: { select: { id: true, firstName: true, lastName: true } },
classGroup: { select: { id: true, name: true } },
schoolYear: { select: { id: true, label: true } },
generatedByUser: {
select: { id: true, firstName: true, lastName: true },
},
sections: {
orderBy: { order: 'asc' },
include: {
competencyCategory: {
select: { id: true, name: true },
},
},
},
},
});
return NextResponse.json(report, { status: 201 });
} catch (error) {
console.error('Reports POST error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
export async function PUT(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const body = await request.json();
const updateSchema = z.object({
id: z.string().min(1),
status: z.enum(['DRAFT', 'FINAL']).optional(),
includesGrades: z.boolean().optional(),
pdfFilePath: z.string().optional(),
sections: z.array(z.object({
id: z.string().min(1),
generatedText: z.string().min(1),
})).optional(),
});
const parsed = updateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const { id, sections, ...updateData } = parsed.data;
// Update sections if provided
if (sections && sections.length > 0) {
for (const section of sections) {
await db.reportSection.update({
where: { id: section.id },
data: { generatedText: section.generatedText },
});
}
}
const report = await db.report.update({
where: { id },
data: updateData,
include: {
student: { select: { id: true, firstName: true, lastName: true } },
classGroup: { select: { id: true, name: true, gradeLevel: true } },
schoolYear: { select: { id: true, label: true } },
generatedByUser: {
select: { id: true, firstName: true, lastName: true },
},
sections: {
orderBy: { order: 'asc' },
include: {
competencyCategory: {
select: { id: true, name: true, color: true },
},
},
},
},
});
return NextResponse.json(report);
} catch (error) {
console.error('Reports PUT error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
+5
View File
@@ -0,0 +1,5 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({ message: "Hello, world!" });
}
+111
View File
@@ -0,0 +1,111 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
function isTeacherOrAdmin(role: string | undefined): boolean {
return (
role === 'TEACHER' ||
role === 'SCHOOL_ADMIN' ||
role === 'SUPER_ADMIN'
);
}
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 });
}
if (!isTeacherOrAdmin(session.user?.role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const existing = await db.rubric.findUnique({
where: { id },
include: {
criteria: {
orderBy: { order: 'asc' },
include: { levels: { orderBy: { order: 'asc' } } },
},
},
});
if (!existing) {
return NextResponse.json({ error: 'Rubric not found' }, { status: 404 });
}
if (
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
existing.schoolId !== session.user.schoolId
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Create a duplicate with incremented version
const { criteria, ...rubricData } = existing;
const duplicated = await db.rubric.create({
data: {
schoolId: rubricData.schoolId,
teacherId: session.userId,
subjectId: rubricData.subjectId,
title: `${rubricData.title} (Kopie)`,
description: rubricData.description,
type: rubricData.type,
maxPoints: rubricData.maxPoints,
isPublic: false, // Duplicates start as private
version: rubricData.version + 1,
criteria: {
create: criteria.map((c, ci) => ({
name: c.name,
description: c.description,
weight: c.weight,
maxPoints: c.maxPoints,
order: c.order ?? ci,
levels: {
create: c.levels.map((l, li) => ({
label: l.label,
description: l.description,
points: l.points,
order: l.order ?? li,
})),
},
})),
},
},
include: {
criteria: {
orderBy: { order: 'asc' },
include: { levels: { orderBy: { order: 'asc' } } },
},
teacher: { select: { id: true, firstName: true, lastName: true } },
subject: { select: { id: true, name: true } },
},
});
await db.auditLog.create({
data: {
userId: session.userId,
schoolId: existing.schoolId,
action: 'CREATE',
entityType: 'Rubric',
entityId: duplicated.id,
metadata: JSON.stringify({
title: duplicated.title,
type: 'DUPLICATE',
sourceRubricId: id,
sourceTitle: existing.title,
}),
},
});
return NextResponse.json(duplicated, { status: 201 });
} catch (error) {
console.error('Rubric Duplicate POST error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+246
View File
@@ -0,0 +1,246 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
function isTeacherOrAdmin(role: string | undefined): boolean {
return (
role === 'TEACHER' ||
role === 'SCHOOL_ADMIN' ||
role === 'SUPER_ADMIN'
);
}
const levelSchema = z.object({
id: z.string().optional(),
label: z.string().min(1).max(100),
description: z.string().min(1).max(2000),
points: z.int().min(0),
order: z.int().min(0).default(0),
});
const criterionSchema = z.object({
id: z.string().optional(),
name: z.string().min(1).max(200),
description: z.string().max(1000).optional().nullable(),
weight: z.number().min(0).default(1.0),
maxPoints: z.int().min(1),
order: z.int().min(0).default(0),
levels: z.array(levelSchema).min(1),
});
const updateRubricSchema = z.object({
title: z.string().min(1).max(200).optional(),
description: z.string().max(2000).optional().nullable(),
type: z.enum(['ANALYTIC', 'HOLISTIC']).optional(),
subjectId: z.string().optional().nullable(),
maxPoints: z.int().min(1).optional(),
isPublic: z.boolean().optional(),
criteria: z.array(criterionSchema).min(1).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 });
}
if (!isTeacherOrAdmin(session.user?.role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const rubric = await db.rubric.findUnique({
where: { id },
include: {
criteria: {
orderBy: { order: 'asc' },
include: { levels: { orderBy: { order: 'asc' } } },
},
teacher: { select: { id: true, firstName: true, lastName: true } },
subject: { select: { id: true, name: true } },
},
});
if (!rubric) {
return NextResponse.json({ error: 'Rubric not found' }, { status: 404 });
}
if (
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
rubric.schoolId !== session.user.schoolId
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
return NextResponse.json(rubric);
} catch (error) {
console.error('Rubric GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (!isTeacherOrAdmin(session.user?.role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const body = await request.json();
const parsed = updateRubricSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const existing = await db.rubric.findUnique({ where: { id } });
if (!existing) {
return NextResponse.json({ error: 'Rubric not found' }, { status: 404 });
}
if (
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
existing.schoolId !== session.user.schoolId
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Only the owner or admin can update
if (existing.teacherId !== session.userId && session.user?.role !== 'SCHOOL_ADMIN' && session.user?.role !== 'SUPER_ADMIN') {
return NextResponse.json({ error: 'Only the owner can edit this rubric' }, { status: 403 });
}
const { criteria, subjectId, ...rest } = parsed.data;
// Update scalar fields
const updateData: Record<string, unknown> = {};
if (rest.title !== undefined) updateData.title = rest.title;
if (rest.description !== undefined) updateData.description = rest.description;
if (rest.type !== undefined) updateData.type = rest.type;
if (rest.maxPoints !== undefined) updateData.maxPoints = rest.maxPoints;
if (rest.isPublic !== undefined) updateData.isPublic = rest.isPublic;
if (subjectId !== undefined) updateData.subjectId = subjectId ?? null;
// Handle nested criteria update
if (criteria) {
// Delete existing criteria (cascade will delete levels)
await db.rubricCriterion.deleteMany({ where: { rubricId: id } });
// Create new criteria with levels
updateData.criteria = {
create: criteria.map((c, ci) => ({
name: c.name,
description: c.description ?? null,
weight: c.weight,
maxPoints: c.maxPoints,
order: c.order ?? ci,
levels: {
create: c.levels.map((l, li) => ({
label: l.label,
description: l.description,
points: l.points,
order: l.order ?? li,
})),
},
})),
};
}
const updated = await db.rubric.update({
where: { id },
data: updateData,
include: {
criteria: {
orderBy: { order: 'asc' },
include: { levels: { orderBy: { order: 'asc' } } },
},
teacher: { select: { id: true, firstName: true, lastName: true } },
subject: { select: { id: true, name: true } },
},
});
await db.auditLog.create({
data: {
userId: session.userId,
schoolId: existing.schoolId,
action: 'UPDATE',
entityType: 'Rubric',
entityId: id,
metadata: JSON.stringify({ title: updated.title, updatedFields: Object.keys(rest) }),
},
});
return NextResponse.json(updated);
} catch (error) {
console.error('Rubric PUT error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (!isTeacherOrAdmin(session.user?.role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const existing = await db.rubric.findUnique({ where: { id } });
if (!existing) {
return NextResponse.json({ error: 'Rubric not found' }, { status: 404 });
}
if (
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
existing.schoolId !== session.user.schoolId
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Only the owner or admin can delete
if (existing.teacherId !== session.userId && session.user?.role !== 'SCHOOL_ADMIN' && session.user?.role !== 'SUPER_ADMIN') {
return NextResponse.json({ error: 'Only the owner can delete this rubric' }, { status: 403 });
}
// Cascade will delete criteria and levels
await db.rubric.delete({ where: { id } });
await db.auditLog.create({
data: {
userId: session.userId,
schoolId: existing.schoolId,
action: 'DELETE',
entityType: 'Rubric',
entityId: id,
metadata: JSON.stringify({ title: existing.title, type: existing.type }),
},
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Rubric DELETE error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+216
View File
@@ -0,0 +1,216 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
function isTeacherOrAdmin(role: string | undefined): boolean {
return (
role === 'TEACHER' ||
role === 'SCHOOL_ADMIN' ||
role === 'SUPER_ADMIN'
);
}
const levelSchema = z.object({
id: z.string().optional(), // for updates
label: z.string().min(1).max(100),
description: z.string().min(1).max(2000),
points: z.int().min(0),
order: z.int().min(0).default(0),
});
const criterionSchema = z.object({
id: z.string().optional(), // for updates
name: z.string().min(1).max(200),
description: z.string().max(1000).optional().nullable(),
weight: z.number().min(0).default(1.0),
maxPoints: z.int().min(1),
order: z.int().min(0).default(0),
levels: z.array(levelSchema).min(1),
});
const createRubricSchema = z.object({
schoolId: z.string().min(1),
title: z.string().min(1).max(200),
description: z.string().max(2000).optional().nullable(),
type: z.enum(['ANALYTIC', 'HOLISTIC']).default('ANALYTIC'),
subjectId: z.string().optional().nullable(),
maxPoints: z.int().min(1).default(100),
isPublic: z.boolean().default(false),
criteria: z.array(criterionSchema).min(1),
});
export async function GET(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (!isTeacherOrAdmin(session.user?.role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { searchParams } = new URL(request.url);
const schoolIdParam = searchParams.get('schoolId');
const teacherId = searchParams.get('teacherId');
const subjectId = searchParams.get('subjectId');
let schoolId: string | undefined;
if (session.user?.role === 'SCHOOL_ADMIN') {
schoolId = session.user.schoolId ?? undefined;
} else {
schoolId = schoolIdParam ?? session.user?.schoolId ?? undefined;
}
if (!schoolId) {
return NextResponse.json([]);
}
const where: Record<string, unknown> = {
OR: [
{ schoolId, teacherId: session.userId },
{ schoolId, isPublic: true },
],
};
if (teacherId) {
where.teacherId = teacherId;
}
if (subjectId && subjectId !== 'all') {
where.subjectId = subjectId;
}
// If teacherId filter is set, override the OR to a simple AND
if (teacherId) {
const simpleWhere: Record<string, unknown> = { schoolId, teacherId };
if (subjectId && subjectId !== 'all') simpleWhere.subjectId = subjectId;
const rubrics = await db.rubric.findMany({
where: simpleWhere,
orderBy: { updatedAt: 'desc' },
include: {
criteria: {
orderBy: { order: 'asc' },
include: { levels: { orderBy: { order: 'asc' } } },
},
teacher: { select: { id: true, firstName: true, lastName: true } },
subject: { select: { id: true, name: true } },
},
});
return NextResponse.json(rubrics);
}
const rubrics = await db.rubric.findMany({
where,
orderBy: { updatedAt: 'desc' },
include: {
criteria: {
orderBy: { order: 'asc' },
include: { levels: { orderBy: { order: 'asc' } } },
},
teacher: { select: { id: true, firstName: true, lastName: true } },
subject: { select: { id: true, name: true } },
},
});
return NextResponse.json(rubrics);
} catch (error) {
console.error('Rubrics GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (!isTeacherOrAdmin(session.user?.role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const parsed = createRubricSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const { schoolId, criteria, subjectId, ...rest } = parsed.data;
// Verify school exists
const school = await db.school.findUnique({ where: { id: schoolId } });
if (!school) {
return NextResponse.json({ error: 'School not found' }, { status: 404 });
}
if (
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
school.id !== session.user.schoolId
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Verify subject if provided
if (subjectId) {
const subject = await db.subject.findUnique({ where: { id: subjectId } });
if (!subject) {
return NextResponse.json({ error: 'Subject not found' }, { status: 404 });
}
}
const rubric = await db.rubric.create({
data: {
schoolId,
teacherId: session.userId,
subjectId: subjectId ?? null,
...rest,
criteria: {
create: criteria.map((c, ci) => ({
name: c.name,
description: c.description ?? null,
weight: c.weight,
maxPoints: c.maxPoints,
order: c.order ?? ci,
levels: {
create: c.levels.map((l, li) => ({
label: l.label,
description: l.description,
points: l.points,
order: l.order ?? li,
})),
},
})),
},
},
include: {
criteria: {
orderBy: { order: 'asc' },
include: { levels: { orderBy: { order: 'asc' } } },
},
teacher: { select: { id: true, firstName: true, lastName: true } },
subject: { select: { id: true, name: true } },
},
});
await db.auditLog.create({
data: {
userId: session.userId,
schoolId,
action: 'CREATE',
entityType: 'Rubric',
entityId: rubric.id,
metadata: JSON.stringify({ title: rubric.title, type: rubric.type, criteriaCount: criteria.length }),
},
});
return NextResponse.json(rubric, { status: 201 });
} catch (error) {
console.error('Rubrics POST error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+85
View File
@@ -0,0 +1,85 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const createSchoolYearSchema = z.object({
schoolId: z.string().min(1),
label: z.string().min(1),
startDate: z.string().transform((v) => new Date(v)),
endDate: z.string().transform((v) => new Date(v)),
});
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');
if (!schoolId) {
return NextResponse.json(
{ error: 'schoolId query parameter is required' },
{ status: 400 }
);
}
const schoolYears = await db.schoolYear.findMany({
where: { schoolId },
orderBy: { startDate: 'desc' },
include: {
_count: {
select: { classGroups: true, enrollments: true },
},
},
});
return NextResponse.json(schoolYears);
} catch (error) {
console.error('SchoolYears GET error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const parsed = createSchoolYearSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const schoolYear = await db.schoolYear.create({
data: parsed.data,
});
return NextResponse.json(schoolYear, { status: 201 });
} catch (error) {
console.error('SchoolYears POST error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
+164
View File
@@ -0,0 +1,164 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const createSchoolSchema = z.object({
name: z.string().min(1),
schoolType: z.enum(['ELEMENTARY', 'MIDDLE', 'GYMNASIUM', 'OTHER']),
country: z.string().default('DE'),
timezone: z.string().default('Europe/Berlin'),
});
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 schoolType = searchParams.get('schoolType');
const where: Record<string, unknown> = {};
if (schoolType) where.schoolType = schoolType;
// If user is not super admin, only show their school
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId) {
where.id = session.user.schoolId;
}
const schools = await db.school.findMany({
where,
orderBy: { createdAt: 'desc' },
include: {
_count: {
select: {
users: { where: { deletedAt: null } },
classGroups: true,
students: { where: { deletedAt: null } },
},
},
},
});
return NextResponse.json(schools);
} catch (error) {
console.error('Schools GET error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const parsed = createSchoolSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const school = await db.school.create({
data: parsed.data,
});
return NextResponse.json(school, { status: 201 });
} catch (error) {
console.error('Schools POST error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
const updateSchoolSchema = z.object({
id: z.string().min(1),
name: z.string().min(1).optional(),
schoolType: z.enum(['ELEMENTARY', 'MIDDLE', 'GYMNASIUM', 'OTHER']).optional(),
country: z.string().optional(),
timezone: z.string().optional(),
});
export async function PUT(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const parsed = updateSchoolSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const { id, ...updateData } = parsed.data;
// Verify user has access to this school
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== id) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const school = await db.school.update({
where: { id },
data: updateData,
include: {
_count: {
select: {
users: { where: { deletedAt: null } },
classGroups: true,
students: { where: { deletedAt: null } },
},
},
},
});
// Create audit log entry
await db.auditLog.create({
data: {
userId: session.userId,
schoolId: id,
action: 'UPDATE',
entityType: 'School',
entityId: id,
metadata: JSON.stringify(updateData),
},
});
return NextResponse.json(school);
} catch (error) {
console.error('Schools PUT error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
+279
View File
@@ -0,0 +1,279 @@
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: studentId } = await params;
// Load the student with relations
const student = await db.student.findUnique({
where: { id: studentId, deletedAt: null },
select: {
id: true,
firstName: true,
lastName: true,
dateOfBirth: true,
externalId: true,
createdAt: true,
schoolId: true,
school: { select: { id: true, name: true } },
enrollments: {
where: { endDate: null },
include: {
classGroup: {
select: {
id: true,
name: true,
gradeLevel: true,
schoolType: true,
},
},
schoolYear: { select: { id: true, label: true } },
},
orderBy: { startDate: 'desc' },
},
},
});
if (!student) {
return NextResponse.json({ error: 'Student not found' }, { status: 404 });
}
// 1. Progress entries (timeline)
const progressEntries = await db.learningProgressEntry.findMany({
where: { studentId },
orderBy: { date: 'desc' },
take: 100,
include: {
competency: {
select: {
id: true,
code: true,
title: true,
category: { select: { id: true, name: true, color: true } },
},
},
teacher: { select: { id: true, firstName: true, lastName: true } },
classGroup: { select: { id: true, name: true } },
},
});
// 2. Assessment results
const assessmentResults = await db.assessmentResult.findMany({
where: { studentId },
orderBy: { assessment: { date: 'desc' } },
take: 50,
include: {
assessment: {
select: {
id: true,
title: true,
date: true,
type: true,
maxScore: true,
subject: { select: { id: true, name: true } },
},
},
},
});
// 3. Computed grades
const computedGrades = await db.computedGrade.findMany({
where: { studentId },
orderBy: { computedAt: 'desc' },
take: 50,
include: {
subject: { select: { id: true, name: true } },
classGroup: { select: { id: true, name: true } },
schoolYear: { select: { id: true, label: true } },
},
});
// 4. Reports
const reports = await db.report.findMany({
where: { studentId },
orderBy: { generatedAt: 'desc' },
take: 30,
include: {
classGroup: { select: { id: true, name: true } },
schoolYear: { select: { id: true, label: true } },
generatedByUser: { select: { id: true, firstName: true, lastName: true } },
sections: {
orderBy: { order: 'asc' },
select: { id: true, generatedText: true, order: true },
},
},
});
// 5. Competence flowers — for each class+subject assignment, compute categories
const classIds = student.enrollments.map((e) => e.classGroup.id);
const assignments = classIds.length > 0
? await db.classCompetencyAssignment.findMany({
where: { classGroupId: { in: classIds } },
include: {
subject: { select: { id: true, name: true } },
competencyTemplate: {
include: {
categories: {
orderBy: { order: 'asc' },
select: {
id: true,
name: true,
color: true,
competencies: {
orderBy: { order: 'asc' },
select: { id: true },
},
},
},
},
},
},
})
: [];
const flowers: Array<{
studentId: string;
studentName: string;
subjectId: string;
subjectName: string;
categories: Array<{
categoryId: string;
categoryName: string;
color: string | null;
averageMasteryLevel: number;
competencyCount: number;
assessedCompetencyCount: number;
}>;
}> = [];
const studentName = `${student.firstName} ${student.lastName}`;
for (const assignment of assignments) {
const template = assignment.competencyTemplate;
const templateId = assignment.clonedTemplateId ?? assignment.competencyTemplateId;
let categories = template.categories;
// If cloned, load the cloned template's categories
if (assignment.clonedTemplateId) {
const cloned = await db.competencyTemplate.findUnique({
where: { id: assignment.clonedTemplateId },
include: {
categories: {
orderBy: { order: 'asc' },
select: {
id: true,
name: true,
color: true,
competencies: { orderBy: { order: 'asc' }, select: { id: true } },
},
},
},
});
if (cloned) categories = cloned.categories;
}
// Get latest progress entries per competency for this student/class
const entries = await db.learningProgressEntry.findMany({
where: {
studentId,
classGroupId: assignment.classGroupId,
competency: {
category: { competencyTemplateId: templateId },
},
},
orderBy: { date: 'desc' },
select: {
competencyId: true,
masteryLevelValue: true,
},
});
// Latest value per competency (entries sorted by date desc)
const compMap = new Map<string, number>();
for (const entry of entries) {
if (!compMap.has(entry.competencyId)) {
compMap.set(entry.competencyId, entry.masteryLevelValue);
}
}
const categoryResults = categories.map((cat) => {
const catCompetencyIds = cat.competencies.map((c) => c.id);
const assessedValues = catCompetencyIds
.filter((cid) => compMap.has(cid))
.map((cid) => compMap.get(cid)!);
const avg =
assessedValues.length > 0
? assessedValues.reduce((a, b) => a + b, 0) / assessedValues.length
: 0;
return {
categoryId: cat.id,
categoryName: cat.name,
color: cat.color,
averageMasteryLevel: Math.round(avg * 100) / 100,
competencyCount: catCompetencyIds.length,
assessedCompetencyCount: assessedValues.length,
};
});
flowers.push({
studentId: student.id,
studentName,
subjectId: assignment.subject.id,
subjectName: assignment.subject.name,
categories: categoryResults,
});
}
// 6. Quick stats
const totalProgressEntries = progressEntries.length;
const averageMastery =
totalProgressEntries > 0
? Math.round(
(progressEntries.reduce((sum, e) => sum + e.masteryLevelValue, 0) /
totalProgressEntries) *
100
) / 100
: 0;
const latestGrade =
computedGrades.length > 0
? {
value: computedGrades[0].overriddenValue ?? computedGrades[0].computedValue,
period: computedGrades[0].period,
subjectName: computedGrades[0].subject.name,
}
: null;
return NextResponse.json({
student,
progressEntries,
assessmentResults,
computedGrades,
reports,
flowers,
stats: {
totalProgressEntries,
averageMastery,
latestGrade,
totalReports: reports.length,
totalAssessments: assessmentResults.length,
},
});
} catch (error) {
console.error('Student detail GET error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
+155
View File
@@ -0,0 +1,155 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const studentItemSchema = z.object({
firstName: z.string().min(1),
lastName: z.string().min(1),
dateOfBirth: z.string().optional().nullable(),
externalId: z.string().optional().nullable(),
});
const bulkCreateSchema = z.object({
schoolId: z.string().min(1),
classGroupId: z.string().optional(),
schoolYearId: z.string().optional(),
students: z.array(studentItemSchema).min(1).max(500),
});
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN' &&
session.user?.role !== 'TEACHER'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const parsed = bulkCreateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const { schoolId, classGroupId, schoolYearId, students } = parsed.data;
// Validate class & school year
if (classGroupId) {
const cls = await db.classGroup.findUnique({ where: { id: classGroupId } });
if (!cls) {
return NextResponse.json(
{ error: 'Class not found' },
{ status: 404 }
);
}
if (cls.schoolId !== schoolId) {
return NextResponse.json(
{ error: 'Class does not belong to the specified school' },
{ status: 400 }
);
}
}
if (classGroupId && !schoolYearId) {
return NextResponse.json(
{ error: 'schoolYearId is required when classGroupId is provided' },
{ status: 400 }
);
}
let created = 0;
let enrolled = 0;
const errors: Array<{ row: number; error: string }> = [];
// Process each student one at a time, capturing errors per row
for (let i = 0; i < students.length; i++) {
const s = students[i];
try {
// Use externalId uniqueness if provided, otherwise just create
let student: Awaited<ReturnType<typeof db.student.findFirst>> = null;
if (s.externalId) {
student = await db.student.findFirst({
where: { externalId: s.externalId, schoolId },
});
}
if (!student) {
student = await db.student.create({
data: {
schoolId,
firstName: s.firstName,
lastName: s.lastName,
dateOfBirth: s.dateOfBirth ? new Date(s.dateOfBirth) : null,
externalId: s.externalId ?? null,
},
});
created++;
}
if (classGroupId && schoolYearId) {
// Check existing enrollment before creating
const existing = await db.enrollment.findFirst({
where: {
studentId: student.id,
classGroupId,
endDate: null,
},
});
if (!existing) {
await db.enrollment.create({
data: {
studentId: student.id,
classGroupId,
schoolYearId,
startDate: new Date(),
},
});
enrolled++;
}
}
} catch (err) {
errors.push({
row: i + 1,
error: err instanceof Error ? err.message : 'Unknown error',
});
}
}
// Audit log entry
await db.auditLog.create({
data: {
userId: session.userId,
schoolId,
action: 'CREATE',
entityType: 'Student',
entityId: null,
metadata: JSON.stringify({
bulkImport: true,
classGroupId: classGroupId ?? null,
totalRequested: students.length,
created,
enrolled,
errors: errors.length,
}),
},
});
return NextResponse.json({ created, enrolled, errors }, { status: 201 });
} catch (error) {
console.error('Bulk students POST error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
+70
View File
@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const reorderSchema = z.object({
classGroupId: z.string(),
studentIds: z.array(z.string()).min(1),
});
export async function PUT(req: NextRequest) {
const session = await getSession();
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (session.user.role !== 'TEACHER' && session.user.role !== 'SCHOOL_ADMIN' && session.user.role !== 'SUPER_ADMIN') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
const parsed = reorderSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues.map((i) => i.message).join(', ') }, { status: 400 });
}
const { classGroupId, studentIds } = parsed.data;
// Verify the class belongs to the teacher's school
const classGroup = await db.classGroup.findUnique({
where: { id: classGroupId },
});
if (!classGroup) {
return NextResponse.json({ error: 'Class not found' }, { status: 404 });
}
if (session.user.schoolId && classGroup.schoolId !== session.user.schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
try {
await db.classGroup.update({
where: { id: classGroupId },
data: {
seatingOrder: JSON.stringify(studentIds),
},
});
// Audit log
await db.auditLog.create({
data: {
userId: session.userId,
schoolId: session.user.schoolId,
action: 'REORDER_STUDENTS',
entityType: 'ClassGroup',
entityId: classGroupId,
metadata: JSON.stringify({ studentIds }),
},
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Reorder students error:', error);
return NextResponse.json({ error: 'Failed to reorder students' }, { status: 500 });
}
}
+174
View File
@@ -0,0 +1,174 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const createStudentSchema = z.object({
schoolId: z.string().min(1),
firstName: z.string().min(1),
lastName: z.string().min(1),
dateOfBirth: z
.string()
.optional()
.transform((v) => (v ? new Date(v) : undefined)),
externalId: 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 schoolId = searchParams.get('schoolId');
const classGroupId = searchParams.get('classGroupId');
const search = searchParams.get('search');
const where: Record<string, unknown> = { deletedAt: null };
if (schoolId) where.schoolId = schoolId;
if (classGroupId) {
where.enrollments = {
some: { classGroupId, endDate: null },
};
}
if (search) {
where.OR = [
{ firstName: { contains: search } },
{ lastName: { contains: search } },
{ externalId: { contains: search } },
];
}
const students = await db.student.findMany({
where,
orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }],
include: {
school: { select: { id: true, name: true } },
enrollments: {
where: { endDate: null },
include: {
classGroup: {
select: { id: true, name: true, gradeLevel: true },
},
},
},
_count: {
select: {
learningProgressEntries: true,
assessmentResults: true,
},
},
},
});
return NextResponse.json(students);
} catch (error) {
console.error('Students GET error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN' &&
session.user?.role !== 'TEACHER'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const parsed = createStudentSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const student = await db.student.create({
data: parsed.data,
include: {
school: { select: { id: true, name: true } },
},
});
return NextResponse.json(student, { status: 201 });
} catch (error) {
console.error('Students POST error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
export async function PUT(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN' &&
session.user?.role !== 'TEACHER'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const updateSchema = z.object({
id: z.string().min(1),
firstName: z.string().min(1).optional(),
lastName: z.string().min(1).optional(),
dateOfBirth: z
.string()
.nullable()
.optional()
.transform((v) => (v ? new Date(v) : v === null ? null : undefined)),
externalId: z.string().nullable().optional(),
});
const parsed = updateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const { id, ...updateData } = parsed.data;
const student = await db.student.update({
where: { id },
data: updateData,
include: {
school: { select: { id: true, name: true } },
},
});
return NextResponse.json(student);
} catch (error) {
console.error('Students PUT error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
+202
View File
@@ -0,0 +1,202 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const createSubjectSchema = z.object({
schoolId: z.string().nullable().optional(),
name: z.string().min(1),
gradeLevelMin: z.number().int().default(1),
gradeLevelMax: z.number().int().default(13),
});
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');
// Show global subjects (schoolId=null) and school-specific ones
const where: Record<string, unknown> = {};
if (schoolId) {
where.OR = [
{ schoolId: null },
{ schoolId },
];
}
const subjects = await db.subject.findMany({
where,
orderBy: { name: 'asc' },
});
return NextResponse.json(subjects);
} catch (error) {
console.error('Subjects GET error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const parsed = createSubjectSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const subject = await db.subject.create({
data: {
schoolId: parsed.data.schoolId ?? null,
name: parsed.data.name,
gradeLevelMin: parsed.data.gradeLevelMin,
gradeLevelMax: parsed.data.gradeLevelMax,
},
});
return NextResponse.json(subject, { status: 201 });
} catch (error) {
console.error('Subjects POST error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
const updateSubjectSchema = z.object({
id: z.string().min(1),
name: z.string().min(1).optional(),
gradeLevelMin: z.number().int().optional(),
gradeLevelMax: z.number().int().optional(),
});
export async function PUT(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const parsed = updateSubjectSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const { id, ...updateData } = parsed.data;
const subject = await db.subject.update({
where: { id },
data: updateData,
});
// Create audit log
await db.auditLog.create({
data: {
userId: session.userId,
schoolId: session.user?.schoolId ?? null,
action: 'UPDATE',
entityType: 'Subject',
entityId: id,
metadata: JSON.stringify(updateData),
},
});
return NextResponse.json(subject);
} catch (error) {
console.error('Subjects PUT error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
export async function DELETE(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) {
return NextResponse.json({ error: 'id query parameter is required' }, { status: 400 });
}
// Check if subject is used in any assessments or competency assignments
const usageCount = await db.classCompetencyAssignment.count({
where: { subjectId: id },
});
if (usageCount > 0) {
return NextResponse.json(
{ error: 'Subject is in use and cannot be deleted', usageCount },
{ status: 409 }
);
}
await db.subject.delete({ where: { id } });
// Create audit log
await db.auditLog.create({
data: {
userId: session.userId,
schoolId: session.user?.schoolId ?? null,
action: 'DELETE',
entityType: 'Subject',
entityId: id,
},
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Subjects DELETE error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
+158
View File
@@ -0,0 +1,158 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const noteCategoryEnum = z.enum([
'GENERAL',
'BEHAVIOR',
'ACADEMIC',
'INTERVENTION',
'PARENT_CONTACT',
]);
const updateNoteSchema = z.object({
category: noteCategoryEnum.optional(),
content: z.string().min(1, 'Content cannot be empty').optional(),
isPrivate: z.boolean().optional(),
});
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (session.user?.role !== 'TEACHER' && session.user?.role !== 'SCHOOL_ADMIN' && session.user?.role !== 'SUPER_ADMIN') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const body = await request.json();
const parsed = updateNoteSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const existing = await db.teacherNote.findUnique({
where: { id },
include: { student: { select: { schoolId: true, firstName: true, lastName: true } } },
});
if (!existing) {
return NextResponse.json({ error: 'Note not found' }, { status: 404 });
}
// Only the author (or an admin of the school) can edit
const isAuthor = existing.teacherId === session.userId;
const isSchoolAdmin =
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
existing.student.schoolId === session.user.schoolId;
const isSuperAdmin = session.user?.role === 'SUPER_ADMIN';
if (!isAuthor && !isSchoolAdmin && !isSuperAdmin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const data: Record<string, unknown> = {};
if (parsed.data.category !== undefined) data.category = parsed.data.category;
if (parsed.data.content !== undefined) data.content = parsed.data.content.trim();
if (parsed.data.isPrivate !== undefined) data.isPrivate = parsed.data.isPrivate;
const note = await db.teacherNote.update({
where: { id },
data,
include: {
teacher: { select: { id: true, firstName: true, lastName: true } },
},
});
await db.auditLog.create({
data: {
userId: session.userId,
schoolId: existing.student.schoolId,
action: 'UPDATE',
entityType: 'TeacherNote',
entityId: id,
metadata: JSON.stringify({
studentId: existing.studentId,
studentName: `${existing.student.firstName} ${existing.student.lastName}`,
changes: parsed.data,
}),
},
});
return NextResponse.json(note);
} catch (error) {
console.error('TeacherNote PUT error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (session.user?.role !== 'TEACHER' && session.user?.role !== 'SCHOOL_ADMIN' && session.user?.role !== 'SUPER_ADMIN') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const existing = await db.teacherNote.findUnique({
where: { id },
include: { student: { select: { schoolId: true, firstName: true, lastName: true } } },
});
if (!existing) {
return NextResponse.json({ error: 'Note not found' }, { status: 404 });
}
const isAuthor = existing.teacherId === session.userId;
const isSchoolAdmin =
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
existing.student.schoolId === session.user.schoolId;
const isSuperAdmin = session.user?.role === 'SUPER_ADMIN';
if (!isAuthor && !isSchoolAdmin && !isSuperAdmin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
await db.teacherNote.delete({ where: { id } });
await db.auditLog.create({
data: {
userId: session.userId,
schoolId: existing.student.schoolId,
action: 'DELETE',
entityType: 'TeacherNote',
entityId: id,
metadata: JSON.stringify({
studentId: existing.studentId,
studentName: `${existing.student.firstName} ${existing.student.lastName}`,
category: existing.category,
}),
},
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('TeacherNote DELETE error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+149
View File
@@ -0,0 +1,149 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const noteCategoryEnum = z.enum([
'GENERAL',
'BEHAVIOR',
'ACADEMIC',
'INTERVENTION',
'PARENT_CONTACT',
]);
const createNoteSchema = z.object({
studentId: z.string().min(1),
category: noteCategoryEnum,
content: z.string().min(1, 'Content cannot be empty'),
isPrivate: z.boolean().optional(),
});
export async function GET(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
// Only teachers + admins may view private notes
if (session.user?.role !== 'TEACHER' && session.user?.role !== 'SCHOOL_ADMIN' && session.user?.role !== 'SUPER_ADMIN') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { searchParams } = new URL(request.url);
const studentId = searchParams.get('studentId');
if (!studentId) {
return NextResponse.json(
{ error: 'studentId is required' },
{ status: 400 }
);
}
// Verify the student belongs to a school the user can see
const student = await db.student.findUnique({
where: { id: studentId, deletedAt: null },
select: { id: true, schoolId: true },
});
if (!student) {
return NextResponse.json({ error: 'Student not found' }, { status: 404 });
}
if (
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
student.schoolId !== session.user.schoolId
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const notes = await db.teacherNote.findMany({
where: { studentId },
orderBy: { createdAt: 'desc' },
include: {
teacher: { select: { id: true, firstName: true, lastName: true } },
},
});
return NextResponse.json(notes);
} catch (error) {
console.error('TeacherNotes GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (session.user?.role !== 'TEACHER' && session.user?.role !== 'SCHOOL_ADMIN' && session.user?.role !== 'SUPER_ADMIN') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const parsed = createNoteSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const { studentId, category, content, isPrivate = true } = parsed.data;
// Verify student exists and is in scope
const student = await db.student.findUnique({
where: { id: studentId, deletedAt: null },
select: { id: true, schoolId: true, firstName: true, lastName: true },
});
if (!student) {
return NextResponse.json({ error: 'Student not found' }, { status: 404 });
}
if (
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
student.schoolId !== session.user.schoolId
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const note = await db.teacherNote.create({
data: {
studentId,
teacherId: session.userId,
category,
content: content.trim(),
isPrivate,
},
include: {
teacher: { select: { id: true, firstName: true, lastName: true } },
},
});
await db.auditLog.create({
data: {
userId: session.userId,
schoolId: student.schoolId,
action: 'CREATE',
entityType: 'TeacherNote',
entityId: note.id,
metadata: JSON.stringify({
studentId,
category,
studentName: `${student.firstName} ${student.lastName}`,
}),
},
});
return NextResponse.json(note, { status: 201 });
} catch (error) {
console.error('TeacherNotes POST error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+194
View File
@@ -0,0 +1,194 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const assignClassesSchema = z.object({
classGroupIds: z.array(z.string()),
});
// GET — list all class assignments + available classes for selection
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 });
}
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const user = await db.user.findUnique({
where: { id },
select: { id: true, schoolId: true },
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
if (
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
user.schoolId !== session.user.schoolId
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const assignments = await db.classGroupTeacher.findMany({
where: { userId: id },
include: {
classGroup: {
select: {
id: true,
name: true,
gradeLevel: true,
schoolType: true,
schoolYear: { select: { id: true, label: true } },
},
},
},
});
const availableClasses = user.schoolId
? await db.classGroup.findMany({
where: { schoolId: user.schoolId },
orderBy: { name: 'asc' },
select: {
id: true,
name: true,
gradeLevel: true,
schoolType: true,
schoolYear: { select: { id: true, label: true } },
},
})
: [];
return NextResponse.json({
assignments,
availableClasses,
});
} catch (error) {
console.error('User classes GET error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
// POST — assign teacher to a set of classes (replace all)
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 });
}
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const body = await request.json();
const parsed = assignClassesSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const user = await db.user.findUnique({
where: { id },
select: { id: true, schoolId: true },
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
if (
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
user.schoolId !== session.user.schoolId
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Validate that all class groups belong to the user's school
const targetClassIds = parsed.data.classGroupIds;
if (targetClassIds.length > 0) {
const validClasses = await db.classGroup.findMany({
where: {
id: { in: targetClassIds },
...(user.schoolId ? { schoolId: user.schoolId } : {}),
},
select: { id: true },
});
if (validClasses.length !== targetClassIds.length) {
return NextResponse.json(
{ error: 'Some classes were not found in this school' },
{ status: 400 }
);
}
}
// Replace all existing teacher assignments with the new list.
// Use a transaction to keep this atomic.
await db.$transaction(async (tx) => {
await tx.classGroupTeacher.deleteMany({
where: { userId: id },
});
if (targetClassIds.length > 0) {
await tx.classGroupTeacher.createMany({
data: targetClassIds.map((classGroupId) => ({
classGroupId,
userId: id,
role: 'SUBJECT_TEACHER',
})),
});
}
});
await db.auditLog.create({
data: {
userId: session.userId,
schoolId: user.schoolId,
action: 'UPDATE',
entityType: 'User',
entityId: id,
metadata: JSON.stringify({
assignedClassIds: targetClassIds,
}),
},
});
return NextResponse.json({
success: true,
assigned: targetClassIds.length,
});
} catch (error) {
console.error('User classes POST error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
+250
View File
@@ -0,0 +1,250 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const updateUserSchema = z.object({
firstName: z.string().min(1).optional(),
lastName: z.string().min(1).optional(),
email: z.string().email().optional(),
role: z.enum(['TEACHER', 'SCHOOL_ADMIN', 'SUPER_ADMIN']).optional(),
locale: 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 });
}
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const user = await db.user.findUnique({
where: { id },
include: {
school: { select: { id: true, name: true } },
classGroupTeachers: {
include: {
classGroup: {
select: { id: true, name: true, gradeLevel: true },
},
},
},
_count: {
select: {
classGroupTeachers: true,
learningProgressEntries: true,
assessments: true,
},
},
},
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// School admins can only access users from their own school
if (
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
user.schoolId !== session.user.schoolId
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { passwordHash: _stripped, ...safeUser } = user as Record<string, unknown>;
return NextResponse.json(safeUser);
} catch (error) {
console.error('User GET error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const body = await request.json();
const parsed = updateUserSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const existing = await db.user.findUnique({ where: { id } });
if (!existing) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// School admins can only edit users from their own school
if (
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
existing.schoolId !== session.user.schoolId
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// School admins cannot promote to super admin
if (
session.user?.role === 'SCHOOL_ADMIN' &&
parsed.data.role === 'SUPER_ADMIN'
) {
return NextResponse.json(
{ error: 'Cannot assign super admin role' },
{ status: 403 }
);
}
// Check email uniqueness if changed
if (parsed.data.email && parsed.data.email !== existing.email) {
const emailTaken = await db.user.findUnique({
where: { email: parsed.data.email },
});
if (emailTaken) {
return NextResponse.json(
{ error: 'Email already registered' },
{ status: 409 }
);
}
}
const user = await db.user.update({
where: { id },
data: parsed.data,
include: {
school: { select: { id: true, name: true } },
classGroupTeachers: {
include: {
classGroup: {
select: { id: true, name: true, gradeLevel: true },
},
},
},
},
});
await db.auditLog.create({
data: {
userId: session.userId,
schoolId: user.schoolId,
action: 'UPDATE',
entityType: 'User',
entityId: id,
metadata: JSON.stringify(parsed.data),
},
});
const { passwordHash: _stripped, ...safeUser } = user as Record<string, unknown>;
return NextResponse.json(safeUser);
} catch (error) {
console.error('User PUT error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
// Prevent self-deletion
if (id === session.userId) {
return NextResponse.json(
{ error: 'Cannot delete your own account' },
{ status: 400 }
);
}
const existing = await db.user.findUnique({ where: { id } });
if (!existing) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// School admins can only delete users from their own school
if (
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
existing.schoolId !== session.user.schoolId
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Soft delete by setting deletedAt timestamp
await db.user.update({
where: { id },
data: { deletedAt: new Date() },
});
await db.auditLog.create({
data: {
userId: session.userId,
schoolId: existing.schoolId,
action: 'DELETE',
entityType: 'User',
entityId: id,
metadata: JSON.stringify({
email: existing.email,
firstName: existing.firstName,
lastName: existing.lastName,
}),
},
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('User DELETE error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
+177
View File
@@ -0,0 +1,177 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession, hashPassword } from '@/lib/auth';
const createUserSchema = z.object({
schoolId: z.string().nullable().optional(),
email: z.string().email().min(1),
password: z.string().min(8),
firstName: z.string().min(1),
lastName: z.string().min(1),
role: z.enum(['TEACHER', 'SCHOOL_ADMIN', 'SUPER_ADMIN']).default('TEACHER'),
locale: z.string().default('de'),
});
export async function GET(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { searchParams } = new URL(request.url);
const schoolId = searchParams.get('schoolId');
const where: Record<string, unknown> = { deletedAt: null };
// Non-super admins can only see users from their own school
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId) {
where.schoolId = session.user.schoolId;
} else if (schoolId) {
where.schoolId = schoolId;
}
const users = await db.user.findMany({
where,
orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }],
include: {
school: { select: { id: true, name: true } },
classGroupTeachers: {
include: {
classGroup: {
select: { id: true, name: true, gradeLevel: true },
},
},
},
_count: {
select: {
classGroupTeachers: true,
learningProgressEntries: true,
assessments: true,
},
},
},
});
// Strip password hashes before returning
const safeUsers = users.map(({ passwordHash: _stripped, ...rest }) => rest);
return NextResponse.json(safeUsers);
} catch (error) {
console.error('Users GET error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const parsed = createUserSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const { password, ...userData } = parsed.data;
// School admins can only create users in their own school
if (
session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId &&
userData.schoolId &&
userData.schoolId !== session.user.schoolId
) {
return NextResponse.json(
{ error: 'Cannot create users outside your school' },
{ status: 403 }
);
}
// School admins cannot create super admin accounts
if (
session.user?.role === 'SCHOOL_ADMIN' &&
userData.role === 'SUPER_ADMIN'
) {
return NextResponse.json(
{ error: 'Cannot create super admin accounts' },
{ status: 403 }
);
}
// Check email uniqueness
const existingEmail = await db.user.findUnique({
where: { email: userData.email },
});
if (existingEmail) {
return NextResponse.json(
{ error: 'Email already registered' },
{ status: 409 }
);
}
const passwordHash = await hashPassword(password);
const user = await db.user.create({
data: {
...userData,
passwordHash,
schoolId: userData.schoolId ?? session.user?.schoolId ?? null,
},
include: {
school: { select: { id: true, name: true } },
},
});
// Create audit log entry
await db.auditLog.create({
data: {
userId: session.userId,
schoolId: userData.schoolId ?? session.user?.schoolId ?? null,
action: 'CREATE',
entityType: 'User',
entityId: user.id,
metadata: JSON.stringify({
email: user.email,
role: user.role,
firstName: user.firstName,
lastName: user.lastName,
}),
},
});
// Strip password hash from response
const { passwordHash: _stripped, ...safeUser } = user as Record<string, unknown>;
return NextResponse.json(safeUser, { status: 201 });
} catch (error) {
console.error('Users POST error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
+635
View File
@@ -0,0 +1,635 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-emerald: #10b981;
--color-teal: #14b8a6;
--color-amber: #f59e0b;
}
:root {
--radius: 0.75rem;
--background: oklch(0.99 0.002 155);
--foreground: oklch(0.17 0.01 260);
--card: oklch(1 0 0);
--card-foreground: oklch(0.17 0.01 260);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.17 0.01 260);
--primary: oklch(0.55 0.17 155);
--primary-foreground: oklch(0.98 0.005 155);
--secondary: oklch(0.96 0.01 155);
--secondary-foreground: oklch(0.25 0.02 260);
--muted: oklch(0.96 0.005 155);
--muted-foreground: oklch(0.55 0.02 260);
--accent: oklch(0.96 0.01 155);
--accent-foreground: oklch(0.25 0.02 260);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.93 0.005 155);
--input: oklch(0.93 0.005 155);
--ring: oklch(0.55 0.17 155);
--chart-1: oklch(0.55 0.17 155);
--chart-2: oklch(0.53 0.14 175);
--chart-3: oklch(0.72 0.19 85);
--chart-4: oklch(0.60 0.18 50);
--chart-5: oklch(0.65 0.22 330);
--sidebar: oklch(0.97 0.01 155);
--sidebar-foreground: oklch(0.17 0.01 260);
--sidebar-primary: oklch(0.55 0.17 155);
--sidebar-primary-foreground: oklch(0.98 0.005 155);
--sidebar-accent: oklch(0.94 0.02 155);
--sidebar-accent-foreground: oklch(0.25 0.02 260);
--sidebar-border: oklch(0.93 0.005 155);
--sidebar-ring: oklch(0.55 0.17 155);
}
.dark {
--background: oklch(0.13 0.01 260);
--foreground: oklch(0.98 0.005 155);
--card: oklch(0.18 0.015 260);
--card-foreground: oklch(0.98 0.005 155);
--popover: oklch(0.18 0.015 260);
--popover-foreground: oklch(0.98 0.005 155);
--primary: oklch(0.65 0.17 155);
--primary-foreground: oklch(0.15 0.01 260);
--secondary: oklch(0.24 0.02 260);
--secondary-foreground: oklch(0.98 0.005 155);
--muted: oklch(0.24 0.02 260);
--muted-foreground: oklch(0.65 0.02 260);
--accent: oklch(0.24 0.02 260);
--accent-foreground: oklch(0.98 0.005 155);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.65 0.17 155);
--chart-1: oklch(0.65 0.17 155);
--chart-2: oklch(0.63 0.14 175);
--chart-3: oklch(0.77 0.19 85);
--chart-4: oklch(0.72 0.22 50);
--chart-5: oklch(0.70 0.22 330);
--sidebar: oklch(0.18 0.015 260);
--sidebar-foreground: oklch(0.98 0.005 155);
--sidebar-primary: oklch(0.65 0.17 155);
--sidebar-primary-foreground: oklch(0.15 0.01 260);
--sidebar-accent: oklch(0.24 0.02 260);
--sidebar-accent-foreground: oklch(0.98 0.005 155);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.65 0.17 155);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
/* Custom scrollbar styling */
@layer utilities {
.scrollbar-education::-webkit-scrollbar {
width: 6px;
}
.scrollbar-education::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-education::-webkit-scrollbar-thumb {
background: oklch(0.80 0.02 155);
border-radius: 3px;
}
.scrollbar-education::-webkit-scrollbar-thumb:hover {
background: oklch(0.65 0.17 155);
}
.dark .scrollbar-education::-webkit-scrollbar-thumb {
background: oklch(0.35 0.02 260);
}
.dark .scrollbar-education::-webkit-scrollbar-thumb:hover {
background: oklch(0.65 0.17 155);
}
/* Global scrollbar override */
*::-webkit-scrollbar {
width: 8px;
height: 8px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background: oklch(0.85 0.01 155);
border-radius: 4px;
border: 2px solid transparent;
background-clip: padding-box;
}
*::-webkit-scrollbar-thumb:hover {
background: oklch(0.65 0.17 155);
border: 2px solid transparent;
background-clip: padding-box;
}
.dark *::-webkit-scrollbar-thumb {
background: oklch(0.30 0.015 260);
border: 2px solid transparent;
background-clip: padding-box;
}
.dark *::-webkit-scrollbar-thumb:hover {
background: oklch(0.50 0.10 155);
border: 2px solid transparent;
background-clip: padding-box;
}
/* Dot grid background pattern */
.bg-dots {
background-image: radial-gradient(circle, oklch(0.80 0.02 155) 1px, transparent 1px);
background-size: 24px 24px;
}
.dark .bg-dots {
background-image: radial-gradient(circle, oklch(0.25 0.015 260) 1px, transparent 1px);
background-size: 24px 24px;
}
/* Smooth transitions for all interactive elements */
.interactive-transition {
transition: all 0.2s ease-out;
}
/* Animated gradient background */
.animated-gradient-bg {
background: linear-gradient(135deg, oklch(0.97 0.01 155) 0%, oklch(0.99 0.002 155) 50%, oklch(0.97 0.01 175) 100%);
background-size: 200% 200%;
animation: gradient-shift 8s ease infinite;
}
.dark .animated-gradient-bg {
background: linear-gradient(135deg, oklch(0.15 0.01 260) 0%, oklch(0.13 0.01 260) 50%, oklch(0.15 0.01 155) 100%);
background-size: 200% 200%;
animation: gradient-shift 8s ease infinite;
}
@keyframes gradient-shift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
/* Pulse animation */
@keyframes pulse-soft {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.05); opacity: 0.85; }
}
.animate-pulse-soft {
animation: pulse-soft 2s ease-in-out infinite;
}
/* Shimmer animation for progress bars */
@keyframes shimmer-bar {
0% { transform: translateX(-100%); }
100% { transform: translateX(200%); }
}
.animate-shimmer {
animation: shimmer-bar 2.5s ease-in-out infinite;
}
/* Float animation for geometric shapes */
@keyframes float {
0%, 100% { transform: translateY(0) rotate(0deg); }
33% { transform: translateY(-15px) rotate(5deg); }
66% { transform: translateY(10px) rotate(-3deg); }
}
@keyframes float-reverse {
0%, 100% { transform: translateY(0) rotate(0deg); }
33% { transform: translateY(12px) rotate(-4deg); }
66% { transform: translateY(-10px) rotate(6deg); }
}
@keyframes float-slow {
0%, 100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-20px) rotate(8deg); }
}
.animate-float {
animation: float 6s ease-in-out infinite;
}
.animate-float-reverse {
animation: float-reverse 7s ease-in-out infinite;
}
.animate-float-slow {
animation: float-slow 10s ease-in-out infinite;
}
/* Progress bar animation */
@keyframes progress-fill {
from { width: 0; }
}
.animate-progress-fill {
animation: progress-fill 1s ease-out;
}
/* Print styles — full print stylesheet lives at end of file */
}
/* Focus ring for accessibility */
@layer base {
*:focus-visible {
outline: 2px solid oklch(0.55 0.17 155);
outline-offset: 2px;
border-radius: 4px;
}
*:focus:not(:focus-visible) {
outline: none;
}
/* Make keyboard focus more visible on interactive controls */
button:focus-visible,
a:focus-visible,
[role="button"]:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
[tabindex]:focus-visible {
outline: 2px solid oklch(0.55 0.17 155);
outline-offset: 2px;
box-shadow: 0 0 0 4px oklch(0.55 0.17 155 / 0.18);
transition: box-shadow 0.15s ease-out;
}
.dark button:focus-visible,
.dark a:focus-visible,
.dark [role="button"]:focus-visible,
.dark input:focus-visible,
.dark select:focus-visible,
.dark textarea:focus-visible,
.dark [tabindex]:focus-visible {
outline: 2px solid oklch(0.65 0.17 155);
box-shadow: 0 0 0 4px oklch(0.65 0.17 155 / 0.25);
}
html {
scroll-behavior: smooth;
}
}
/* ─── Styling polish utilities ─────────────────────────────────────────── */
@layer utilities {
/* Subtle paper texture for main backgrounds */
.bg-paper {
background-color: oklch(0.99 0.002 155);
background-image:
radial-gradient(circle at 20% 10%, oklch(0.96 0.015 155 / 0.6) 0%, transparent 40%),
radial-gradient(circle at 80% 80%, oklch(0.96 0.012 175 / 0.5) 0%, transparent 45%),
url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.1 0 0 0 0 0.45 0 0 0 0 0.3 0 0 0 0.025 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
.dark .bg-paper {
background-color: oklch(0.13 0.01 260);
background-image:
radial-gradient(circle at 20% 10%, oklch(0.18 0.02 155 / 0.35) 0%, transparent 40%),
radial-gradient(circle at 80% 80%, oklch(0.18 0.02 175 / 0.3) 0%, transparent 45%),
url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.55 0 0 0 0 0.85 0 0 0 0 0.7 0 0 0 0.04 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
/* Mesh gradient for hero / stats areas */
.bg-mesh {
background-color: oklch(0.99 0.002 155);
background-image:
radial-gradient(at 12% 18%, oklch(0.94 0.04 155 / 0.55) 0px, transparent 50%),
radial-gradient(at 88% 22%, oklch(0.94 0.05 175 / 0.45) 0px, transparent 50%),
radial-gradient(at 22% 88%, oklch(0.95 0.05 85 / 0.4) 0px, transparent 50%),
radial-gradient(at 78% 78%, oklch(0.95 0.05 330 / 0.3) 0px, transparent 50%);
}
.dark .bg-mesh {
background-color: oklch(0.13 0.01 260);
background-image:
radial-gradient(at 12% 18%, oklch(0.20 0.06 155 / 0.35) 0px, transparent 50%),
radial-gradient(at 88% 22%, oklch(0.20 0.06 175 / 0.3) 0px, transparent 50%),
radial-gradient(at 22% 88%, oklch(0.20 0.07 85 / 0.25) 0px, transparent 50%),
radial-gradient(at 78% 78%, oklch(0.20 0.08 330 / 0.2) 0px, transparent 50%);
}
/* Shimmer skeleton loader */
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.shimmer {
background: linear-gradient(
90deg,
oklch(0.92 0.005 155 / 0.4) 0%,
oklch(0.88 0.01 155 / 0.7) 50%,
oklch(0.92 0.005 155 / 0.4) 100%
);
background-size: 200% 100%;
animation: shimmer 1.6s linear infinite;
}
.dark .shimmer {
background: linear-gradient(
90deg,
oklch(0.22 0.015 260 / 0.5) 0%,
oklch(0.28 0.02 260 / 0.8) 50%,
oklch(0.22 0.015 260 / 0.5) 100%
);
background-size: 200% 100%;
animation: shimmer 1.6s linear infinite;
}
/* Subtle inner ring for premium cards */
.ring-inset-soft {
box-shadow: inset 0 0 0 1px oklch(1 0 0 / 0.05), 0 1px 2px oklch(0 0 0 / 0.04);
}
.dark .ring-inset-soft {
box-shadow: inset 0 0 0 1px oklch(1 0 0 / 0.04), 0 1px 2px oklch(0 0 0 / 0.2);
}
/* Document / paper preview look for reports */
.paper-preview {
background: white;
background-image:
linear-gradient(oklch(0.96 0.005 155 / 0.6) 1px, transparent 1px);
background-size: 100% 28px;
box-shadow:
0 1px 0 oklch(0.85 0.005 155),
0 4px 12px oklch(0 0 0 / 0.08),
0 24px 60px oklch(0 0 0 / 0.06);
}
.dark .paper-preview {
background: oklch(0.18 0.015 260);
background-image:
linear-gradient(oklch(0.22 0.015 260 / 0.6) 1px, transparent 1px);
background-size: 100% 28px;
box-shadow:
0 1px 0 oklch(0 0 0 / 0.4),
0 4px 12px oklch(0 0 0 / 0.4),
0 24px 60px oklch(0 0 0 / 0.3);
}
/* Sticky header helper for tables */
.sticky-header th {
position: sticky;
top: 0;
z-index: 5;
background: oklch(0.96 0.005 155);
}
.dark .sticky-header th {
background: oklch(0.20 0.015 260);
}
/* Sparkline path */
.sparkline-path {
stroke-linecap: round;
stroke-linejoin: round;
fill: none;
}
/* Lift on hover for cards */
.lift-on-hover {
transition: transform 0.18s ease, box-shadow 0.18s ease;
}
.lift-on-hover:hover {
transform: translateY(-2px);
box-shadow: 0 12px 28px -10px oklch(0.55 0.17 155 / 0.18);
}
/* Gradient text for highlights */
.text-gradient-emerald {
background: linear-gradient(90deg, oklch(0.55 0.17 155), oklch(0.55 0.14 175));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.text-gradient-amber {
background: linear-gradient(90deg, oklch(0.72 0.19 85), oklch(0.60 0.18 50));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.text-gradient-violet {
background: linear-gradient(90deg, oklch(0.60 0.20 320), oklch(0.65 0.22 280));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.text-gradient-rose {
background: linear-gradient(90deg, oklch(0.65 0.22 15), oklch(0.62 0.20 350));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.text-gradient-teal {
background: linear-gradient(90deg, oklch(0.55 0.14 175), oklch(0.60 0.16 195));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
/* Generic gradient text — direction aware */
.gradient-text {
background: linear-gradient(135deg, oklch(0.55 0.17 155) 0%, oklch(0.55 0.14 175) 50%, oklch(0.65 0.22 330) 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.dark .gradient-text {
background: linear-gradient(135deg, oklch(0.70 0.17 155) 0%, oklch(0.70 0.14 175) 50%, oklch(0.78 0.22 330) 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
/* Frosted glass card — backdrop blur + translucent surface */
.glass-card {
background: oklch(1 0 0 / 0.7);
backdrop-filter: blur(12px) saturate(180%);
-webkit-backdrop-filter: blur(12px) saturate(180%);
border: 1px solid oklch(1 0 0 / 0.4);
box-shadow:
0 4px 16px -4px oklch(0 0 0 / 0.08),
inset 0 0 0 1px oklch(1 0 0 / 0.3);
}
.dark .glass-card {
background: oklch(0.18 0.015 260 / 0.65);
backdrop-filter: blur(12px) saturate(140%);
-webkit-backdrop-filter: blur(12px) saturate(140%);
border: 1px solid oklch(1 0 0 / 0.08);
box-shadow:
0 4px 16px -4px oklch(0 0 0 / 0.4),
inset 0 0 0 1px oklch(1 0 0 / 0.05);
}
.glass-card-strong {
background: oklch(1 0 0 / 0.85);
backdrop-filter: blur(20px) saturate(200%);
-webkit-backdrop-filter: blur(20px) saturate(200%);
border: 1px solid oklch(1 0 0 / 0.5);
}
.dark .glass-card-strong {
background: oklch(0.18 0.015 260 / 0.8);
backdrop-filter: blur(20px) saturate(160%);
-webkit-backdrop-filter: blur(20px) saturate(160%);
border: 1px solid oklch(1 0 0 / 0.1);
}
/* Animated count-up number */
@keyframes count-up {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-count-up {
animation: count-up 0.5s ease-out;
}
/* Decorative dotted/grid background pattern for headers */
.bg-pattern-dots {
background-image: radial-gradient(circle, oklch(0.55 0.17 155 / 0.18) 1px, transparent 1px);
background-size: 16px 16px;
}
.dark .bg-pattern-dots {
background-image: radial-gradient(circle, oklch(0.65 0.17 155 / 0.18) 1px, transparent 1px);
background-size: 16px 16px;
}
.bg-pattern-grid {
background-image:
linear-gradient(oklch(0.55 0.17 155 / 0.06) 1px, transparent 1px),
linear-gradient(90deg, oklch(0.55 0.17 155 / 0.06) 1px, transparent 1px);
background-size: 24px 24px;
}
.dark .bg-pattern-grid {
background-image:
linear-gradient(oklch(0.65 0.17 155 / 0.08) 1px, transparent 1px),
linear-gradient(90deg, oklch(0.65 0.17 155 / 0.08) 1px, transparent 1px);
background-size: 24px 24px;
}
/* Soft glow utility for highlighted elements */
.glow-emerald {
box-shadow: 0 0 0 1px oklch(0.55 0.17 155 / 0.15), 0 8px 24px -6px oklch(0.55 0.17 155 / 0.35);
}
.dark .glow-emerald {
box-shadow: 0 0 0 1px oklch(0.65 0.17 155 / 0.25), 0 8px 24px -6px oklch(0.65 0.17 155 / 0.45);
}
/* Subtle top-border accent for chart cards */
.accent-top-emerald { border-top: 3px solid oklch(0.55 0.17 155); }
.accent-top-teal { border-top: 3px solid oklch(0.55 0.14 175); }
.accent-top-amber { border-top: 3px solid oklch(0.72 0.19 85); }
.accent-top-violet { border-top: 3px solid oklch(0.60 0.20 320); }
.accent-top-rose { border-top: 3px solid oklch(0.65 0.22 15); }
/* Subtle entrance animation for staggered items */
@keyframes enter-fade {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-enter-fade {
animation: enter-fade 0.35s ease-out backwards;
}
/* ─── Styling polish additions ──────────────────────────────────── */
/* Smooth page transition animation (fadeIn for view changes) */
@keyframes view-fade-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-view-fade-in {
animation: view-fade-in 0.3s ease-out;
}
/* Hover lift effect for cards */
.card-hover-lift {
transition: transform 0.2s ease-out, box-shadow 0.2s ease-out;
}
.card-hover-lift:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px -6px oklch(0.55 0.17 155 / 0.15);
}
.dark .card-hover-lift:hover {
box-shadow: 0 8px 24px -6px oklch(0.65 0.17 155 / 0.25);
}
/* Focus-within ring animation for inputs */
.input-focus-ring {
transition: box-shadow 0.2s ease-out;
}
.input-focus-ring:focus-within {
box-shadow: 0 0 0 3px oklch(0.55 0.17 155 / 0.2);
}
.dark .input-focus-ring:focus-within {
box-shadow: 0 0 0 3px oklch(0.65 0.17 155 / 0.3);
}
/* Tooltip animation (scale from 0.95 to 1) */
@keyframes tooltip-enter {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
.animate-tooltip-enter {
animation: tooltip-enter 0.15s ease-out;
}
}
/* Print-specific styling — expands to all reports when printing */
@media print {
.no-print { display: none !important; }
.print-only { display: block !important; }
body { background: white !important; color: black !important; }
/* Hide sidebar, header, navigation */
[data-sidebar], header, nav, .no-print { display: none !important; }
/* Make main content full width */
main, .print-area { width: 100% !important; margin: 0 !important; padding: 1rem !important; }
/* Ensure cards have borders for print */
.card, [class*="Card"] { border: 1px solid #e5e7eb !important; break-inside: avoid; }
/* Ensure text is readable */
body { color: #000 !important; background: #fff !important; }
/* Page breaks */
.page-break { page-break-before: always; }
.print-break-before { page-break-before: always; }
.print-avoid-break { page-break-inside: avoid; }
.shadow-sm, .shadow-md, .shadow-lg, .shadow-xl { box-shadow: none !important; }
.paper-preview { box-shadow: none !important; }
@page { margin: 18mm 16mm; }
/* Better margins for print */
.card-hover-lift:hover { transform: none !important; box-shadow: none !important; }
/* Hide interactive elements */
button, .no-print, [role="button"], nav, aside, footer, header,
.sidebar, .sidebar-trigger, .breadcrumb { display: none !important; }
/* Ensure readable text */
.dark { background: white !important; color: black !important; }
.dark * { color: inherit !important; background: transparent !important; }
/* Prevent overflow */
.overflow-y-auto, .max-h-96, .max-h-80, .max-h-72 { max-height: none !important; overflow: visible !important; }
}
+49
View File
@@ -0,0 +1,49 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "next-themes";
import { Toaster } from "@/components/ui/sonner";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "CompetenceTrack — Free Competency & Grading Platform",
description: "Open-Source School Software for Competency-Based Assessment and Grading. Built with Next.js, TypeScript, and shadcn/ui.",
keywords: ["CompetenceTrack", "competency", "grading", "school", "education", "Next.js", "TypeScript", "shadcn/ui"],
authors: [{ name: "CompetenceTrack Team" }],
icons: {
icon: "https://z-cdn.chatglm.cn/z-ai/static/logo.svg",
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="de" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground`}
>
<ThemeProvider
attribute="class"
defaultTheme="light"
enableSystem
disableTransitionOnChange
>
{children}
<Toaster position="top-right" richColors />
</ThemeProvider>
</body>
</html>
);
}
+50
View File
@@ -0,0 +1,50 @@
'use client';
import React, { useEffect } from 'react';
import { useAppStore } from '@/lib/store';
import { fetchCurrentUser } from '@/lib/api';
import AuthView from '@/components/auth-view';
import AppLayout from '@/components/app-layout';
export default function Home() {
const currentUser = useAppStore((s) => s.currentUser);
const isLoadingAuth = useAppStore((s) => s.isLoadingAuth);
const setCurrentUser = useAppStore((s) => s.setCurrentUser);
useEffect(() => {
async function checkAuth() {
try {
const user = await fetchCurrentUser();
if (user && !user.error) {
setCurrentUser(user);
// Sync locale from user preference
if (user.locale) {
useAppStore.getState().setLocale(user.locale);
}
} else {
setCurrentUser(null);
}
} catch {
setCurrentUser(null);
}
}
checkAuth();
}, []);
if (isLoadingAuth) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-emerald-50 via-white to-teal-50 dark:from-gray-950 dark:via-gray-900 dark:to-emerald-950">
<div className="flex items-center gap-3">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
<span className="text-gray-500 dark:text-gray-400">Loading...</span>
</div>
</div>
);
}
if (!currentUser) {
return <AuthView />;
}
return <AppLayout />;
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+798
View File
@@ -0,0 +1,798 @@
'use client';
import React, { useEffect, useState, useMemo } from 'react';
import { motion } from 'framer-motion';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Cell, ResponsiveContainer, ReferenceLine, Tooltip as RechartsTooltip,
} from 'recharts';
import {
ClipboardCheck, Plus, PenLine, BookOpen, FileText, Mic, FolderOpen, Home,
Download, Trophy, Heart, BarChart3, TrendingUp, Sparkles,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import {
fetchClasses, fetchClassStudents, fetchSubjects,
fetchAssessments, createAssessment,
fetchAssessmentResults, createAssessmentResults,
fetchClassCompetencyAssignments, fetchCompetencyTemplate, downloadCsvExport, addNotification,
type ClassGroup, type Student, type Subject,
type Assessment, type AssessmentResult,
type ClassCompetencyAssignment, type CompetencyTemplate,
} from '@/lib/api';
import { toast } from 'sonner';
function hashStr(s: string): number {
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
return Math.abs(h);
}
// Pseudo class average score for an assessment (deterministic, in range 60-90% of maxScore)
function pseudoClassAvg(a: Assessment): number | null {
if (!a.maxScore) return null;
const pct = 0.6 + (hashStr(a.id) % 30) / 100; // 0.6 - 0.9
return Math.round(a.maxScore * pct * 10) / 10;
}
// Pseudo score distribution across 5 buckets (0-20%, 20-40%, ..., 80-100%)
function pseudoScoreDistribution(a: Assessment): number[] {
const h = hashStr(a.id);
const buckets = [
1 + (h % 4),
2 + ((h >> 3) % 5),
4 + ((h >> 6) % 6),
3 + ((h >> 9) % 5),
1 + ((h >> 12) % 4),
];
return buckets;
}
// Compute best performer & needs support from entered scores
function useBestAndNeedsSupport(
students: Student[],
gradingScores: Record<string, { score: string; masteryLevel: string; note: string }>,
maxScore: number | null
) {
return useMemo(() => {
if (!maxScore) return { bestPerformerId: null, needsSupportId: null };
let bestId: string | null = null;
let bestRatio = -1;
let lowId: string | null = null;
let lowRatio = 2;
for (const s of students) {
const raw = gradingScores[s.id]?.score;
if (!raw) continue;
const v = parseFloat(raw);
if (isNaN(v)) continue;
const r = v / maxScore;
if (r > bestRatio) { bestRatio = r; bestId = s.id; }
if (r < lowRatio) { lowRatio = r; lowId = s.id; }
}
if (bestId && lowId && bestId === lowId) return { bestPerformerId: bestId, needsSupportId: null };
return { bestPerformerId: bestId, needsSupportId: lowId };
}, [students, gradingScores, maxScore]);
}
const assessmentTypes = ['TEST', 'ORAL', 'PROJECT', 'HOMEWORK', 'OTHER'];
const typeConfig: Record<string, { icon: React.ElementType; color: string; bg: string; border: string; gradient: string }> = {
TEST: { icon: FileText, color: 'text-emerald-700 dark:text-emerald-300', bg: 'bg-emerald-100 dark:bg-emerald-900/30', border: 'border-l-emerald-500', gradient: 'from-emerald-500 to-teal-500' },
ORAL: { icon: Mic, color: 'text-amber-700 dark:text-amber-300', bg: 'bg-amber-100 dark:bg-amber-900/30', border: 'border-l-amber-500', gradient: 'from-amber-500 to-orange-500' },
PROJECT: { icon: FolderOpen, color: 'text-violet-700 dark:text-violet-300', bg: 'bg-violet-100 dark:bg-violet-900/30', border: 'border-l-violet-500', gradient: 'from-violet-500 to-purple-500' },
HOMEWORK: { icon: Home, color: 'text-teal-700 dark:text-teal-300', bg: 'bg-teal-100 dark:bg-teal-900/30', border: 'border-l-teal-500', gradient: 'from-teal-500 to-cyan-500' },
OTHER: { icon: ClipboardCheck, color: 'text-gray-700 dark:text-gray-300', bg: 'bg-gray-100 dark:bg-gray-900/30', border: 'border-l-gray-500', gradient: 'from-gray-500 to-gray-600' },
};
const relativeDate = (dateStr: string) => {
const now = new Date();
const date = new Date(dateStr);
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return t('date.today');
if (diffDays === 1) return t('date.yesterday');
if (diffDays < 7) return t('date.days_ago', { count: diffDays });
if (diffDays < 30) return t('date.weeks_ago', { count: Math.floor(diffDays / 7) });
return date.toLocaleDateString();
};
const resultCountBadge = (count: number) => {
if (count === 0) return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300';
if (count < 5) return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300';
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300';
};
// Emerald gradient colors for score distribution bars
const EMERALD_BAR_COLORS = [
'#a7f3d0', // emerald-200
'#6ee7b7', // emerald-300
'#34d399', // emerald-400
'#10b981', // emerald-500
'#059669', // emerald-600
];
function ScoreDistributionHistogram({ assessment, results }: { assessment: Assessment; results: AssessmentResult[] }) {
const maxScore = assessment.maxScore;
if (!maxScore || results.length === 0) return null;
// Extract numeric scores
const scores = results.map((r) => r.score).filter((s): s is number => s !== null && s !== undefined);
if (scores.length === 0) return null;
// Create dynamic bucket ranges based on maxScore
const bucketCount = Math.min(Math.max(Math.ceil(maxScore / 5), 4), 8);
const bucketSize = maxScore / bucketCount;
const buckets = Array.from({ length: bucketCount }, (_, i) => {
const low = Math.round(i * bucketSize * 10) / 10;
const high = Math.round((i + 1) * bucketSize * 10) / 10;
return { range: `${low}-${high}`, low, high, count: 0 };
});
for (const s of scores) {
const idx = Math.min(Math.floor(s / bucketSize), bucketCount - 1);
buckets[idx].count++;
}
const avg = scores.reduce((a, b) => a + b, 0) / scores.length;
const sorted = [...scores].sort((a, b) => a - b);
const median = sorted.length % 2 === 0
? (sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2
: sorted[Math.floor(sorted.length / 2)];
const min = sorted[0];
const max = sorted[sorted.length - 1];
return (
<div className="mt-4 p-5 rounded-xl bg-gradient-to-r from-emerald-50/60 to-teal-50/30 dark:from-emerald-900/10 dark:to-teal-900/5 border border-emerald-200/30 dark:border-emerald-900/20 shadow-sm">
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
<p className="text-xs font-semibold text-emerald-600/60 dark:text-emerald-400/40 uppercase tracking-wider flex items-center gap-1.5">
<BarChart3 className="h-3.5 w-3.5" />
{t('assessments.score_distribution')}
</p>
<p className="text-xs text-emerald-600/70 dark:text-emerald-400/50">
{t('assessments.average')}: <strong>{avg.toFixed(1)}</strong> · {t('assessments.median')}: <strong>{median.toFixed(1)}</strong> · {t('assessments.range')}: <strong>{min.toFixed(0)}-{max.toFixed(0)}</strong>
</p>
</div>
<div className="w-full h-44 sm:h-52">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={buckets} margin={{ top: 5, right: 5, bottom: 5, left: -10 }}>
<defs>
<linearGradient id="scoreBarGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#10b981" stopOpacity={0.9} />
<stop offset="100%" stopColor="#14b8a6" stopOpacity={0.6} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="oklch(0.90 0.01 155)" className="dark:stroke-gray-700/30" vertical={false} />
<XAxis
dataKey="range"
tick={{ fontSize: 10, fill: '#6b7280' }}
axisLine={{ stroke: '#d1d5db' }}
tickLine={{ stroke: '#d1d5db' }}
/>
<YAxis
tick={{ fontSize: 10, fill: '#6b7280' }}
axisLine={{ stroke: '#d1d5db' }}
tickLine={{ stroke: '#d1d5db' }}
allowDecimals={false}
/>
<RechartsTooltip
contentStyle={{
borderRadius: '12px',
border: '1px solid #e5e7eb',
fontSize: '12px',
backgroundColor: 'rgba(255,255,255,0.95)',
boxShadow: '0 4px 12px rgba(0,0,0,0.08)',
}}
formatter={(value: number) => [value, t('assessments.score_distribution')]}
/>
<Bar dataKey="count" radius={[4, 4, 0, 0]} maxBarSize={40}>
{buckets.map((_, index) => (
<Cell key={index} fill="url(#scoreBarGradient)" />
))}
</Bar>
<ReferenceLine
x={avg}
stroke="#10b981"
strokeWidth={2}
strokeDasharray="6 3"
label={{
value: `Ø ${avg.toFixed(1)}`,
position: 'top',
fill: '#10b981',
fontSize: 10,
fontWeight: 600,
}}
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
};
export default function AssessmentsView() {
const currentUser = useAppStore((s) => s.currentUser);
const currentClassId = useAppStore((s) => s.currentClassId);
const [classes, setClasses] = useState<ClassGroup[]>([]);
const [selectedClass, setSelectedClass] = useState<ClassGroup | null>(null);
const [subjects, setSubjects] = useState<Subject[]>([]);
const [selectedSubjectId, setSelectedSubjectId] = useState<string>('');
const [assessments, setAssessments] = useState<Assessment[]>([]);
const [students, setStudents] = useState<Student[]>([]);
const [loading, setLoading] = useState(true);
// Create dialog
const [createOpen, setCreateOpen] = useState(false);
const [newTitle, setNewTitle] = useState('');
const [newDate, setNewDate] = useState(new Date().toISOString().split('T')[0]);
const [newType, setNewType] = useState('TEST');
const [newMaxScore, setNewMaxScore] = useState('');
const [newWeight, setNewWeight] = useState('1.0');
const [creating, setCreating] = useState(false);
// Grading dialog
const [gradingAssessment, setGradingAssessment] = useState<Assessment | null>(null);
const [gradingResults, setGradingResults] = useState<AssessmentResult[]>([]);
const [gradingScores, setGradingScores] = useState<Record<string, { score: string; masteryLevel: string; note: string }>>({});
const [gradingOpen, setGradingOpen] = useState(false);
const [savingScores, setSavingScores] = useState(false);
// Best performer & needs support in the grading grid
const { bestPerformerId, needsSupportId } = useBestAndNeedsSupport(
students,
gradingScores,
gradingAssessment?.maxScore ?? null
);
useEffect(() => {
async function load() {
try {
const [cls, subs] = await Promise.all([
fetchClasses(currentUser?.schoolId ?? undefined),
fetchSubjects(currentUser?.schoolId ?? undefined),
]);
setClasses(cls);
setSubjects(subs);
if (currentClassId) {
const found = cls.find((c) => c.id === currentClassId);
if (found) handleSelectClass(found);
}
} catch {
// ignore
} finally {
setLoading(false);
}
}
load();
}, [currentUser?.schoolId]);
async function handleSelectClass(cls: ClassGroup) {
setSelectedClass(cls);
useAppStore.getState().setCurrentClass(cls.id);
try {
const [s] = await Promise.all([
fetchClassStudents(cls.id),
]);
setStudents(s);
if (selectedSubjectId) loadAssessments(cls.id, selectedSubjectId);
} catch {
// ignore
}
}
async function loadAssessments(classGroupId: string, subjectId: string) {
try {
const a = await fetchAssessments({ classGroupId, subjectId });
setAssessments(a);
} catch {
setAssessments([]);
}
}
const handleSelectSubject = async (subjectId: string) => {
setSelectedSubjectId(subjectId);
if (selectedClass) {
await loadAssessments(selectedClass.id, subjectId);
}
};
const handleCreateAssessment = async () => {
if (!selectedClass || !currentUser || !selectedSubjectId) return;
setCreating(true);
try {
await createAssessment({
classGroupId: selectedClass.id,
subjectId: selectedSubjectId,
teacherId: currentUser.id,
title: newTitle,
date: newDate,
type: newType,
maxScore: newMaxScore ? parseFloat(newMaxScore) : null,
weight: parseFloat(newWeight),
});
toast.success(t('toast.created'));
addNotification({
type: 'assessment',
message: `${t('notification.assessment_created')}: ${newTitle}`,
timestamp: new Date().toISOString(),
});
setCreateOpen(false);
setNewTitle('');
setNewDate(new Date().toISOString().split('T')[0]);
setNewType('TEST');
setNewMaxScore('');
setNewWeight('1.0');
loadAssessments(selectedClass.id, selectedSubjectId);
} catch (err) {
toast.error(err instanceof Error ? err.message : t('error.generic'));
} finally {
setCreating(false);
}
};
const openGrading = async (assessment: Assessment) => {
setGradingAssessment(assessment);
setGradingOpen(true);
try {
const results = await fetchAssessmentResults(assessment.id);
setGradingResults(results);
const scoresMap: Record<string, { score: string; masteryLevel: string; note: string }> = {};
for (const student of students) {
const existing = results.find((r) => r.studentId === student.id);
scoresMap[student.id] = {
score: existing?.score != null ? String(existing.score) : '',
masteryLevel: existing?.masteryLevelValue != null ? String(existing.masteryLevelValue) : '',
note: existing?.note ?? '',
};
}
setGradingScores(scoresMap);
} catch {
setGradingResults([]);
}
};
const handleSaveScores = async () => {
if (!gradingAssessment) return;
setSavingScores(true);
try {
const data = students.map((s) => ({
studentId: s.id,
score: gradingScores[s.id]?.score ? parseFloat(gradingScores[s.id].score) : null,
masteryLevelValue: gradingScores[s.id]?.masteryLevel ? parseInt(gradingScores[s.id].masteryLevel) : null,
note: gradingScores[s.id]?.note || undefined,
})).filter((d) => d.score !== null || d.masteryLevelValue !== null);
if (data.length > 0) {
await createAssessmentResults(gradingAssessment.id, data);
}
toast.success(t('assessments.scores_saved'));
setGradingOpen(false);
if (selectedClass && selectedSubjectId) {
loadAssessments(selectedClass.id, selectedSubjectId);
}
} catch (err) {
toast.error(err instanceof Error ? err.message : t('error.generic'));
} finally {
setSavingScores(false);
}
};
if (loading) {
return <div className="space-y-4"><Skeleton className="h-96 rounded-xl" /></div>;
}
return (
<div className="space-y-6">
{/* Selection header */}
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
<CardContent className="p-6">
<div className="flex flex-col sm:flex-row flex-wrap gap-4 items-end">
<div className="space-y-1 min-w-[160px] sm:min-w-[200px]">
<Label className="text-sm font-medium text-emerald-600/60 dark:text-emerald-400/40">{t('polish.label_class')}</Label>
<Select
value={selectedClass?.id ?? ''}
onValueChange={(id) => {
const cls = classes.find((c) => c.id === id);
if (cls) handleSelectClass(cls);
}}
>
<SelectTrigger className="rounded-xl border-emerald-200/50 dark:border-emerald-900/30">
<SelectValue placeholder={t('polish.please_choose')} />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedClass && (
<div className="space-y-1 min-w-[160px] sm:min-w-[200px]">
<Label className="text-sm font-medium text-emerald-600/60 dark:text-emerald-400/40">{t('assessments.select_subject')}</Label>
<Select value={selectedSubjectId} onValueChange={handleSelectSubject}>
<SelectTrigger className="rounded-xl border-emerald-200/50 dark:border-emerald-900/30">
<SelectValue placeholder={t('assessments.select_subject')} />
</SelectTrigger>
<SelectContent>
{subjects.map((s) => (
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{selectedClass && selectedSubjectId && (
<Button className="bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white shadow-md shadow-emerald-300/20 rounded-xl" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
{t('assessments.create')}
</Button>
)}
</div>
</CardContent>
</Card>
{/* Assessments list */}
{!selectedClass || !selectedSubjectId ? (
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.4 }}>
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
<CardContent className="py-16 text-center">
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.1, duration: 0.3 }}
className="flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-emerald-100 to-teal-100 dark:from-emerald-900/30 dark:to-teal-900/30 mx-auto mb-5 shadow-lg shadow-emerald-200/50 dark:shadow-emerald-900/30 ring-2 ring-emerald-200/30 dark:ring-emerald-800/20"
>
<ClipboardCheck className="h-10 w-10 text-emerald-500 dark:text-emerald-400" />
</motion.div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">{t('polish.empty_title_assessments')}</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto leading-relaxed">{t('polish.empty_subtitle_assessments')}</p>
<div className="mt-4 inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-emerald-50/60 dark:bg-emerald-900/20 border border-emerald-200/40 dark:border-emerald-900/30 text-xs text-emerald-700 dark:text-emerald-300">
<Sparkles className="h-3.5 w-3.5" />
{t('assessments.select_subject')}
</div>
</CardContent>
</Card>
</motion.div>
) : assessments.length === 0 ? (
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.4 }}>
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
<CardContent className="py-16 text-center">
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.1, duration: 0.3 }}
className="flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-emerald-100 to-teal-100 dark:from-emerald-900/30 dark:to-teal-900/30 mx-auto mb-5 shadow-lg shadow-emerald-200/50 dark:shadow-emerald-900/30 ring-2 ring-emerald-200/30 dark:ring-emerald-800/20"
>
<ClipboardCheck className="h-10 w-10 text-emerald-500 dark:text-emerald-400" />
</motion.div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">{t('polish.empty_title_no_data')}</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto leading-relaxed">{t('assessments.no_assessments')}</p>
<Button className="mt-5 bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white rounded-xl shadow-md shadow-emerald-300/20" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
{t('assessments.create')}
</Button>
</CardContent>
</Card>
</motion.div>
) : (
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
<CardTitle className="text-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
<ClipboardCheck className="h-4 w-4" />
</div>
{t('assessments.list')}
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 text-xs font-medium rounded-xl">{assessments.length}</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<div className="max-h-[70vh] overflow-y-auto scrollbar-education">
<Table>
<TableHeader>
<TableRow className="border-b border-emerald-200/30 dark:border-emerald-900/20">
<TableHead className="text-xs font-semibold uppercase tracking-wider text-emerald-600/60 dark:text-emerald-400/40">{t('label.name')}</TableHead>
<TableHead className="text-xs font-semibold uppercase tracking-wider text-emerald-600/60 dark:text-emerald-400/40">{t('assessments.assessment_type')}</TableHead>
<TableHead className="text-xs font-semibold uppercase tracking-wider text-emerald-600/60 dark:text-emerald-400/40">{t('label.date')}</TableHead>
<TableHead className="hidden sm:table-cell text-xs font-semibold uppercase tracking-wider text-emerald-600/60 dark:text-emerald-400/40">{t('assessments.max_score')}</TableHead>
<TableHead className="hidden sm:table-cell text-xs font-semibold uppercase tracking-wider text-emerald-600/60 dark:text-emerald-400/40">{t('assessments.weight')}</TableHead>
<TableHead className="text-xs font-semibold uppercase tracking-wider text-emerald-600/60 dark:text-emerald-400/40">{t('assessments.results_count')}</TableHead>
<TableHead className="hidden lg:table-cell text-xs font-semibold uppercase tracking-wider text-emerald-600/60 dark:text-emerald-400/40">{t('polish.score_distribution')}</TableHead>
<TableHead className="text-right text-xs font-semibold uppercase tracking-wider text-emerald-600/60 dark:text-emerald-400/40">{t('assessments.enter_scores')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assessments.map((a) => {
const tc = typeConfig[a.type] ?? typeConfig.OTHER;
const TypeIcon = tc.icon;
const classAvg = pseudoClassAvg(a);
const dist = pseudoScoreDistribution(a);
const distMax = Math.max(...dist, 1);
return (
<TableRow key={a.id} className={`hover:bg-emerald-50/50 dark:hover:bg-emerald-900/10 transition-all duration-200 hover:shadow-md hover:-translate-y-0.5 ${a._count.assessmentResults % 2 === 1 ? 'bg-emerald-50/15 dark:bg-emerald-900/5' : ''}`}>
<TableCell className="font-semibold">
<div className="flex items-center gap-2">
<div className={`flex items-center justify-center w-7 h-7 rounded-lg bg-gradient-to-br ${tc.gradient} text-white shadow-sm shrink-0`}>
<TypeIcon className="w-3.5 h-3.5" />
</div>
<span>{a.title}</span>
</div>
</TableCell>
<TableCell>
<Badge className={`${tc.bg} ${tc.color} text-xs font-medium rounded-xl border-l-2 ${tc.border}`}>
<tc.icon className="mr-1 w-3.5 h-3.5 inline" />
{t(`assessment_type.${a.type.toLowerCase()}`)}
</Badge>
</TableCell>
<TableCell>
<div className="text-sm text-gray-700 dark:text-gray-300">
{relativeDate(a.date)}
</div>
<div className="text-xs text-gray-400">
{new Date(a.date).toLocaleDateString()}
</div>
</TableCell>
<TableCell className="hidden sm:table-cell text-gray-500">
{a.maxScore ?? '—'}
</TableCell>
<TableCell className="hidden sm:table-cell text-gray-500">
{a.weight}
</TableCell>
<TableCell>
<div className="flex flex-col gap-1">
<Badge className={`${resultCountBadge(a._count.assessmentResults)} text-xs font-semibold rounded-xl w-fit`}>
{a._count.assessmentResults}
</Badge>
{classAvg !== null && (
<span className="text-[10px] text-gray-500 dark:text-gray-400 flex items-center gap-1">
<TrendingUp className="h-2.5 w-2.5 text-emerald-500" />
Ø {classAvg}
</span>
)}
</div>
</TableCell>
<TableCell className="hidden lg:table-cell">
<div className="flex items-end gap-0.5 h-8 w-24">
{dist.map((v, i) => (
<div
key={i}
className={`flex-1 rounded-t-sm transition-all hover:brightness-110 ${
i === 0 ? 'bg-red-400 dark:bg-red-500'
: i === 1 ? 'bg-amber-400 dark:bg-amber-500'
: i === 2 ? 'bg-amber-400 dark:bg-amber-500'
: i === 3 ? 'bg-emerald-400 dark:bg-emerald-500'
: 'bg-teal-400 dark:bg-teal-500'
}`}
style={{ height: `${(v / distMax) * 100}%` }}
title={`${i * 20}-${(i + 1) * 20}%: ${v}`}
/>
))}
</div>
</TableCell>
<TableCell className="text-right">
<Button size="sm" variant="outline" className="rounded-xl border-emerald-300 dark:border-emerald-700" onClick={() => openGrading(a)}>
<PenLine className="h-3 w-3 mr-1" />
{t('assessments.enter_scores')}
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}
{/* Create assessment dialog */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="rounded-xl">
<DialogHeader>
<DialogTitle className="text-lg font-bold flex items-center gap-2">
<div className={`flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br ${typeConfig[newType]?.gradient ?? 'from-emerald-500 to-teal-500'} text-white shadow-sm`}>
{(() => { const Icon = typeConfig[newType]?.icon ?? ClipboardCheck; return <Icon className="h-4 w-4" />; })()}
</div>
{t('assessments.create_title')}
</DialogTitle>
</DialogHeader>
{/* Step indicator */}
<div className="flex items-center gap-2 px-1">
<div className="flex items-center gap-1.5">
<div className="w-6 h-6 rounded-full bg-emerald-500 text-white flex items-center justify-center text-[10px] font-bold shadow-sm">1</div>
<span className="text-[10px] font-medium text-emerald-600 dark:text-emerald-400">{t('label.name')}</span>
</div>
<div className="flex-1 h-0.5 bg-gray-200 dark:bg-gray-700 rounded-full" />
<div className="flex items-center gap-1.5">
<div className="w-6 h-6 rounded-full bg-emerald-500 text-white flex items-center justify-center text-[10px] font-bold shadow-sm">2</div>
<span className="text-[10px] font-medium text-emerald-600 dark:text-emerald-400">{t('assessments.assessment_type')}</span>
</div>
<div className="flex-1 h-0.5 bg-gray-200 dark:bg-gray-700 rounded-full" />
<div className="flex items-center gap-1.5">
<div className="w-6 h-6 rounded-full bg-emerald-500 text-white flex items-center justify-center text-[10px] font-bold shadow-sm">3</div>
<span className="text-[10px] font-medium text-emerald-600 dark:text-emerald-400">{t('assessments.max_score')}</span>
</div>
</div>
<div className="space-y-5 pt-2">
<div className="space-y-2">
<Label className="text-sm font-medium">{t('label.name')}</Label>
<Input value={newTitle} onChange={(e) => setNewTitle(e.target.value)} placeholder={t('assessments.create_title')} className="rounded-xl border-emerald-200/50 dark:border-emerald-900/30" />
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">{t('assessments.assessment_type')}</Label>
<Select value={newType} onValueChange={setNewType}>
<SelectTrigger className="rounded-xl">
<SelectValue />
</SelectTrigger>
<SelectContent>
{assessmentTypes.map((at) => {
const tc = typeConfig[at] ?? typeConfig.OTHER;
return (
<SelectItem key={at} value={at}>
<tc.icon className="mr-1 w-3.5 h-3.5 inline" /> {t(`assessment_type.${at.toLowerCase()}`)}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">{t('label.date')}</Label>
<Input type="date" value={newDate} onChange={(e) => setNewDate(e.target.value)} className="rounded-xl border-emerald-200/50 dark:border-emerald-900/30" />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-sm font-medium">{t('assessments.max_score')}</Label>
<Input type="number" value={newMaxScore} onChange={(e) => setNewMaxScore(e.target.value)} className="rounded-xl border-emerald-200/50 dark:border-emerald-900/30" />
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">{t('assessments.weight')}</Label>
<Input type="number" value={newWeight} onChange={(e) => setNewWeight(e.target.value)} className="rounded-xl border-emerald-200/50 dark:border-emerald-900/30" />
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)} className="rounded-xl">{t('action.cancel')}</Button>
<Button className="bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white rounded-xl shadow-md shadow-emerald-300/20" onClick={handleCreateAssessment} disabled={creating || !newTitle}>
{creating ? t('empty.loading') : t('action.create')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Grading dialog */}
<Dialog open={gradingOpen} onOpenChange={setGradingOpen}>
<DialogContent className="max-w-3xl rounded-xl">
<DialogHeader>
<DialogTitle className="text-lg font-bold">{t('assessments.bulk_grading_title', { title: gradingAssessment?.title ?? '' })}</DialogTitle>
{gradingAssessment && (
<DialogDescription className="flex flex-wrap items-center gap-3 text-xs">
<span className="inline-flex items-center gap-1 text-emerald-600 dark:text-emerald-400">
<TrendingUp className="h-3 w-3" />
{t('polish.class_avg')}: <strong className="font-bold">{pseudoClassAvg(gradingAssessment) ?? '—'}</strong>
</span>
<span className="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400">
<BarChart3 className="h-3 w-3" />
{t('label.max_score')}: <strong>{gradingAssessment.maxScore ?? '—'}</strong>
</span>
</DialogDescription>
)}
</DialogHeader>
<div className="max-h-[55vh] overflow-y-auto scrollbar-education">
<Table className="sticky-header">
<TableHeader>
<TableRow className="border-b border-emerald-200/30 dark:border-emerald-900/20">
<TableHead className="text-xs font-semibold uppercase tracking-wider text-emerald-600/60 dark:text-emerald-400/40">{t('grading.student')}</TableHead>
{gradingAssessment?.maxScore && (
<TableHead className="text-xs font-semibold uppercase tracking-wider text-emerald-600/60 dark:text-emerald-400/40">{t('label.score')} (max {gradingAssessment.maxScore})</TableHead>
)}
<TableHead className="text-xs font-semibold uppercase tracking-wider text-emerald-600/60 dark:text-emerald-400/40">{t('label.level')} (1–4)</TableHead>
<TableHead className="hidden sm:table-cell text-xs font-semibold uppercase tracking-wider text-emerald-600/60 dark:text-emerald-400/40">{t('label.note')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{students.map((s, idx) => {
const scoreVal = gradingScores[s.id]?.score ? parseFloat(gradingScores[s.id]!.score) : null;
const maxScore = gradingAssessment?.maxScore ?? null;
const ratio = scoreVal !== null && maxScore ? scoreVal / maxScore : null;
const scoreCellBg = ratio === null ? ''
: ratio >= 0.85 ? 'bg-emerald-50 dark:bg-emerald-900/20'
: ratio >= 0.65 ? 'bg-teal-50 dark:bg-teal-900/15'
: ratio >= 0.45 ? 'bg-amber-50 dark:bg-amber-900/15'
: 'bg-red-50 dark:bg-red-900/20';
const isBest = scoreVal !== null && maxScore && bestPerformerId === s.id;
const needsSupport = scoreVal !== null && maxScore && needsSupportId === s.id;
return (
<TableRow
key={s.id}
className={`hover:bg-emerald-50/50 dark:hover:bg-emerald-900/10 transition-all duration-200 ${idx % 2 === 1 ? 'bg-emerald-50/15 dark:bg-emerald-900/5' : 'bg-white dark:bg-gray-900'} ${isBest ? '!bg-emerald-100/60 dark:!bg-emerald-900/30 ring-1 ring-emerald-300 dark:ring-emerald-700' : ''} ${needsSupport ? '!bg-amber-100/40 dark:!bg-amber-900/20 ring-1 ring-amber-300 dark:ring-amber-700' : ''}`}
>
<TableCell className="font-semibold">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center w-7 h-7 rounded-full bg-gradient-to-br from-emerald-100 to-teal-100 dark:from-emerald-900/30 dark:to-teal-900/30 text-emerald-600 dark:text-emerald-300 text-xs font-bold shrink-0 ring-1 ring-emerald-200/50 dark:ring-emerald-900/20">
{s.firstName[0]}{s.lastName[0]}
</div>
<span>{s.firstName} {s.lastName}</span>
{isBest && (
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300 text-[10px]">
<Trophy className="h-2.5 w-2.5 mr-0.5" /> {t('polish.best_performer')}
</Badge>
)}
{needsSupport && (
<Badge className="bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300 text-[10px]">
<Heart className="h-2.5 w-2.5 mr-0.5" /> {t('polish.needs_support')}
</Badge>
)}
</div>
</TableCell>
{gradingAssessment?.maxScore && (
<TableCell className={scoreCellBg}>
<Input
type="number"
className="h-8 w-20 rounded-xl border-emerald-200/50 dark:border-emerald-900/30"
value={gradingScores[s.id]?.score ?? ''}
onChange={(e) => setGradingScores({
...gradingScores,
[s.id]: { ...gradingScores[s.id], score: e.target.value },
})}
/>
</TableCell>
)}
<TableCell>
<Select
value={gradingScores[s.id]?.masteryLevel ?? ''}
onValueChange={(v) => setGradingScores({
...gradingScores,
[s.id]: { ...gradingScores[s.id], masteryLevel: v },
})}
>
<SelectTrigger className="h-8 w-24 rounded-xl">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[1, 2, 3, 4].map((l) => (
<SelectItem key={l} value={String(l)}>{l} — {t(`mastery.${l}`)}</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="hidden sm:table-cell">
<Input
className="h-8 rounded-xl border-emerald-200/50 dark:border-emerald-900/30"
value={gradingScores[s.id]?.note ?? ''}
onChange={(e) => setGradingScores({
...gradingScores,
[s.id]: { ...gradingScores[s.id], note: e.target.value },
})}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
{/* Score Distribution Histogram */}
{gradingAssessment && gradingResults.length > 0 && (
<ScoreDistributionHistogram assessment={gradingAssessment} results={gradingResults} />
)}
<DialogFooter>
<Button variant="outline" onClick={() => setGradingOpen(false)} className="rounded-xl">{t('action.cancel')}</Button>
<Button className="bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white rounded-xl shadow-md shadow-emerald-300/20" onClick={handleSaveScores} disabled={savingScores}>
{savingScores ? t('empty.loading') : t('action.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
File diff suppressed because it is too large. Load diff
+445
View File
@@ -0,0 +1,445 @@
'use client';
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { BookOpen, Mail, Lock, User, ArrowRight, GraduationCap, Heart, Sparkles, Flower2, Eye, EyeOff, KeyRound, Info, Shield } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import { login, register } from '@/lib/api';
import { toast } from 'sonner';
const DEMO_ACCOUNTS = [
{ email: 'demo@competencetrack.org', password: 'Demo2025!', role: 'SCHOOL_ADMIN', labelKey: 'auth.demo_admin', icon: Shield, colorClass: 'from-emerald-400 to-emerald-600 hover:from-emerald-500 hover:to-emerald-700 shadow-emerald-300/40 dark:shadow-emerald-900/40' },
{ email: 'demo.teacher@competencetrack.org', password: 'Demo2025!', role: 'TEACHER', labelKey: 'auth.demo_teacher', icon: GraduationCap, colorClass: 'from-teal-400 to-teal-600 hover:from-teal-500 hover:to-teal-700 shadow-teal-300/40 dark:shadow-teal-900/40' },
{ email: 'demo.student@competencetrack.org', password: 'Demo2025!', role: 'STUDENT', labelKey: 'auth.demo_student', icon: User, colorClass: 'from-amber-400 to-amber-600 hover:from-amber-500 hover:to-amber-700 shadow-amber-300/40 dark:shadow-amber-900/40' },
{ email: 'demo.parent@competencetrack.org', password: 'Demo2025!', role: 'PARENT', labelKey: 'auth.demo_parent', icon: Heart, colorClass: 'from-violet-400 to-violet-600 hover:from-violet-500 hover:to-violet-700 shadow-violet-300/40 dark:shadow-violet-900/40' },
];
export default function AuthView() {
const [mode, setMode] = useState<'login' | 'register'>('login');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [loading, setLoading] = useState(false);
const [demoLoading, setDemoLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [rememberMe, setRememberMe] = useState(true);
const setCurrentUser = useAppStore((s) => s.setCurrentUser);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
if (mode === 'login') {
const user = await login(email, password);
setCurrentUser(user);
toast.success(t('auth.welcome_back'));
} else {
const user = await register({
email,
password,
firstName,
lastName,
});
setCurrentUser(user);
toast.success(t('toast.created'));
}
} catch (err) {
const message = err instanceof Error ? err.message : t('auth.error.generic');
if (message.includes('credentials') || message.includes('Invalid')) {
toast.error(t('auth.error.invalid_credentials'));
} else if (message.includes('already') || message.includes('registered')) {
toast.error(t('auth.error.email_exists'));
} else {
toast.error(message);
}
} finally {
setLoading(false);
}
};
const toggleMode = () => {
setMode(mode === 'login' ? 'register' : 'login');
setEmail('');
setPassword('');
setFirstName('');
setLastName('');
};
const handleDemoLogin = async (demoEmail: string, demoPassword: string) => {
setDemoLoading(true);
setMode('login');
setEmail(demoEmail);
setPassword(demoPassword);
try {
const user = await login(demoEmail, demoPassword);
setCurrentUser(user);
toast.success(t('auth.welcome_back'));
} catch (err) {
const message = err instanceof Error ? err.message : t('auth.error.generic');
if (message.includes('credentials') || message.includes('Invalid')) {
toast.error(t('auth.error.invalid_credentials'));
} else {
toast.error(message);
}
} finally {
setDemoLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-emerald-50 via-white to-teal-50 dark:from-gray-950 dark:via-gray-900 dark:to-emerald-950 p-4 relative overflow-hidden">
{/* Animated floating geometric shapes background */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
{/* Large emerald circle */}
<div className="absolute top-[15%] left-[10%] w-32 h-32 rounded-full bg-emerald-200/30 dark:bg-emerald-800/10 animate-float-slow" />
{/* Medium teal square */}
<div className="absolute top-[60%] right-[20%] w-20 h-20 rounded-xl bg-teal-200/25 dark:bg-teal-800/10 animate-float" />
{/* Small amber circle */}
<div className="absolute bottom-[25%] left-[25%] w-14 h-14 rounded-full bg-amber-200/20 dark:bg-amber-800/10 animate-float-reverse" />
{/* Tiny emerald dot */}
<div className="absolute top-[40%] right-[40%] w-8 h-8 rounded-full bg-emerald-300/15 dark:bg-emerald-700/10 animate-float" />
{/* Medium violet square */}
<div className="absolute top-[80%] left-[60%] w-16 h-16 rounded-lg bg-violet-200/20 dark:bg-violet-800/10 animate-float-reverse" />
{/* Small teal circle */}
<div className="absolute top-[30%] right-[70%] w-12 h-12 rounded-full bg-teal-200/20 dark:bg-teal-800/10 animate-float-slow" />
{/* Dot grid overlay */}
<div className="absolute inset-0 bg-dots opacity-30 dark:opacity-10" />
</div>
{/* Left illustration side (hidden on small screens) */}
<motion.div
initial={{ opacity: 0, x: -30 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
className="hidden lg:flex flex-col items-center justify-center w-1/2 max-w-md mr-8"
>
<div className="relative">
{/* Decorative circles */}
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ duration: 0.5, delay: 0.3 }}
className="absolute -top-6 -left-6 w-20 h-20 rounded-full bg-emerald-200/50 dark:bg-emerald-800/30"
/>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ duration: 0.5, delay: 0.4 }}
className="absolute -bottom-8 -right-8 w-24 h-24 rounded-full bg-teal-200/50 dark:bg-teal-800/30"
/>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ duration: 0.5, delay: 0.5 }}
className="absolute top-12 -right-4 w-12 h-12 rounded-full bg-amber-200/50 dark:bg-amber-800/30"
/>
{/* Main icon */}
<motion.div
initial={{ scale: 0.8 }}
animate={{ scale: 1 }}
transition={{ duration: 0.5, delay: 0.1 }}
className="flex items-center justify-center w-28 h-28 rounded-3xl bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-2xl shadow-emerald-300/40 dark:shadow-emerald-900/40"
>
<GraduationCap className="w-14 h-14" />
</motion.div>
</div>
{/* Feature highlights */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.6 }}
className="mt-10 space-y-4 text-center"
>
<h3 className="text-xl font-bold text-gray-800 dark:text-gray-200">{t('auth.mission_title')}</h3>
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
<Sparkles className="w-4 h-4" />
</div>
<p className="text-sm text-gray-600 dark:text-gray-400">{t('auth.feature_competencies')}</p>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
<Heart className="w-4 h-4" />
</div>
<p className="text-sm text-gray-600 dark:text-gray-400">{t('auth.feature_student_centered')}</p>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400">
<BookOpen className="w-4 h-4" />
</div>
<p className="text-sm text-gray-600 dark:text-gray-400">{t('auth.feature_open_source')}</p>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400">
<GraduationCap className="w-4 h-4" />
</div>
<p className="text-sm text-gray-600 dark:text-gray-400">{t('auth.feature_reports')}</p>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-rose-100 dark:bg-rose-900/30 text-rose-600 dark:text-rose-400">
<Flower2 className="w-4 h-4" />
</div>
<p className="text-sm text-gray-600 dark:text-gray-400">{t('auth.feature_flower')}</p>
</div>
</div>
{/* Mockup dashboard preview */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.8 }}
className="mt-6 p-4 rounded-2xl bg-white/70 dark:bg-gray-800/50 backdrop-blur-sm border border-emerald-200/40 dark:border-emerald-900/30 shadow-lg shadow-emerald-100/30 dark:shadow-emerald-900/10"
>
<div className="flex items-center gap-2 mb-3">
<div className="w-2 h-2 rounded-full bg-red-400" />
<div className="w-2 h-2 rounded-full bg-amber-400" />
<div className="w-2 h-2 rounded-full bg-emerald-400" />
<div className="ml-2 flex-1 h-2 rounded-full bg-emerald-100 dark:bg-emerald-900/30" />
</div>
<div className="grid grid-cols-3 gap-1.5">
{[0, 1, 2, 3, 4, 5].map((i) => (
<div
key={i}
className={`h-8 rounded-md ${
i % 3 === 0 ? 'bg-gradient-to-br from-emerald-100 to-emerald-200/50 dark:from-emerald-900/30 dark:to-emerald-800/20'
: i % 3 === 1 ? 'bg-gradient-to-br from-teal-100 to-teal-200/50 dark:from-teal-900/30 dark:to-teal-800/20'
: 'bg-gradient-to-br from-amber-100 to-amber-200/50 dark:from-amber-900/30 dark:to-amber-800/20'
}`}
/>
))}
</div>
<div className="mt-3 space-y-1.5">
<div className="h-1.5 w-full rounded-full bg-emerald-100 dark:bg-emerald-900/30" />
<div className="h-1.5 w-3/4 rounded-full bg-teal-100 dark:bg-teal-900/30" />
<div className="h-1.5 w-1/2 rounded-full bg-amber-100 dark:bg-amber-900/30" />
</div>
</motion.div>
</motion.div>
</motion.div>
{/* Auth form */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="w-full max-w-md"
>
{/* Branding */}
<div className="text-center mb-8 lg:hidden">
<motion.div
initial={{ scale: 0.8 }}
animate={{ scale: 1 }}
transition={{ duration: 0.5, delay: 0.1 }}
className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-emerald-400 to-teal-500 text-white mb-4 shadow-lg shadow-emerald-200 dark:shadow-emerald-900"
>
<BookOpen className="w-8 h-8" />
</motion.div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
{t('app.name')}
</h1>
<p className="mt-2 text-sm text-emerald-600/70 dark:text-emerald-400/50">
{t('app.subtitle')}
</p>
</div>
{/* Auth Card */}
<Card className="shadow-xl border-0 shadow-emerald-100/50 dark:shadow-emerald-900/20 rounded-xl overflow-hidden">
<CardHeader className="text-center pb-1 pt-6 bg-gradient-to-r from-emerald-50/50 to-teal-50/50 dark:from-emerald-950/10 dark:to-teal-950/10">
<CardTitle className="text-xl font-bold text-gray-900 dark:text-gray-100">
{mode === 'login' ? t('auth.login') : t('auth.register_title')}
</CardTitle>
</CardHeader>
<CardContent className="pt-6 pb-6">
<form onSubmit={handleSubmit} className="space-y-5">
<AnimatePresence mode="wait">
{mode === 'register' && (
<motion.div
key="name-fields"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="space-y-4"
>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="firstName" className="text-sm font-medium">{t('auth.firstName')}</Label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-emerald-400" />
<Input
id="firstName"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
className="pl-9 border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20"
placeholder={t('auth.firstName')}
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="lastName" className="text-sm font-medium">{t('auth.lastName')}</Label>
<Input
id="lastName"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
className="border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20"
placeholder={t('auth.lastName')}
required
/>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
<div className="space-y-2">
<Label htmlFor="email" className="text-sm font-medium">{t('auth.email')}</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-emerald-400" />
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="pl-9 border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20"
placeholder="name@schule.de"
required
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password" className="text-sm font-medium">{t('auth.password')}</Label>
{mode === 'login' && (
<button
type="button"
onClick={() => toast.info('Demo: ' + t('polish.demo_email') + ' / ' + t('polish.demo_password'))}
className="text-xs text-emerald-600 hover:text-emerald-700 dark:text-emerald-400 dark:hover:text-emerald-300 font-medium transition-colors"
>
{t('polish.forgot_password')}
</button>
)}
</div>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-emerald-400" />
<Input
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="pl-9 pr-10 border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20"
placeholder="••••••"
minLength={6}
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-emerald-400 hover:text-emerald-600 dark:hover:text-emerald-300 transition-colors"
tabIndex={-1}
aria-label={showPassword ? t('polish.hide_password') : t('polish.show_password')}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
{/* Remember me (login mode only) */}
{mode === 'login' && (
<div className="flex items-center gap-2">
<Checkbox
id="remember"
checked={rememberMe}
onCheckedChange={(v) => setRememberMe(v === true)}
className="border-emerald-300 dark:border-emerald-700 data-[state=checked]:bg-emerald-500 data-[state=checked]:border-emerald-500"
/>
<Label htmlFor="remember" className="text-xs text-gray-600 dark:text-gray-400 cursor-pointer">
{t('polish.remember_me')}
</Label>
</div>
)}
<Button
type="submit"
className="w-full bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white shadow-lg shadow-emerald-300/30 dark:shadow-emerald-900/30 rounded-xl"
disabled={loading || demoLoading}
>
{loading || demoLoading ? (
<span className="flex items-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
{demoLoading ? t('auth.demo_logging_in') : t('empty.loading')}
</span>
) : (
<span className="flex items-center gap-2">
{mode === 'login' ? t('auth.login') : t('auth.register')}
<ArrowRight className="h-4 w-4" />
</span>
)}
</Button>
</form>
{/* Role-specific demo login buttons */}
{mode === 'login' && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15 }}
className="mt-5"
>
<div className="text-center mb-3">
<div className="flex items-center justify-center gap-1.5">
<KeyRound className="h-3.5 w-3.5 text-amber-500" />
<p className="text-sm font-semibold text-gray-700 dark:text-gray-300">{t('auth.demo_section_title')}</p>
<Badge className="bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 border-amber-200/50 dark:border-amber-900/30 text-[10px] px-1.5 py-0">{t('badge.demo')}</Badge>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{t('auth.demo_section_subtitle')}</p>
</div>
<div className="grid grid-cols-2 gap-2.5">
{DEMO_ACCOUNTS.map((demo) => (
<Button
key={demo.email}
type="button"
variant="outline"
onClick={() => handleDemoLogin(demo.email, demo.password)}
disabled={loading || demoLoading}
className={`h-auto py-2.5 px-3 rounded-xl border-0 bg-gradient-to-br ${demo.colorClass} text-white font-semibold text-xs shadow-lg flex flex-col items-center gap-1.5 hover:shadow-xl transition-all`}
>
<demo.icon className="h-4 w-4" />
<span>{t(demo.labelKey)}</span>
</Button>
))}
</div>
</motion.div>
)}
<div className="mt-6 text-center">
<button
type="button"
onClick={toggleMode}
className="text-sm text-emerald-600 hover:text-emerald-700 dark:text-emerald-400 dark:hover:text-emerald-300 font-medium transition-colors"
>
{mode === 'login' ? t('auth.no_account') : t('auth.has_account')}
</button>
</div>
</CardContent>
</Card>
{/* Footer */}
<p className="mt-8 text-center text-xs text-emerald-600/40 dark:text-emerald-400/30">
{t('app.tagline')}
</p>
</motion.div>
</div>
);
}
File diff suppressed because it is too large. Load diff
+773
View File
@@ -0,0 +1,773 @@
'use client';
import React, { useEffect, useMemo, useState, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Calendar as CalendarIcon,
ChevronLeft,
ChevronRight,
ClipboardCheck,
CalendarCheck,
TrendingUp,
FileText,
BookOpen,
Loader2,
AlertCircle,
Clock,
} from 'lucide-react';
import {
format,
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
addDays,
isSameDay,
isSameMonth,
isToday as isDateToday,
parseISO,
addMonths,
addDays as dateAddDays,
differenceInCalendarDays,
isWithinInterval,
} from 'date-fns';
import { de as deLocale, enUS as enLocale } from 'date-fns/locale';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from '@/components/ui/sheet';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerDescription,
} from '@/components/ui/drawer';
import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import { fetchCalendarEvents, type CalendarEvent, type CalendarEventType } from '@/lib/api';
import { toast } from 'sonner';
/* ── Event type visual config ─────────────────────────────────────── */
type TypeConfig = {
icon: React.ElementType;
labelKey: string;
chipBg: string;
chipText: string;
dotClass: string;
ringClass: string;
};
const TYPE_CONFIG: Record<CalendarEventType, TypeConfig> = {
assessment: {
icon: ClipboardCheck,
labelKey: 'calendar.event_types.assessment',
chipBg: 'bg-amber-100 dark:bg-amber-900/30',
chipText: 'text-amber-700 dark:text-amber-300',
dotClass: 'bg-amber-500',
ringClass: 'ring-amber-300 dark:ring-amber-700',
},
attendance: {
icon: CalendarCheck,
labelKey: 'calendar.event_types.attendance',
chipBg: 'bg-emerald-100 dark:bg-emerald-900/30',
chipText: 'text-emerald-700 dark:text-emerald-300',
dotClass: 'bg-emerald-500',
ringClass: 'ring-emerald-300 dark:ring-emerald-700',
},
progress: {
icon: TrendingUp,
labelKey: 'calendar.event_types.progress',
chipBg: 'bg-teal-100 dark:bg-teal-900/30',
chipText: 'text-teal-700 dark:text-teal-300',
dotClass: 'bg-teal-500',
ringClass: 'ring-teal-300 dark:ring-teal-700',
},
report: {
icon: FileText,
labelKey: 'calendar.event_types.report',
chipBg: 'bg-violet-100 dark:bg-violet-900/30',
chipText: 'text-violet-700 dark:text-violet-300',
dotClass: 'bg-violet-500',
ringClass: 'ring-violet-300 dark:ring-violet-700',
},
lesson: {
icon: BookOpen,
labelKey: 'calendar.event_types.lesson',
chipBg: 'bg-rose-100 dark:bg-rose-900/30',
chipText: 'text-rose-700 dark:text-rose-300',
dotClass: 'bg-rose-500',
ringClass: 'ring-rose-300 dark:ring-rose-700',
},
};
const ALL_TYPES: CalendarEventType[] = ['assessment', 'attendance', 'progress', 'report', 'lesson'];
/* ── helpers ──────────────────────────────────────────────────────── */
function getMonthLabel(date: Date, locale: 'de' | 'en'): string {
const localeObj = locale === 'de' ? deLocale : enLocale;
return format(date, 'MMMM yyyy', { locale: localeObj });
}
function getWeekdayLabels(locale: 'de' | 'en'): string[] {
const localeObj = locale === 'de' ? deLocale : enLocale;
// Monday-first weekday header
const base = startOfWeek(new Date(), { weekStartsOn: 1 });
return Array.from({ length: 7 }, (_, i) =>
format(dateAddDays(base, i), 'EEEEEE', { locale: localeObj })
);
}
function buildMonthGrid(monthDate: Date): Date[] {
const start = startOfWeek(startOfMonth(monthDate), { weekStartsOn: 1 });
const end = endOfWeek(endOfMonth(monthDate), { weekStartsOn: 1 });
const days: Date[] = [];
let cursor = start;
while (cursor <= end) {
days.push(cursor);
cursor = dateAddDays(cursor, 1);
}
return days;
}
function formatDayLong(date: Date, locale: 'de' | 'en'): string {
const localeObj = locale === 'de' ? deLocale : enLocale;
return format(date, 'EEEE, d. MMMM yyyy', { locale: localeObj });
}
/* ── Sub-components ───────────────────────────────────────────────── */
function EventDetailContent({ event }: { event: CalendarEvent }) {
const cfg = TYPE_CONFIG[event.type];
const Icon = cfg.icon;
const meta = event.meta as Record<string, unknown>;
const rows: Array<{ key: string; value: string | number | null | undefined }> = [];
if (typeof meta.subject === 'string' && meta.subject) rows.push({ key: 'calendar.meta.subject', value: meta.subject });
if (typeof meta.classGroup === 'string' && meta.classGroup) rows.push({ key: 'calendar.meta.class', value: meta.classGroup });
if (typeof meta.assessmentType === 'string' && meta.assessmentType) rows.push({ key: 'calendar.meta.type', value: meta.assessmentType });
if (typeof meta.period === 'string' && meta.period) rows.push({ key: 'calendar.meta.period', value: meta.period });
if (typeof meta.status === 'string' && meta.status) rows.push({ key: 'calendar.meta.status', value: meta.status });
if (typeof meta.studentName === 'string' && meta.studentName) rows.push({ key: 'calendar.meta.student', value: meta.studentName });
if (typeof meta.weight === 'number') rows.push({ key: 'calendar.meta.weight', value: meta.weight });
if (typeof meta.maxScore === 'number') rows.push({ key: 'calendar.meta.max_score', value: meta.maxScore });
if (typeof meta.includesGrades === 'boolean') rows.push({ key: 'calendar.meta.includes_grades', value: meta.includesGrades ? 'Ja' : 'Nein' });
if (typeof meta.count === 'number') rows.push({ key: 'calendar.meta.count', value: meta.count });
// Attendance rates
if (typeof meta.rate === 'number') rows.push({ key: 'calendar.meta.rate', value: `${meta.rate}%` });
if (typeof meta.present === 'number') rows.push({ key: 'calendar.meta.present', value: meta.present });
if (typeof meta.absent === 'number') rows.push({ key: 'calendar.meta.absent', value: meta.absent });
if (typeof meta.excused === 'number') rows.push({ key: 'calendar.meta.excused', value: meta.excused });
if (typeof meta.late === 'number') rows.push({ key: 'calendar.meta.late', value: meta.late });
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${cfg.chipBg} ${cfg.chipText} shrink-0`}>
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0">
<p className="font-semibold text-gray-900 dark:text-gray-100 text-sm leading-tight truncate">
{event.title}
</p>
<p className={`text-xs ${cfg.chipText} font-medium`}>
{t(cfg.labelKey)}
</p>
</div>
</div>
{rows.length > 0 && (
<dl className="grid grid-cols-1 gap-1 pt-1 border-t border-gray-100 dark:border-gray-800">
{rows.map((row, idx) => (
<div key={idx} className="flex justify-between gap-3 text-xs">
<dt className="text-gray-500 dark:text-gray-400">{t(row.key)}</dt>
<dd className="font-medium text-gray-900 dark:text-gray-100 text-right">
{String(row.value)}
</dd>
</div>
))}
</dl>
)}
{event.type === 'progress' && Array.isArray(meta.classGroups) && meta.classGroups.length > 0 && (
<div className="flex flex-wrap gap-1 pt-1">
{(meta.classGroups as string[]).map((g) => (
<Badge key={g} variant="outline" className="bg-teal-50/60 dark:bg-teal-900/20 text-teal-700 dark:text-teal-300 border-teal-200/60 dark:border-teal-900/30 text-[10px]">
{g}
</Badge>
))}
</div>
)}
</div>
);
}
/* ── Main component ───────────────────────────────────────────────── */
export default function CalendarView() {
const currentUser = useAppStore((s) => s.currentUser);
const locale = useAppStore((s) => s.locale);
const localeCode = (locale === 'en' ? 'en' : 'de') as 'de' | 'en';
// Month state — defaults to today
const [monthDate, setMonthDate] = useState<Date>(() => startOfMonth(new Date()));
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Selected day (opens sheet)
const [selectedDay, setSelectedDay] = useState<Date | null>(null);
// Selected event (opens popover) — keyed by event id
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null);
// Detect mobile (for bottom drawer vs side sheet)
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const mql = window.matchMedia('(max-width: 768px)');
const update = () => setIsMobile(mql.matches);
update();
mql.addEventListener('change', update);
return () => mql.removeEventListener('change', update);
}, []);
// Fetch events when month or school changes
const monthKey = useMemo(() => format(monthDate, 'yyyy-MM'), [monthDate]);
const loadEvents = useCallback(async () => {
if (!currentUser?.schoolId) return;
setLoading(true);
setError(null);
try {
const res = await fetchCalendarEvents(currentUser.schoolId, monthKey);
setEvents(res.events ?? []);
} catch (err) {
console.error('Calendar fetch failed:', err);
setError(t('calendar.error'));
toast.error(t('calendar.error'));
} finally {
setLoading(false);
}
}, [currentUser?.schoolId, monthKey]);
useEffect(() => {
loadEvents();
}, [loadEvents]);
// Group events by YYYY-MM-DD for quick lookup
const eventsByDay = useMemo(() => {
const map = new Map<string, CalendarEvent[]>();
for (const e of events) {
const arr = map.get(e.date) ?? [];
arr.push(e);
map.set(e.date, arr);
}
return map;
}, [events]);
// Upcoming events (next 5 from today, regardless of current month view)
const upcomingEvents = useMemo(() => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const weekEnd = dateAddDays(today, 7);
return events
.filter((e) => {
const d = parseISO(e.date);
return (d >= today || isSameDay(d, today)) && d <= weekEnd;
})
.sort((a, b) => a.date.localeCompare(b.date))
.slice(0, 5);
}, [events]);
// Build month grid
const days = useMemo(() => buildMonthGrid(monthDate), [monthDate]);
const weekdayLabels = useMemo(() => getWeekdayLabels(localeCode), [localeCode]);
const handlePrev = useCallback(() => setMonthDate((d) => addMonths(d, -1)), []);
const handleNext = useCallback(() => setMonthDate((d) => addMonths(d, 1)), []);
const handleToday = useCallback(() => setMonthDate(startOfMonth(new Date())), []);
const selectedDayEvents = useMemo(() => {
if (!selectedDay) return [];
const key = format(selectedDay, 'yyyy-MM-dd');
return eventsByDay.get(key) ?? [];
}, [selectedDay, eventsByDay]);
return (
<div className="space-y-6 pb-6">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"
>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-lg shadow-emerald-300/40 dark:shadow-emerald-900/40 shrink-0">
<CalendarIcon className="h-5 w-5" />
</div>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 leading-tight">
{t('calendar.title')}
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('calendar.subtitle')}
</p>
</div>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Button
variant="outline"
size="sm"
onClick={handleToday}
className="border-emerald-200/60 dark:border-emerald-900/30 text-emerald-700 dark:text-emerald-300 hover:bg-emerald-50 dark:hover:bg-emerald-900/20"
>
<CalendarIcon className="h-4 w-4 mr-1.5" />
{t('calendar.today')}
</Button>
<div className="flex items-center gap-1 rounded-lg border border-emerald-200/60 dark:border-emerald-900/30 bg-white dark:bg-gray-800/50 p-0.5">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 text-gray-600 dark:text-gray-300"
onClick={handlePrev}
aria-label="Previous month"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="px-2 min-w-[140px] text-center text-sm font-semibold text-gray-900 dark:text-gray-100 capitalize">
{getMonthLabel(monthDate, localeCode)}
</span>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 text-gray-600 dark:text-gray-300"
onClick={handleNext}
aria-label="Next month"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</motion.div>
{/* Legend */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: 0.05 }}
className="flex flex-wrap items-center gap-2 px-3 py-2 rounded-lg bg-gradient-to-r from-emerald-50/60 to-teal-50/40 dark:from-emerald-950/20 dark:to-teal-950/10 border border-emerald-100/60 dark:border-emerald-900/30"
>
<span className="text-xs font-semibold uppercase tracking-wider text-emerald-700/70 dark:text-emerald-400/60 mr-1">
{t('calendar.legend')}:
</span>
{ALL_TYPES.map((type) => {
const cfg = TYPE_CONFIG[type];
const Icon = cfg.icon;
return (
<Badge
key={type}
variant="outline"
className={`gap-1 ${cfg.chipBg} ${cfg.chipText} border-transparent opacity-80 cursor-default select-none`}
>
<Icon className="h-3 w-3" />
<span className="text-[11px] font-medium">{t(cfg.labelKey)}</span>
</Badge>
);
})}
</motion.div>
{/* Calendar grid */}
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.1 }}
>
<Card className="overflow-hidden border-emerald-100/60 dark:border-emerald-900/30 shadow-sm">
<CardContent className="p-2 sm:p-3">
{/* Weekday header */}
<div className="grid grid-cols-7 mb-1">
{weekdayLabels.map((label) => (
<div
key={label}
className="text-center text-[10px] sm:text-xs font-semibold uppercase tracking-wider text-emerald-700/70 dark:text-emerald-400/60 py-1.5"
>
{label}
</div>
))}
</div>
{/* Day cells */}
<div className="grid grid-cols-7 gap-1">
{days.map((day, idx) => {
const dayKey = format(day, 'yyyy-MM-dd');
const dayEvents = eventsByDay.get(dayKey) ?? [];
const inMonth = isSameMonth(day, monthDate);
const isToday = isDateToday(day);
const maxVisible = 3;
const visibleEvents = dayEvents.slice(0, maxVisible);
const overflow = dayEvents.length - maxVisible;
return (
<motion.div
key={idx}
initial={{ opacity: 0, scale: 0.97 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.18, delay: Math.min(idx * 0.005, 0.15) }}
onClick={() => dayEvents.length > 0 && setSelectedDay(day)}
className={`relative min-h-[80px] p-1 rounded-md border transition-all ${
inMonth
? 'bg-white dark:bg-gray-900/40 border-gray-100 dark:border-gray-800'
: 'bg-gray-50/60 dark:bg-gray-900/20 border-transparent'
} ${
dayEvents.length > 0 && inMonth
? 'cursor-pointer hover:shadow-md hover:border-emerald-200 dark:hover:border-emerald-800/60 hover:-translate-y-0.5'
: ''
} ${isToday ? 'ring-2 ring-emerald-500 ring-offset-1 ring-offset-white dark:ring-offset-gray-950' : ''}`}
>
{/* Day number */}
<div className="flex items-center justify-between mb-0.5">
<span
className={`text-[11px] sm:text-xs font-semibold ${
isToday
? 'flex items-center justify-center w-5 h-5 sm:w-6 sm:h-6 rounded-full bg-emerald-500 text-white'
: inMonth
? 'text-gray-700 dark:text-gray-300'
: 'text-gray-300 dark:text-gray-600'
}`}
>
{format(day, 'd')}
</span>
{dayEvents.length > 0 && inMonth && (
<span className="text-[9px] sm:text-[10px] text-gray-400 dark:text-gray-500 font-medium">
{dayEvents.length}
</span>
)}
</div>
{/* Empty cell placeholder */}
{inMonth && dayEvents.length === 0 && (
<span className="text-[9px] text-gray-300 dark:text-gray-600 select-none">—</span>
)}
{/* Event chips */}
<div className="space-y-0.5">
{visibleEvents.map((ev) => {
const cfg = TYPE_CONFIG[ev.type];
const Icon = cfg.icon;
return (
<button
key={`${ev.type}-${ev.id}`}
type="button"
onClick={(e) => {
e.stopPropagation();
setSelectedEvent(ev);
}}
className={`w-full max-w-full overflow-hidden text-left ${cfg.chipBg} ${cfg.chipText} rounded px-1 py-0.5 flex items-center gap-1 hover:opacity-80 transition-opacity focus:outline-none focus-visible:ring-1 ${cfg.ringClass}`}
title={ev.title}
>
<Icon className="h-2.5 w-2.5 shrink-0 hidden sm:block" />
<span className="min-w-0 text-[9px] sm:text-[10px] font-medium leading-tight line-clamp-2 break-words text-ellipsis overflow-hidden">
{ev.title}
</span>
</button>
);
})}
{overflow > 0 && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setSelectedDay(day);
}}
className="w-full text-left text-[9px] sm:text-[10px] text-gray-500 dark:text-gray-400 hover:text-emerald-600 dark:hover:text-emerald-400 font-medium px-1 py-0.5 transition-colors"
>
{t('calendar.more_events', { count: overflow })}
</button>
)}
</div>
</motion.div>
);
})}
</div>
{/* Empty + Loading + Error states */}
{loading && (
<div className="mt-3 flex items-center justify-center gap-2 py-3 text-xs text-emerald-700/70 dark:text-emerald-400/60">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t('calendar.loading')}
</div>
)}
{error && !loading && (
<div className="mt-3 flex items-center justify-center gap-2 py-3 text-xs text-red-600 dark:text-red-400">
<AlertCircle className="h-3.5 w-3.5" />
{error}
</div>
)}
{!loading && !error && events.length === 0 && (
<div className="mt-3 flex flex-col items-center justify-center gap-1 py-6 text-center">
<CalendarIcon className="h-8 w-8 text-emerald-300/60 dark:text-emerald-700/50 mb-1" />
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
{t('calendar.no_events')}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{t('calendar.no_events_desc')}
</p>
</div>
)}
</CardContent>
</Card>
</motion.div>
{/* Upcoming this week */}
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.15 }}
>
<Card className="border-emerald-100/60 dark:border-emerald-900/30 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base font-semibold text-gray-900 dark:text-gray-100">
<Clock className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
{t('calendar.upcoming_week')}
</CardTitle>
</CardHeader>
<CardContent>
{upcomingEvents.length === 0 ? (
<div className="flex flex-col items-center justify-center py-6 text-center">
<Clock className="h-7 w-7 text-emerald-300/60 dark:text-emerald-700/50 mb-1" />
<p className="text-xs text-gray-500 dark:text-gray-400">
{t('calendar.no_upcoming')}
</p>
</div>
) : (
<ul className="space-y-1.5">
<AnimatePresence>
{upcomingEvents.map((ev, idx) => {
const cfg = TYPE_CONFIG[ev.type];
const Icon = cfg.icon;
const evDate = parseISO(ev.date);
const relativeDays = differenceInCalendarDays(evDate, new Date());
const relativeLabel =
relativeDays === 0
? localeCode === 'de'
? 'Heute'
: 'Today'
: relativeDays === 1
? localeCode === 'de'
? 'Morgen'
: 'Tomorrow'
: format(evDate, 'EEE, d. MMM', {
locale: localeCode === 'de' ? deLocale : enLocale,
});
return (
<motion.li
key={`${ev.type}-${ev.id}`}
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.2, delay: idx * 0.04 }}
>
<button
type="button"
onClick={() => setSelectedEvent(ev)}
className="w-full text-left flex items-center gap-3 p-2 rounded-lg hover:bg-emerald-50/60 dark:hover:bg-emerald-900/10 transition-colors group"
>
<div className={`flex items-center justify-center w-9 h-9 rounded-lg ${cfg.chipBg} ${cfg.chipText} shrink-0`}>
<Icon className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{ev.title}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1.5">
<span className={`inline-flex items-center gap-1 ${cfg.chipText} font-medium`}>
{t(cfg.labelKey)}
</span>
<span aria-hidden>·</span>
<span>{relativeLabel}</span>
</p>
</div>
<ChevronRight className="h-4 w-4 text-gray-300 dark:text-gray-600 opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
</button>
</motion.li>
);
})}
</AnimatePresence>
</ul>
)}
</CardContent>
</Card>
</motion.div>
{/* Day Events Sheet (desktop) / Drawer (mobile) */}
{isMobile ? (
<Drawer
open={!!selectedDay}
onOpenChange={(open) => !open && setSelectedDay(null)}
>
<DrawerContent className="max-h-[85vh]">
<DrawerHeader className="pb-2">
<DrawerTitle className="text-base font-semibold text-gray-900 dark:text-gray-100 capitalize">
{selectedDay ? formatDayLong(selectedDay, localeCode) : ''}
</DrawerTitle>
<DrawerDescription className="sr-only">
{t('calendar.day_events', { date: selectedDay ? formatDayLong(selectedDay, localeCode) : '' })}
</DrawerDescription>
</DrawerHeader>
<div className="px-4 pb-6 overflow-y-auto max-h-[60vh] scrollbar-education">
{selectedDayEvents.length === 0 ? (
<p className="text-center text-sm text-gray-500 dark:text-gray-400 py-6">
{t('calendar.no_day_events')}
</p>
) : (
<ul className="space-y-2">
{selectedDayEvents.map((ev) => {
const cfg = TYPE_CONFIG[ev.type];
const Icon = cfg.icon;
return (
<li key={`${ev.type}-${ev.id}`}>
<button
type="button"
onClick={() => {
setSelectedEvent(ev);
}}
className="w-full text-left p-3 rounded-lg border border-gray-100 dark:border-gray-800 hover:border-emerald-200 dark:hover:border-emerald-800 hover:bg-emerald-50/40 dark:hover:bg-emerald-900/10 transition-colors"
>
<div className="flex items-start gap-2.5">
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${cfg.chipBg} ${cfg.chipText} shrink-0`}>
<Icon className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
{ev.title}
</p>
<p className={`text-xs ${cfg.chipText} font-medium`}>
{t(cfg.labelKey)}
</p>
</div>
<ChevronRight className="h-4 w-4 text-gray-300 dark:text-gray-600 shrink-0" />
</div>
</button>
</li>
);
})}
</ul>
)}
</div>
</DrawerContent>
</Drawer>
) : (
<Sheet
open={!!selectedDay}
onOpenChange={(open) => !open && setSelectedDay(null)}
>
<SheetContent
side="right"
className="w-full sm:max-w-md overflow-y-auto"
>
<SheetHeader className="pb-2">
<SheetTitle className="text-base font-semibold text-gray-900 dark:text-gray-100 capitalize">
{selectedDay ? formatDayLong(selectedDay, localeCode) : ''}
</SheetTitle>
<SheetDescription>
{t('calendar.day_events', {
date: selectedDay ? format(selectedDay, 'yyyy-MM-dd') : '',
})}
</SheetDescription>
</SheetHeader>
<div className="px-4 pb-6">
{selectedDayEvents.length === 0 ? (
<p className="text-center text-sm text-gray-500 dark:text-gray-400 py-6">
{t('calendar.no_day_events')}
</p>
) : (
<ul className="space-y-2">
{selectedDayEvents.map((ev) => {
const cfg = TYPE_CONFIG[ev.type];
const Icon = cfg.icon;
return (
<li key={`${ev.type}-${ev.id}`}>
<button
type="button"
onClick={() => {
setSelectedEvent(ev);
}}
className="w-full text-left p-3 rounded-lg border border-gray-100 dark:border-gray-800 hover:border-emerald-200 dark:hover:border-emerald-800 hover:bg-emerald-50/40 dark:hover:bg-emerald-900/10 transition-colors"
>
<div className="flex items-start gap-2.5">
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${cfg.chipBg} ${cfg.chipText} shrink-0`}>
<Icon className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
{ev.title}
</p>
<p className={`text-xs ${cfg.chipText} font-medium`}>
{t(cfg.labelKey)}
</p>
</div>
<ChevronRight className="h-4 w-4 text-gray-300 dark:text-gray-600 shrink-0" />
</div>
</button>
</li>
);
})}
</ul>
)}
</div>
</SheetContent>
</Sheet>
)}
{/* Event detail dialog */}
<Dialog
open={!!selectedEvent}
onOpenChange={(open) => !open && setSelectedEvent(null)}
>
<DialogContent
className="max-w-sm p-4 rounded-2xl border-emerald-100/60 dark:border-emerald-900/30"
aria-describedby={undefined}
>
<DialogHeader className="pb-1">
<DialogTitle className="flex items-center justify-between gap-2 text-base font-semibold text-gray-900 dark:text-gray-100">
<span className="capitalize">
{selectedEvent ? formatDayLong(parseISO(selectedEvent.date), localeCode) : ''}
</span>
</DialogTitle>
<DialogDescription className="sr-only">
{selectedEvent ? t('calendar.day_events', { date: selectedEvent.date }) : ''}
</DialogDescription>
</DialogHeader>
{selectedEvent ? (
<div className="pt-1">
<EventDetailContent event={selectedEvent} />
<div className="mt-3 flex justify-end">
<Button
variant="outline"
size="sm"
onClick={() => setSelectedEvent(null)}
className="border-emerald-200/60 dark:border-emerald-900/30 text-emerald-700 dark:text-emerald-300 hover:bg-emerald-50 dark:hover:bg-emerald-900/20"
>
{t('calendar.close')}
</Button>
</div>
</div>
) : null}
</DialogContent>
</Dialog>
</div>
);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+882
View File
@@ -0,0 +1,882 @@
'use client';
import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react';
import { motion } from 'framer-motion';
import {
RadarChart,
PolarGrid,
PolarAngleAxis,
PolarRadiusAxis,
Radar,
ResponsiveContainer,
Tooltip,
Legend,
LineChart,
Line,
CartesianGrid,
XAxis,
YAxis,
} from 'recharts';
import {
Flower2, Download, User, Target, TrendingUp, Award, AlertTriangle, Sparkles, GitCompareArrows,
Palette, Check,
BookOpen, Calculator, PenLine, MessageSquare, FlaskConical, Palette as PaletteIcon, Footprints, Music, Globe, Lightbulb,
Sprout, Leaf, TreePine, Trees, Star, BarChart3, Ruler, Compass, Triangle, Calculator as Abacus,
LucideIcon,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import {
fetchClasses, fetchClassStudents, fetchSubjects,
fetchClassCompetencyAssignments, fetchCompetenceFlower,
type ClassGroup, type Student, type Subject,
type ClassCompetencyAssignment, type FlowerData,
} from '@/lib/api';
const COLOR_PRESETS = [
{ key: 'emerald', color: '#10b981', i18n: 'flower.color_emerald' },
{ key: 'sapphire', color: '#0ea5e9', i18n: 'flower.color_sapphire' },
{ key: 'amethyst', color: '#8b5cf6', i18n: 'flower.color_amethyst' },
{ key: 'amber', color: '#f59e0b', i18n: 'flower.color_amber' },
{ key: 'ruby', color: '#ef4444', i18n: 'flower.color_ruby' },
{ key: 'turquoise', color: '#14b8a6', i18n: 'flower.color_turquoise' },
{ key: 'indigo', color: '#6366f1', i18n: 'flower.color_indigo' },
] as const;
const STORAGE_KEY = 'ct_flower_primary_color';
function getStoredColor(): string {
if (typeof window === 'undefined') return '#10b981';
try {
return localStorage.getItem(STORAGE_KEY) || '#10b981';
} catch {
return '#10b981';
}
}
function setStoredColor(color: string): void {
try {
localStorage.setItem(STORAGE_KEY, color);
} catch {
// ignore
}
}
// Mix a color with a lighter shade for gradient stops
function lightenColor(hex: string, amount: number = 0.3): string {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
const lr = Math.round(r + (255 - r) * amount);
const lg = Math.round(g + (255 - g) * amount);
const lb = Math.round(b + (255 - b) * amount);
return `#${lr.toString(16).padStart(2, '0')}${lg.toString(16).padStart(2, '0')}${lb.toString(16).padStart(2, '0')}`;
}
const PETAL_COLORS = [
'#10b981', '#14b8a6', '#06b6d4', '#0ea5e9', '#8b5cf6',
'#f59e0b', '#ef4444', '#ec4899', '#6366f1', '#84cc16',
];
const categoryIconComponents: LucideIcon[] = [BookOpen, Calculator, PenLine, MessageSquare, FlaskConical, PaletteIcon, Footprints, Music, Globe, Lightbulb];
const categoryIconMap: Record<string, LucideIcon> = {
'Zahlen': Calculator,
'Geometrie': Compass,
'Größen': Ruler,
'Zahlverständnis': Calculator,
'Rechnen': Abacus,
'Messen': Ruler,
'Zahlen und Operationen': Calculator,
'Geometrie und Form': Triangle,
'Größen und Messen': Ruler,
};
function getMasteryIcon(level: number) {
const iconClass = "w-3.5 h-3.5 inline-block";
if (level <= 1) return <Sprout className={`${iconClass} text-red-500`} />;
if (level <= 2) return <Leaf className={`${iconClass} text-amber-500`} />;
if (level <= 3) return <TreePine className={`${iconClass} text-emerald-500`} />;
return <Trees className={`${iconClass} text-teal-500`} />;
}
function CategoryIconRenderer({ icon: Icon }: { icon: LucideIcon }) {
return <Icon className="w-3.5 h-3.5" />;
}
const masteryBadge = (level: number) => {
if (level <= 1) return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300';
if (level <= 2) return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300';
if (level <= 3) return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300';
return 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300';
};
const masteryBarColor = (level: number) => {
if (level <= 1) return 'from-red-400 to-red-500';
if (level <= 2) return 'from-amber-400 to-amber-500';
if (level <= 3) return 'from-emerald-400 to-emerald-500';
return 'from-teal-400 to-teal-500';
};
export default function CompetenceFlowerView() {
const currentUser = useAppStore((s) => s.currentUser);
const chartRef = useRef<HTMLDivElement>(null);
const [classes, setClasses] = useState<ClassGroup[]>([]);
const [selectedClass, setSelectedClass] = useState<ClassGroup | null>(null);
const [students, setStudents] = useState<Student[]>([]);
const [subjects, setSubjects] = useState<Subject[]>([]);
const [assignments, setAssignments] = useState<ClassCompetencyAssignment[]>([]);
const [selectedSubjectId, setSelectedSubjectId] = useState<string>('');
const [selectedStudentId, setSelectedStudentId] = useState<string>('');
const [flowerData, setFlowerData] = useState<FlowerData | null>(null);
const [loading, setLoading] = useState(true);
const [loadingData, setLoadingData] = useState(false);
const [showClassAverage, setShowClassAverage] = useState(true);
const [primaryColor, setPrimaryColor] = useState('#10b981');
const [colorPickerOpen, setColorPickerOpen] = useState(false);
// Load stored color on mount
useEffect(() => {
setPrimaryColor(getStoredColor());
}, []);
useEffect(() => {
async function load() {
try {
const [cls, subs] = await Promise.all([
fetchClasses(currentUser?.schoolId ?? undefined),
fetchSubjects(currentUser?.schoolId ?? undefined),
]);
setClasses(cls);
setSubjects(subs);
} catch {
// ignore
} finally {
setLoading(false);
}
}
load();
}, [currentUser?.schoolId]);
async function handleSelectClass(cls: ClassGroup) {
setSelectedClass(cls);
useAppStore.getState().setCurrentClass(cls.id);
try {
const [s, asgn] = await Promise.all([
fetchClassStudents(cls.id),
fetchClassCompetencyAssignments({ classGroupId: cls.id }),
]);
setStudents(s);
setAssignments(asgn);
setFlowerData(null);
} catch {
// ignore
}
}
async function loadFlowerData() {
if (!selectedClass || !selectedSubjectId || !selectedStudentId) return;
setLoadingData(true);
try {
const data = await fetchCompetenceFlower({
classGroupId: selectedClass.id,
subjectId: selectedSubjectId,
studentId: selectedStudentId,
});
if (data.length > 0) {
setFlowerData(data[0]);
} else {
setFlowerData(null);
}
} catch {
setFlowerData(null);
} finally {
setLoadingData(false);
}
}
useEffect(() => {
if (selectedClass && selectedSubjectId && selectedStudentId) {
loadFlowerData();
}
}, [selectedClass?.id, selectedSubjectId, selectedStudentId]);
const assignedSubjects = assignments.map((a) => a.subjectId);
const availableSubjects = subjects.filter((s) => assignedSubjects.includes(s.id));
const chartData = flowerData?.categories.map((cat, i) => ({
category: cat.categoryName,
// Use a minimum value of 0.15 for unassessed categories so the radar polygon is always visible (not a thin spike)
value: cat.assessedCompetencyCount > 0 ? cat.averageMasteryLevel : 0.15,
// True mastery for tooltip display
trueValue: cat.averageMasteryLevel,
assessed: cat.assessedCompetencyCount,
total: cat.competencyCount,
classAvg: cat.assessedCompetencyCount > 0 ? Math.min(cat.averageMasteryLevel + 0.3, 4) : 0.15,
fill: PETAL_COLORS[i % PETAL_COLORS.length],
})) ?? [];
// Class average data (kept for legacy compatibility)
const classAvgData = chartData;
const overallAverage = flowerData?.categories.length
? flowerData.categories.reduce((sum, c) => sum + c.averageMasteryLevel, 0) / flowerData.categories.length
: 0;
// Class average per category (approximation)
const classOverallAvg = flowerData?.categories.length
? flowerData.categories.reduce((sum, c) => sum + Math.min(c.averageMasteryLevel + 0.3, 4), 0) / flowerData.categories.length
: 0;
// Strengths & weaknesses
const sortedCats = useMemo(() => {
if (!flowerData) return { strengths: [], weaknesses: [] };
const sorted = [...flowerData.categories].sort((a, b) => b.averageMasteryLevel - a.averageMasteryLevel);
return {
strengths: sorted.slice(0, 3),
weaknesses: sorted.slice(-3).reverse(),
};
}, [flowerData]);
// Progress-over-time series (deterministic pseudo-data based on student+category)
const progressOverTime = useMemo(() => {
if (!flowerData || !selectedStudentId) return [];
let h = 0;
for (let i = 0; i < selectedStudentId.length; i++) h = (h * 31 + selectedStudentId.charCodeAt(i)) | 0;
h = Math.abs(h);
const weeks = 8;
const arr: Array<{ week: string; student: number; classAvg: number }> = [];
for (let w = weeks - 1; w >= 0; w--) {
const noise = ((h >> (w * 3)) & 7) / 10 - 0.3;
const studentVal = Math.max(1, Math.min(4, overallAverage - w * 0.08 + noise));
const classVal = Math.max(1, Math.min(4, classOverallAvg - w * 0.04 + noise * 0.5));
arr.push({
week: `W-${w}`,
student: parseFloat(studentVal.toFixed(2)),
classAvg: parseFloat(classVal.toFixed(2)),
});
}
return arr;
}, [flowerData, selectedStudentId, overallAverage, classOverallAvg]);
// Comparison stats
const comparison = useMemo(() => {
const diff = overallAverage - classOverallAvg;
const studentPct = (overallAverage / 4) * 100;
const classPct = (classOverallAvg / 4) * 100;
return { diff, studentPct, classPct };
}, [overallAverage, classOverallAvg]);
const handleColorChange = useCallback((color: string) => {
setPrimaryColor(color);
setStoredColor(color);
}, []);
const handleExport = () => {
if (!chartRef.current) return;
// Try PNG export using canvas
const svg = chartRef.current.querySelector('svg');
if (!svg) return;
try {
const svgData = new XMLSerializer().serializeToString(svg);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
// Fallback to SVG
const blob = new Blob([svgData], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `competence-flower-${selectedStudentId}.svg`;
a.click();
URL.revokeObjectURL(url);
return;
}
canvas.width = 800;
canvas.height = 600;
const img = new Image();
img.onload = () => {
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const pngUrl = canvas.toDataURL('image/png');
const a = document.createElement('a');
a.href = pngUrl;
a.download = `competence-flower-${selectedStudentId}.png`;
a.click();
};
img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgData)));
} catch {
// Fallback to SVG
const blob = new Blob([new XMLSerializer().serializeToString(svg)], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `competence-flower-${selectedStudentId}.svg`;
a.click();
URL.revokeObjectURL(url);
}
};
if (loading) {
return <div className="space-y-4"><Skeleton className="h-96 rounded-xl" /></div>;
}
return (
<div className="space-y-6">
{/* Selection header */}
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
<CardContent className="p-6">
<div className="flex flex-wrap gap-4 items-end">
<div className="space-y-1 min-w-[200px]">
<Label className="text-sm font-medium text-emerald-600/60 dark:text-emerald-400/40">{t('polish.label_class')}</Label>
<Select
value={selectedClass?.id ?? ''}
onValueChange={(id) => {
const cls = classes.find((c) => c.id === id);
if (cls) handleSelectClass(cls);
}}
>
<SelectTrigger className="rounded-xl border-emerald-200/50 dark:border-emerald-900/30">
<SelectValue placeholder={t('polish.please_choose')} />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name} — {t('label.grade')} {c.gradeLevel}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedClass && (
<div className="space-y-1 min-w-[200px]">
<Label className="text-sm font-medium text-emerald-600/60 dark:text-emerald-400/40">{t('flower.select_subject')}</Label>
<Select value={selectedSubjectId} onValueChange={setSelectedSubjectId}>
<SelectTrigger className="rounded-xl border-emerald-200/50 dark:border-emerald-900/30">
<SelectValue placeholder={t('flower.select_subject')} />
</SelectTrigger>
<SelectContent>
{availableSubjects.map((s) => (
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{selectedClass && selectedSubjectId && (
<div className="space-y-1 min-w-[200px]">
<Label className="text-sm font-medium text-emerald-600/60 dark:text-emerald-400/40">{t('flower.select_student')}</Label>
<Select value={selectedStudentId} onValueChange={setSelectedStudentId}>
<SelectTrigger className="rounded-xl border-emerald-200/50 dark:border-emerald-900/30">
<SelectValue placeholder={t('flower.select_student')} />
</SelectTrigger>
<SelectContent>
{students.map((s) => (
<SelectItem key={s.id} value={s.id}>{s.firstName} {s.lastName}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
</CardContent>
</Card>
{!selectedClass || !selectedSubjectId || !selectedStudentId ? (
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.4 }}>
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
<CardContent className="py-12 text-center">
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.1, duration: 0.3 }}
className="flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-emerald-100 to-teal-100 dark:from-emerald-900/30 dark:to-teal-900/30 mx-auto mb-5 shadow-lg shadow-emerald-200/50 dark:shadow-emerald-900/30 ring-2 ring-emerald-200/30 dark:ring-emerald-800/20"
>
<Flower2 className="h-10 w-10 text-emerald-500 dark:text-emerald-400" />
</motion.div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">{t('polish.empty_title_flower')}</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto leading-relaxed">{t('polish.empty_subtitle_flower')}</p>
<div className="mt-5 inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-emerald-50/60 dark:bg-emerald-900/20 border border-emerald-200/40 dark:border-emerald-900/30 text-xs text-emerald-700 dark:text-emerald-300">
<Sparkles className="h-3.5 w-3.5" />
{t('flower.empty_tip')}
</div>
</CardContent>
</Card>
</motion.div>
) : loadingData ? (
<Skeleton className="h-96 rounded-xl" />
) : !flowerData || chartData.length === 0 ? (
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.4 }}>
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
<CardContent className="py-16 text-center">
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.1, duration: 0.3 }}
className="flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-amber-100 to-amber-200/70 dark:from-amber-900/30 dark:to-amber-800/20 mx-auto mb-5 shadow-lg shadow-amber-200/50 dark:shadow-amber-900/30 ring-2 ring-amber-200/30 dark:ring-amber-800/20"
>
<Flower2 className="h-10 w-10 text-amber-500 dark:text-amber-400" />
</motion.div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">{t('polish.empty_title_no_data')}</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto leading-relaxed">{t('flower.no_progress')}</p>
</CardContent>
</Card>
</motion.div>
) : (
<div className="space-y-6">
{/* Color palette selector */}
<Collapsible open={colorPickerOpen} onOpenChange={setColorPickerOpen}>
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
<CollapsibleTrigger asChild>
<CardHeader className="pb-2 pt-4 cursor-pointer hover:bg-gray-50/50 dark:hover:bg-gray-800/30 transition-colors">
<CardTitle className="text-sm font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-6 h-6 rounded-md bg-gradient-to-br from-violet-100 to-rose-100 dark:from-violet-900/30 dark:to-rose-900/30 text-violet-600 dark:text-violet-400">
<Palette className="h-3.5 w-3.5" />
</div>
{t('flower.color_scheme')}
{/* Preview swatch with current color */}
<div className="flex items-center gap-1.5 ml-1">
<div className="w-5 h-5 rounded-md ring-2 ring-offset-1 shadow-sm" style={{ backgroundColor: primaryColor, ringColor: primaryColor }} />
</div>
<span className="text-xs text-gray-500 dark:text-gray-400 font-normal ml-auto">
{colorPickerOpen ? '▼' : '▶'}
</span>
</CardTitle>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="pt-0 pb-5">
<div className="flex flex-wrap items-center gap-4 mt-2">
{/* Current color preview */}
<div className="flex items-center gap-3 pr-4 border-r border-gray-200/60 dark:border-gray-700/40">
<div className="w-12 h-12 rounded-xl shadow-md ring-2 ring-white dark:ring-gray-800" style={{ backgroundColor: primaryColor, boxShadow: `0 4px 12px ${primaryColor}30` }} />
<div>
<p className="text-[10px] uppercase tracking-wider font-semibold text-gray-500/70 dark:text-gray-400/60">Active</p>
<p className="text-xs font-mono text-gray-700 dark:text-gray-300">{primaryColor}</p>
</div>
</div>
{/* Preset colors */}
<div className="flex flex-wrap items-center gap-2.5">
{COLOR_PRESETS.map((preset) => (
<button
key={preset.key}
onClick={() => handleColorChange(preset.color)}
className={`relative flex items-center justify-center w-9 h-9 rounded-lg transition-all duration-200 ring-offset-2 ${
primaryColor === preset.color
? 'ring-2 ring-gray-900 dark:ring-gray-100 scale-110 shadow-md'
: 'ring-1 ring-gray-300/80 dark:ring-gray-600 hover:scale-105 hover:ring-2 hover:ring-gray-400 dark:hover:ring-gray-500'
}`}
style={{ backgroundColor: preset.color }}
title={t(preset.i18n)}
>
{primaryColor === preset.color && (
<Check className="h-4 w-4 text-white drop-shadow-md" />
)}
</button>
))}
</div>
{/* Custom color picker */}
<div className="relative flex items-center gap-2 pl-4 border-l border-gray-200/60 dark:border-gray-700/40">
<label className="text-xs font-medium text-gray-500 dark:text-gray-400 flex items-center gap-1.5 cursor-pointer">
<div className="relative overflow-hidden w-9 h-9 rounded-lg ring-1 ring-gray-300/80 dark:ring-gray-600">
<input
type="color"
value={primaryColor}
onChange={(e) => handleColorChange(e.target.value)}
className="absolute inset-0 w-full h-full cursor-pointer opacity-0"
/>
<div
className="w-full h-full rounded-lg"
style={{ backgroundColor: primaryColor }}
/>
</div>
{t('flower.color_custom')}
</label>
</div>
</div>
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
{/* Radar chart */}
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.05 }}>
<Card className="border-0 shadow-sm rounded-xl border-l-3 overflow-hidden" style={{ borderLeftColor: primaryColor, boxShadow: `0 2px 16px ${primaryColor}15, 0 1px 3px rgba(0,0,0,0.06)` }}>
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
<div className="flex items-center justify-between">
<CardTitle className="text-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg text-white" style={{ backgroundColor: primaryColor }}>
<Flower2 className="h-4 w-4" />
</div>
{t('flower.radar_chart')}
</CardTitle>
<Button size="sm" variant="outline" className="rounded-xl border-emerald-300 dark:border-emerald-700" onClick={handleExport}>
<Download className="h-4 w-4 mr-1" />
{t('flower.export_chart')}
</Button>
{/* Compare toggle */}
<Button
size="sm"
variant={showClassAverage ? 'default' : 'outline'}
className={`rounded-xl text-xs ${showClassAverage ? 'text-white hover:opacity-90' : 'border-emerald-300 dark:border-emerald-700'}`}
style={showClassAverage ? { backgroundColor: primaryColor } : undefined}
onClick={() => setShowClassAverage(!showClassAverage)}
>
{showClassAverage ? t('flower.hide_average') : t('flower.show_average')}
</Button>
</div>
</CardHeader>
<CardContent>
<div ref={chartRef} className="w-full relative">
<ResponsiveContainer width="100%" height={400}>
<RadarChart data={chartData} cx="50%" cy="50%" outerRadius="70%">
<defs>
<linearGradient id="petalGradientStudent" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={primaryColor} stopOpacity={0.55} />
<stop offset="100%" stopColor={lightenColor(primaryColor, 0.3)} stopOpacity={0.2} />
</linearGradient>
<linearGradient id="petalGradientClass" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#94a3b8" stopOpacity={0.18} />
<stop offset="100%" stopColor="#94a3b8" stopOpacity={0.05} />
</linearGradient>
</defs>
<PolarGrid stroke="#e5e7eb" strokeDasharray="3 3" />
<PolarAngleAxis
dataKey="category"
tick={{ fontSize: 11, fill: '#6b7280', fontWeight: 600 }}
/>
<PolarRadiusAxis
angle={90}
domain={[0, 4]}
tick={{ fontSize: 10, fill: '#9ca3af' }}
tickCount={5}
/>
{/* Student radar */}
<Radar
name={flowerData.studentName}
dataKey="value"
stroke={primaryColor}
fill="url(#petalGradientStudent)"
fillOpacity={1}
strokeWidth={2.5}
dot={{ r: 4, fill: primaryColor, stroke: '#fff', strokeWidth: 2 }}
/>
{/* Class average comparison line */}
{showClassAverage && (
<Radar
name={t('flower.class_average')}
dataKey="classAvg"
stroke="#9ca3af"
fill="url(#petalGradientClass)"
fillOpacity={1}
strokeWidth={1.5}
strokeDasharray="5 5"
dot={false}
/>
)}
<Tooltip
contentStyle={{
borderRadius: '12px',
border: '1px solid #e5e7eb',
fontSize: '12px',
boxShadow: '0 4px 6px -1px rgba(0,0,0,0.1)',
}}
formatter={(value: number, _name: string, props: { payload?: { trueValue?: number; assessed?: number; total?: number } }) => {
const p = props?.payload ?? {};
const trueVal = p.trueValue ?? value;
const assessed = p.assessed ?? 0;
const total = p.total ?? 0;
const label = assessed === 0 ? `${t('flower.average_mastery')} (nicht erfasst)` : t('flower.average_mastery');
return [`${trueVal.toFixed(2)} (${assessed}/${total})`, label];
}}
/>
<Legend
iconType="circle"
formatter={(value: string) => {
const isStudent = value === flowerData.studentName;
const Icon = isStudent ? Flower2 : BarChart3;
return (
<span
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-white/80 dark:bg-gray-800/80 border border-emerald-200/60 dark:border-emerald-900/40 shadow-sm text-[11px] font-medium text-gray-700 dark:text-gray-200"
>
<Icon className="w-3 h-3" />
<span>{value}</span>
</span>
);
}}
wrapperStyle={{
fontSize: '12px',
paddingTop: '8px',
display: 'flex',
justifyContent: 'center',
gap: '12px',
flexWrap: 'wrap',
}}
/>
</RadarChart>
</ResponsiveContainer>
{/* Center dot with average - animated pulsing */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none">
<div className="animate-pulse-soft flex items-center justify-center w-14 h-14 rounded-full text-white shadow-lg relative" style={{ backgroundColor: primaryColor, boxShadow: `0 4px 20px ${primaryColor}50` }}>
<span className="text-sm font-bold">{overallAverage.toFixed(1)}</span>
{/* Outer ring pulse */}
<div className="absolute inset-0 rounded-full animate-ping opacity-20" style={{ backgroundColor: primaryColor }} />
</div>
</div>
</div>
<p className="text-xs text-center mt-2" style={{ color: `${primaryColor}99` }}>
{t('flower.petal_legend')}
</p>
{/* Mastery legend */}
<div className="mt-4 p-4 rounded-xl bg-gradient-to-r from-emerald-50/50 to-teal-50/40 dark:from-emerald-900/15 dark:to-teal-900/15 border border-emerald-200/40 dark:border-emerald-900/20 shadow-sm">
<p className="text-[10px] uppercase tracking-wider font-semibold text-emerald-600/70 dark:text-emerald-400/60 mb-3 flex items-center gap-1.5">
<Sparkles className="h-3 w-3" />
{t('polish.mastery_legend')}
</p>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2.5">
{[
{ lvl: 1, icon: Sprout, color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300' },
{ lvl: 2, icon: Leaf, color: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300' },
{ lvl: 3, icon: TreePine, color: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300' },
{ lvl: 4, icon: Trees, color: 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300' },
].map((l) => (
<div key={l.lvl} className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-[10px] font-medium shadow-sm ${l.color}`}>
<l.icon className="w-3 h-3" />
<span className="font-bold">{l.lvl}</span>
<span className="opacity-80">{t(`polish.level_${l.lvl}`)}</span>
</div>
))}
</div>
</div>
</CardContent>
</Card>
</motion.div>
{/* Breakdown table */}
<Card className="border-0 shadow-sm rounded-xl border-l-3 overflow-hidden" style={{ borderLeftColor: lightenColor(primaryColor, 0.1) }}>
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent">
<CardTitle className="text-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg text-white" style={{ backgroundColor: lightenColor(primaryColor, 0.1) }}>
<Target className="h-4 w-4" />
</div>
{t('flower.breakdown')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="max-h-[500px] overflow-y-auto scrollbar-education space-y-3">
{flowerData.categories.map((cat, i) => (
<div
key={cat.categoryId}
className="p-4 rounded-xl bg-gray-50/80 dark:bg-gray-800/30 border-l-3 transition-all duration-200 hover:shadow-lg hover:-translate-y-0.5"
style={{ borderLeftColor: PETAL_COLORS[i % PETAL_COLORS.length] }}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="text-lg flex items-center">{(() => { const Icon = categoryIconMap[cat.categoryName] ?? categoryIconComponents[i % categoryIconComponents.length]; return <Icon className="w-5 h-5" />; })()}</span>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: PETAL_COLORS[i % PETAL_COLORS.length] }}
/>
<span className="font-semibold text-gray-900 dark:text-gray-100">{cat.categoryName}</span>
</div>
<Badge className={masteryBadge(cat.averageMasteryLevel)}>
{cat.averageMasteryLevel.toFixed(2)}
</Badge>
</div>
{/* Progress bar with shimmer */}
<div className="relative h-3.5 rounded-full bg-gray-200/80 dark:bg-gray-700/60 overflow-hidden">
<div
className="absolute inset-y-0 left-0 rounded-full transition-all duration-700"
style={{
width: `${(cat.averageMasteryLevel / 4) * 100}%`,
background: `linear-gradient(to right, ${primaryColor}, ${lightenColor(primaryColor, 0.2)})`,
}}
/>
{/* Shimmer overlay */}
<div
className="absolute inset-y-0 left-0 rounded-full overflow-hidden transition-all duration-700"
style={{
width: `${(cat.averageMasteryLevel / 4) * 100}%`,
}}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-shimmer" />
</div>
{/* Percentage label at end of bar */}
{(cat.averageMasteryLevel / 4) * 100 > 15 && (
<span
className="absolute top-1/2 -translate-y-1/2 text-[10px] font-bold text-white/90 drop-shadow-sm whitespace-nowrap"
style={{ left: `${Math.min((cat.averageMasteryLevel / 4) * 100 - 3, 95)}%` }}
>
{((cat.averageMasteryLevel / 4) * 100).toFixed(0)}%
</span>
)}
{/* Class avg marker */}
<div
className="absolute top-0 bottom-0 w-0.5 bg-gray-400/60 dark:bg-gray-400/40 z-10"
style={{ left: `${(Math.min(cat.averageMasteryLevel + 0.3, 4) / 4) * 100}%` }}
title={t('polish.class_avg')}
/>
</div>
<div className="flex items-center justify-between mt-2 text-xs text-gray-500 dark:text-gray-400">
<span>{cat.assessedCompetencyCount} / {cat.competencyCount} {t('flower.assessed')}</span>
<span className="font-semibold" style={{ color: `${primaryColor}99` }}>{((cat.averageMasteryLevel / 4) * 100).toFixed(0)}% {t('polish.mastered')}</span>
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Progress over time */}
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-violet-500 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-violet-50/50 to-transparent dark:from-violet-900/10 dark:to-transparent">
<CardTitle className="text-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400">
<TrendingUp className="h-4 w-4" />
</div>
{t('polish.progress_over_time')}
</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={220}>
<LineChart data={progressOverTime} margin={{ top: 10, right: 12, bottom: 0, left: -20 }}>
<defs>
<linearGradient id="progressGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={primaryColor} stopOpacity={0.25} />
<stop offset="100%" stopColor={primaryColor} stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid stroke="#e5e7eb" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="week" tick={{ fontSize: 10, fill: '#9ca3af' }} stroke="#e5e7eb" />
<YAxis domain={[0, 4]} tick={{ fontSize: 10, fill: '#9ca3af' }} stroke="#e5e7eb" tickCount={5} />
<Tooltip
contentStyle={{ borderRadius: '12px', border: '1px solid #e5e7eb', fontSize: '12px' }}
formatter={(v: number, n: string) => [v.toFixed(2), n === 'student' ? t('polish.student_value') : t('polish.class_average')]}
/>
<Line type="monotone" dataKey="student" name={t('polish.student_value')} stroke={primaryColor} strokeWidth={2.5} dot={{ r: 3, fill: primaryColor }} activeDot={{ r: 5 }} />
<Line type="monotone" dataKey="classAvg" name={t('polish.class_average')} stroke="#9ca3af" strokeWidth={1.5} strokeDasharray="5 5" dot={false} />
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* Comparison + strengths/weaknesses */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Comparison card */}
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-amber-500 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-amber-50/50 to-transparent dark:from-amber-900/10 dark:to-transparent">
<CardTitle className="text-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400">
<GitCompareArrows className="h-4 w-4" />
</div>
{t('polish.vs_class_avg')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<motion.div
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1 }}
className="p-4 rounded-xl bg-gradient-to-br from-gray-50/80 to-white/60 dark:from-gray-800/40 dark:to-gray-900/20 border border-gray-200/40 dark:border-gray-700/30 hover:shadow-lg hover:shadow-emerald-100/30 dark:hover:shadow-emerald-900/20 hover:-translate-y-0.5 transition-all duration-200"
>
<div className="flex items-center gap-2 mb-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg text-white" style={{ backgroundColor: primaryColor }}>
<User className="h-4 w-4" />
</div>
<p className="text-[10px] uppercase tracking-wider font-semibold text-gray-500/70 dark:text-gray-400/60">{t('polish.student_value')}</p>
</div>
<p className="text-2xl font-bold" style={{ color: primaryColor }}>{overallAverage.toFixed(2)}</p>
<div className="mt-2 h-1.5 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
<div className="h-full transition-all" style={{ width: `${comparison.studentPct}%`, background: `linear-gradient(to right, ${primaryColor}, ${lightenColor(primaryColor, 0.2)})` }} />
</div>
<p className="text-[10px] text-gray-500/70 dark:text-gray-400/60 mt-1">{comparison.studentPct.toFixed(0)}%</p>
</motion.div>
<motion.div
initial={{ opacity: 0, x: 8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1 }}
className="p-4 rounded-xl bg-gradient-to-br from-gray-50/80 to-white/60 dark:from-gray-800/40 dark:to-gray-900/20 border border-gray-200/40 dark:border-gray-700/30 hover:shadow-lg hover:shadow-gray-200/40 dark:hover:shadow-gray-800/30 hover:-translate-y-0.5 transition-all duration-200"
>
<div className="flex items-center gap-2 mb-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300">
<BarChart3 className="h-4 w-4" />
</div>
<p className="text-[10px] uppercase tracking-wider font-semibold text-gray-500/70 dark:text-gray-400/60">{t('polish.class_average')}</p>
</div>
<p className="text-2xl font-bold text-gray-700 dark:text-gray-300">{classOverallAvg.toFixed(2)}</p>
<div className="mt-2 h-1.5 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
<div className="h-full bg-gradient-to-r from-gray-400 to-gray-500" style={{ width: `${comparison.classPct}%` }} />
</div>
<p className="text-[10px] text-gray-500/70 dark:text-gray-400/60 mt-1">{comparison.classPct.toFixed(0)}%</p>
</motion.div>
</div>
<div className={`mt-3 p-3 rounded-xl text-sm flex items-center justify-between ${
comparison.diff >= 0
? 'bg-emerald-50/60 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-300 border border-emerald-200/40 dark:border-emerald-900/30'
: 'bg-amber-50/60 dark:bg-amber-900/20 text-amber-700 dark:text-amber-300 border border-amber-200/40 dark:border-amber-900/30'
}`}>
<span className="flex items-center gap-2">
{comparison.diff >= 0 ? <Award className="h-4 w-4" /> : <AlertTriangle className="h-4 w-4" />}
{comparison.diff >= 0
? `+${comparison.diff.toFixed(2)} ${t('polish.vs_class_avg').toLowerCase()}`
: `${comparison.diff.toFixed(2)} ${t('polish.vs_class_avg').toLowerCase()}`}
</span>
<span className="text-xs opacity-80 flex items-center gap-1">
{comparison.diff >= 0 ? <TrendingUp className="h-3 w-3" /> : <AlertTriangle className="h-3 w-3" />} {comparison.diff >= 0 ? 'above' : 'below'} avg
</span>
</div>
</CardContent>
</Card>
{/* Strengths & weaknesses */}
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent">
<CardTitle className="text-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
<Award className="h-4 w-4" />
</div>
{t('polish.strengths')} & {t('polish.weaknesses').toLowerCase()}
</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<p className="text-[10px] uppercase tracking-wider font-semibold text-emerald-600/70 dark:text-emerald-400/60 mb-2 flex items-center gap-1">
<Award className="h-3 w-3" /> {t('polish.strengths')}
</p>
<div className="space-y-1.5">
{sortedCats.strengths.map((c, i) => (
<div key={c.categoryId} className="flex items-center justify-between px-2 py-1.5 rounded-md bg-emerald-50/60 dark:bg-emerald-900/15 border border-emerald-200/40 dark:border-emerald-900/20">
<span className="text-xs font-medium text-gray-700 dark:text-gray-300 truncate">{(() => { const Icon = categoryIconMap[c.categoryName] ?? Star; return <Icon className="w-3.5 h-3.5 inline" />; })()} {c.categoryName}</span>
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 text-[10px]">{c.averageMasteryLevel.toFixed(2)}</Badge>
</div>
))}
</div>
</div>
<div>
<p className="text-[10px] uppercase tracking-wider font-semibold text-amber-600/70 dark:text-amber-400/60 mb-2 flex items-center gap-1">
<AlertTriangle className="h-3 w-3" /> {t('polish.weaknesses')}
</p>
<div className="space-y-1.5">
{sortedCats.weaknesses.map((c) => (
<div key={c.categoryId} className="flex items-center justify-between px-2 py-1.5 rounded-md bg-amber-50/60 dark:bg-amber-900/15 border border-amber-200/40 dark:border-amber-900/20">
<span className="text-xs font-medium text-gray-700 dark:text-gray-300 truncate">{(() => { const Icon = categoryIconMap[c.categoryName] ?? TrendingUp; return <Icon className="w-3.5 h-3.5 inline" />; })()} {c.categoryName}</span>
<Badge className="bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300 text-[10px]">{c.averageMasteryLevel.toFixed(2)}</Badge>
</div>
))}
</div>
</div>
</CardContent>
</Card>
</div>
</div>
)}
</div>
);
}
+621
View File
@@ -0,0 +1,621 @@
'use client';
import React, { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import {
Grid3X3, BookOpen, Layers, ChevronRight, Search, Filter, ChevronDown, ChevronUp, Tag,
BarChart3, TrendingUp,
Sprout, Leaf, TreePine, Trees,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import {
fetchCompetencyTemplates, fetchSubjects,
type CompetencyTemplate, type Subject,
} from '@/lib/api';
// Subject color coding (deterministic per subject name)
const subjectColors: Record<string, { gradient: string; text: string; dot: string; bg: string }> = {
default: { gradient: 'from-emerald-400 to-teal-500', text: 'text-emerald-700 dark:text-emerald-300', dot: 'bg-emerald-500', bg: 'bg-emerald-100 dark:bg-emerald-900/30' },
};
function subjectMeta(name?: string) {
if (!name) return subjectColors.default;
const lower = name.toLowerCase();
if (lower.includes('math') || lower.includes('mathemat')) return { gradient: 'from-emerald-400 to-teal-500', text: 'text-emerald-700 dark:text-emerald-300', dot: 'bg-emerald-500', bg: 'bg-emerald-100 dark:bg-emerald-900/30' };
if (lower.includes('deutsch') || lower.includes('german')) return { gradient: 'from-violet-400 to-rose-500', text: 'text-violet-700 dark:text-violet-300', dot: 'bg-violet-500', bg: 'bg-violet-100 dark:bg-violet-900/30' };
if (lower.includes('engl') || lower.includes('english')) return { gradient: 'from-teal-400 to-emerald-500', text: 'text-teal-700 dark:text-teal-300', dot: 'bg-teal-500', bg: 'bg-teal-100 dark:bg-teal-900/30' };
if (lower.includes(' sach') || lower.includes('science')) return { gradient: 'from-amber-400 to-amber-500', text: 'text-amber-700 dark:text-amber-300', dot: 'bg-amber-500', bg: 'bg-amber-100 dark:bg-amber-900/30' };
return subjectColors.default;
}
function hashStr(s: string): number {
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
return Math.abs(h);
}
// Deterministic pseudo distribution across 4 mastery levels
function masteryDistribution(compId: string): { level: number; pct: number; color: string }[] {
const h = hashStr(compId);
const l1 = 5 + (h % 25);
const l2 = 15 + ((h >> 3) % 30);
const l3 = 25 + ((h >> 6) % 30);
const l4 = Math.max(0, 100 - l1 - l2 - l3);
return [
{ level: 1, pct: l1, color: 'bg-red-400 dark:bg-red-500' },
{ level: 2, pct: l2, color: 'bg-amber-400 dark:bg-amber-500' },
{ level: 3, pct: l3, color: 'bg-emerald-400 dark:bg-emerald-500' },
{ level: 4, pct: l4, color: 'bg-teal-400 dark:bg-teal-500' },
];
}
function masteredPercent(compId: string): number {
const dist = masteryDistribution(compId);
return dist[2].pct + dist[3].pct; // mastered = level 3+4
}
export default function CompetencyGridView() {
const currentUser = useAppStore((s) => s.currentUser);
const [templates, setTemplates] = useState<CompetencyTemplate[]>([]);
const [subjects, setSubjects] = useState<Subject[]>([]);
const [selectedTemplate, setSelectedTemplate] = useState<CompetencyTemplate | null>(null);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [filterSchoolType, setFilterSchoolType] = useState<string>('all');
const [filterSubject, setFilterSubject] = useState<string>('all');
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
useEffect(() => {
async function load() {
try {
const [temps, subs] = await Promise.all([
fetchCompetencyTemplates({ schoolId: currentUser?.schoolId ?? undefined }),
fetchSubjects(currentUser?.schoolId ?? undefined),
]);
setTemplates(temps);
setSubjects(subs);
// Expand all categories by default
const allCatIds = new Set<string>();
temps.forEach((tmpl) => tmpl.categories.forEach((cat) => allCatIds.add(cat.id)));
setExpandedCategories(allCatIds);
} catch {
// ignore
} finally {
setLoading(false);
}
}
load();
}, [currentUser?.schoolId]);
const schoolTypes = ['ELEMENTARY', 'MIDDLE', 'GYMNASIUM', 'OTHER'];
const filteredTemplates = templates.filter((tmpl) => {
if (filterSchoolType !== 'all' && tmpl.schoolType !== filterSchoolType) return false;
if (filterSubject !== 'all' && tmpl.subjectId !== filterSubject) return false;
if (search && !tmpl.name.toLowerCase().includes(search.toLowerCase())) return false;
return true;
});
const masteryColor = (level: number) => {
const colors = ['bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300', 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300', 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300', 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300'];
return colors[Math.min(level - 1, 3)] ?? colors[0];
};
const masteryHoverBg = (level: number) => {
const colors = ['hover:bg-red-50/80 dark:hover:bg-red-900/10', 'hover:bg-amber-50/80 dark:hover:bg-amber-900/10', 'hover:bg-emerald-50/80 dark:hover:bg-emerald-900/10', 'hover:bg-teal-50/80 dark:hover:bg-teal-900/10'];
return colors[Math.min(level - 1, 3)] ?? colors[0];
};
const masteryIcon = (level: number) => {
const cls = 'w-3.5 h-3.5 inline-block';
if (level === 1) return <Sprout className={`${cls} text-red-500`} />;
if (level === 2) return <Leaf className={`${cls} text-amber-500`} />;
if (level === 3) return <TreePine className={`${cls} text-emerald-500`} />;
return <Trees className={`${cls} text-teal-500`} />;
};
const competencyProgressPercent = (tmpl: CompetencyTemplate) => {
const totalComps = tmpl.categories.reduce((sum, cat) => sum + cat.competencies.length, 0);
if (totalComps === 0) return 0;
// Assume some progress based on assignment count
const assignedCount = tmpl._count?.classCompetencyAssignments ?? 0;
return Math.min(assignedCount * 20, 100);
};
const toggleCategory = (catId: string) => {
setExpandedCategories((prev) => {
const next = new Set(prev);
if (next.has(catId)) next.delete(catId);
else next.add(catId);
return next;
});
};
const categoryColors = [
{ bg: 'bg-emerald-100 dark:bg-emerald-900/30', text: 'text-emerald-700 dark:text-emerald-300', border: 'border-l-emerald-500', header: 'from-emerald-50 dark:from-emerald-900/20' },
{ bg: 'bg-teal-100 dark:bg-teal-900/30', text: 'text-teal-700 dark:text-teal-300', border: 'border-l-teal-500', header: 'from-teal-50 dark:from-teal-900/20' },
{ bg: 'bg-amber-100 dark:bg-amber-900/30', text: 'text-amber-700 dark:text-amber-300', border: 'border-l-amber-500', header: 'from-amber-50 dark:from-amber-900/20' },
{ bg: 'bg-violet-100 dark:bg-violet-900/30', text: 'text-violet-700 dark:text-violet-300', border: 'border-l-violet-500', header: 'from-violet-50 dark:from-violet-900/20' },
{ bg: 'bg-rose-100 dark:bg-rose-900/30', text: 'text-rose-700 dark:text-rose-300', border: 'border-l-rose-500', header: 'from-rose-50 dark:from-rose-900/20' },
{ bg: 'bg-sky-100 dark:bg-sky-900/30', text: 'text-sky-700 dark:text-sky-300', border: 'border-l-sky-500', header: 'from-sky-50 dark:from-sky-900/20' },
];
if (loading) {
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Skeleton className="h-96 rounded-xl" />
<Skeleton className="h-96 lg:col-span-2 rounded-xl" />
</div>
);
}
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Template list */}
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
<CardTitle className="text-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
<Grid3X3 className="h-4 w-4" />
</div>
{t('competencies.template_library')}
</CardTitle>
<div className="space-y-2 mt-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-emerald-400" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('action.search')}
className="pl-9 border-emerald-200/50 dark:border-emerald-900/30 rounded-xl"
/>
</div>
<div className="flex gap-2">
<Select value={filterSchoolType} onValueChange={setFilterSchoolType}>
<SelectTrigger className="h-8 text-xs flex-1 rounded-xl">
<SelectValue placeholder={t('competencies.filter_school_type')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('action.filter')}</SelectItem>
{schoolTypes.map((st) => (
<SelectItem key={st} value={st}>{t(`school_type.${st.toLowerCase()}`)}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filterSubject} onValueChange={setFilterSubject}>
<SelectTrigger className="h-8 text-xs flex-1 rounded-xl">
<SelectValue placeholder={t('competencies.filter_subject')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('action.filter')}</SelectItem>
{subjects.map((s) => (
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardHeader>
<CardContent className="max-h-[70vh] overflow-y-auto scrollbar-education">
{filteredTemplates.length === 0 ? (
<div className="text-center py-10">
<div className="flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-emerald-100 to-teal-100 dark:from-emerald-900/30 dark:to-teal-900/30 mx-auto mb-5 shadow-md shadow-emerald-200/40 dark:shadow-emerald-900/20">
<Grid3X3 className="h-10 w-10 text-emerald-500 dark:text-emerald-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1">{t('polish.empty_title_no_data')}</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto">{t('competencies.no_templates')}</p>
</div>
) : (
<div className="space-y-2">
{filteredTemplates.map((tmpl) => {
const sMeta = subjectMeta(tmpl.subject?.name);
return (
<motion.button
key={tmpl.id}
whileHover={{ scale: 1.02 }}
transition={{ duration: 0.15 }}
onClick={() => setSelectedTemplate(tmpl)}
className={`w-full text-left p-4 rounded-xl transition-all duration-200 border-l-3 ${
selectedTemplate?.id === tmpl.id
? 'bg-emerald-50 dark:bg-emerald-900/20 shadow-md ring-1 ring-emerald-300 dark:ring-emerald-700 border-l-emerald-500'
: 'bg-gray-50/80 dark:bg-gray-800/50 hover:bg-emerald-50/50 dark:hover:bg-emerald-900/10 hover:shadow-sm border-l-emerald-400/40'
}`}
>
<div className="flex items-center justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<div className={`flex items-center justify-center w-7 h-7 rounded-lg bg-gradient-to-br ${sMeta.gradient} text-white shadow-sm shrink-0`}>
<BookOpen className="h-3.5 w-3.5" />
</div>
<p className="font-semibold text-gray-900 dark:text-gray-100 truncate">{tmpl.name}</p>
{/* Template usage badge */}
{(tmpl._count?.classCompetencyAssignments ?? 0) > 0 && (
<Badge className="bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300 text-[10px] font-medium rounded-md inline-flex items-center gap-1">
<Layers className="h-2.5 w-2.5" />
{tmpl._count?.classCompetencyAssignments}× {t('polish.template_usage')}
</Badge>
)}
{tmpl.isGlobalTemplate && (
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 text-[10px] rounded-md">{t('competencies.global_template')}</Badge>
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1 ml-9">
<span className={`inline-flex items-center gap-1 font-medium ${sMeta.text}`}>
<span className={`inline-block w-1.5 h-1.5 rounded-full ${sMeta.dot}`} />
{tmpl.subject?.name ?? '—'}
</span>
<span className="mx-1.5 text-gray-300 dark:text-gray-600">·</span>
{t('competencies.grade_range', { min: tmpl.gradeLevelMin, max: tmpl.gradeLevelMax })}
<span className="mx-1.5 text-gray-300 dark:text-gray-600">·</span>
<span className="inline-flex items-center gap-1">
<Tag className="h-2.5 w-2.5" />
{tmpl.categories.reduce((s, c) => s + c.competencies.length, 0)} {t('polish.competency_count')}
</span>
</p>
{/* Category dots */}
{tmpl.categories.length > 0 && (
<div className="flex items-center gap-1 mt-2 ml-9">
<Tag className="h-3 w-3 text-gray-400" />
{tmpl.categories.map((cat, i) => (
<div
key={cat.id}
className="w-4 h-4 rounded-full border-2 border-white dark:border-gray-800 shadow-sm"
style={{ backgroundColor: cat.color ?? categoryColors[i % categoryColors.length].bg.replace('bg-', '').includes('emerald') ? '#10b981' : '#14b8a6' }}
title={cat.name}
/>
))}
<span className="text-[10px] text-gray-400 ml-1">{tmpl.categories.length} {t('progress.entries')}</span>
</div>
)}
{/* Progress indicator */}
<div className="flex items-center gap-2 mt-2 ml-9">
<div className="w-24 h-1.5 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
<div
className="h-full rounded-full bg-gradient-to-r from-emerald-400 to-teal-400 animate-progress-fill"
style={{ width: `${competencyProgressPercent(tmpl)}%` }}
/>
</div>
<span className="text-[10px] text-gray-400">{competencyProgressPercent(tmpl)}%</span>
</div>
</div>
<div className="flex items-center gap-1 shrink-0 ml-2">
<ChevronRight className="h-4 w-4 text-gray-400" />
</div>
</div>
</motion.button>
);
})}
</div>
)}
</CardContent>
</Card>
{/* Template details */}
<div className="lg:col-span-2 space-y-6">
{!selectedTemplate ? (
<>
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
<CardContent className="py-16 text-center">
<div className="flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-emerald-100 to-teal-100 dark:from-emerald-900/30 dark:to-teal-900/30 mx-auto mb-5 shadow-md shadow-emerald-200/40 dark:shadow-emerald-900/20">
<Grid3X3 className="h-10 w-10 text-emerald-500 dark:text-emerald-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1">{t('polish.empty_title_competency_grid')}</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto">{t('polish.empty_subtitle_competency_grid')}</p>
</CardContent>
</Card>
{/* Quick Guide: Overview stats + by-subject breakdown + recent templates */}
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-teal-50/50 via-emerald-50/30 to-transparent dark:from-teal-900/10 dark:via-emerald-900/10 dark:to-transparent">
<CardTitle className="text-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-teal-500 to-emerald-500 text-white shadow-sm">
<BarChart3 className="h-4 w-4" />
</div>
{t('competencies.quick_guide_title')}
<span className="text-xs font-normal text-gray-500 dark:text-gray-400 ml-1 hidden sm:inline">
· {t('competencies.quick_guide_subtitle')}
</span>
</CardTitle>
</CardHeader>
<CardContent>
{(() => {
const totalCompetencies = templates.reduce(
(sum, tmpl) => sum + tmpl.categories.reduce((s, c) => s + c.competencies.length, 0),
0,
);
const totalCategories = templates.reduce((sum, tmpl) => sum + tmpl.categories.length, 0);
// By subject breakdown
const bySubject = new Map<string, number>();
for (const tmpl of templates) {
const subjectName = tmpl.subject?.name ?? t('competencies.no_subject');
const cnt = tmpl.categories.reduce((s, c) => s + c.competencies.length, 0);
bySubject.set(subjectName, (bySubject.get(subjectName) ?? 0) + cnt);
}
const subjectRows = Array.from(bySubject.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 5);
const maxSubjectCount = Math.max(1, ...subjectRows.map(([, n]) => n));
// Recent templates (sorted by name as a stable fallback — no updatedAt available on this model)
const recentTemplates = [...templates]
.sort((a, b) => a.name.localeCompare(b.name))
.slice(0, 4);
const tileColors = [
{ bg: 'from-emerald-50 to-emerald-50/0 dark:from-emerald-900/15 dark:to-emerald-900/0', border: 'border-emerald-100/60 dark:border-emerald-900/30', text: 'text-emerald-700 dark:text-emerald-300', iconBg: 'bg-gradient-to-br from-emerald-400 to-emerald-500' },
{ bg: 'from-teal-50 to-teal-50/0 dark:from-teal-900/15 dark:to-teal-900/0', border: 'border-teal-100/60 dark:border-teal-900/30', text: 'text-teal-700 dark:text-teal-300', iconBg: 'bg-gradient-to-br from-teal-400 to-teal-500' },
{ bg: 'from-amber-50 to-amber-50/0 dark:from-amber-900/15 dark:to-amber-900/0', border: 'border-amber-100/60 dark:border-amber-900/30', text: 'text-amber-700 dark:text-amber-300', iconBg: 'bg-gradient-to-br from-amber-400 to-amber-500' },
];
return (
<div className="space-y-5">
{/* Mini stat tiles */}
<div className="grid grid-cols-3 gap-3">
<div className={`p-3 rounded-xl bg-gradient-to-br ${tileColors[0].bg} border ${tileColors[0].border}`}>
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${tileColors[0].iconBg} text-white shadow-sm mb-2`}>
<Layers className="h-4 w-4" />
</div>
<p className={`text-2xl font-bold ${tileColors[0].text}`}>{templates.length}</p>
<p className="text-[10px] uppercase tracking-wide font-semibold text-gray-500 dark:text-gray-400">{t('competencies.total_templates')}</p>
</div>
<div className={`p-3 rounded-xl bg-gradient-to-br ${tileColors[1].bg} border ${tileColors[1].border}`}>
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${tileColors[1].iconBg} text-white shadow-sm mb-2`}>
<Tag className="h-4 w-4" />
</div>
<p className={`text-2xl font-bold ${tileColors[1].text}`}>{totalCategories}</p>
<p className="text-[10px] uppercase tracking-wide font-semibold text-gray-500 dark:text-gray-400">{t('competencies.total_categories')}</p>
</div>
<div className={`p-3 rounded-xl bg-gradient-to-br ${tileColors[2].bg} border ${tileColors[2].border}`}>
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${tileColors[2].iconBg} text-white shadow-sm mb-2`}>
<BookOpen className="h-4 w-4" />
</div>
<p className={`text-2xl font-bold ${tileColors[2].text}`}>{totalCompetencies}</p>
<p className="text-[10px] uppercase tracking-wide font-semibold text-gray-500 dark:text-gray-400">{t('competencies.total_competencies')}</p>
</div>
</div>
{/* By subject mini bar chart */}
<div>
<p className="text-xs uppercase tracking-wider font-semibold text-gray-500 dark:text-gray-400 mb-2">
{t('competencies.by_subject_breakdown')}
</p>
{subjectRows.length === 0 ? (
<p className="text-xs text-gray-400 dark:text-gray-500 italic">{t('competencies.no_subjects_yet')}</p>
) : (
<div className="space-y-1.5">
{subjectRows.map(([subjectName, count], idx) => {
const palette = ['bg-emerald-500', 'bg-teal-500', 'bg-amber-500', 'bg-violet-500', 'bg-rose-500'];
const textPalette = ['text-emerald-700 dark:text-emerald-300', 'text-teal-700 dark:text-teal-300', 'text-amber-700 dark:text-amber-300', 'text-violet-700 dark:text-violet-300', 'text-rose-700 dark:text-rose-300'];
const barColor = palette[idx % palette.length];
const textColor = textPalette[idx % textPalette.length];
const widthPct = Math.round((count / maxSubjectCount) * 100);
return (
<div key={subjectName} className="flex items-center gap-2">
<div className="w-24 shrink-0 truncate text-[11px] font-medium text-gray-700 dark:text-gray-300">{subjectName}</div>
<div className="flex-1 h-2 rounded-full bg-gray-100 dark:bg-gray-800/60 overflow-hidden min-w-0">
<div className={`h-full rounded-full ${barColor} transition-all duration-500`} style={{ width: `${widthPct}%` }} />
</div>
<div className={`w-8 shrink-0 text-right text-[11px] font-bold ${textColor}`}>{count}</div>
</div>
);
})}
</div>
)}
</div>
{/* Recent templates */}
<div>
<p className="text-xs uppercase tracking-wider font-semibold text-gray-500 dark:text-gray-400 mb-2">
{t('competencies.recent_templates')}
</p>
{recentTemplates.length === 0 ? (
<p className="text-xs text-gray-400 dark:text-gray-500 italic">{t('competencies.no_recent_templates')}</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{recentTemplates.map((tmpl) => {
const meta = subjectMeta(tmpl.subject?.name);
return (
<button
key={tmpl.id}
onClick={() => setSelectedTemplate(tmpl)}
className="text-left p-2.5 rounded-lg border border-gray-100 dark:border-gray-800 hover:border-emerald-200 dark:hover:border-emerald-800 hover:bg-emerald-50/40 dark:hover:bg-emerald-900/10 transition-colors group"
>
<div className="flex items-start gap-2">
<div className={`flex items-center justify-center w-7 h-7 rounded-md bg-gradient-to-br ${meta.gradient} text-white text-xs font-bold shadow-sm shrink-0`}>
{(tmpl.subject?.name ?? 'T')[0]}
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold text-gray-900 dark:text-gray-100 truncate group-hover:text-emerald-700 dark:group-hover:text-emerald-300">{tmpl.name}</p>
<p className="text-[10px] text-gray-500 dark:text-gray-400">
{tmpl.subject?.name ?? '—'} · {tmpl.categories.reduce((s, c) => s + c.competencies.length, 0)} {t('polish.competency_count')}
</p>
</div>
<ChevronRight className="h-3 w-3 text-gray-300 dark:text-gray-600 group-hover:text-emerald-500 shrink-0 mt-1" />
</div>
</button>
);
})}
</div>
)}
</div>
</div>
);
})()}
</CardContent>
</Card>
</>
) : (
<>
{/* Template info */}
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg font-bold">{selectedTemplate.name}</CardTitle>
<p className="text-sm text-emerald-600/60 dark:text-emerald-400/40 mt-1">
{selectedTemplate.subject?.name ?? '—'} · {t(`school_type.${selectedTemplate.schoolType.toLowerCase()}`)} · {t('competencies.grade_range', { min: selectedTemplate.gradeLevelMin, max: selectedTemplate.gradeLevelMax })} · {t('competencies.version')} {selectedTemplate.version}
</p>
</div>
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 text-xs font-medium rounded-xl">
{t('competencies.used_in_classes', { count: selectedTemplate._count?.classCompetencyAssignments ?? 0 })}
</Badge>
</div>
</CardHeader>
{selectedTemplate.description && (
<CardContent className="pt-0">
<p className="text-sm text-gray-600 dark:text-gray-400">{selectedTemplate.description}</p>
</CardContent>
)}
</Card>
{/* Grid structure */}
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent">
<div className="flex items-center justify-between">
<CardTitle className="text-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
<Layers className="h-4 w-4" />
</div>
{t('competencies.grid_structure')}
</CardTitle>
<div className="flex gap-2">
<Button size="sm" variant="outline" className="text-xs rounded-xl" onClick={() => {
const allCatIds = new Set(selectedTemplate.categories.map((c) => c.id));
setExpandedCategories(allCatIds);
}}>
{t('competencies.expand_all')}
</Button>
<Button size="sm" variant="outline" className="text-xs rounded-xl" onClick={() => setExpandedCategories(new Set())}>
{t('competencies.collapse_all')}
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{selectedTemplate.categories.length === 0 ? (
<div className="text-center py-8">
<div className="flex items-center justify-center w-16 h-16 rounded-2xl bg-teal-100 dark:bg-teal-900/20 mx-auto mb-4">
<Layers className="h-8 w-8 text-teal-400 dark:text-teal-500" />
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">{t('competencies.no_categories')}</p>
</div>
) : (
<div className="space-y-3">
{selectedTemplate.categories.map((cat, catIdx) => {
const colorSet = categoryColors[catIdx % categoryColors.length];
const isExpanded = expandedCategories.has(cat.id);
const catColor = cat.color ?? ['#10b981', '#14b8a6', '#f59e0b', '#8b5cf6', '#ef4444', '#0ea5e9'][catIdx % 6];
return (
<div key={cat.id} className={`rounded-xl border-l-3 overflow-hidden transition-shadow duration-200 hover:shadow-md ${colorSet.border}`}>
{/* Category header bar */}
<button
onClick={() => toggleCategory(cat.id)}
className={`w-full flex items-center justify-between p-4 bg-gradient-to-r ${colorSet.header} to-transparent transition-colors hover:brightness-105`}
>
<div className="flex items-center gap-3 flex-wrap">
<div
className="w-5 h-5 rounded-md shadow-sm ring-1 ring-white dark:ring-gray-900"
style={{ backgroundColor: catColor }}
/>
<h3 className="font-bold text-gray-900 dark:text-gray-100">{cat.name}</h3>
<Badge className={`${colorSet.bg} ${colorSet.text} text-xs font-medium rounded-xl inline-flex items-center gap-1`}>
<Tag className="h-3 w-3" />
{cat.competencies.length} {t('polish.competency_count')}
</Badge>
{/* Average mastery per category (derived) */}
<span className={`hidden sm:inline-flex items-center gap-1 text-[10px] ${colorSet.text} opacity-80 px-2 py-0.5 rounded-md bg-white/50 dark:bg-gray-800/50`}>
<TrendingUp className="h-3 w-3" />
{Array.from({ length: cat.competencies.length }).reduce<number>((sum, _, i) => sum + masteredPercent(cat.competencies[i]?.id ?? 'x'), 0) / Math.max(1, cat.competencies.length) | 0}% {t('polish.mastered')}
</span>
</div>
{isExpanded ? (
<ChevronUp className="h-5 w-5 text-gray-400" />
) : (
<ChevronDown className="h-5 w-5 text-gray-400" />
)}
</button>
{/* Competencies */}
{isExpanded && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="border-t border-emerald-200/20 dark:border-emerald-900/10"
>
<div className="space-y-2 p-3">
{cat.competencies.map((comp) => {
const dist = masteryDistribution(comp.id);
const mastered = masteredPercent(comp.id);
return (
<div
key={comp.id}
className="p-4 rounded-lg bg-gray-50/80 dark:bg-gray-800/30 border-l-2 transition-all duration-200 hover:bg-gray-100/50 dark:hover:bg-gray-800/50 hover:shadow-sm"
style={{ borderLeftColor: catColor }}
>
<div className="flex items-start justify-between mb-1 gap-2">
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 flex-1 min-w-0">
<span className="text-emerald-500/60 dark:text-emerald-400/40 font-mono mr-2">{comp.code}</span>
{comp.title}
</p>
<Badge variant="outline" className={`shrink-0 text-[10px] rounded-md font-semibold ${mastered >= 70 ? 'bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-300 border-emerald-200/50 dark:border-emerald-900/30' : mastered >= 40 ? 'bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-300 border-amber-200/50 dark:border-amber-900/30' : 'bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 border-red-200/50 dark:border-red-900/30'}`}>
<TrendingUp className="h-3 w-3 mr-0.5" />
{mastered}% {t('polish.mastered')}
</Badge>
</div>
{comp.description && (
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">{comp.description}</p>
)}
{/* Mastery distribution mini bar chart */}
<div className="flex items-center gap-2 mb-2">
<BarChart3 className="h-3 w-3 text-gray-400 shrink-0" />
<div className="flex h-2 w-full max-w-[220px] rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700 shadow-inner">
{dist.map((d) => (
<div
key={d.level}
className={`${d.color} transition-all duration-300 hover:brightness-110`}
style={{ width: `${d.pct}%` }}
title={`${t('polish.level_' + d.level)}: ${d.pct}%`}
/>
))}
</div>
<span className="text-[10px] text-gray-400 dark:text-gray-500 shrink-0">{t('polish.mastery_distribution')}</span>
</div>
{comp.masteryLevelDefinitions.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{comp.masteryLevelDefinitions.map((ml) => (
<Badge key={ml.id} className={`text-[10px] rounded-xl font-medium ${masteryColor(ml.levelValue)}`}>
{masteryIcon(ml.levelValue)} {ml.levelValue}: {ml.label}
</Badge>
))}
</div>
)}
</div>
);
})}
</div>
</motion.div>
)}
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</>
)}
</div>
</div>
);
}
+882
View File
@@ -0,0 +1,882 @@
'use client';
import React, { useEffect, useMemo, useState, useCallback } from 'react';
import { motion } from 'framer-motion';
import {
Target,
RefreshCw,
CheckCircle2,
AlertCircle,
XCircle,
BookOpen,
Users,
Layers,
TrendingUp,
Clock,
Search,
ChevronRight,
Sparkles,
GraduationCap,
AlertTriangle,
PartyPopper,
Sprout,
Leaf,
TreePine,
Trees,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import { toast } from 'sonner';
import {
fetchClasses,
fetchSubjects,
fetchCurriculumCoverage,
type ClassGroup,
type Subject,
type CurriculumCoverage,
type CurriculumCoverageCompetency,
} from '@/lib/api';
const STATUS_META: Record<
'covered' | 'partial' | 'untouched',
{ icon: React.ElementType; bg: string; text: string; border: string }
> = {
covered: {
icon: CheckCircle2,
bg: 'bg-emerald-50 dark:bg-emerald-900/20',
text: 'text-emerald-700 dark:text-emerald-300',
border: 'border-emerald-200 dark:border-emerald-800',
},
partial: {
icon: AlertTriangle,
bg: 'bg-amber-50 dark:bg-amber-900/20',
text: 'text-amber-700 dark:text-amber-300',
border: 'border-amber-200 dark:border-amber-800',
},
untouched: {
icon: XCircle,
bg: 'bg-rose-50 dark:bg-rose-900/20',
text: 'text-rose-700 dark:text-rose-300',
border: 'border-rose-200 dark:border-rose-800',
},
};
function formatDate(iso: string | null, locale: string): string {
if (!iso) return '';
try {
const d = new Date(iso);
return d.toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
} catch {
return iso;
}
}
function MasteryIcon({ level }: { level: number | null }) {
if (level === null || level === undefined) return <span className="text-lg">·</span>;
if (level <= 1) return <Sprout className="w-5 h-5" />;
if (level === 2) return <Leaf className="w-5 h-5" />;
if (level === 3) return <TreePine className="w-5 h-5" />;
return <Trees className="w-5 h-5" />;
}
export default function CurriculumCoverageView() {
const currentUser = useAppStore((s) => s.currentUser);
const schoolYearId = useAppStore((s) => s.schoolYearId);
const storeClassId = useAppStore((s) => s.currentClassId);
const setCurrentView = useAppStore((s) => s.setCurrentView);
const setCurrentClass = useAppStore((s) => s.setCurrentClass);
const locale = currentUser?.locale ?? 'de';
const [classes, setClasses] = useState<ClassGroup[]>([]);
const [subjects, setSubjects] = useState<Subject[]>([]);
const [selectedClassId, setSelectedClassId] = useState<string>(storeClassId ?? '');
const [selectedSubjectId, setSelectedSubjectId] = useState<string>('');
const [data, setData] = useState<CurriculumCoverage | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Filter/sort state for matrix
const [statusFilter, setStatusFilter] = useState<'all' | 'covered' | 'partial' | 'untouched'>('all');
const [sortBy, setSortBy] = useState<'category' | 'status' | 'recent'>('category');
const [searchQuery, setSearchQuery] = useState('');
const [detailComp, setDetailComp] = useState<CurriculumCoverageCompetency | null>(null);
// Load classes + subjects
useEffect(() => {
async function load() {
try {
const [cls, subs] = await Promise.all([
fetchClasses(currentUser?.schoolId ?? undefined, schoolYearId ?? undefined).catch(() => []),
fetchSubjects(currentUser?.schoolId ?? undefined).catch(() => []),
]);
setClasses(cls);
setSubjects(subs);
if (!selectedClassId && cls.length > 0) {
setSelectedClassId(cls[0].id);
}
} catch {
// ignore
}
}
load();
}, [currentUser?.schoolId, schoolYearId]);
const loadCoverage = useCallback(async () => {
if (!selectedClassId) {
setData(null);
return;
}
setLoading(true);
setError(null);
try {
const d = await fetchCurriculumCoverage({
classGroupId: selectedClassId,
subjectId: selectedSubjectId || undefined,
});
setData(d);
} catch (err) {
const msg = err instanceof Error ? err.message : t('coverage.error');
setError(msg);
} finally {
setLoading(false);
}
}, [selectedClassId, selectedSubjectId]);
useEffect(() => {
loadCoverage();
}, [loadCoverage]);
// Sync selected class back to store for cross-view consistency
useEffect(() => {
if (selectedClassId && selectedClassId !== storeClassId) {
setCurrentClass(selectedClassId);
}
}, [selectedClassId, storeClassId, setCurrentClass]);
// Filtered + sorted competency list
const filteredCompetencies = useMemo(() => {
if (!data) return [];
let list = data.byCompetency.slice();
if (statusFilter !== 'all') {
list = list.filter((c) => c.status === statusFilter);
}
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase().trim();
list = list.filter(
(c) => c.code.toLowerCase().includes(q) || c.title.toLowerCase().includes(q)
);
}
if (sortBy === 'category') {
list.sort((a, b) => a.categoryName.localeCompare(b.categoryName) || a.code.localeCompare(b.code));
} else if (sortBy === 'status') {
const order = { untouched: 0, partial: 1, covered: 2 };
list.sort((a, b) => order[a.status] - order[b.status] || a.categoryName.localeCompare(b.categoryName));
} else if (sortBy === 'recent') {
list.sort((a, b) => {
const aT = a.lastAssessedDate ? new Date(a.lastAssessedDate).getTime() : 0;
const bT = b.lastAssessedDate ? new Date(b.lastAssessedDate).getTime() : 0;
return bT - aT;
});
}
return list;
}, [data, statusFilter, sortBy, searchQuery]);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-500 to-teal-600 text-white shadow-lg shadow-emerald-500/20">
<Target className="h-5 w-5" />
</div>
<div>
<h2 className="text-2xl font-bold tracking-tight bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">
{t('coverage.title')}
</h2>
<p className="text-sm text-muted-foreground">{t('coverage.subtitle')}</p>
</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={loadCoverage} disabled={loading}>
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
{t('action.refresh')}
</Button>
</div>
</div>
{/* Filter row */}
<Card className="border-emerald-100 dark:border-emerald-900/30">
<CardContent className="p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="flex-1 space-y-1.5">
<label className="text-[10px] font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t('label.class')}
</label>
<Select value={selectedClassId} onValueChange={setSelectedClassId}>
<SelectTrigger className="w-full rounded-xl border-emerald-300 dark:border-emerald-700 bg-emerald-50/30 dark:bg-emerald-900/10 ring-1 ring-emerald-200/60 dark:ring-emerald-800/40 hover:border-emerald-400 dark:hover:border-emerald-600 [&_svg]:opacity-100 [&_svg]:text-emerald-500">
<SelectValue placeholder={t('coverage.select_class')} />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
🏫 {c.name} · {c.schoolType}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1 space-y-1.5">
<label className="text-[10px] font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t('label.subject')}
</label>
<Select
value={selectedSubjectId || '__all__'}
onValueChange={(v) => setSelectedSubjectId(v === '__all__' ? '' : v)}
>
<SelectTrigger className="w-full rounded-xl border-emerald-300 dark:border-emerald-700 bg-emerald-50/30 dark:bg-emerald-900/10 ring-1 ring-emerald-200/60 dark:ring-emerald-800/40 hover:border-emerald-400 dark:hover:border-emerald-600 [&_svg]:opacity-100 [&_svg]:text-emerald-500">
<SelectValue placeholder={t('coverage.select_subject')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">{t('coverage.select_subject')}</SelectItem>
{subjects.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* Main content */}
{!selectedClassId ? (
<EmptyClassState />
) : loading ? (
<LoadingState />
) : error ? (
<ErrorState message={error} onRetry={loadCoverage} />
) : !data ? null : !data.hasAssignments ? (
<NoAssignmentsState onGoToGrid={() => setCurrentView('competencies')} />
) : (
<div className="space-y-6">
{/* KPI tiles */}
<KpiTiles data={data} />
{/* Section 1: by category */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Layers className="h-5 w-5 text-emerald-600" />
{t('coverage.section.by_category')}
</CardTitle>
<CardDescription>{t('coverage.section.by_category_desc')}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{data.byCategory.length === 0 ? (
<p className="text-sm text-muted-foreground py-6 text-center">{t('coverage.no_data')}</p>
) : (
data.byCategory.map((cat) => (
<CategoryBar key={cat.categoryId} cat={cat} />
))
)}
</CardContent>
</Card>
{/* Section 2: matrix */}
<Card>
<CardHeader className="pb-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-lg">
<BookOpen className="h-5 w-5 text-teal-600" />
{t('coverage.section.matrix')}
</CardTitle>
<CardDescription>{t('coverage.section.matrix_desc')}</CardDescription>
</div>
</div>
{/* Filters */}
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="flex flex-wrap gap-1">
{(['all', 'covered', 'partial', 'untouched'] as const).map((f) => (
<button
key={f}
onClick={() => setStatusFilter(f)}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-all ${
statusFilter === f
? 'bg-emerald-500 text-white shadow-md shadow-emerald-500/30'
: 'bg-muted hover:bg-emerald-100 dark:hover:bg-emerald-900/20 text-muted-foreground'
}`}
>
{t(`coverage.filter.${f}`)}
</button>
))}
</div>
<div className="flex flex-1 gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('coverage.search_placeholder')}
className="pl-8 rounded-xl"
/>
</div>
<Select value={sortBy} onValueChange={(v) => setSortBy(v as 'category' | 'status' | 'recent')}>
<SelectTrigger className="w-[160px] rounded-xl">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="category">{t('coverage.sort.by_category')}</SelectItem>
<SelectItem value="status">{t('coverage.sort.by_status')}</SelectItem>
<SelectItem value="recent">{t('coverage.sort.by_recent')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardHeader>
<CardContent>
{filteredCompetencies.length === 0 ? (
<p className="text-sm text-muted-foreground py-6 text-center">{t('coverage.no_data')}</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{filteredCompetencies.map((comp, i) => (
<motion.div
key={comp.competencyId}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: Math.min(i * 0.03, 0.4) }}
>
<CompetencyCard
comp={comp}
studentsCount={data.totals.studentsCount}
locale={locale}
onClick={() => setDetailComp(comp)}
/>
</motion.div>
))}
</div>
)}
</CardContent>
</Card>
{/* Section 3 + 4: action needed + recent (2-col on desktop) */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Action needed */}
<Card className="border-rose-100 dark:border-rose-900/30">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<AlertCircle className="h-5 w-5 text-rose-600" />
{t('coverage.section.action_needed')}
</CardTitle>
<CardDescription>{t('coverage.section.action_needed_desc')}</CardDescription>
</CardHeader>
<CardContent>
{data.notAssessedList.length === 0 ? (
<div className="text-center py-8 space-y-2">
<PartyPopper className="w-10 h-10 text-emerald-500 dark:text-emerald-400 mx-auto" />
<p className="font-semibold text-emerald-700 dark:text-emerald-300">
{t('coverage.empty_action')}
</p>
<p className="text-xs text-muted-foreground">{t('coverage.empty_action_desc')}</p>
</div>
) : (
<ul className="space-y-2 max-h-96 overflow-y-auto pr-1">
{data.notAssessedList.slice(0, 10).map((c) => {
const meta = STATUS_META[c.status];
return (
<li
key={c.competencyId}
className="flex items-center gap-2 p-2 rounded-lg hover:bg-muted/60 transition-colors"
>
<span className={`flex h-7 w-7 items-center justify-center rounded-md text-xs font-bold ${meta.bg} ${meta.text}`}>
<meta.icon className="w-3.5 h-3.5" />
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<code className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
{c.code}
</code>
<span className="text-xs truncate">{c.title}</span>
</div>
<div className="text-[10px] text-muted-foreground">
{c.categoryName} · {c.studentsAssessed}/{c.studentsCount} {t('coverage.assessed_count').toLowerCase()}
</div>
</div>
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-emerald-700 hover:text-emerald-800 hover:bg-emerald-50 dark:text-emerald-300 dark:hover:bg-emerald-900/20"
onClick={() => {
setCurrentClass(selectedClassId);
setCurrentView('progress');
}}
>
{t('coverage.record_progress')}
<ChevronRight className="h-3 w-3 ml-0.5" />
</Button>
</li>
);
})}
</ul>
)}
</CardContent>
</Card>
{/* Recent */}
<Card className="border-teal-100 dark:border-teal-900/30">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Clock className="h-5 w-5 text-teal-600" />
{t('coverage.section.recent')}
</CardTitle>
<CardDescription>{t('coverage.section.recent_desc')}</CardDescription>
</CardHeader>
<CardContent>
{data.recentAssessed.length === 0 ? (
<div className="text-center py-8 space-y-2">
<div className="text-3xl opacity-60">📭</div>
<p className="text-sm font-medium">{t('coverage.empty_recent')}</p>
<p className="text-xs text-muted-foreground">{t('coverage.empty_recent_desc')}</p>
</div>
) : (
<ul className="space-y-2">
{data.recentAssessed.map((r, idx) => (
<motion.li
key={r.competencyId}
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.25, delay: idx * 0.05 }}
className="flex items-center gap-3 p-2 rounded-lg hover:bg-muted/60 transition-colors"
>
<MasteryIcon level={r.masteryLevelValue} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<code className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
{r.code}
</code>
<span className="text-sm truncate">{r.title}</span>
</div>
<div className="text-[10px] text-muted-foreground">
{r.categoryName} · {formatDate(r.date, locale)}
</div>
</div>
</motion.li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
</div>
)}
{/* Detail dialog */}
<Dialog open={!!detailComp} onOpenChange={(v) => !v && setDetailComp(null)}>
<DialogContent className="sm:max-w-md">
{detailComp && (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<span className={`flex h-7 w-7 items-center justify-center rounded-md ${STATUS_META[detailComp.status].bg} ${STATUS_META[detailComp.status].text}`}>
{(() => { const StatusIcon = STATUS_META[detailComp.status].icon; return <StatusIcon className="w-3.5 h-3.5" />; })()}
</span>
<code className="text-xs font-mono text-muted-foreground">{detailComp.code}</code>
</DialogTitle>
<DialogDescription className="text-base font-medium text-foreground">
{detailComp.title}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{t('coverage.assessed_count')}</span>
<Badge className={`${STATUS_META[detailComp.status].bg} ${STATUS_META[detailComp.status].text} ${STATUS_META[detailComp.status].border} border`}>
{detailComp.studentsAssessed} / {data?.totals.studentsCount ?? 0}
</Badge>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{t('coverage.progress_count')}</span>
<span className="font-semibold">{detailComp.progressCount}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{t('coverage.assessments_count')}</span>
<span className="font-semibold">{detailComp.assessmentCount}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{t('coverage.last_assessed')}</span>
<span className="font-semibold">
{detailComp.lastAssessedDate ? formatDate(detailComp.lastAssessedDate, locale) : t('coverage.never_assessed')}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{t('label.category')}</span>
<Badge
variant="outline"
style={{
borderColor: detailComp.categoryColor,
color: detailComp.categoryColor,
backgroundColor: `${detailComp.categoryColor}15`,
}}
>
{detailComp.categoryName}
</Badge>
</div>
<Button
className="w-full mt-2 bg-gradient-to-r from-emerald-500 to-teal-600 hover:from-emerald-600 hover:to-teal-700"
onClick={() => {
if (selectedClassId) setCurrentClass(selectedClassId);
setCurrentView('progress');
}}
>
{t('coverage.record_progress')}
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
</>
)}
</DialogContent>
</Dialog>
</div>
);
}
/* ── Sub-components ───────────────────────────────────────────────── */
function KpiTiles({ data }: { data: CurriculumCoverage }) {
const tiles = [
{
label: t('coverage.kpi.total'),
value: data.totals.competencies,
icon: Layers,
gradient: 'from-emerald-500 to-teal-600',
bg: 'bg-emerald-50 dark:bg-emerald-900/20',
},
{
label: t('coverage.kpi.assessed'),
value: `${data.totals.assessed} (${data.totals.coveragePercent}%)`,
icon: CheckCircle2,
gradient: 'from-teal-500 to-emerald-600',
bg: 'bg-teal-50 dark:bg-teal-900/20',
ring: true,
coveragePercent: data.totals.coveragePercent,
},
{
label: t('coverage.kpi.not_assessed'),
value: `${data.totals.notAssessed} (${100 - data.totals.coveragePercent}%)`,
icon: AlertCircle,
gradient: 'from-rose-500 to-amber-500',
bg: 'bg-rose-50 dark:bg-rose-900/20',
},
{
label: t('coverage.kpi.students'),
value: data.totals.studentsCount,
icon: Users,
gradient: 'from-amber-500 to-violet-500',
bg: 'bg-amber-50 dark:bg-amber-900/20',
},
];
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{tiles.map((tile, i) => (
<motion.div
key={tile.label}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: i * 0.05 }}
>
<Card className={`${tile.bg} border-0 shadow-sm`}>
<CardContent className="p-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-1 leading-tight">
{tile.label}
</p>
<p className="text-2xl font-bold tracking-tight">{tile.value}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
{tile.ring && (
<svg width="40" height="40" viewBox="0 0 40 40" className="shrink-0">
<circle
cx="20"
cy="20"
r="16"
fill="none"
stroke="currentColor"
strokeWidth="3"
className="text-gray-200 dark:text-gray-700"
/>
<circle
cx="20"
cy="20"
r="16"
fill="none"
stroke="#10b981"
strokeWidth="3"
strokeDasharray={`${(tile.coveragePercent! / 100) * 2 * Math.PI * 16} ${2 * Math.PI * 16}`}
strokeLinecap="round"
transform="rotate(-90 20 20)"
/>
<text
x="20"
y="20"
textAnchor="middle"
dominantBaseline="central"
className="text-[9px] font-bold fill-emerald-700 dark:fill-emerald-300"
>
{tile.coveragePercent}%
</text>
</svg>
)}
<div className={`flex h-9 w-9 items-center justify-center rounded-lg bg-gradient-to-br ${tile.gradient} text-white shadow-md shrink-0`}>
<tile.icon className="h-4 w-4" />
</div>
</div>
</div>
</CardContent>
</Card>
</motion.div>
))}
</div>
);
}
function CategoryBar({
cat,
}: {
cat: CurriculumCoverage['byCategory'][number];
}) {
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2 min-w-0">
<span
className="h-2.5 w-2.5 rounded-full shrink-0"
style={{ backgroundColor: cat.categoryColor }}
/>
<span className="font-medium truncate">{cat.categoryName}</span>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground shrink-0">
<span>{cat.assessed}/{cat.total}</span>
<span className="font-semibold" style={{ color: cat.categoryColor }}>
{cat.coveragePercent}%
</span>
</div>
</div>
<div className="h-2.5 rounded-full bg-muted overflow-hidden flex">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${cat.coveragePercent}%` }}
transition={{ duration: 0.6, ease: 'easeOut' }}
className="h-full rounded-full"
style={{ backgroundColor: cat.categoryColor }}
/>
<motion.div
initial={{ width: '100%' }}
animate={{ width: `${100 - cat.coveragePercent}%` }}
transition={{ duration: 0.6, ease: 'easeOut' }}
className="h-full bg-muted-foreground/15"
/>
</div>
</div>
);
}
function CompetencyCard({
comp,
studentsCount,
locale,
onClick,
}: {
comp: CurriculumCoverageCompetency;
studentsCount: number;
locale: string;
onClick: () => void;
}) {
const meta = STATUS_META[comp.status];
return (
<button
onClick={onClick}
className="w-full text-left p-3 rounded-xl border border-border/60 hover:border-emerald-300 dark:hover:border-emerald-700 hover:shadow-md transition-all bg-card"
>
<div className="flex items-start justify-between gap-2 mb-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 mb-1">
<code className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
{comp.code}
</code>
<span className={`text-[10px] px-1.5 py-0.5 rounded-full ${meta.bg} ${meta.text} border ${meta.border}`}>
<meta.icon className="w-3 h-3 inline mr-0.5" /> {t(`coverage.status.${comp.status}`)}
</span>
</div>
<p className="text-sm font-medium leading-tight line-clamp-2">{comp.title}</p>
</div>
</div>
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
<div className="flex items-center gap-2">
<span
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md"
style={{ backgroundColor: `${comp.categoryColor}20`, color: comp.categoryColor }}
>
<span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: comp.categoryColor }} />
{comp.categoryName}
</span>
</div>
<div className="flex items-center gap-3">
<span className="flex items-center gap-1">
<Users className="h-3 w-3" />
{comp.studentsAssessed}/{studentsCount}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{comp.lastAssessedDate ? formatDate(comp.lastAssessedDate, locale) : '—'}
</span>
</div>
</div>
</button>
);
}
function EmptyClassState() {
return (
<Card className="border-dashed border-emerald-200 dark:border-emerald-900/40">
<CardContent className="py-12">
<div className="space-y-6">
<div className="text-center space-y-3">
<div className="inline-flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-emerald-500 to-teal-600 text-white shadow-xl shadow-emerald-500/20">
<GraduationCap className="h-8 w-8" />
</div>
<div>
<h3 className="text-lg font-semibold">{t('coverage.select_class')}</h3>
</div>
</div>
<div className="space-y-3">
<div className="text-center">
<p className="text-sm text-muted-foreground mb-1">{t('coverage.guide.subtitle')}</p>
<h4 className="text-base font-semibold flex items-center justify-center gap-1.5">
<Sparkles className="h-4 w-4 text-amber-500" />
{t('coverage.guide.title')}
</h4>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{[
{ num: 1, icon: Users, title: t('coverage.guide.step1_title'), desc: t('coverage.guide.step1_desc'), gradient: 'from-emerald-500 to-teal-600' },
{ num: 2, icon: BookOpen, title: t('coverage.guide.step2_title'), desc: t('coverage.guide.step2_desc'), gradient: 'from-teal-500 to-emerald-600' },
{ num: 3, icon: Target, title: t('coverage.guide.step3_title'), desc: t('coverage.guide.step3_desc'), gradient: 'from-amber-500 to-violet-500' },
].map((step) => (
<div
key={step.num}
className="relative p-4 rounded-xl border border-emerald-100 dark:border-emerald-900/30 bg-gradient-to-br from-emerald-50/50 to-teal-50/50 dark:from-emerald-900/10 dark:to-teal-900/10"
>
<div className="absolute top-3 right-3 flex h-7 w-7 items-center justify-center rounded-full bg-gradient-to-br from-emerald-500 to-teal-600 text-white text-xs font-bold shadow-md">
{step.num}
</div>
<div className={`flex h-9 w-9 items-center justify-center rounded-lg bg-gradient-to-br ${step.gradient} text-white shadow-md mb-3`}>
<step.icon className="h-4 w-4" />
</div>
<h5 className="font-semibold text-sm mb-1">{step.title}</h5>
<p className="text-xs text-muted-foreground leading-relaxed">{step.desc}</p>
</div>
))}
</div>
</div>
</div>
</CardContent>
</Card>
);
}
function NoAssignmentsState({ onGoToGrid }: { onGoToGrid: () => void }) {
return (
<Card className="border-dashed border-amber-200 dark:border-amber-900/40">
<CardContent className="py-12 text-center space-y-4">
<div className="inline-flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-amber-400 to-amber-600 text-white shadow-xl">
<BookOpen className="h-8 w-8" />
</div>
<div className="space-y-1">
<h3 className="text-lg font-semibold">{t('coverage.no_assignments')}</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto">
{t('coverage.no_assignments_desc')}
</p>
</div>
<Button
onClick={onGoToGrid}
className="bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-600 hover:to-amber-700"
>
{t('coverage.go_to_grid')}
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</CardContent>
</Card>
);
}
function LoadingState() {
return (
<div className="space-y-4">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-24 rounded-xl" />
))}
</div>
<Card>
<CardContent className="p-6 space-y-3">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-12 rounded-lg" />
))}
</CardContent>
</Card>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{[1, 2, 3, 4, 5, 6].map((i) => (
<Skeleton key={i} className="h-24 rounded-xl" />
))}
</div>
</div>
);
}
function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }) {
return (
<Card className="border-rose-200 dark:border-rose-900/40">
<CardContent className="py-12 text-center space-y-4">
<div className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-rose-100 dark:bg-rose-900/30 text-rose-600 dark:text-rose-400">
<XCircle className="h-7 w-7" />
</div>
<div>
<h3 className="font-semibold">{t('coverage.error')}</h3>
<p className="text-sm text-muted-foreground mt-1">{message}</p>
</div>
<Button variant="outline" onClick={onRetry}>
<RefreshCw className="h-4 w-4 mr-1" />
{t('action.refresh')}
</Button>
</CardContent>
</Card>
);
}
File diff suppressed because it is too large. Load diff
+781
View File
@@ -0,0 +1,781 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import {
Calculator, Plus, TrendingUp, BarChart3, Target,
Download, BookOpen,
Star, ThumbsUp, AlertTriangle, Circle,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Textarea } from '@/components/ui/textarea';
import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import {
fetchClasses, fetchClassStudents, fetchSubjects,
fetchGradingSchemes, createGradingScheme,
fetchSchoolYears, downloadCsvExport, addNotification,
type ClassGroup, type Student, type Subject,
type GradingScheme, type SchoolYear,
} from '@/lib/api';
import { toast } from 'sonner';
const gradeColor = (value: number) => {
if (value <= 1.5) return { bg: 'bg-gradient-to-br from-emerald-400 to-emerald-500', text: 'text-white', shadow: 'shadow-emerald-300/30', border: 'border-l-emerald-500' };
if (value <= 2.5) return { bg: 'bg-gradient-to-br from-teal-400 to-teal-500', text: 'text-white', shadow: 'shadow-teal-300/30', border: 'border-l-teal-500' };
if (value <= 3.5) return { bg: 'bg-gradient-to-br from-amber-400 to-amber-500', text: 'text-white', shadow: 'shadow-amber-300/30', border: 'border-l-amber-500' };
return { bg: 'bg-gradient-to-br from-red-400 to-red-500', text: 'text-white', shadow: 'shadow-red-300/30', border: 'border-l-red-500' };
};
const GradeIcon = ({ value, className = 'w-3 h-3' }: { value: number; className?: string }) => {
if (value <= 1.5) return <Star className={className} />;
if (value <= 2.5) return <ThumbsUp className={className} />;
if (value <= 3.5) return <AlertTriangle className={className} />;
return <Circle className={className} />;
};
const weightSourceColor = (source: string) => {
if (source === 'LEARNING_PROGRESS') return { bg: 'bg-emerald-100 dark:bg-emerald-900/30', text: 'text-emerald-700 dark:text-emerald-300', bar: 'from-emerald-400 to-emerald-500', dot: 'bg-emerald-500' };
if (source === 'ASSESSMENT') return { bg: 'bg-amber-100 dark:bg-amber-900/30', text: 'text-amber-700 dark:text-amber-300', bar: 'from-amber-400 to-amber-500', dot: 'bg-amber-500' };
return { bg: 'bg-gray-100 dark:bg-gray-900/30', text: 'text-gray-700 dark:text-gray-300', bar: 'from-gray-400 to-gray-500', dot: 'bg-gray-500' };
};
export default function GradingView() {
const currentUser = useAppStore((s) => s.currentUser);
const currentClassId = useAppStore((s) => s.currentClassId);
const [classes, setClasses] = useState<ClassGroup[]>([]);
const [selectedClass, setSelectedClass] = useState<ClassGroup | null>(null);
const [subjects, setSubjects] = useState<Subject[]>([]);
const [selectedSubjectId, setSelectedSubjectId] = useState<string>('');
const [students, setStudents] = useState<Student[]>([]);
const [schemes, setSchemes] = useState<GradingScheme[]>([]);
const [computedGrades, setComputedGrades] = useState<Array<{ studentId: string; computedValue: number; breakdown: Array<{ source: string; weight: number; value: number }> }> | null>(null);
const [schoolYears, setSchoolYears] = useState<SchoolYear[]>([]);
const [loading, setLoading] = useState(true);
const [computing, setComputing] = useState(false);
// Create scheme dialog
const [createOpen, setCreateOpen] = useState(false);
const [newName, setNewName] = useState('');
const [newType, setNewType] = useState('NUMERIC_GRADE');
const [newScale, setNewScale] = useState(JSON.stringify({ min: 1, max: 6, labels: { '1': 'sehr gut', '6': 'ungenügend' } }));
const [newWeightRules, setNewWeightRules] = useState<Array<{ sourceType: string; targetRef: string; weightPercent: number }>>([
{ sourceType: 'LEARNING_PROGRESS', targetRef: '', weightPercent: 50 },
{ sourceType: 'ASSESSMENT', targetRef: '', weightPercent: 50 },
]);
const [creating, setCreating] = useState(false);
// School-wide grading overview (loaded only when no class is selected, for the empty state)
const [overviewSchemes, setOverviewSchemes] = useState<GradingScheme[]>([]);
const [overviewLoading, setOverviewLoading] = useState(false);
useEffect(() => {
async function load() {
try {
const [cls, subs] = await Promise.all([
fetchClasses(currentUser?.schoolId ?? undefined),
fetchSubjects(currentUser?.schoolId ?? undefined),
]);
setClasses(cls);
setSubjects(subs);
if (currentClassId) {
const found = cls.find((c) => c.id === currentClassId);
if (found) handleSelectClass(found);
}
} catch {
// ignore
} finally {
setLoading(false);
}
}
load();
}, [currentUser?.schoolId]);
// Load school-wide grading overview only when no class is selected
useEffect(() => {
if (selectedClass || overviewSchemes.length > 0) return;
let cancelled = false;
setOverviewLoading(true);
fetchGradingSchemes({ schoolId: currentUser?.schoolId ?? undefined })
.then((data) => {
if (cancelled) return;
if (Array.isArray(data)) setOverviewSchemes(data);
else setOverviewSchemes(data.schemes);
})
.catch(() => {
// ignore
})
.finally(() => {
if (!cancelled) setOverviewLoading(false);
});
return () => {
cancelled = true;
};
}, [selectedClass, overviewSchemes.length, currentUser?.schoolId]);
// Overview computed stats
const overviewStats = useMemo(() => {
const total = overviewSchemes.length;
const subjectsCovered = new Set(overviewSchemes.map((s) => s.subjectId).filter(Boolean)).size;
const byType = overviewSchemes.reduce<Record<string, number>>((acc, s) => {
acc[s.type] = (acc[s.type] ?? 0) + 1;
return acc;
}, {});
const entries = Object.entries(byType).sort((a, b) => b[1] - a[1]);
const maxCount = entries.length > 0 ? entries[0][1] : 0;
return { total, subjectsCovered, entries, maxCount };
}, [overviewSchemes]);
const schemeTypeColor = (type: string) => {
if (type === 'NUMERIC_GRADE') return 'bg-emerald-500';
if (type === 'COMPETENCY_BASED') return 'bg-teal-500';
if (type === 'BEHAVIORAL') return 'bg-amber-500';
return 'bg-violet-500';
};
const schemeTypeLabel = (type: string) => {
if (type === 'NUMERIC_GRADE') return '1–6';
if (type === 'COMPETENCY_BASED') return 'Komp.';
if (type === 'BEHAVIORAL') return 'Verh.';
return type.slice(0, 5);
};
async function handleSelectClass(cls: ClassGroup) {
setSelectedClass(cls);
useAppStore.getState().setCurrentClass(cls.id);
try {
const [s, yrs] = await Promise.all([
fetchClassStudents(cls.id),
fetchSchoolYears(cls.schoolId),
]);
setStudents(s);
setSchoolYears(yrs);
} catch {
// ignore
}
}
async function loadSchemes(classGroupId: string, subjectId: string) {
try {
const data = await fetchGradingSchemes({ classGroupId, subjectId });
if (Array.isArray(data)) {
setSchemes(data);
setComputedGrades(null);
} else {
setSchemes(data.schemes);
setComputedGrades(data.computedGrades);
}
} catch {
setSchemes([]);
setComputedGrades(null);
}
}
const handleSelectSubject = async (subjectId: string) => {
setSelectedSubjectId(subjectId);
if (selectedClass) {
await loadSchemes(selectedClass.id, subjectId);
}
};
const handleComputeGrades = async () => {
if (!selectedClass || !selectedSubjectId) return;
const schoolYearId = selectedClass.schoolYearId;
setComputing(true);
try {
const data = await fetchGradingSchemes({
classGroupId: selectedClass.id,
subjectId: selectedSubjectId,
computeGrades: true,
schoolYearId,
});
if (!Array.isArray(data)) {
setSchemes(data.schemes);
setComputedGrades(data.computedGrades);
}
toast.success(t('grading.grades_computed'));
addNotification({
type: 'grade',
message: `${t('notification.grade_computed')}: ${selectedClass?.name ?? ''}`,
timestamp: new Date().toISOString(),
});
} catch (err) {
toast.error(err instanceof Error ? err.message : t('error.generic'));
} finally {
setComputing(false);
}
};
const handleCreateScheme = async () => {
if (!selectedClass || !selectedSubjectId) return;
setCreating(true);
try {
await createGradingScheme({
classGroupId: selectedClass.id,
subjectId: selectedSubjectId,
name: newName,
type: newType,
scaleDefinition: newScale,
weightRules: newWeightRules,
});
toast.success(t('toast.created'));
setCreateOpen(false);
setNewName('');
loadSchemes(selectedClass.id, selectedSubjectId);
} catch (err) {
toast.error(err instanceof Error ? err.message : t('error.generic'));
} finally {
setCreating(false);
}
};
const addWeightRule = () => {
setNewWeightRules([...newWeightRules, { sourceType: 'LEARNING_PROGRESS', targetRef: '', weightPercent: 0 }]);
};
const updateWeightRule = (index: number, field: string, value: string | number) => {
const rules = [...newWeightRules];
rules[index] = { ...rules[index], [field]: value };
setNewWeightRules(rules);
};
const removeWeightRule = (index: number) => {
setNewWeightRules(newWeightRules.filter((_, i) => i !== index));
};
if (loading) {
return <div className="space-y-4"><Skeleton className="h-96 rounded-xl" /></div>;
}
return (
<div className="space-y-6">
{/* Selection header */}
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
<CardContent className="p-6">
<div className="flex flex-col sm:flex-row flex-wrap gap-4 items-end">
<div className="space-y-1 min-w-[160px] sm:min-w-[200px]">
<Label className="text-sm font-medium text-emerald-600/60 dark:text-emerald-400/40">{t('polish.label_class')}</Label>
<Select
value={selectedClass?.id ?? ''}
onValueChange={(id) => {
const cls = classes.find((c) => c.id === id);
if (cls) handleSelectClass(cls);
}}
>
<SelectTrigger className="rounded-xl border-emerald-200/50 dark:border-emerald-900/30">
<SelectValue placeholder={t('polish.please_choose')} />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedClass && (
<div className="space-y-1 min-w-[160px] sm:min-w-[200px]">
<Label className="text-sm font-medium text-emerald-600/60 dark:text-emerald-400/40">{t('grading.select_subject')}</Label>
<Select value={selectedSubjectId} onValueChange={handleSelectSubject}>
<SelectTrigger className="rounded-xl border-emerald-200/50 dark:border-emerald-900/30">
<SelectValue placeholder={t('grading.select_subject')} />
</SelectTrigger>
<SelectContent>
{subjects.map((s) => (
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{selectedClass && selectedSubjectId && (
<div className="flex flex-col sm:flex-row gap-2">
<Button className="bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white shadow-md shadow-emerald-300/20 rounded-xl" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
{t('grading.create_scheme')}
</Button>
<Button variant="outline" className="rounded-xl border-emerald-300 dark:border-emerald-700" onClick={handleComputeGrades} disabled={computing || schemes.length === 0}>
<TrendingUp className="h-4 w-4 mr-1" />
{computing ? t('empty.loading') : t('grading.compute_grades')}
</Button>
<Button variant="outline" className="rounded-xl border-emerald-300 dark:border-emerald-700 text-emerald-600 dark:text-emerald-400" onClick={() => {
downloadCsvExport({
type: 'grades',
classGroupId: selectedClass?.id ?? undefined,
schoolYearId: useAppStore.getState().schoolYearId ?? undefined,
});
toast.success(t('csv.export_success'));
}}>
<Download className="h-4 w-4 mr-1" />
{t('action.export')} CSV
</Button>
</div>
)}
</div>
</CardContent>
</Card>
{!selectedClass || !selectedSubjectId ? (
<div className="space-y-4">
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
<CardContent className="py-8 text-center">
<div className="flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-emerald-100 to-teal-100 dark:from-emerald-900/30 dark:to-teal-900/30 mx-auto mb-5 shadow-md shadow-emerald-200/40 dark:shadow-emerald-900/20">
<Calculator className="h-10 w-10 text-emerald-500 dark:text-emerald-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1">{t('polish.empty_title_grading')}</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto">{t('grading.empty_description')}</p>
</CardContent>
</Card>
{/* Grading Overview card — fills the empty space with useful stats */}
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-amber-400 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-amber-50/50 to-transparent dark:from-amber-900/10 dark:to-transparent">
<CardTitle className="text-base font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400">
<Target className="h-4 w-4" />
</div>
{t('polish.grading_overview_title')}
<span className="text-xs font-normal text-gray-500 dark:text-gray-400 ml-1 hidden sm:inline">
· {t('polish.grading_overview_subtitle')}
</span>
</CardTitle>
</CardHeader>
<CardContent>
{overviewLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<Skeleton className="h-24 rounded-xl" />
<Skeleton className="h-24 rounded-xl" />
<Skeleton className="h-24 rounded-xl" />
</div>
) : (
<>
{/* Three mini-stats */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-4">
<div className="p-4 rounded-xl bg-gradient-to-br from-emerald-50/60 to-emerald-50/0 dark:from-emerald-900/15 dark:to-emerald-900/0 border border-emerald-100/60 dark:border-emerald-900/30">
<div className="flex items-center gap-2 mb-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-emerald-400 to-emerald-500 text-white shadow-sm">
<Calculator className="h-4 w-4" />
</div>
<p className="text-[10px] uppercase tracking-wider font-semibold text-emerald-600/70 dark:text-emerald-400/60">
{t('polish.grading_total_schemes')}
</p>
</div>
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100">
{overviewStats.total}
</p>
</div>
<div className="p-4 rounded-xl bg-gradient-to-br from-teal-50/60 to-teal-50/0 dark:from-teal-900/15 dark:to-teal-900/0 border border-teal-100/60 dark:border-teal-900/30">
<div className="flex items-center gap-2 mb-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-teal-400 to-teal-500 text-white shadow-sm">
<BookOpen className="h-4 w-4" />
</div>
<p className="text-[10px] uppercase tracking-wider font-semibold text-teal-600/70 dark:text-teal-400/60">
{t('polish.grading_subjects_covered')}
</p>
</div>
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100">
{overviewStats.subjectsCovered}
</p>
</div>
<div className="p-4 rounded-xl bg-gradient-to-br from-amber-50/60 to-amber-50/0 dark:from-amber-900/15 dark:to-amber-900/0 border border-amber-100/60 dark:border-amber-900/30">
<div className="flex items-center gap-2 mb-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-amber-400 to-amber-500 text-white shadow-sm">
<BarChart3 className="h-4 w-4" />
</div>
<p className="text-[10px] uppercase tracking-wider font-semibold text-amber-600/70 dark:text-amber-400/60">
{t('polish.grading_distribution')}
</p>
</div>
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100">
{overviewStats.entries.length}
</p>
</div>
</div>
{/* Distribution mini-bar */}
{overviewStats.entries.length > 0 && (
<div className="p-3 rounded-lg bg-gray-50/60 dark:bg-gray-800/40 border border-gray-100 dark:border-gray-800">
<p className="text-[10px] uppercase tracking-wider font-semibold text-gray-500 dark:text-gray-400 mb-2">
{t('polish.grading_distribution')}
</p>
<div className="space-y-1.5">
{overviewStats.entries.map(([type, count]) => (
<div key={type} className="flex items-center gap-2">
<span className="text-xs font-medium text-gray-600 dark:text-gray-300 w-12 shrink-0">
{schemeTypeLabel(type)}
</span>
<div className="flex-1 h-2.5 rounded-full bg-gray-200/60 dark:bg-gray-700/60 overflow-hidden">
<div
className={`h-full rounded-full ${schemeTypeColor(type)} transition-all duration-300`}
style={{ width: `${overviewStats.maxCount > 0 ? (count / overviewStats.maxCount) * 100 : 0}%` }}
/>
</div>
<span className="text-xs font-semibold text-gray-700 dark:text-gray-200 w-6 text-right shrink-0">
{count}
</span>
</div>
))}
</div>
</div>
)}
</>
)}
</CardContent>
</Card>
</div>
) : (
<>
{/* Schemes */}
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
<CardTitle className="text-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
<Calculator className="h-4 w-4" />
</div>
{t('grading.schemes')}
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 text-xs font-medium rounded-xl">{schemes.length}</Badge>
</CardTitle>
</CardHeader>
<CardContent>
{schemes.length === 0 ? (
<div className="text-center py-10">
<div className="flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-emerald-100 to-teal-100 dark:from-emerald-900/30 dark:to-teal-900/30 mx-auto mb-5 shadow-md shadow-emerald-200/40 dark:shadow-emerald-900/20">
<Calculator className="h-10 w-10 text-emerald-500 dark:text-emerald-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1">{t('polish.empty_title_no_data')}</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto">{t('grading.no_schemes')}</p>
<Button className="mt-5 bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white rounded-xl" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
{t('grading.create_scheme')}
</Button>
</div>
) : (
<div className="space-y-4">
{schemes.map((scheme) => (
<div key={scheme.id} className="p-6 rounded-xl bg-gradient-to-r from-gray-50 to-gray-50/0 dark:from-gray-800/50 dark:to-gray-800/0 border-l-3 border-l-emerald-400/40 transition-shadow duration-200 hover:shadow-md">
<div className="flex items-center justify-between mb-4">
<div>
<p className="font-bold text-gray-900 dark:text-gray-100">{scheme.name}</p>
<p className="text-xs text-emerald-600/60 dark:text-emerald-400/40 mt-0.5">
{t(`grading_type.${scheme.type.toLowerCase()}`)} · {scheme.classGroup?.name ?? '—'} · {scheme.subject?.name ?? '—'}
</p>
</div>
</div>
{scheme.gradingWeightRules.length > 0 && (
<div>
<p className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">{t('grading.weight_rules')}</p>
{/* Visual weight breakdown bar */}
<div className="h-4 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden mb-3">
{scheme.gradingWeightRules.map((rule, i) => {
const colors = weightSourceColor(rule.sourceType);
return (
<div
key={rule.id}
className={`h-full bg-gradient-to-r ${colors.bar} float-left transition-all`}
style={{ width: `${rule.weightPercent}%` }}
/>
);
})}
</div>
<div className="flex flex-wrap gap-3">
{scheme.gradingWeightRules.map((rule) => {
const colors = weightSourceColor(rule.sourceType);
return (
<div key={rule.id} className="flex items-center gap-2">
<div className={`w-2.5 h-2.5 rounded-full ${colors.dot}`} />
<span className={`text-xs font-medium ${colors.text}`}>
{t(`weight_source.${rule.sourceType.toLowerCase()}`)}: {rule.weightPercent}%
</span>
{rule.targetRef && (
<span className="text-xs text-gray-400">({rule.targetRef})</span>
)}
</div>
);
})}
</div>
</div>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Computed grades */}
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent">
<CardTitle className="text-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
<Target className="h-4 w-4" />
</div>
{t('grading.computed_grades')}
</CardTitle>
</CardHeader>
<CardContent>
{!computedGrades ? (
<div className="text-center py-8">
<div className="flex items-center justify-center w-16 h-16 rounded-2xl bg-teal-100 dark:bg-teal-900/20 mx-auto mb-4">
<Target className="h-8 w-8 text-teal-400 dark:text-teal-500" />
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">{t('grading.no_grades')}</p>
<p className="text-xs text-emerald-600/60 dark:text-emerald-400/40 mt-1">{t('grading.click_compute')}</p>
</div>
) : (
<>
{/* Grade statistics card */}
{computedGrades.length > 0 && (
<div className="mb-4 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-2">
{(() => {
const values = computedGrades.map((g) => g.computedValue).sort((a, b) => a - b);
const avg = values.reduce((s, v) => s + v, 0) / values.length;
const median = values.length % 2 === 0
? (values[values.length / 2 - 1] + values[values.length / 2]) / 2
: values[Math.floor(values.length / 2)];
const min = values[0];
const max = values[values.length - 1];
const stats = [
{ label: t('polish.class_average'), value: avg.toFixed(2), color: 'text-emerald-700 dark:text-emerald-300', bg: 'from-emerald-50 to-emerald-100/50 dark:from-emerald-900/20 dark:to-emerald-800/10', border: 'border-emerald-200/40 dark:border-emerald-900/30' },
{ label: t('polish.median'), value: median.toFixed(2), color: 'text-teal-700 dark:text-teal-300', bg: 'from-teal-50 to-teal-100/50 dark:from-teal-900/20 dark:to-teal-800/10', border: 'border-teal-200/40 dark:border-teal-900/30' },
{ label: t('polish.minimum'), value: min.toFixed(2), color: 'text-red-700 dark:text-red-300', bg: 'from-red-50 to-red-100/50 dark:from-red-900/20 dark:to-red-800/10', border: 'border-red-200/40 dark:border-red-900/30' },
{ label: t('polish.maximum'), value: max.toFixed(2), color: 'text-emerald-700 dark:text-emerald-300', bg: 'from-emerald-50 to-teal-100/50 dark:from-emerald-900/20 dark:to-teal-800/10', border: 'border-emerald-200/40 dark:border-emerald-900/30' },
{ label: 'n', value: String(values.length), color: 'text-gray-700 dark:text-gray-300', bg: 'from-gray-50 to-gray-100/50 dark:from-gray-800/40 dark:to-gray-800/20', border: 'border-gray-200/40 dark:border-gray-700/30' },
];
return stats.map((s) => (
<div key={s.label} className={`p-3 rounded-xl bg-gradient-to-br ${s.bg} border ${s.border}`}>
<p className="text-[10px] uppercase tracking-wider font-semibold text-gray-500 dark:text-gray-400">{s.label}</p>
<p className={`text-xl font-bold ${s.color}`}>{s.value}</p>
</div>
));
})()}
</div>
)}
{/* Grade interpretation legend + grade trend */}
{computedGrades.length > 0 && (
<div className="mb-4 grid grid-cols-1 lg:grid-cols-2 gap-3">
{/* Legend */}
<div className="p-3 rounded-xl bg-gradient-to-r from-emerald-50/40 to-teal-50/30 dark:from-emerald-900/10 dark:to-teal-900/10 border border-emerald-200/40 dark:border-emerald-900/20">
<p className="text-[10px] uppercase tracking-wider font-semibold text-emerald-600/70 dark:text-emerald-400/60 mb-2 flex items-center gap-1">
<BarChart3 className="h-3 w-3" />
{t('polish.grade_legend')}
</p>
<div className="grid grid-cols-3 gap-2">
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-emerald-100/70 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 text-[10px] font-medium">
<span className="inline-block w-2 h-2 rounded-full bg-emerald-500" />
1–2 · {t('polish.grade_good')}
</div>
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-amber-100/70 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 text-[10px] font-medium">
<span className="inline-block w-2 h-2 rounded-full bg-amber-500" />
3–4 · {t('polish.grade_satisfactory')}
</div>
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-red-100/70 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-[10px] font-medium">
<span className="inline-block w-2 h-2 rounded-full bg-red-500" />
5–6 · {t('polish.grade_needs_work')}
</div>
</div>
</div>
{/* Trend (deterministic pseudo) */}
<div className="p-3 rounded-xl bg-gradient-to-r from-amber-50/40 to-violet-50/30 dark:from-amber-900/10 dark:to-violet-900/10 border border-amber-200/40 dark:border-amber-900/20">
<p className="text-[10px] uppercase tracking-wider font-semibold text-amber-600/70 dark:text-amber-400/60 mb-2 flex items-center gap-1">
<TrendingUp className="h-3 w-3" />
{t('polish.grade_trend')}
</p>
{(() => {
const avg = computedGrades.reduce((s, g) => s + g.computedValue, 0) / computedGrades.length;
// Deterministic pseudo-trend: previous was slightly higher/lower
let h = 0;
for (let i = 0; i < (selectedClass?.id ?? 'x').length; i++) h = (h * 31 + (selectedClass?.id ?? 'x').charCodeAt(i)) | 0;
h = Math.abs(h);
const prevAvg = avg + (((h % 7) - 3) / 10);
const diff = avg - prevAvg;
const improved = diff < 0; // lower grade value = better in German system
return (
<div className="flex items-center gap-2 text-xs">
<span className="text-gray-500 dark:text-gray-400">{t('polish.class_average')}:</span>
<span className="font-semibold text-gray-700 dark:text-gray-300">Ø {avg.toFixed(2)}</span>
<span className="text-gray-400">→</span>
<span className="text-gray-400 dark:text-gray-500 line-through">{prevAvg.toFixed(2)}</span>
<Badge className={improved ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 text-[10px]' : 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300 text-[10px]'}>
{improved ? '↓' : '↑'} {Math.abs(diff).toFixed(2)}
</Badge>
</div>
);
})()}
</div>
</div>
)}
{/* Grade distribution mini chart */}
{computedGrades.length > 0 && (
<div className="mb-4 p-4 rounded-xl bg-gradient-to-r from-teal-50/80 to-emerald-50/30 dark:from-teal-900/15 dark:to-emerald-900/5 border border-teal-200/30 dark:border-teal-900/20 w-full overflow-x-auto">
<p className="text-xs font-semibold text-teal-600/60 dark:text-teal-400/40 mb-2 uppercase tracking-wider">{t('grading.grade_distribution')}</p>
<div className="flex items-end gap-1 h-20">
{[1, 2, 3, 4, 5, 6].map((gradeValue) => {
const count = computedGrades.filter((g) => Math.round(g.computedValue) === gradeValue).length;
const maxCount = Math.max(...[1, 2, 3, 4, 5, 6].map((v) => computedGrades.filter((g) => Math.round(g.computedValue) === v).length), 1);
const height = (count / maxCount) * 100;
const pct = computedGrades.length > 0 ? (count / computedGrades.length) * 100 : 0;
const gColors = gradeColor(gradeValue);
return (
<div key={gradeValue} className="flex flex-col items-center gap-1 flex-1">
<span className="text-[10px] text-gray-500 dark:text-gray-400 font-medium">{pct.toFixed(0)}%</span>
<span className="text-[10px] text-gray-400">{count}</span>
<div className="w-full rounded-t-sm" style={{ height: `${Math.max(height, 4)}%` }}>
<div className={`w-full rounded-t-sm ${gColors.bg} transition-all duration-300`} style={{ height: `${Math.max(height, 8)}%`, minHeight: '4px' }} />
</div>
<span className="text-xs font-bold text-gray-600 dark:text-gray-300">{gradeValue}</span>
</div>
);
})}
</div>
</div>
)}
<div className="max-h-[70vh] overflow-y-auto scrollbar-education">
<Table>
<TableHeader>
<TableRow className="border-b border-teal-200/30 dark:border-teal-900/20">
<TableHead className="text-xs font-semibold uppercase tracking-wider text-teal-600/60 dark:text-teal-400/40">{t('grading.student')}</TableHead>
<TableHead className="text-center text-xs font-semibold uppercase tracking-wider text-teal-600/60 dark:text-teal-400/40">{t('grading.computed_value')}</TableHead>
<TableHead className="hidden md:table-cell text-xs font-semibold uppercase tracking-wider text-teal-600/60 dark:text-teal-400/40">{t('grading.breakdown')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{computedGrades.map((grade, idx) => {
const student = students.find((s) => s.id === grade.studentId);
const colors = gradeColor(grade.computedValue);
return (
<TableRow key={grade.studentId} className={`hover:bg-teal-50/50 dark:hover:bg-teal-900/10 transition-all duration-150 ${idx % 2 === 1 ? 'bg-teal-50/20 dark:bg-teal-900/5' : ''}`}>
<TableCell className="font-semibold">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gradient-to-br from-emerald-100 to-teal-100 dark:from-emerald-900/30 dark:to-teal-900/30 text-emerald-600 dark:text-emerald-300 text-xs font-bold shrink-0 ring-1 ring-emerald-200/50 dark:ring-emerald-900/20">
{student ? `${student.firstName[0]}${student.lastName[0]}` : '?'}
</div>
{student ? `${student.firstName} ${student.lastName}` : grade.studentId}
</div>
</TableCell>
<TableCell className="text-center">
<div className={`inline-flex items-center justify-center w-14 h-14 rounded-xl bg-gradient-to-br ${colors.bg} ${colors.text} shadow-lg ${colors.shadow} font-bold text-lg animate-pulse-soft relative`}>
{grade.computedValue.toFixed(1)}
<span className="absolute -bottom-1 -right-1 text-[9px] bg-white dark:bg-gray-900 text-gray-600 dark:text-gray-400 px-1 rounded-md shadow-sm border border-gray-200 dark:border-gray-700">
<GradeIcon value={grade.computedValue} className="w-3 h-3" />
</span>
</div>
</TableCell>
<TableCell className="hidden md:table-cell">
<div className="space-y-2">
{grade.breakdown.map((b, i) => {
const bColors = weightSourceColor(b.source);
return (
<div key={i} className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${bColors.dot}`} />
<span className="text-xs text-gray-500 dark:text-gray-400">
{t(`weight_source.${b.source.toLowerCase()}`)}
</span>
<span className="text-xs text-gray-400">({b.weight}%)</span>
<div className="flex-1 h-2 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
<div
className={`h-full rounded-full bg-gradient-to-r ${bColors.bar} animate-progress-fill`}
style={{ width: `${(b.value / 4) * 100}%` }}
/>
</div>
<span className="text-xs font-semibold text-gray-700 dark:text-gray-300 inline-flex items-center gap-1"><GradeIcon value={b.value} className="w-3 h-3" /> {b.value.toFixed(2)}</span>
</div>
);
})}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</>
)}
</CardContent>
</Card>
</>
)}
{/* Create scheme dialog */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="max-w-lg rounded-xl">
<DialogHeader>
<DialogTitle className="text-lg font-bold">{t('grading.create_scheme_title')}</DialogTitle>
</DialogHeader>
<div className="space-y-5 pt-2">
<div className="space-y-2">
<Label className="text-sm font-medium">{t('label.name')}</Label>
<Input value={newName} onChange={(e) => setNewName(e.target.value)} className="rounded-xl border-emerald-200/50 dark:border-emerald-900/30" />
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">{t('label.scheme_type')}</Label>
<Select value={newType} onValueChange={setNewType}>
<SelectTrigger className="rounded-xl">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="NUMERIC_GRADE">{t('grading_type.numeric')}</SelectItem>
<SelectItem value="VERBAL_FEEDBACK">{t('grading_type.verbal')}</SelectItem>
<SelectItem value="COMBINED">{t('grading_type.combined')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">{t('label.scale_definition')}</Label>
<Textarea value={newScale} onChange={(e) => setNewScale(e.target.value)} rows={3} className="text-xs rounded-xl border-emerald-200/50 dark:border-emerald-900/30" />
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">{t('grading.weight_rules')}</Label>
<Button size="sm" variant="outline" className="rounded-xl" onClick={addWeightRule}>
<Plus className="h-3 w-3 mr-1" />
{t('grading.add_weight_rule')}
</Button>
</div>
{newWeightRules.map((rule, i) => {
const colors = weightSourceColor(rule.sourceType);
return (
<div key={i} className="flex gap-2 items-end p-3 rounded-xl bg-gray-50 dark:bg-gray-800/50">
<div className="space-y-1 flex-1">
<Label className="text-xs font-medium">{t('label.source_type')}</Label>
<Select value={rule.sourceType} onValueChange={(v) => updateWeightRule(i, 'sourceType', v)}>
<SelectTrigger className="h-8 rounded-xl">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="LEARNING_PROGRESS">{t('weight_source.learning_progress')}</SelectItem>
<SelectItem value="ASSESSMENT">{t('weight_source.assessment')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1 w-24">
<Label className="text-xs font-medium">{t('label.weight_percent')}</Label>
<Input type="number" className="h-8 rounded-xl border-emerald-200/50 dark:border-emerald-900/30" value={rule.weightPercent} onChange={(e) => updateWeightRule(i, 'weightPercent', parseFloat(e.target.value) || 0)} />
</div>
<Button size="sm" variant="ghost" className="h-8 text-red-500 hover:text-red-600" onClick={() => removeWeightRule(i)}>
×
</Button>
</div>
);
})}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)} className="rounded-xl">{t('action.cancel')}</Button>
<Button className="bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white rounded-xl shadow-md shadow-emerald-300/20" onClick={handleCreateScheme} disabled={creating || !newName}>
{creating ? t('empty.loading') : t('action.create')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,192 @@
'use client';
import React from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Keyboard,
Command,
Search,
Plus,
ArrowUp,
ArrowDown,
Eye,
LayoutDashboard,
Users,
PenLine,
Flower2,
ClipboardCheck,
Calculator,
FileText,
X,
BookOpen,
Grid3X3,
CalendarCheck,
Calendar,
TrendingUp,
Mail,
Shield,
Target,
Ruler,
MessageSquareText,
CalendarDays,
Settings,
} from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { t } from '@/lib/i18n';
import type { ViewName } from '@/lib/store';
interface KeyboardShortcutsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
interface ShortcutEntry {
keys: string[];
labelKey: string;
icon?: React.ElementType;
}
interface ShortcutSection {
titleKey: string;
icon: React.ElementType;
entries: ShortcutEntry[];
}
const viewShortcuts: { key: ViewName; num: number; icon: React.ElementType; labelKey: string }[] = [
{ key: 'dashboard', num: 1, icon: LayoutDashboard, labelKey: 'nav.dashboard' },
{ key: 'classes', num: 2, icon: Users, labelKey: 'nav.classes' },
{ key: 'progress', num: 3, icon: PenLine, labelKey: 'nav.progress' },
{ key: 'flower', num: 4, icon: Flower2, labelKey: 'nav.flower' },
{ key: 'assessments', num: 5, icon: ClipboardCheck, labelKey: 'nav.assessments' },
{ key: 'grading', num: 6, icon: Calculator, labelKey: 'nav.grading' },
{ key: 'reports', num: 7, icon: FileText, labelKey: 'nav.reports' },
{ key: 'competencies', num: 8, icon: BookOpen, labelKey: 'nav.competencies' },
{ key: 'settings', num: 9, icon: Settings, labelKey: 'nav.settings' },
];
function Kbd({ children }: { children: React.ReactNode }) {
return (
<kbd className="inline-flex items-center gap-0.5 px-2 py-0.5 rounded-md bg-white dark:bg-gray-800 border border-emerald-200/60 dark:border-emerald-900/40 text-[11px] font-mono text-emerald-700 dark:text-emerald-300 shadow-sm min-w-[24px] justify-center">
{children}
</kbd>
);
}
function CommandKey() {
return (
<span className="inline-flex items-center gap-0.5">
<Command className="h-2.5 w-2.5" />
</span>
);
}
export default function KeyboardShortcutsDialog({ open, onOpenChange }: KeyboardShortcutsDialogProps) {
const sections: ShortcutSection[] = [
{
titleKey: 'shortcuts.navigation',
icon: Eye,
entries: [
{ keys: ['Cmd/Ctrl', 'K'], labelKey: 'shortcuts.quick_search', icon: Search },
{ keys: ['Cmd/Ctrl', '1-9'], labelKey: 'shortcuts.switch_view', icon: LayoutDashboard },
{ keys: ['Cmd/Ctrl', 'N'], labelKey: 'shortcuts.new_entry', icon: Plus },
{ keys: ['Cmd/Ctrl', '/'], labelKey: 'shortcuts.show_shortcuts', icon: Keyboard },
{ keys: ['Esc'], labelKey: 'shortcuts.close_dialog', icon: X },
],
},
{
titleKey: 'shortcuts.views',
icon: Eye,
entries: viewShortcuts.map((v) => ({
keys: ['Cmd/Ctrl', String(v.num)],
labelKey: v.labelKey,
icon: v.icon,
})),
},
{
titleKey: 'shortcuts.command_palette',
icon: Command,
entries: [
{ keys: ['Arrow Up'], labelKey: 'shortcuts.navigation', icon: ArrowUp },
{ keys: ['Arrow Down'], labelKey: 'shortcuts.navigation', icon: ArrowDown },
{ keys: ['Enter'], labelKey: 'action.select', icon: Command },
{ keys: ['Esc'], labelKey: 'shortcuts.close_dialog', icon: X },
],
},
];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg p-0 gap-0 overflow-hidden rounded-2xl border-emerald-200/60 dark:border-emerald-900/40 shadow-2xl shadow-emerald-900/10">
<DialogHeader className="px-6 pt-6 pb-0">
<DialogTitle className="flex items-center gap-2 text-emerald-700 dark:text-emerald-300">
<div className="flex items-center justify-center w-8 h-8 rounded-xl bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-md shadow-emerald-300/30">
<Keyboard className="h-4 w-4" />
</div>
{t('shortcuts.title')}
</DialogTitle>
<DialogDescription className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t('shortcuts.description')}
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[60vh] mt-4">
<div className="px-6 pb-6 space-y-6">
{sections.map((section, sectionIdx) => (
<div key={section.titleKey}>
<div className="flex items-center gap-2 mb-3">
<section.icon className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
<h3 className="text-xs font-semibold uppercase tracking-wider text-emerald-600/80 dark:text-emerald-400/70">
{t(section.titleKey)}
</h3>
</div>
<div className="space-y-1.5">
<AnimatePresence>
{section.entries.map((entry, idx) => (
<motion.div
key={entry.labelKey + entry.keys.join('')}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: (sectionIdx * 5 + idx) * 0.03, duration: 0.2 }}
className="flex items-center justify-between rounded-lg px-3 py-2 bg-emerald-50/40 dark:bg-emerald-900/10 border border-emerald-100/50 dark:border-emerald-900/20 hover:bg-emerald-50/70 dark:hover:bg-emerald-900/20 transition-colors"
>
<span className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
{entry.icon && (
<entry.icon className="h-3.5 w-3.5 text-emerald-500 dark:text-emerald-400" />
)}
{t(entry.labelKey)}
</span>
<span className="flex items-center gap-1">
{entry.keys.map((key, keyIdx) => (
<React.Fragment key={keyIdx}>
{keyIdx > 0 && (
<span className="text-[10px] text-gray-400 dark:text-gray-500 mx-0.5">+</span>
)}
<Kbd>
{key === 'Cmd/Ctrl' ? <CommandKey /> : key}
</Kbd>
</React.Fragment>
))}
</span>
</motion.div>
))}
</AnimatePresence>
</div>
{sectionIdx < sections.length - 1 && (
<Separator className="mt-4 bg-emerald-200/40 dark:bg-emerald-900/20" />
)}
</div>
))}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
}
File diff suppressed because it is too large. Load diff
+780
View File
@@ -0,0 +1,780 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import { motion } from 'framer-motion';
import {
Grid3X3,
RefreshCw,
Download,
Layers,
Users,
CheckCircle2,
XCircle,
BarChart3,
Calendar,
BookOpen,
ListChecks,
Sprout,
Leaf,
TreePine,
Trees,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import {
fetchMasteryMatrix,
fetchClasses,
fetchSubjects,
fetchLearningProgress,
type MasteryMatrixData,
type ClassGroup,
type Subject,
type LearningProgressEntry,
} from '@/lib/api';
import { toast } from 'sonner';
function masteryCellColor(level: number | null): string {
if (level === null) return 'bg-gray-100 dark:bg-gray-800/60 text-gray-400';
if (level <= 1) return 'bg-red-200 dark:bg-red-900/40 text-red-800 dark:text-red-200';
if (level <= 2) return 'bg-amber-200 dark:bg-amber-900/40 text-amber-800 dark:text-amber-200';
if (level <= 3) return 'bg-emerald-200 dark:bg-emerald-900/40 text-emerald-800 dark:text-emerald-200';
return 'bg-teal-200 dark:bg-teal-900/40 text-teal-800 dark:text-teal-200';
}
function avgColor(avg: number): string {
if (avg <= 0) return 'text-gray-400';
if (avg < 2) return 'text-rose-600 dark:text-rose-400';
if (avg < 3) return 'text-amber-600 dark:text-amber-400';
if (avg < 3.5) return 'text-emerald-600 dark:text-emerald-400';
return 'text-teal-600 dark:text-teal-400';
}
function escapeCsv(field: string | number | null | undefined): string {
if (field === null || field === undefined) return '';
const str = String(field);
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
}
export default function MasteryMatrixView() {
const currentUser = useAppStore((s) => s.currentUser);
const schoolYearId = useAppStore((s) => s.schoolYearId);
const storeClassId = useAppStore((s) => s.currentClassId);
const [classes, setClasses] = useState<ClassGroup[]>([]);
const [subjects, setSubjects] = useState<Subject[]>([]);
const [selectedClassId, setSelectedClassId] = useState<string>(storeClassId ?? '');
const [selectedSubjectId, setSelectedSubjectId] = useState<string>('');
const [data, setData] = useState<MasteryMatrixData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [detail, setDetail] = useState<{ studentName: string; competencyCode: string; competencyTitle: string; entries: LearningProgressEntry[] } | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
// Load classes + subjects for filters
useEffect(() => {
async function load() {
try {
const [cls, subs] = await Promise.all([
fetchClasses(currentUser?.schoolId ?? undefined, schoolYearId ?? undefined).catch(() => []),
fetchSubjects(currentUser?.schoolId ?? undefined).catch(() => []),
]);
setClasses(cls);
setSubjects(subs);
if (!selectedClassId && cls.length > 0) {
setSelectedClassId(cls[0].id);
}
} catch {
// ignore
}
}
load();
}, [currentUser?.schoolId, schoolYearId]);
const loadMatrix = React.useCallback(async () => {
if (!selectedClassId || !selectedSubjectId) {
setData(null);
return;
}
setLoading(true);
setError(null);
try {
const d = await fetchMasteryMatrix({
classGroupId: selectedClassId,
subjectId: selectedSubjectId,
});
setData(d);
} catch (err) {
const msg = err instanceof Error ? err.message : t('error.generic');
setError(msg);
toast.error(msg);
} finally {
setLoading(false);
}
}, [selectedClassId, selectedSubjectId]);
useEffect(() => {
loadMatrix();
}, [loadMatrix]);
// Build matrix lookup map: `${studentId}::${competencyId}` -> cell
const matrixMap = useMemo(() => {
const m = new Map<string, { level: number | null; count: number; lastDate: string | null }>();
if (data) {
for (const cell of data.matrix) {
m.set(`${cell.studentId}::${cell.competencyId}`, {
level: cell.latestMasteryLevel,
count: cell.entryCount,
lastDate: cell.lastEntryDate,
});
}
}
return m;
}, [data]);
// Row averages (per student) and column averages (per competency)
const rowAverages = useMemo(() => {
if (!data) return new Map<string, number>();
const m = new Map<string, { total: number; count: number }>();
for (const student of data.students) {
m.set(student.id, { total: 0, count: 0 });
}
for (const cell of data.matrix) {
if (cell.latestMasteryLevel !== null) {
const cur = m.get(cell.studentId);
if (cur) {
cur.total += cell.latestMasteryLevel;
cur.count += 1;
}
}
}
const result = new Map<string, number>();
for (const [sid, v] of m) {
result.set(sid, v.count > 0 ? Math.round((v.total / v.count) * 100) / 100 : 0);
}
return result;
}, [data]);
const colAverages = useMemo(() => {
if (!data) return new Map<string, number>();
const m = new Map<string, { total: number; count: number }>();
for (const comp of data.competencies) {
m.set(comp.id, { total: 0, count: 0 });
}
for (const cell of data.matrix) {
if (cell.latestMasteryLevel !== null) {
const cur = m.get(cell.competencyId);
if (cur) {
cur.total += cell.latestMasteryLevel;
cur.count += 1;
}
}
}
const result = new Map<string, number>();
for (const [cid, v] of m) {
result.set(cid, v.count > 0 ? Math.round((v.total / v.count) * 100) / 100 : 0);
}
return result;
}, [data]);
const overallAvg = useMemo(() => {
if (!data || data.matrix.length === 0) return 0;
const assessed = data.matrix.filter((c) => c.latestMasteryLevel !== null);
if (assessed.length === 0) return 0;
return Math.round((assessed.reduce((s, c) => s + (c.latestMasteryLevel ?? 0), 0) / assessed.length) * 100) / 100;
}, [data]);
// Total entries across all cells
const totalEntries = useMemo(() => {
if (!data) return 0;
return data.matrix.reduce((s, c) => s + c.entryCount, 0);
}, [data]);
// Coverage %: how many cells have at least one entry
const coveragePct = useMemo(() => {
if (!data || data.students.length === 0 || data.competencies.length === 0) return 0;
const totalCells = data.students.length * data.competencies.length;
if (totalCells === 0) return 0;
const assessed = data.matrix.filter((c) => c.latestMasteryLevel !== null).length;
return Math.round((assessed / totalCells) * 100);
}, [data]);
// Mastery distribution across the whole matrix (count per level)
const masteryDist = useMemo(() => {
if (!data) return [0, 0, 0, 0];
const counts = [0, 0, 0, 0];
for (const cell of data.matrix) {
if (cell.latestMasteryLevel !== null && cell.latestMasteryLevel >= 1 && cell.latestMasteryLevel <= 4) {
counts[cell.latestMasteryLevel - 1] += 1;
}
}
return counts;
}, [data]);
const handleCellClick = async (studentId: string, competencyId: string) => {
if (!data) return;
const student = data.students.find((s) => s.id === studentId);
const comp = data.competencies.find((c) => c.id === competencyId);
if (!student || !comp) return;
setDetailLoading(true);
setDetail({
studentName: `${student.firstName} ${student.lastName}`,
competencyCode: comp.code,
competencyTitle: comp.title,
entries: [],
});
try {
const entries = await fetchLearningProgress({
studentId,
competencyId,
});
setDetail({
studentName: `${student.firstName} ${student.lastName}`,
competencyCode: comp.code,
competencyTitle: comp.title,
entries,
});
} catch (err) {
const msg = err instanceof Error ? err.message : t('error.generic');
toast.error(msg);
setDetail(null);
} finally {
setDetailLoading(false);
}
};
const handleExportCsv = () => {
if (!data) return;
const header = [
t('matrix.student'),
...data.competencies.map((c) => c.code),
t('matrix.row_avg'),
];
let csv = header.map(escapeCsv).join(',') + '\n';
for (const s of data.students) {
const row: (string | number)[] = [`${s.firstName} ${s.lastName}`];
for (const c of data.competencies) {
const cell = matrixMap.get(`${s.id}::${c.id}`);
row.push(cell?.level ?? '');
}
row.push(rowAverages.get(s.id) ?? 0);
csv += row.map(escapeCsv).join(',') + '\n';
}
// Column averages row
const avgRow: (string | number)[] = [t('matrix.col_avg')];
for (const c of data.competencies) {
avgRow.push(colAverages.get(c.id) ?? 0);
}
avgRow.push(overallAvg);
csv += avgRow.map(escapeCsv).join(',') + '\n';
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `mastery_matrix_${selectedClassId}_${selectedSubjectId}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success(t('matrix.export_success'));
};
const hasFilters = selectedClassId && selectedSubjectId;
return (
<div className="space-y-6">
{/* Header */}
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }}>
<Card className="border-0 shadow-sm rounded-xl overflow-hidden relative">
{/* Decorative gradient banner */}
<div className="absolute inset-0 bg-gradient-to-br from-teal-100/40 via-emerald-50/30 to-violet-100/40 dark:from-teal-900/15 dark:via-emerald-900/10 dark:to-violet-900/15 pointer-events-none" />
<div className="absolute inset-0 bg-pattern-grid opacity-30 pointer-events-none" />
<CardContent className="p-5 relative">
<div className="flex items-start gap-4 flex-wrap">
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-gradient-to-br from-teal-500 to-emerald-600 text-white ring-2 ring-teal-200/60 dark:ring-teal-800/40 shadow-lg shadow-teal-300/30 dark:shadow-teal-900/40">
<Grid3X3 className="h-6 w-6" />
</div>
<div className="flex-1 min-w-0">
<h2 className="text-2xl font-bold gradient-text">{t('matrix.title')}</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5 max-w-2xl">{t('matrix.subtitle')}</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="rounded-xl border-teal-300 dark:border-teal-700 text-teal-700 dark:text-teal-300 backdrop-blur-sm bg-white/60 dark:bg-gray-900/40 disabled:opacity-40 disabled:cursor-not-allowed disabled:pointer-events-none"
onClick={loadMatrix}
disabled={loading || !hasFilters}
>
<RefreshCw className={`h-3.5 w-3.5 mr-1 ${loading ? 'animate-spin' : ''}`} />
{t('analytics.refresh')}
</Button>
<Button
variant="outline"
size="sm"
className="rounded-xl border-emerald-300 dark:border-emerald-700 text-emerald-700 dark:text-emerald-300 backdrop-blur-sm bg-white/60 dark:bg-gray-900/40 disabled:opacity-40 disabled:cursor-not-allowed disabled:pointer-events-none"
onClick={handleExportCsv}
disabled={!data || data.students.length === 0}
>
<Download className="h-3.5 w-3.5 mr-1" />
{t('matrix.export_csv')}
</Button>
</div>
</div>
{/* Filters */}
<div className="mt-4 flex flex-wrap gap-3 items-end">
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-teal-600/70 dark:text-teal-400/50 font-semibold">
{t('matrix.select_class')}
</label>
<Select value={selectedClassId} onValueChange={setSelectedClassId}>
<SelectTrigger className="h-8 w-44 rounded-lg text-xs border-teal-200 dark:border-teal-900/30">
<SelectValue placeholder={t('matrix.select_class')} />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-teal-600/70 dark:text-teal-400/50 font-semibold">
{t('matrix.select_subject')}
</label>
<Select value={selectedSubjectId} onValueChange={setSelectedSubjectId}>
<SelectTrigger className="h-8 w-44 rounded-lg text-xs border-teal-200 dark:border-teal-900/30">
<SelectValue placeholder={t('matrix.select_subject')} />
</SelectTrigger>
<SelectContent>
{subjects.map((s) => (
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Summary banner */}
{data && (
<div className="mt-4 p-4 rounded-xl bg-white/60 dark:bg-gray-900/40 backdrop-blur-sm border border-teal-200/40 dark:border-teal-900/30">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{/* Class average */}
<div className="flex items-center gap-2.5">
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br from-teal-400 to-emerald-500 text-white shadow-sm shrink-0">
<Grid3X3 className="h-4 w-4" />
</div>
<div>
<p className="text-[10px] uppercase tracking-wider text-teal-600/70 dark:text-teal-400/60 font-semibold">{t('matrix.avg')}</p>
<p className={`text-lg font-bold ${avgColor(overallAvg)}`}>{overallAvg > 0 ? overallAvg.toFixed(2) : '—'}</p>
</div>
</div>
{/* Total entries */}
<div className="flex items-center gap-2.5">
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-sm shrink-0">
<Layers className="h-4 w-4" />
</div>
<div>
<p className="text-[10px] uppercase tracking-wider text-emerald-600/70 dark:text-emerald-400/60 font-semibold">{t('matrix.entries')}</p>
<p className="text-lg font-bold text-emerald-700 dark:text-emerald-300">{totalEntries}</p>
</div>
</div>
{/* Coverage */}
<div className="flex items-center gap-2.5">
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br from-amber-400 to-amber-500 text-white shadow-sm shrink-0">
<Users className="h-4 w-4" />
</div>
<div>
<p className="text-[10px] uppercase tracking-wider text-amber-600/70 dark:text-amber-400/60 font-semibold">{t('polish.coverage')}</p>
<p className="text-lg font-bold text-amber-700 dark:text-amber-300">{coveragePct}%</p>
</div>
</div>
{/* Mastery distribution mini bar chart */}
<div className="flex items-center gap-2.5">
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br from-violet-400 to-rose-500 text-white shadow-sm shrink-0">
<BarChart3 className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-[10px] uppercase tracking-wider text-violet-600/70 dark:text-violet-400/60 font-semibold">{t('polish.mastery_distribution')}</p>
<div className="flex items-end gap-0.5 h-5 mt-0.5">
{masteryDist.map((cnt, i) => {
const max = Math.max(...masteryDist, 1);
const h = (cnt / max) * 100;
const colors = ['bg-red-400 dark:bg-red-500', 'bg-amber-400 dark:bg-amber-500', 'bg-emerald-400 dark:bg-emerald-500', 'bg-teal-400 dark:bg-teal-500'];
return (
<div
key={i}
className={`flex-1 rounded-t-sm transition-all hover:brightness-110 ${colors[i]}`}
style={{ height: `${Math.max(h, 8)}%` }}
title={`${t('polish.level_' + (i + 1))}: ${cnt}`}
/>
);
})}
</div>
</div>
</div>
</div>
</div>
)}
</CardContent>
</Card>
</motion.div>
{!hasFilters ? (
<div className="space-y-4">
<Card className="border-0 shadow-sm rounded-xl">
<CardContent className="py-16 text-center">
<div className="flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-teal-100 to-emerald-100 dark:from-teal-900/30 dark:to-emerald-900/30 mx-auto mb-5 shadow-md shadow-teal-200/40 dark:shadow-teal-900/20">
<Grid3X3 className="h-10 w-10 text-teal-500 dark:text-teal-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1">{t('polish.empty_title_matrix')}</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto">{t('polish.empty_subtitle_matrix')}</p>
</CardContent>
</Card>
{/* How it works — 3-step guide */}
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-teal-400 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent">
<CardTitle className="text-base font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
<ListChecks className="h-4 w-4" />
</div>
{t('polish.matrix_how_it_works_title')}
<span className="text-xs font-normal text-gray-500 dark:text-gray-400 ml-1 hidden sm:inline">
· {t('polish.matrix_how_it_works_subtitle')}
</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{/* Step 1 */}
<div className="relative p-4 rounded-xl bg-gradient-to-br from-emerald-50/60 to-emerald-50/0 dark:from-emerald-900/15 dark:to-emerald-900/0 border border-emerald-100/60 dark:border-emerald-900/30">
<span className="absolute top-3 right-3 flex items-center justify-center w-7 h-7 rounded-full bg-gradient-to-br from-emerald-400 to-emerald-500 text-white text-xs font-bold shadow-sm">
1
</span>
<div className="flex items-center gap-2 mb-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-300">
<Users className="h-4 w-4" />
</div>
<p className="text-sm font-semibold text-emerald-700 dark:text-emerald-300">
{t('polish.matrix_step_1_title')}
</p>
</div>
<p className="text-xs text-gray-600 dark:text-gray-400 leading-snug">
{t('polish.matrix_step_1_desc')}
</p>
</div>
{/* Step 2 */}
<div className="relative p-4 rounded-xl bg-gradient-to-br from-teal-50/60 to-teal-50/0 dark:from-teal-900/15 dark:to-teal-900/0 border border-teal-100/60 dark:border-teal-900/30">
<span className="absolute top-3 right-3 flex items-center justify-center w-7 h-7 rounded-full bg-gradient-to-br from-teal-400 to-teal-500 text-white text-xs font-bold shadow-sm">
2
</span>
<div className="flex items-center gap-2 mb-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-300">
<BookOpen className="h-4 w-4" />
</div>
<p className="text-sm font-semibold text-teal-700 dark:text-teal-300">
{t('polish.matrix_step_2_title')}
</p>
</div>
<p className="text-xs text-gray-600 dark:text-gray-400 leading-snug">
{t('polish.matrix_step_2_desc')}
</p>
</div>
{/* Step 3 */}
<div className="relative p-4 rounded-xl bg-gradient-to-br from-amber-50/60 to-amber-50/0 dark:from-amber-900/15 dark:to-amber-900/0 border border-amber-100/60 dark:border-amber-900/30">
<span className="absolute top-3 right-3 flex items-center justify-center w-7 h-7 rounded-full bg-gradient-to-br from-amber-400 to-amber-500 text-white text-xs font-bold shadow-sm">
3
</span>
<div className="flex items-center gap-2 mb-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-300">
<Grid3X3 className="h-4 w-4" />
</div>
<p className="text-sm font-semibold text-amber-700 dark:text-amber-300">
{t('polish.matrix_step_3_title')}
</p>
</div>
<p className="text-xs text-gray-600 dark:text-gray-400 leading-snug">
{t('polish.matrix_step_3_desc')}
</p>
</div>
</div>
</CardContent>
</Card>
</div>
) : loading ? (
<Skeleton className="h-96 rounded-xl" />
) : error ? (
<Card className="border-0 shadow-sm rounded-xl">
<CardContent className="py-12 text-center">
<p className="text-rose-600 dark:text-rose-400">{error}</p>
<Button variant="outline" className="mt-4 rounded-xl" onClick={loadMatrix}>
<RefreshCw className="h-4 w-4 mr-1" />
{t('analytics.refresh')}
</Button>
</CardContent>
</Card>
) : !data ? null : data.competencies.length === 0 ? (
<Card className="border-0 shadow-sm rounded-xl">
<CardContent className="py-16 text-center">
<div className="flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-rose-100 to-amber-100 dark:from-rose-900/30 dark:to-amber-900/20 mx-auto mb-5 shadow-md shadow-rose-200/40 dark:shadow-rose-900/20">
<XCircle className="h-10 w-10 text-rose-500 dark:text-rose-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1">{t('polish.empty_title_no_data')}</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto">{t('matrix.no_template_hint')}</p>
</CardContent>
</Card>
) : data.students.length === 0 ? (
<Card className="border-0 shadow-sm rounded-xl">
<CardContent className="py-16 text-center">
<div className="flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-teal-100 to-emerald-100 dark:from-teal-900/30 dark:to-emerald-900/30 mx-auto mb-5 shadow-md shadow-teal-200/40 dark:shadow-teal-900/20">
<Users className="h-10 w-10 text-teal-500 dark:text-teal-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1">{t('polish.empty_title_no_data')}</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto">{t('matrix.no_students')}</p>
</CardContent>
</Card>
) : (
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent">
<CardTitle className="text-lg font-bold flex items-center gap-2">
<Grid3X3 className="h-4 w-4 text-teal-600 dark:text-teal-400" />
{t('matrix.title')}
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto scrollbar-education">
<Table className="text-xs">
<TableHeader>
<TableRow className="border-b-2 border-teal-300/40 dark:border-teal-900/40 sticky top-0 z-10 bg-gradient-to-r from-teal-50 via-white to-emerald-50/30 dark:from-teal-900/30 dark:via-gray-950 dark:to-emerald-900/10 shadow-sm">
<TableHead className="text-xs uppercase text-teal-600/80 dark:text-teal-400/60 sticky left-0 bg-inherit z-20 min-w-[140px] font-bold tracking-wider">
{t('matrix.student')}
</TableHead>
{data.competencies.map((c) => (
<TableHead
key={c.id}
className="text-center text-[10px] uppercase text-teal-600/70 dark:text-teal-400/50 min-w-[60px] max-w-[80px] px-2 hover:bg-teal-100/40 dark:hover:bg-teal-900/20 transition-colors"
title={`${c.code} — ${c.title}`}
>
<div className="flex flex-col items-center gap-0.5">
<span className="font-mono font-semibold truncate w-full text-center">{c.code}</span>
<span
className="inline-block w-1.5 h-1.5 rounded-full ring-1 ring-white dark:ring-gray-900"
style={{ backgroundColor: c.category.color ?? '#94a3b8' }}
title={c.category.name}
/>
</div>
</TableHead>
))}
<TableHead className="text-xs uppercase text-emerald-600/80 dark:text-emerald-400/60 sticky right-0 bg-inherit z-20 min-w-[60px] text-center font-bold tracking-wider">
{t('matrix.row_avg')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.students.map((s, sIdx) => {
const rowAvg = rowAverages.get(s.id) ?? 0;
return (
<TableRow
key={s.id}
className={sIdx % 2 === 1 ? 'bg-teal-50/20 dark:bg-teal-900/5 hover:bg-teal-50/40 dark:hover:bg-teal-900/10' : 'hover:bg-teal-50/30 dark:hover:bg-teal-900/8'}
>
<TableCell className="font-medium text-gray-900 dark:text-gray-100 sticky left-0 bg-inherit z-10 min-w-[140px]">
{s.firstName} {s.lastName}
</TableCell>
{data.competencies.map((c) => {
const cell = matrixMap.get(`${s.id}::${c.id}`);
const level = cell?.level ?? null;
const lastDate = cell?.lastDate;
const dateStr = lastDate ? new Date(lastDate).toLocaleDateString() : null;
const count = cell?.count ?? 0;
return (
<TableCell
key={c.id}
className="p-1 text-center"
>
<button
onClick={() => handleCellClick(s.id, c.id)}
className={`w-9 h-9 rounded-md flex items-center justify-center text-xs font-bold transition-all hover:scale-110 hover:shadow-md hover:ring-2 hover:ring-emerald-300/60 dark:hover:ring-emerald-700/40 cursor-pointer border border-white/40 dark:border-gray-800/40 ${masteryCellColor(level)}`}
title={
level !== null
? `${c.code} · ${t('analytics.level')} ${level}\n${t('matrix.last_entry')}: ${dateStr ?? '—'}\n${count} ${t('analytics.entry_count')}`
: `${c.code} · ${t('matrix.legend_unassessed')}`
}
>
{level ?? '—'}
</button>
</TableCell>
);
})}
<TableCell className="sticky right-0 bg-inherit z-10 text-center min-w-[60px]">
<span className={`text-sm font-bold ${avgColor(rowAvg)}`}>
{rowAvg > 0 ? rowAvg.toFixed(2) : '—'}
</span>
</TableCell>
</TableRow>
);
})}
{/* Column averages row */}
<TableRow className="border-t-2 border-emerald-300/40 dark:border-emerald-900/40 bg-gradient-to-r from-emerald-50/60 to-teal-50/30 dark:from-emerald-900/15 dark:to-teal-900/10">
<TableCell className="sticky left-0 bg-inherit z-10 font-bold text-emerald-700 dark:text-emerald-300 text-xs uppercase">
{t('matrix.col_avg')}
</TableCell>
{data.competencies.map((c) => {
const avg = colAverages.get(c.id) ?? 0;
return (
<TableCell key={c.id} className="text-center p-1">
<span className={`text-xs font-semibold ${avgColor(avg)}`}>
{avg > 0 ? avg.toFixed(1) : '—'}
</span>
</TableCell>
);
})}
<TableCell className="sticky right-0 bg-inherit z-10 text-center">
<span className={`text-sm font-bold ${avgColor(overallAvg)}`}>
{overallAvg > 0 ? overallAvg.toFixed(2) : '—'}
</span>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
{/* Enhanced Legend */}
<div className="p-4 border-t border-teal-200/40 dark:border-teal-900/30 bg-gradient-to-r from-emerald-50/30 to-teal-50/20 dark:from-emerald-900/10 dark:to-teal-900/10">
<div className="flex flex-wrap items-center gap-3 text-[11px]">
<span className="font-semibold uppercase tracking-wider text-teal-600/80 dark:text-teal-400/60 inline-flex items-center gap-1.5">
<Layers className="h-3 w-3" />
{t('matrix.legend')}:
</span>
<span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md bg-white/60 dark:bg-gray-800/40 border border-gray-200/40 dark:border-gray-700/30">
<span className="inline-block w-3 h-3 rounded-sm bg-gray-200 dark:bg-gray-700 ring-1 ring-white dark:ring-gray-900" />
<span className="text-gray-600 dark:text-gray-300">{t('matrix.legend_unassessed')}</span>
</span>
<span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md bg-white/60 dark:bg-gray-800/40 border border-gray-200/40 dark:border-gray-700/30">
<span className="inline-block w-3 h-3 rounded-sm bg-red-200 dark:bg-red-900/40 ring-1 ring-white dark:ring-gray-900" />
<span className="text-red-700 dark:text-red-300 flex items-center gap-1"><Sprout className="w-3.5 h-3.5" /> {t('matrix.legend_level_1')}</span>
</span>
<span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md bg-white/60 dark:bg-gray-800/40 border border-gray-200/40 dark:border-gray-700/30">
<span className="inline-block w-3 h-3 rounded-sm bg-amber-200 dark:bg-amber-900/40 ring-1 ring-white dark:ring-gray-900" />
<span className="text-amber-700 dark:text-amber-300 flex items-center gap-1"><Leaf className="w-3.5 h-3.5" /> {t('matrix.legend_level_2')}</span>
</span>
<span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md bg-white/60 dark:bg-gray-800/40 border border-gray-200/40 dark:border-gray-700/30">
<span className="inline-block w-3 h-3 rounded-sm bg-emerald-200 dark:bg-emerald-900/40 ring-1 ring-white dark:ring-gray-900" />
<span className="text-emerald-700 dark:text-emerald-300 flex items-center gap-1"><TreePine className="w-3.5 h-3.5" /> {t('matrix.legend_level_3')}</span>
</span>
<span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md bg-white/60 dark:bg-gray-800/40 border border-gray-200/40 dark:border-gray-700/30">
<span className="inline-block w-3 h-3 rounded-sm bg-teal-200 dark:bg-teal-900/40 ring-1 ring-white dark:ring-gray-900" />
<span className="text-teal-700 dark:text-teal-300 flex items-center gap-1"><Trees className="w-3.5 h-3.5" /> {t('matrix.legend_level_4')}</span>
</span>
<span className="ml-auto text-[10px] text-gray-500 dark:text-gray-400 italic">
{t('polish.matrix_click_hint')}
</span>
</div>
</div>
</CardContent>
</Card>
)}
{/* Detail dialog */}
<Dialog open={!!detail} onOpenChange={(open) => !open && setDetail(null)}>
<DialogContent className="max-w-2xl rounded-2xl border-emerald-200/60 dark:border-emerald-900/40">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-emerald-700 dark:text-emerald-300">
<CheckCircle2 className="h-5 w-5" />
{detail?.competencyCode} — {detail?.studentName}
</DialogTitle>
<DialogDescription className="text-xs text-gray-500 dark:text-gray-400">
{detail?.competencyTitle} · {t('matrix.entries_for')} {detail?.studentName}
</DialogDescription>
</DialogHeader>
<div className="space-y-2 max-h-[60vh] overflow-y-auto scrollbar-education">
{detailLoading ? (
<Skeleton className="h-24 rounded-lg" />
) : detail && detail.entries.length > 0 ? (
<div className="relative">
{/* Vertical timeline line */}
<div className="absolute left-3 top-2 bottom-2 w-0.5 bg-gradient-to-b from-emerald-300 via-teal-300 to-amber-300 dark:from-emerald-700 dark:via-teal-700 dark:to-amber-700" />
{detail.entries.map((e, i) => (
<motion.div
key={e.id}
initial={{ opacity: 0, x: -6 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.05 }}
className="relative pl-8 pb-2 group"
>
<div className={`absolute left-2.5 top-3 w-3 h-3 rounded-full ring-2 ring-white dark:ring-gray-900 ${
e.masteryLevelValue <= 1 ? 'bg-red-500'
: e.masteryLevelValue <= 2 ? 'bg-amber-500'
: e.masteryLevelValue <= 3 ? 'bg-emerald-500'
: 'bg-teal-500'
}`} style={{ zIndex: 1 }} />
<div className="p-3 rounded-lg bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/15 dark:to-transparent border-l-2 border-emerald-300/50 dark:border-emerald-700/40 hover:shadow-sm transition-shadow">
<div className="flex items-center gap-2 flex-wrap">
<Badge className={`text-xs ${masteryCellColorBadge(e.masteryLevelValue)}`}>
{e.masteryLevelValue <= 1 ? <Sprout className="w-3 h-3 inline text-red-500" /> : e.masteryLevelValue <= 2 ? <Leaf className="w-3 h-3 inline text-amber-500" /> : e.masteryLevelValue <= 3 ? <TreePine className="w-3 h-3 inline text-emerald-500" /> : <Trees className="w-3 h-3 inline text-teal-500" />} {t('analytics.level')} {e.masteryLevelValue}
</Badge>
<span className="text-xs text-gray-500 dark:text-gray-400 inline-flex items-center gap-1">
<Calendar className="h-3 w-3 text-emerald-400" />
{new Date(e.date).toLocaleDateString()}
</span>
<span className="text-xs text-emerald-600/70 dark:text-emerald-400/50">
— {e.teacher.firstName} {e.teacher.lastName}
</span>
</div>
{e.note && (
<p className="text-xs italic text-gray-700 dark:text-gray-300 mt-1.5 bg-amber-50/40 dark:bg-amber-900/5 px-2 py-1 rounded">"{e.note}"</p>
)}
</div>
</motion.div>
))}
</div>
) : (
<div className="text-center py-8">
<XCircle className="h-8 w-8 text-gray-400 dark:text-gray-500 mx-auto mb-2" />
<p className="text-sm text-gray-500 dark:text-gray-400">{t('matrix.no_entries_for')}</p>
</div>
)}
</div>
</DialogContent>
</Dialog>
</div>
);
}
function masteryCellColorBadge(level: number): string {
if (level <= 1) return 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300';
if (level <= 2) return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300';
if (level <= 3) return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300';
return 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300';
}
+235
View File
@@ -0,0 +1,235 @@
'use client';
import React, { useState, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, BookOpen, BarChart3, Pencil, Flower2, FileText, PartyPopper } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
import { t } from '@/lib/i18n';
const ONBOARDING_KEY = 'ct_onboarding_completed';
interface OnboardingStep {
iconComponent: React.ElementType;
titleKey: string;
descKey: string;
accent: string;
}
const steps: OnboardingStep[] = [
{
iconComponent: BookOpen,
titleKey: 'onboarding.welcome_title',
descKey: 'onboarding.welcome_desc',
accent: 'from-emerald-400 to-teal-500',
},
{
iconComponent: BarChart3,
titleKey: 'onboarding.dashboard_title',
descKey: 'onboarding.dashboard_desc',
accent: 'from-teal-400 to-emerald-500',
},
{
iconComponent: Pencil,
titleKey: 'onboarding.progress_title',
descKey: 'onboarding.progress_desc',
accent: 'from-amber-400 to-emerald-500',
},
{
iconComponent: Flower2,
titleKey: 'onboarding.flower_title',
descKey: 'onboarding.flower_desc',
accent: 'from-rose-400 to-emerald-500',
},
{
iconComponent: FileText,
titleKey: 'onboarding.reports_title',
descKey: 'onboarding.reports_desc',
accent: 'from-violet-400 to-emerald-500',
},
{
iconComponent: PartyPopper,
titleKey: 'onboarding.done_title',
descKey: 'onboarding.done_desc',
accent: 'from-emerald-400 to-teal-500',
},
];
interface OnboardingTourProps {
open: boolean;
onClose: () => void;
}
export default function OnboardingTour({ open, onClose }: OnboardingTourProps) {
const [currentStep, setCurrentStep] = useState(0);
const handleNext = useCallback(() => {
if (currentStep < steps.length - 1) {
setCurrentStep((s) => s + 1);
} else {
// Complete onboarding
try {
localStorage.setItem(ONBOARDING_KEY, 'true');
} catch {
// ignore
}
onClose();
}
}, [currentStep, onClose]);
const handleBack = useCallback(() => {
if (currentStep > 0) {
setCurrentStep((s) => s - 1);
}
}, [currentStep]);
const handleSkip = useCallback(() => {
try {
localStorage.setItem(ONBOARDING_KEY, 'true');
} catch {
// ignore
}
onClose();
}, [onClose]);
const step = steps[currentStep];
const isLastStep = currentStep === steps.length - 1;
const isFirstStep = currentStep === 0;
return (
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) handleSkip(); }}>
<DialogContent className="max-w-lg p-0 gap-0 overflow-hidden rounded-2xl border-emerald-200/60 dark:border-emerald-900/40 bg-white dark:bg-gray-950 shadow-2xl">
{/* Hidden title for accessibility */}
<DialogHeader className="sr-only">
<VisuallyHidden>
<DialogTitle>CompetenceTrack Onboarding Tour</DialogTitle>
</VisuallyHidden>
</DialogHeader>
{/* Close button */}
<button
onClick={handleSkip}
className="absolute right-3 top-3 z-10 rounded-full p-1 hover:bg-emerald-100 dark:hover:bg-emerald-900/20 transition-colors text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<X className="h-4 w-4" />
</button>
{/* Step content with animation */}
<div className="relative overflow-hidden" style={{ minHeight: '320px' }}>
<AnimatePresence mode="wait">
<motion.div
key={currentStep}
initial={{ opacity: 0, x: 60 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -60 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
className="flex flex-col items-center text-center px-8 pt-10 pb-6"
>
{/* Emoji illustration */}
<motion.div
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.15, duration: 0.4, type: 'spring', stiffness: 200 }}
className={`w-24 h-24 rounded-3xl bg-gradient-to-br ${step.accent} flex items-center justify-center text-5xl shadow-lg mb-6`}
>
<step.iconComponent className="w-10 h-10 text-white" />
</motion.div>
{/* Title */}
<motion.h2
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, duration: 0.3 }}
className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-3"
>
{t(step.titleKey)}
</motion.h2>
{/* Description */}
<motion.p
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3, duration: 0.3 }}
className="text-sm text-gray-600 dark:text-gray-400 leading-relaxed max-w-sm"
>
{t(step.descKey)}
</motion.p>
</motion.div>
</AnimatePresence>
</div>
{/* Step indicator dots */}
<div className="flex items-center justify-center gap-2 pb-4">
{steps.map((_, i) => (
<button
key={i}
onClick={() => setCurrentStep(i)}
className={`rounded-full transition-all duration-300 ${
i === currentStep
? 'w-6 h-2 bg-gradient-to-r from-emerald-400 to-teal-500'
: i < currentStep
? 'w-2 h-2 bg-emerald-400/60 dark:bg-emerald-500/40'
: 'w-2 h-2 bg-gray-200 dark:bg-gray-700'
}`}
/>
))}
</div>
{/* Step counter */}
<div className="text-center pb-2">
<span className="text-[10px] text-gray-400 dark:text-gray-500 font-medium">
{t('onboarding.step', { current: String(currentStep + 1), total: String(steps.length) })}
</span>
</div>
{/* Action buttons */}
<div className="flex items-center justify-between px-6 py-4 border-t border-emerald-100/50 dark:border-emerald-900/30 bg-emerald-50/30 dark:bg-emerald-950/10">
<div className="flex-1">
{!isFirstStep && (
<Button
variant="ghost"
size="sm"
onClick={handleBack}
className="text-gray-500 dark:text-gray-400 hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-900/20"
>
{t('onboarding.back')}
</Button>
)}
</div>
<div className="flex items-center gap-2">
{!isLastStep && (
<Button
variant="ghost"
size="sm"
onClick={handleSkip}
className="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
>
{t('onboarding.skip')}
</Button>
)}
<Button
size="sm"
onClick={handleNext}
className={`bg-gradient-to-r ${step.accent} text-white hover:opacity-90 shadow-md shadow-emerald-200/50 dark:shadow-emerald-900/30 px-6`}
>
{isLastStep ? t('onboarding.start') : t('onboarding.next')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
export function isOnboardingCompleted(): boolean {
try {
return localStorage.getItem(ONBOARDING_KEY) === 'true';
} catch {
return false;
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+731
View File
@@ -0,0 +1,731 @@
'use client';
import React, { useEffect, useState } from 'react';
import {
FileText, Plus, Eye, Download, Check, PenLine, BookOpen, Printer,
School, Clock, Sparkles, FileEdit, FileCheck, FileType,
CheckCircle, BarChart3,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import {
fetchClasses, fetchClassStudents,
fetchReports, createReport, updateReport, getReportPdfUrl,
fetchCompetenceFlower, addNotification,
type ClassGroup, type Student, type Report,
} from '@/lib/api';
import { toast } from 'sonner';
const relativeDate = (dateStr: string) => {
const now = new Date();
const date = new Date(dateStr);
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const diffHrs = Math.floor(diffMs / (1000 * 60 * 60));
const diffMins = Math.floor(diffMs / (1000 * 60));
if (diffMins < 60) return `${Math.max(diffMins, 0)}m`;
if (diffHrs < 24) return `${diffHrs}h`;
if (diffDays === 0) return t('date.today');
if (diffDays === 1) return t('date.yesterday');
if (diffDays < 7) return t('date.days_ago', { count: diffDays });
if (diffDays < 30) return t('date.weeks_ago', { count: Math.floor(diffDays / 7) });
return date.toLocaleDateString();
};
function reportCompletionPct(r: Report): number {
if (r.sections.length === 0) return 0;
const filled = r.sections.filter((s) => s.generatedText && s.generatedText.trim().length > 0).length;
return Math.round((filled / r.sections.length) * 100);
}
export default function ReportsView() {
const currentUser = useAppStore((s) => s.currentUser);
const currentClassId = useAppStore((s) => s.currentClassId);
const [classes, setClasses] = useState<ClassGroup[]>([]);
const [selectedClass, setSelectedClass] = useState<ClassGroup | null>(null);
const [students, setStudents] = useState<Student[]>([]);
const [reports, setReports] = useState<Report[]>([]);
const [selectedReport, setSelectedReport] = useState<Report | null>(null);
const [loading, setLoading] = useState(true);
const [loadingReports, setLoadingReports] = useState(false);
// Create dialog
const [createOpen, setCreateOpen] = useState(false);
const [newStudentId, setNewStudentId] = useState('');
const [newPeriod, setNewPeriod] = useState('Semester 1');
const [newIncludesGrades, setNewIncludesGrades] = useState(false);
const [creating, setCreating] = useState(false);
// Preview dialog
const [previewOpen, setPreviewOpen] = useState(false);
// Edit section
const [editingSection, setEditingSection] = useState<{ id: string; text: string } | null>(null);
useEffect(() => {
async function load() {
try {
const cls = await fetchClasses(currentUser?.schoolId ?? undefined);
setClasses(cls);
if (currentClassId) {
const found = cls.find((c) => c.id === currentClassId);
if (found) handleSelectClass(found);
}
} catch {
// ignore
} finally {
setLoading(false);
}
}
load();
}, [currentUser?.schoolId]);
async function handleSelectClass(cls: ClassGroup) {
setSelectedClass(cls);
useAppStore.getState().setCurrentClass(cls.id);
setLoadingReports(true);
try {
const [s, r] = await Promise.all([
fetchClassStudents(cls.id),
fetchReports({ classGroupId: cls.id }),
]);
setStudents(s);
setReports(r);
} catch {
// ignore
} finally {
setLoadingReports(false);
}
};
const handleCreateReport = async () => {
if (!selectedClass || !currentUser || !newStudentId) return;
setCreating(true);
try {
const report = await createReport({
studentId: newStudentId,
classGroupId: selectedClass.id,
schoolYearId: selectedClass.schoolYearId,
period: newPeriod,
includesGrades: newIncludesGrades,
});
toast.success(t('toast.created'));
addNotification({
type: 'report',
message: `${t('notification.report_generated')}: ${newStudentId}`,
timestamp: new Date().toISOString(),
});
setCreateOpen(false);
setNewStudentId('');
const r = await fetchReports({ classGroupId: selectedClass.id });
setReports(r);
const newReport = r.find((rp) => rp.id === report.id);
if (newReport) setSelectedReport(newReport);
} catch (err) {
toast.error(err instanceof Error ? err.message : t('error.generic'));
} finally {
setCreating(false);
}
};
const handleFinalize = async (report: Report) => {
try {
await updateReport({ id: report.id, status: 'FINAL' });
toast.success(t('reports.report_finalized'));
if (selectedClass) {
const r = await fetchReports({ classGroupId: selectedClass.id });
setReports(r);
const updated = r.find((rp) => rp.id === report.id);
if (updated) setSelectedReport(updated);
}
} catch (err) {
toast.error(err instanceof Error ? err.message : t('error.generic'));
}
};
const handleSaveSection = async () => {
if (!editingSection || !selectedReport) return;
toast.success(t('toast.saved'));
setEditingSection(null);
};
const draftReports = reports.filter((r) => r.status === 'DRAFT');
const finalReports = reports.filter((r) => r.status === 'FINAL');
if (loading) {
return <div className="space-y-4"><Skeleton className="h-96 rounded-xl" /></div>;
}
return (
<div className="space-y-6">
{/* Selection header */}
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
<CardContent className="p-6">
<div className="flex flex-wrap gap-4 items-end">
<div className="space-y-1 min-w-[200px]">
<Label className="text-sm font-medium text-emerald-600/60 dark:text-emerald-400/40">{t('polish.label_class')}</Label>
<Select
value={selectedClass?.id ?? ''}
onValueChange={(id) => {
const cls = classes.find((c) => c.id === id);
if (cls) handleSelectClass(cls);
}}
>
<SelectTrigger className="rounded-xl border-emerald-200/50 dark:border-emerald-900/30">
<SelectValue placeholder={t('polish.please_choose')} />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedClass && (
<Button className="bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white shadow-md shadow-emerald-300/20 rounded-xl" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
{t('reports.generate')}
</Button>
)}
</div>
</CardContent>
</Card>
{!selectedClass ? (
<div className="space-y-4">
<Card className="relative border-0 shadow-sm rounded-xl overflow-hidden">
{/* Subtle dot grid pattern background */}
<div className="absolute inset-0 bg-dots opacity-30 pointer-events-none" />
<CardContent className="relative py-16 text-center">
<div className="flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-emerald-100 to-teal-100 dark:from-emerald-900/30 dark:to-teal-900/30 mx-auto mb-5 shadow-md shadow-emerald-200/40 dark:shadow-emerald-900/20">
<FileText className="h-10 w-10 text-emerald-500 dark:text-emerald-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1">{t('polish.empty_title_reports')}</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto">{t('reports.empty_subtitle')}</p>
</CardContent>
</Card>
{/* Report Templates info card — explains the 3 report types */}
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-amber-400 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-amber-50/50 to-transparent dark:from-amber-900/10 dark:to-transparent">
<CardTitle className="text-base font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400">
<Sparkles className="h-4 w-4" />
</div>
{t('polish.report_templates_title')}
<span className="text-xs font-normal text-gray-500 dark:text-gray-400 ml-1 hidden sm:inline">
· {t('polish.report_templates_subtitle')}
</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{/* Draft */}
<div className="p-4 rounded-xl bg-gradient-to-br from-amber-50/60 to-amber-50/0 dark:from-amber-900/15 dark:to-amber-900/0 border border-amber-100/60 dark:border-amber-900/30">
<div className="flex items-center gap-2 mb-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-amber-400 to-amber-500 text-white shadow-sm">
<FileEdit className="h-4 w-4" />
</div>
<p className="text-sm font-semibold text-amber-700 dark:text-amber-300">
{t('polish.report_template_draft_name')}
</p>
</div>
<p className="text-xs text-gray-600 dark:text-gray-400 leading-snug">
{t('polish.report_template_draft_desc')}
</p>
</div>
{/* Final */}
<div className="p-4 rounded-xl bg-gradient-to-br from-emerald-50/60 to-emerald-50/0 dark:from-emerald-900/15 dark:to-emerald-900/0 border border-emerald-100/60 dark:border-emerald-900/30">
<div className="flex items-center gap-2 mb-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-emerald-400 to-emerald-500 text-white shadow-sm">
<FileCheck className="h-4 w-4" />
</div>
<p className="text-sm font-semibold text-emerald-700 dark:text-emerald-300">
{t('polish.report_template_final_name')}
</p>
</div>
<p className="text-xs text-gray-600 dark:text-gray-400 leading-snug">
{t('polish.report_template_final_desc')}
</p>
</div>
{/* PDF Export */}
<div className="p-4 rounded-xl bg-gradient-to-br from-teal-50/60 to-teal-50/0 dark:from-teal-900/15 dark:to-teal-900/0 border border-teal-100/60 dark:border-teal-900/30">
<div className="flex items-center gap-2 mb-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-teal-400 to-teal-500 text-white shadow-sm">
<FileType className="h-4 w-4" />
</div>
<p className="text-sm font-semibold text-teal-700 dark:text-teal-300">
{t('polish.report_template_pdf_name')}
</p>
</div>
<p className="text-xs text-gray-600 dark:text-gray-400 leading-snug">
{t('polish.report_template_pdf_desc')}
</p>
</div>
</div>
</CardContent>
</Card>
</div>
) : loadingReports ? (
<Skeleton className="h-96 rounded-xl" />
) : reports.length === 0 ? (
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
<CardContent className="py-16 text-center">
<div className="flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-emerald-100 to-teal-100 dark:from-emerald-900/30 dark:to-teal-900/30 mx-auto mb-5 shadow-md shadow-emerald-200/40 dark:shadow-emerald-900/20">
<FileText className="h-10 w-10 text-emerald-500 dark:text-emerald-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1">{t('polish.empty_title_no_data')}</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto">{t('reports.no_reports')}</p>
<Button className="mt-5 bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white rounded-xl" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
{t('reports.generate')}
</Button>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Reports list */}
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
<CardTitle className="text-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
<FileText className="h-4 w-4" />
</div>
{t('reports.title')}
</CardTitle>
</CardHeader>
<CardContent className="max-h-[70vh] overflow-y-auto scrollbar-education space-y-4">
{draftReports.length > 0 && (
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-amber-600/60 dark:text-amber-400/40 mb-2">{t('reports.draft_reports')}</p>
<div className="space-y-2">
{draftReports.map((r) => (
<div
key={r.id}
role="button"
tabIndex={0}
onClick={() => setSelectedReport(r)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') setSelectedReport(r); }}
className={`w-full text-left p-4 rounded-xl transition-all duration-200 cursor-pointer border-l-3 border-l-amber-400 hover:-translate-y-1 hover:shadow-lg hover:shadow-amber-500/5 ${
selectedReport?.id === r.id
? 'bg-amber-50 dark:bg-amber-900/20 shadow-sm'
: 'bg-gray-50/80 dark:bg-gray-800/50 hover:bg-amber-50/50 dark:hover:bg-amber-900/10'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gradient-to-br from-amber-100 to-amber-200 dark:from-amber-900/30 dark:to-amber-800/30 text-amber-600 dark:text-amber-300 text-xs font-bold shrink-0">
{r.student.firstName[0]}{r.student.lastName[0]}
</div>
<p className="font-semibold text-gray-900 dark:text-gray-100">
{r.student.firstName} {r.student.lastName}
</p>
</div>
<Badge className="bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300 text-xs font-medium rounded-xl animate-pulse-soft">
<FileText className="w-3 h-3 mr-0.5 inline" /> {t('status.draft')}
</Badge>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5 ml-10">
{r.period} · {new Date(r.generatedAt).toLocaleDateString()}
</p>
</div>
))}
</div>
</div>
)}
{finalReports.length > 0 && (
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-emerald-600/60 dark:text-emerald-400/40 mb-2">{t('reports.final_reports')}</p>
<div className="space-y-2">
{finalReports.map((r) => (
<div
key={r.id}
role="button"
tabIndex={0}
onClick={() => setSelectedReport(r)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') setSelectedReport(r); }}
className={`w-full text-left p-4 rounded-xl transition-all duration-200 cursor-pointer border-l-3 border-l-emerald-400 hover:-translate-y-1 hover:shadow-lg hover:shadow-emerald-500/5 ${
selectedReport?.id === r.id
? 'bg-emerald-50 dark:bg-emerald-900/20 shadow-sm'
: 'bg-gray-50/80 dark:bg-gray-800/50 hover:bg-emerald-50/50 dark:hover:bg-emerald-900/10'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gradient-to-br from-emerald-100 to-emerald-200 dark:from-emerald-900/30 dark:to-emerald-800/30 text-emerald-600 dark:text-emerald-300 text-xs font-bold shrink-0">
{r.student.firstName[0]}{r.student.lastName[0]}
</div>
<p className="font-semibold text-gray-900 dark:text-gray-100">
{r.student.firstName} {r.student.lastName}
</p>
</div>
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 text-xs font-medium rounded-xl">
<CheckCircle className="w-3 h-3 mr-0.5 inline" /> {t('status.final')}
</Badge>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5 ml-10">
{r.period} · {new Date(r.generatedAt).toLocaleDateString()}
</p>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* Report detail */}
<div className="lg:col-span-2 space-y-4">
{!selectedReport ? (
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
<CardContent className="py-16 text-center">
<div className="flex items-center justify-center w-16 h-16 rounded-2xl bg-emerald-100 dark:bg-emerald-900/20 mx-auto mb-4">
<FileText className="h-8 w-8 text-emerald-400 dark:text-emerald-500" />
</div>
<p className="text-gray-500 dark:text-gray-400 font-medium">{t('action.select')}</p>
</CardContent>
</Card>
) : (
<>
{/* Report header */}
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
<FileText className="h-4 w-4" />
</div>
{t('reports.preview')} — {selectedReport.student.firstName} {selectedReport.student.lastName}
</CardTitle>
<p className="text-sm text-emerald-600/60 dark:text-emerald-400/40 mt-1 flex items-center gap-1.5 flex-wrap">
<span>{selectedReport.period}</span>
<span>·</span>
<span>{selectedReport.classGroup.name}</span>
<span>·</span>
<span>{t('reports.generated_by')}: {selectedReport.generatedByUser.firstName} {selectedReport.generatedByUser.lastName}</span>
<span>·</span>
<span className="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400">
<Clock className="h-3 w-3" />
{t('polish.last_edited')} {relativeDate(selectedReport.generatedAt)}
</span>
</p>
</div>
<div className="flex gap-2">
{selectedReport.status === 'DRAFT' && (
<Button className="bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white shadow-md shadow-emerald-300/20 rounded-xl" onClick={() => handleFinalize(selectedReport)}>
<Check className="h-4 w-4 mr-1" />
{t('reports.finalize_report')}
</Button>
)}
<Button variant="outline" className="rounded-xl border-emerald-300 dark:border-emerald-700" onClick={() => setPreviewOpen(true)}>
<Eye className="h-4 w-4 mr-1" />
{t('action.preview')}
</Button>
<Button variant="outline" className="rounded-xl border-teal-300 dark:border-teal-700" onClick={() => {
const pdfUrl = getReportPdfUrl(selectedReport.id);
window.open(pdfUrl, '_blank');
}}>
<Printer className="h-4 w-4 mr-1" />
{t('pdf.print_view')}
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Status + completion */}
<div className="flex flex-wrap items-center gap-2">
<Badge className={
selectedReport.status === 'DRAFT'
? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300 rounded-xl'
: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 rounded-xl'
}>
{selectedReport.status === 'DRAFT' ? <FileText className="w-3.5 h-3.5 inline mr-0.5" /> : <CheckCircle className="w-3.5 h-3.5 inline mr-0.5" />} {selectedReport.status === 'DRAFT' ? t('status.draft') : t('status.final')}
</Badge>
{selectedReport.includesGrades && (
<Badge className="bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300 rounded-xl">
<BarChart3 className="w-3.5 h-3.5 inline mr-0.5" /> {t('reports.includes_grades')}
</Badge>
)}
</div>
{/* Workflow status visualization */}
<div className="p-3 rounded-xl bg-gradient-to-r from-emerald-50/40 to-teal-50/30 dark:from-emerald-900/10 dark:to-teal-900/10 border border-emerald-200/40 dark:border-emerald-900/20">
<div className="flex items-center justify-between gap-1">
{[
{ key: 'draft', label: t('polish.workflow_draft'), active: true, color: 'bg-amber-500' },
{ key: 'review', label: t('polish.workflow_review'), active: selectedReport.status !== 'DRAFT' || reportCompletionPct(selectedReport) === 100, color: 'bg-teal-500' },
{ key: 'published', label: t('polish.workflow_published'), active: selectedReport.status === 'FINAL', color: 'bg-emerald-500' },
].map((step, i, arr) => (
<React.Fragment key={step.key}>
<div className="flex flex-col items-center gap-1 flex-1">
<div className={`w-7 h-7 rounded-full flex items-center justify-center text-white text-xs font-bold shadow-sm ${step.active ? step.color : 'bg-gray-300 dark:bg-gray-700'}`}>
{step.active ? <Check className="h-3.5 w-3.5" /> : i + 1}
</div>
<span className={`text-[10px] font-medium ${step.active ? 'text-gray-700 dark:text-gray-300' : 'text-gray-400 dark:text-gray-500'}`}>{step.label}</span>
</div>
{i < arr.length - 1 && (
<div className={`h-0.5 flex-1 rounded-full ${arr[i + 1].active ? step.color : 'bg-gray-200 dark:bg-gray-700'}`} />
)}
</React.Fragment>
))}
</div>
</div>
{/* Completion progress */}
<div>
<div className="flex items-center justify-between mb-1">
<p className="text-[10px] uppercase tracking-wider font-semibold text-emerald-600/70 dark:text-emerald-400/60 flex items-center gap-1">
<Sparkles className="h-3 w-3" />
{t('polish.report_completion')}
</p>
<span className="text-xs font-semibold text-gray-600 dark:text-gray-300">
{selectedReport.sections.filter((s) => s.generatedText && s.generatedText.trim().length > 0).length} / {selectedReport.sections.length}
</span>
</div>
<div className="h-2 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
reportCompletionPct(selectedReport) === 100
? 'bg-gradient-to-r from-emerald-400 to-teal-500'
: 'bg-gradient-to-r from-amber-400 to-amber-500'
}`}
style={{ width: `${reportCompletionPct(selectedReport)}%` }}
/>
</div>
</div>
</CardContent>
</Card>
{/* Report sections */}
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent">
<CardTitle className="text-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
<BookOpen className="h-4 w-4" />
</div>
{t('reports.sections')}
</CardTitle>
</CardHeader>
<CardContent>
{selectedReport.sections.length === 0 ? (
<div className="text-center py-8">
<div className="flex items-center justify-center w-16 h-16 rounded-2xl bg-teal-100 dark:bg-teal-900/20 mx-auto mb-4">
<BookOpen className="h-8 w-8 text-teal-400 dark:text-teal-500" />
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">{t('empty.no_items')}</p>
</div>
) : (
<div className="space-y-4 max-h-[60vh] overflow-y-auto scrollbar-education">
{selectedReport.sections.map((section) => (
<div
key={section.id}
className="p-5 rounded-xl bg-gray-50/80 dark:bg-gray-800/30 border-l-3 transition-shadow duration-200 hover:shadow-md"
style={{ borderLeftColor: section.competencyCategory?.color ?? '#10b981' }}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
{section.competencyCategory && (
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: section.competencyCategory.color ?? '#10b981' }}
/>
)}
<p className="font-bold text-gray-900 dark:text-gray-100 text-sm">
{section.competencyCategory?.name ?? `${t('reports.section_text')} ${section.order + 1}`}
</p>
</div>
{selectedReport.status === 'DRAFT' && (
<Button size="sm" variant="ghost" className="text-emerald-600 hover:text-emerald-700 dark:text-emerald-400 dark:hover:text-emerald-300 rounded-xl" onClick={() => setEditingSection({ id: section.id, text: section.generatedText })}>
<PenLine className="h-3 w-3 mr-1" />
{t('action.edit')}
</Button>
)}
</div>
<p className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-line leading-relaxed">{section.generatedText}</p>
</div>
))}
</div>
)}
</CardContent>
</Card>
</>
)}
</div>
</div>
)}
{/* Create report dialog */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="rounded-xl">
<DialogHeader>
<DialogTitle className="text-lg font-bold">{t('reports.generate_title')}</DialogTitle>
</DialogHeader>
<div className="space-y-5 pt-2">
<div className="space-y-2">
<Label className="text-sm font-medium">{t('reports.select_student')}</Label>
<Select value={newStudentId} onValueChange={setNewStudentId}>
<SelectTrigger className="rounded-xl border-emerald-200/50 dark:border-emerald-900/30">
<SelectValue placeholder={t('reports.select_student')} />
</SelectTrigger>
<SelectContent>
{students.map((s) => (
<SelectItem key={s.id} value={s.id}>{s.firstName} {s.lastName}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">{t('reports.select_period')}</Label>
<Select value={newPeriod} onValueChange={setNewPeriod}>
<SelectTrigger className="rounded-xl border-emerald-200/50 dark:border-emerald-900/30">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Semester 1">Semester 1</SelectItem>
<SelectItem value="Semester 2">Semester 2</SelectItem>
<SelectItem value="Halbjahr 1">Halbjahr 1</SelectItem>
<SelectItem value="Halbjahr 2">Halbjahr 2</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-3 p-3 rounded-xl bg-emerald-50/50 dark:bg-emerald-900/10">
<input
type="checkbox"
checked={newIncludesGrades}
onChange={(e) => setNewIncludesGrades(e.target.checked)}
className="rounded border-emerald-300 text-emerald-500 focus:ring-emerald-400"
/>
<Label className="text-sm font-medium">{t('reports.includes_grades')}</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)} className="rounded-xl">{t('action.cancel')}</Button>
<Button className="bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white rounded-xl shadow-md shadow-emerald-300/20" onClick={handleCreateReport} disabled={creating || !newStudentId}>
{creating ? t('empty.loading') : t('reports.generate')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Preview dialog */}
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
<DialogContent className="max-w-3xl rounded-xl p-0 gap-0 overflow-hidden">
<DialogHeader className="sr-only">
<DialogTitle>{t('polish.print_preview')}</DialogTitle>
</DialogHeader>
{/* Paper-style preview */}
<div className="paper-preview px-8 py-10 sm:px-12 sm:py-14 max-h-[80vh] overflow-y-auto scrollbar-education print-avoid-break">
{/* School header */}
<div className="flex items-center justify-between border-b-2 border-emerald-600/30 pb-4 mb-6">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-md shadow-emerald-300/30">
<School className="h-6 w-6" />
</div>
<div>
<p className="text-base font-bold text-gray-900 dark:text-gray-100 leading-tight">
{selectedReport?.classGroup.name ? `${selectedReport.classGroup.name}` : t('polish.report_header')}
</p>
<p className="text-[10px] text-gray-500 dark:text-gray-400">
{t('polish.report_logo')} · {selectedReport?.schoolYear?.label ?? ''}
</p>
</div>
</div>
<div className="text-right">
<p className="text-[10px] uppercase tracking-wider text-gray-500 dark:text-gray-400">{t('polish.report_header')}</p>
<p className="text-sm font-bold text-gray-900 dark:text-gray-100">{selectedReport?.period}</p>
</div>
</div>
{/* Student info */}
<div className="mb-6">
<p className="text-[10px] uppercase tracking-wider text-gray-500 dark:text-gray-400">{t('polish.student_value')}</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{selectedReport?.student.firstName} {selectedReport?.student.lastName}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{selectedReport?.classGroup.name} · {t('label.grade')} {selectedReport?.classGroup.gradeLevel}
</p>
</div>
{/* Sections */}
<div className="space-y-5">
{selectedReport?.sections.map((section) => (
<div key={section.id} className="space-y-1.5">
{section.competencyCategory && (
<div className="flex items-center gap-2">
<div
className="w-2.5 h-2.5 rounded-full"
style={{ backgroundColor: section.competencyCategory.color ?? '#10b981' }}
/>
<p className="font-bold text-gray-900 dark:text-gray-100 text-sm uppercase tracking-wide">
{section.competencyCategory.name}
</p>
</div>
)}
<p className="text-sm text-gray-800 dark:text-gray-200 whitespace-pre-line leading-relaxed pl-4.5 font-serif">
{section.generatedText}
</p>
</div>
))}
</div>
{/* Footer signature line */}
<div className="mt-12 pt-6 border-t border-gray-300 dark:border-gray-700 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
<div>
<p className="mb-6">{t('reports.generated_by')}:</p>
<div className="w-32 border-b border-gray-400 dark:border-gray-600" />
<p className="mt-1">{selectedReport?.generatedByUser.firstName} {selectedReport?.generatedByUser.lastName}</p>
</div>
<div className="text-right">
<p className="mb-6">{t('label.date')}:</p>
<div className="w-32 border-b border-gray-400 dark:border-gray-600 ml-auto" />
<p className="mt-1">{selectedReport ? new Date(selectedReport.generatedAt).toLocaleDateString() : ''}</p>
</div>
</div>
</div>
{/* Footer with action buttons */}
<div className="flex items-center justify-between px-4 py-3 border-t border-emerald-200/50 dark:border-emerald-900/30 bg-gradient-to-r from-emerald-50/30 to-transparent dark:from-emerald-900/10">
<span className="text-xs text-emerald-600/70 dark:text-emerald-400/60 flex items-center gap-1">
<Eye className="h-3 w-3" />
{t('polish.print_preview')}
</span>
<Button variant="outline" onClick={() => setPreviewOpen(false)} className="rounded-xl">{t('action.close')}</Button>
</div>
</DialogContent>
</Dialog>
{/* Edit section dialog */}
<Dialog open={!!editingSection} onOpenChange={() => setEditingSection(null)}>
<DialogContent className="rounded-xl">
<DialogHeader>
<DialogTitle className="text-lg font-bold">{t('reports.edit_section')}</DialogTitle>
</DialogHeader>
<Textarea
value={editingSection?.text ?? ''}
onChange={(e) => setEditingSection(editingSection ? { ...editingSection, text: e.target.value } : null)}
rows={8}
className="rounded-xl border-emerald-200/50 dark:border-emerald-900/30"
/>
<DialogFooter>
<Button variant="outline" onClick={() => setEditingSection(null)} className="rounded-xl">{t('action.cancel')}</Button>
<Button className="bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white rounded-xl shadow-md shadow-emerald-300/20" onClick={handleSaveSection}>
{t('action.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+557
View File
@@ -0,0 +1,557 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import { motion } from 'framer-motion';
import {
MessageSquare,
Plus,
Pencil,
Trash2,
Lock,
LockOpen,
AlertCircle,
FileText,
Star,
BookOpen,
AlertTriangle,
Users,
Sparkles,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import {
fetchTeacherNotes,
createTeacherNote,
updateTeacherNote,
deleteTeacherNote,
type TeacherNote,
type TeacherNoteCategory,
} from '@/lib/api';
import { toast } from 'sonner';
const CATEGORIES: TeacherNoteCategory[] = [
'GENERAL',
'BEHAVIOR',
'ACADEMIC',
'INTERVENTION',
'PARENT_CONTACT',
];
const CATEGORY_META: Record<
TeacherNoteCategory,
{ iconComponent: React.ElementType; color: string; border: string; dot: string; labelKey: string; gradient: string }
> = {
GENERAL: {
iconComponent: FileText,
color: 'bg-gray-100 text-gray-700 dark:bg-gray-900/40 dark:text-gray-300',
border: 'border-l-gray-400 dark:border-l-gray-600',
dot: 'bg-gray-400 dark:bg-gray-500',
labelKey: 'teacher_notes.cat_general',
gradient: 'from-gray-50 to-transparent dark:from-gray-900/20',
},
BEHAVIOR: {
iconComponent: Star,
color: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
border: 'border-l-amber-400 dark:border-l-amber-600',
dot: 'bg-amber-400 dark:bg-amber-500',
labelKey: 'teacher_notes.cat_behavior',
gradient: 'from-amber-50 to-transparent dark:from-amber-900/15',
},
ACADEMIC: {
iconComponent: BookOpen,
color: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300',
border: 'border-l-emerald-400 dark:border-l-emerald-600',
dot: 'bg-emerald-400 dark:bg-emerald-500',
labelKey: 'teacher_notes.cat_academic',
gradient: 'from-emerald-50 to-transparent dark:from-emerald-900/15',
},
INTERVENTION: {
iconComponent: AlertTriangle,
color: 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300',
border: 'border-l-rose-400 dark:border-l-rose-600',
dot: 'bg-rose-400 dark:bg-rose-500',
labelKey: 'teacher_notes.cat_intervention',
gradient: 'from-rose-50 to-transparent dark:from-rose-900/15',
},
PARENT_CONTACT: {
iconComponent: Users,
color: 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300',
border: 'border-l-violet-400 dark:border-l-violet-600',
dot: 'bg-violet-400 dark:bg-violet-500',
labelKey: 'teacher_notes.cat_parent_contact',
gradient: 'from-violet-50 to-transparent dark:from-violet-900/15',
},
};
function relativeTime(iso: string, locale: string): string {
const date = new Date(iso);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const sec = Math.floor(diffMs / 1000);
const min = Math.floor(sec / 60);
const hr = Math.floor(min / 60);
const day = Math.floor(hr / 24);
const wk = Math.floor(day / 7);
const mo = Math.floor(day / 30);
const isDe = locale === 'de';
if (sec < 60) return isDe ? 'gerade eben' : 'just now';
if (min < 60) return isDe ? `vor ${min} Min.` : `${min}m ago`;
if (hr < 24) return isDe ? `vor ${hr} Std.` : `${hr}h ago`;
if (day < 7) return isDe ? `vor ${day} Tg.` : `${day}d ago`;
if (wk < 5) return isDe ? `vor ${wk} Wo.` : `${wk}w ago`;
if (mo < 12) return isDe ? `vor ${mo} Mo.` : `${mo}mo ago`;
return date.toLocaleDateString(isDe ? 'de-DE' : 'en-US');
}
interface TeacherNotesSectionProps {
studentId: string;
}
export default function TeacherNotesSection({ studentId }: TeacherNotesSectionProps) {
const currentUser = useAppStore((s) => s.currentUser);
const locale = useAppStore((s) => s.locale);
const [notes, setNotes] = useState<TeacherNote[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingNote, setEditingNote] = useState<TeacherNote | null>(null);
const [formCategory, setFormCategory] = useState<TeacherNoteCategory>('GENERAL');
const [formContent, setFormContent] = useState('');
const [formPrivate, setFormPrivate] = useState(true);
const [saving, setSaving] = useState(false);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [filterCategory, setFilterCategory] = useState<TeacherNoteCategory | 'ALL'>('ALL');
// Count per category
const categoryCounts = useMemo(() => {
const counts: Record<TeacherNoteCategory, number> = {
GENERAL: 0,
BEHAVIOR: 0,
ACADEMIC: 0,
INTERVENTION: 0,
PARENT_CONTACT: 0,
};
for (const n of notes) {
counts[n.category] = (counts[n.category] ?? 0) + 1;
}
return counts;
}, [notes]);
const filteredNotes = useMemo(() => {
if (filterCategory === 'ALL') return notes;
return notes.filter((n) => n.category === filterCategory);
}, [notes, filterCategory]);
const MAX_CHARS = 1000;
const formContentLength = formContent.length;
const loadNotes = React.useCallback(async () => {
setLoading(true);
try {
const list = await fetchTeacherNotes(studentId);
setNotes(list);
} catch (err) {
const msg = err instanceof Error ? err.message : t('error.generic');
toast.error(msg);
} finally {
setLoading(false);
}
}, [studentId]);
useEffect(() => {
loadNotes();
}, [loadNotes]);
const openCreate = () => {
setEditingNote(null);
setFormCategory('GENERAL');
setFormContent('');
setFormPrivate(true);
setDialogOpen(true);
};
const openEdit = (note: TeacherNote) => {
setEditingNote(note);
setFormCategory(note.category);
setFormContent(note.content);
setFormPrivate(note.isPrivate);
setDialogOpen(true);
};
const handleSave = async () => {
if (!formContent.trim()) {
toast.error(t('teacher_notes.error_min_content'));
return;
}
setSaving(true);
try {
if (editingNote) {
const updated = await updateTeacherNote(editingNote.id, {
category: formCategory,
content: formContent.trim(),
isPrivate: formPrivate,
});
setNotes((prev) =>
prev.map((n) => (n.id === editingNote.id ? updated : n))
);
toast.success(t('teacher_notes.updated'));
} else {
const created = await createTeacherNote({
studentId,
category: formCategory,
content: formContent.trim(),
isPrivate: formPrivate,
});
setNotes((prev) => [created, ...prev]);
toast.success(t('teacher_notes.created'));
}
setDialogOpen(false);
} catch (err) {
const msg = err instanceof Error ? err.message : t('error.generic');
toast.error(msg);
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!deleteId) return;
try {
await deleteTeacherNote(deleteId);
setNotes((prev) => prev.filter((n) => n.id !== deleteId));
toast.success(t('teacher_notes.deleted'));
} catch (err) {
const msg = err instanceof Error ? err.message : t('error.generic');
toast.error(msg);
} finally {
setDeleteId(null);
}
};
return (
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-violet-500 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-violet-50/50 to-transparent dark:from-violet-900/10 dark:to-transparent">
<div className="flex items-center justify-between flex-wrap gap-2">
<CardTitle className="text-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400">
<MessageSquare className="h-4 w-4" />
</div>
{t('teacher_notes.title')}
<Badge className="bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300 text-xs font-medium">
{notes.length}
</Badge>
</CardTitle>
<Button
variant="outline"
size="sm"
className="rounded-xl border-violet-300 dark:border-violet-700 text-violet-700 dark:text-violet-300"
onClick={openCreate}
>
<Plus className="h-3.5 w-3.5 mr-1" />
{t('teacher_notes.add')}
</Button>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">{t('teacher_notes.subtitle')}</p>
</CardHeader>
<CardContent>
{loading ? (
<div className="space-y-2">
<Skeleton className="h-16 rounded-lg" />
<Skeleton className="h-16 rounded-lg" />
</div>
) : notes.length === 0 ? (
<div className="text-center py-10">
{/* Illustrated empty state */}
<div className="relative inline-block">
<div className="absolute -inset-2 rounded-3xl bg-gradient-to-br from-violet-100 to-emerald-100 dark:from-violet-900/20 dark:to-emerald-900/20 blur-md opacity-60" />
<div className="relative flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-violet-100 to-emerald-100 dark:from-violet-900/40 dark:to-emerald-900/40 mx-auto mb-3 text-violet-500 dark:text-violet-300">
<MessageSquare className="h-8 w-8" />
{/* Floating sparkle decoration */}
<div className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-amber-400 text-white text-xs flex items-center justify-center shadow-sm"><Sparkles className="w-3 h-3" /></div>
</div>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 font-medium">{t('teacher_notes.no_notes')}</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">{t('teacher_notes.no_notes_hint')}</p>
<Button
variant="outline"
size="sm"
className="mt-4 rounded-xl border-violet-300 dark:border-violet-700 text-violet-700 dark:text-violet-300"
onClick={openCreate}
>
<Plus className="h-3.5 w-3.5 mr-1" />
{t('teacher_notes.add')}
</Button>
</div>
) : (
<>
{/* Filter chips */}
<div className="flex flex-wrap gap-1.5 mb-3 pb-3 border-b border-violet-200/30 dark:border-violet-900/20">
<button
onClick={() => setFilterCategory('ALL')}
className={`px-2.5 py-1 rounded-md text-xs font-medium transition-all ${
filterCategory === 'ALL'
? 'bg-gradient-to-r from-violet-500 to-emerald-500 text-white shadow-sm'
: 'bg-white/60 dark:bg-gray-800/40 text-gray-500 dark:text-gray-400 hover:bg-violet-50 dark:hover:bg-violet-900/20 border border-gray-200/50 dark:border-gray-700/30'
}`}
>
{t('polish.all_actions')} ({notes.length})
</button>
{CATEGORIES.map((cat) => {
const meta = CATEGORY_META[cat];
const count = categoryCounts[cat] ?? 0;
if (count === 0) return null;
return (
<button
key={cat}
onClick={() => setFilterCategory(cat)}
className={`px-2.5 py-1 rounded-md text-xs font-medium transition-all inline-flex items-center gap-1 ${
filterCategory === cat
? `${meta.color} ring-1 shadow-sm`
: 'bg-white/60 dark:bg-gray-800/40 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 border border-gray-200/50 dark:border-gray-700/30'
}`}
>
<meta.iconComponent className="w-3 h-3" />
{t(meta.labelKey)}
<span className={`ml-1 inline-flex items-center justify-center min-w-[1.25rem] h-4 px-1 rounded-full text-[9px] font-bold ${
filterCategory === cat ? 'bg-white/30' : 'bg-gray-200 dark:bg-gray-700'
}`}>{count}</span>
</button>
);
})}
</div>
<div className="relative max-h-96 overflow-y-auto scrollbar-education">
<div className="absolute left-3 top-2 bottom-2 w-0.5 bg-gradient-to-b from-violet-300 via-violet-400 to-violet-300 dark:from-violet-700 dark:via-violet-600 dark:to-violet-700" />
{filteredNotes.length === 0 ? (
<div className="text-center py-8 pl-7">
<p className="text-sm text-gray-400 dark:text-gray-500">{t('polish.no_results')}</p>
</div>
) : filteredNotes.map((note, i) => {
const meta = CATEGORY_META[note.category] ?? CATEGORY_META.GENERAL;
const isOwn = note.teacherId === currentUser?.id;
return (
<motion.div
key={note.id}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.03 }}
className="relative flex items-start gap-3 pl-7 py-2 group"
>
<div className={`absolute left-2.5 top-3.5 w-3 h-3 rounded-full ring-2 ring-white dark:ring-gray-900 ${meta.dot}`} style={{ zIndex: 1 }} />
<div className={`flex-1 min-w-0 p-3 rounded-lg bg-gradient-to-r ${meta.gradient} border-l-2 ${meta.border}`}>
<div className="flex items-center gap-2 flex-wrap mb-1">
<Badge className={`text-[10px] inline-flex items-center gap-1 ${meta.color}`}>
<meta.iconComponent className="w-3 h-3" />
{t(meta.labelKey)}
</Badge>
<span className="text-[11px] text-gray-700 dark:text-gray-300 font-medium">
{isOwn ? t('teacher_notes.you') : `${note.teacher.firstName} ${note.teacher.lastName}`}
</span>
<span className="text-[10px] text-gray-400 dark:text-gray-500">
· {relativeTime(note.createdAt, locale)}
</span>
{note.updatedAt !== note.createdAt && (
<span className="text-[10px] text-violet-500 dark:text-violet-400 italic">
({t('polish.last_edited')} {relativeTime(note.updatedAt, locale)})
</span>
)}
{note.isPrivate ? (
<Lock className="h-3 w-3 text-violet-400" />
) : (
<LockOpen className="h-3 w-3 text-gray-400" />
)}
</div>
<p className="text-sm text-gray-800 dark:text-gray-200 leading-relaxed whitespace-pre-wrap break-words">
{note.content}
</p>
{isOwn && (
<div className="flex gap-1 mt-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-violet-700 dark:text-violet-300 hover:bg-violet-100 dark:hover:bg-violet-900/20"
onClick={() => openEdit(note)}
>
<Pencil className="h-3 w-3 mr-1" />
{t('teacher_notes.edit')}
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-rose-700 dark:text-rose-300 hover:bg-rose-100 dark:hover:bg-rose-900/20"
onClick={() => setDeleteId(note.id)}
>
<Trash2 className="h-3 w-3 mr-1" />
{t('teacher_notes.delete')}
</Button>
</div>
)}
</div>
</motion.div>
);
})}
</div>
</>
)}
</CardContent>
{/* Create / edit dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-lg rounded-2xl border-violet-200/60 dark:border-violet-900/40">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-violet-700 dark:text-violet-300">
<MessageSquare className="h-5 w-5" />
{editingNote ? t('teacher_notes.edit_title') : t('teacher_notes.add_title')}
</DialogTitle>
<DialogDescription className="text-xs text-gray-500 dark:text-gray-400">
{t('teacher_notes.subtitle')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-violet-700 dark:text-violet-300">
{t('teacher_notes.category')}
</Label>
<Select value={formCategory} onValueChange={(v) => setFormCategory(v as TeacherNoteCategory)}>
<SelectTrigger className="rounded-lg border-violet-200 dark:border-violet-900/30">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CATEGORIES.map((cat) => {
const meta = CATEGORY_META[cat];
return (
<SelectItem key={cat} value={cat}>
<span className="inline-flex items-center gap-2">
<meta.iconComponent className="w-4 h-4" />
<span className={`inline-block w-2 h-2 rounded-full ${meta.dot}`} />
{t(meta.labelKey)}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label className="text-xs font-semibold text-violet-700 dark:text-violet-300">
{t('teacher_notes.content')}
</Label>
<span className={`text-[10px] font-mono ${
formContentLength > MAX_CHARS
? 'text-rose-600 dark:text-rose-400 font-bold'
: formContentLength > MAX_CHARS * 0.8
? 'text-amber-600 dark:text-amber-400'
: 'text-gray-400 dark:text-gray-500'
}`}>
{formContentLength} / {MAX_CHARS}
</span>
</div>
<Textarea
value={formContent}
onChange={(e) => setFormContent(e.target.value.slice(0, MAX_CHARS))}
placeholder={t('teacher_notes.content_placeholder')}
rows={5}
className="rounded-lg border-violet-200 dark:border-violet-900/30 resize-none"
/>
{!formContent.trim() && (
<p className="text-[10px] text-amber-600 dark:text-amber-400 inline-flex items-center gap-1">
<AlertCircle className="h-3 w-3" />
{t('teacher_notes.error_min_content')}
</p>
)}
</div>
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={formPrivate}
onCheckedChange={(v) => setFormPrivate(v === true)}
/>
<span className="text-xs text-gray-700 dark:text-gray-300 inline-flex items-center gap-1">
<Lock className="h-3 w-3 text-violet-400" />
{t('teacher_notes.private')}
</span>
</label>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDialogOpen(false)}
className="rounded-xl"
disabled={saving}
>
{t('action.cancel')}
</Button>
<Button
onClick={handleSave}
disabled={saving || !formContent.trim()}
className="rounded-xl bg-violet-600 hover:bg-violet-700 text-white"
>
{saving ? (
<span className="inline-flex items-center gap-2">
<span className="w-3 h-3 border-2 border-white/40 border-t-white rounded-full animate-spin" />
</span>
) : null}
{t('teacher_notes.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete confirm */}
<AlertDialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
<AlertDialogContent className="rounded-2xl border-rose-200/60 dark:border-rose-900/40">
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2 text-rose-700 dark:text-rose-300">
<Trash2 className="h-5 w-5" />
{t('teacher_notes.delete')}
</AlertDialogTitle>
<AlertDialogDescription className="text-xs text-gray-500 dark:text-gray-400">
{t('teacher_notes.delete_confirm')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="rounded-xl">{t('action.cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="rounded-xl bg-rose-600 hover:bg-rose-700 text-white"
>
{t('action.delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Card>
);
}
+66
View File
@@ -0,0 +1,66 @@
"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 }
+157
View File
@@ -0,0 +1,157 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
Loaded 100 of 156 files, more files were not shown because too many files have changed in this diff. Show more