From 0c1f7399894ce62619d17cbd895f6f09d1448afe Mon Sep 17 00:00:00 2001 From: Mustafa Cetin Date: Wed, 29 Jul 2026 05:46:10 +0000 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20ExaMetra=20v1.0.0=20?= =?UTF-8?q?=E2=80=94=20Free=20&=20open-source=20competence=20&=20grading?= =?UTF-8?q?=20platform?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 5 + .gitignore | 82 + LICENSE | 21 + README.md | 155 + components.json | 21 + eslint.config.mjs | 50 + next.config.ts | 12 + package.json | 105 + postcss.config.mjs | 5 + prisma/schema.prisma | 713 ++++ prisma/seed.ts | 1545 ++++++++ public/logo.svg | 29 + public/robots.txt | 14 + src/app/api/analytics/route.ts | 439 +++ src/app/api/assessments/[id]/results/route.ts | 151 + src/app/api/assessments/route.ts | 133 + src/app/api/attendance/[id]/route.ts | 146 + src/app/api/attendance/route.ts | 298 ++ src/app/api/audit-log/route.ts | 52 + src/app/api/auth/route.ts | 157 + src/app/api/behavior-categories/[id]/route.ts | 201 + src/app/api/behavior-categories/route.ts | 137 + src/app/api/behavior-incidents/[id]/route.ts | 241 ++ src/app/api/behavior-incidents/route.ts | 224 ++ src/app/api/calendar/route.ts | 326 ++ .../api/class-competency-assignments/route.ts | 153 + src/app/api/classes/[id]/students/route.ts | 127 + src/app/api/classes/route.ts | 204 + src/app/api/comment-bank/[id]/route.ts | 212 + src/app/api/comment-bank/route.ts | 183 + src/app/api/comment-categories/[id]/route.ts | 195 + src/app/api/comment-categories/route.ts | 130 + src/app/api/competence-flower/route.ts | 229 ++ .../api/competency-templates/[id]/route.ts | 169 + src/app/api/competency-templates/route.ts | 197 + src/app/api/curriculum-coverage/route.ts | 332 ++ src/app/api/dashboard/route.ts | 209 + src/app/api/data-export/csv/route.ts | 280 ++ src/app/api/data-export/route.ts | 184 + src/app/api/demo-accounts/[id]/route.ts | 96 + src/app/api/demo-accounts/route.ts | 116 + src/app/api/grading/route.ts | 371 ++ src/app/api/learning-progress/batch/route.ts | 57 + src/app/api/learning-progress/route.ts | 130 + src/app/api/lesson-plans/[id]/route.ts | 230 ++ src/app/api/lesson-plans/route.ts | 192 + src/app/api/mastery-matrix/route.ts | 187 + src/app/api/notifications/route.ts | 185 + src/app/api/parent-messages/[id]/route.ts | 293 ++ src/app/api/parent-messages/route.ts | 252 ++ src/app/api/parents/[id]/route.ts | 205 + src/app/api/parents/route.ts | 196 + src/app/api/reports/pdf/route.ts | 204 + src/app/api/reports/route.ts | 214 ++ src/app/api/route.ts | 5 + src/app/api/rubrics/[id]/duplicate/route.ts | 111 + src/app/api/rubrics/[id]/route.ts | 246 ++ src/app/api/rubrics/route.ts | 216 ++ src/app/api/school-years/route.ts | 85 + src/app/api/schools/route.ts | 164 + src/app/api/students/[id]/details/route.ts | 279 ++ src/app/api/students/bulk/route.ts | 155 + src/app/api/students/reorder/route.ts | 70 + src/app/api/students/route.ts | 174 + src/app/api/subjects/route.ts | 202 + src/app/api/teacher-notes/[id]/route.ts | 158 + src/app/api/teacher-notes/route.ts | 149 + src/app/api/users/[id]/classes/route.ts | 194 + src/app/api/users/[id]/route.ts | 250 ++ src/app/api/users/route.ts | 177 + src/app/globals.css | 635 +++ src/app/layout.tsx | 49 + src/app/page.tsx | 50 + src/components/analytics-view.tsx | 1492 ++++++++ src/components/app-layout.tsx | 1072 ++++++ src/components/assessments-view.tsx | 798 ++++ src/components/attendance-view.tsx | 1290 +++++++ src/components/auth-view.tsx | 445 +++ src/components/behavior-tracking-view.tsx | 1855 +++++++++ src/components/calendar-view.tsx | 773 ++++ src/components/classes-view.tsx | 1402 +++++++ src/components/comment-bank-view.tsx | 1185 ++++++ src/components/competence-flower-view.tsx | 882 +++++ src/components/competency-grid-view.tsx | 621 +++ src/components/curriculum-coverage-view.tsx | 882 +++++ src/components/dashboard-view.tsx | 1149 ++++++ src/components/grading-view.tsx | 781 ++++ src/components/keyboard-shortcuts-dialog.tsx | 192 + src/components/lesson-plans-view.tsx | 1166 ++++++ src/components/mastery-matrix-view.tsx | 780 ++++ src/components/onboarding-tour.tsx | 235 ++ src/components/parent-communication-view.tsx | 2051 ++++++++++ src/components/progress-entries-view.tsx | 1093 ++++++ src/components/reports-view.tsx | 731 ++++ src/components/rubric-library-view.tsx | 1080 ++++++ src/components/settings-view.tsx | 1695 ++++++++ src/components/student-detail-view.tsx | 1148 ++++++ src/components/teacher-notes-section.tsx | 557 +++ src/components/ui/accordion.tsx | 66 + src/components/ui/alert-dialog.tsx | 157 + src/components/ui/alert.tsx | 66 + src/components/ui/aspect-ratio.tsx | 11 + src/components/ui/avatar.tsx | 53 + src/components/ui/badge.tsx | 46 + src/components/ui/breadcrumb.tsx | 109 + src/components/ui/button.tsx | 59 + src/components/ui/calendar.tsx | 213 ++ src/components/ui/card.tsx | 92 + src/components/ui/carousel.tsx | 241 ++ src/components/ui/chart.tsx | 353 ++ src/components/ui/checkbox.tsx | 32 + src/components/ui/collapsible.tsx | 33 + src/components/ui/command.tsx | 184 + src/components/ui/context-menu.tsx | 252 ++ src/components/ui/dialog.tsx | 144 + src/components/ui/drawer.tsx | 135 + src/components/ui/dropdown-menu.tsx | 257 ++ src/components/ui/form.tsx | 167 + src/components/ui/hover-card.tsx | 44 + src/components/ui/input-otp.tsx | 77 + src/components/ui/input.tsx | 21 + src/components/ui/label.tsx | 24 + src/components/ui/menubar.tsx | 276 ++ src/components/ui/navigation-menu.tsx | 168 + src/components/ui/pagination.tsx | 127 + src/components/ui/popover.tsx | 48 + src/components/ui/progress.tsx | 31 + src/components/ui/radio-group.tsx | 45 + src/components/ui/resizable.tsx | 56 + src/components/ui/scroll-area.tsx | 58 + src/components/ui/select.tsx | 185 + src/components/ui/separator.tsx | 28 + src/components/ui/sheet.tsx | 139 + src/components/ui/sidebar.tsx | 726 ++++ src/components/ui/skeleton.tsx | 13 + src/components/ui/slider.tsx | 63 + src/components/ui/sonner.tsx | 27 + src/components/ui/switch.tsx | 31 + src/components/ui/table.tsx | 116 + src/components/ui/tabs.tsx | 66 + src/components/ui/textarea.tsx | 18 + src/components/ui/toast.tsx | 129 + src/components/ui/toaster.tsx | 35 + src/components/ui/toggle-group.tsx | 73 + src/components/ui/toggle.tsx | 47 + src/components/ui/tooltip.tsx | 61 + src/hooks/use-mobile.ts | 19 + src/hooks/use-toast.ts | 194 + src/lib/api.ts | 2002 ++++++++++ src/lib/auth.ts | 69 + src/lib/db.ts | 13 + src/lib/i18n.ts | 3404 +++++++++++++++++ src/lib/store.ts | 157 + src/lib/utils.ts | 6 + tailwind.config.ts | 64 + tsconfig.json | 42 + 156 files changed, 51090 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 components.json create mode 100644 eslint.config.mjs create mode 100644 next.config.ts create mode 100644 package.json create mode 100644 postcss.config.mjs create mode 100644 prisma/schema.prisma create mode 100644 prisma/seed.ts create mode 100644 public/logo.svg create mode 100644 public/robots.txt create mode 100644 src/app/api/analytics/route.ts create mode 100644 src/app/api/assessments/[id]/results/route.ts create mode 100644 src/app/api/assessments/route.ts create mode 100644 src/app/api/attendance/[id]/route.ts create mode 100644 src/app/api/attendance/route.ts create mode 100644 src/app/api/audit-log/route.ts create mode 100644 src/app/api/auth/route.ts create mode 100644 src/app/api/behavior-categories/[id]/route.ts create mode 100644 src/app/api/behavior-categories/route.ts create mode 100644 src/app/api/behavior-incidents/[id]/route.ts create mode 100644 src/app/api/behavior-incidents/route.ts create mode 100644 src/app/api/calendar/route.ts create mode 100644 src/app/api/class-competency-assignments/route.ts create mode 100644 src/app/api/classes/[id]/students/route.ts create mode 100644 src/app/api/classes/route.ts create mode 100644 src/app/api/comment-bank/[id]/route.ts create mode 100644 src/app/api/comment-bank/route.ts create mode 100644 src/app/api/comment-categories/[id]/route.ts create mode 100644 src/app/api/comment-categories/route.ts create mode 100644 src/app/api/competence-flower/route.ts create mode 100644 src/app/api/competency-templates/[id]/route.ts create mode 100644 src/app/api/competency-templates/route.ts create mode 100644 src/app/api/curriculum-coverage/route.ts create mode 100644 src/app/api/dashboard/route.ts create mode 100644 src/app/api/data-export/csv/route.ts create mode 100644 src/app/api/data-export/route.ts create mode 100644 src/app/api/demo-accounts/[id]/route.ts create mode 100644 src/app/api/demo-accounts/route.ts create mode 100644 src/app/api/grading/route.ts create mode 100644 src/app/api/learning-progress/batch/route.ts create mode 100644 src/app/api/learning-progress/route.ts create mode 100644 src/app/api/lesson-plans/[id]/route.ts create mode 100644 src/app/api/lesson-plans/route.ts create mode 100644 src/app/api/mastery-matrix/route.ts create mode 100644 src/app/api/notifications/route.ts create mode 100644 src/app/api/parent-messages/[id]/route.ts create mode 100644 src/app/api/parent-messages/route.ts create mode 100644 src/app/api/parents/[id]/route.ts create mode 100644 src/app/api/parents/route.ts create mode 100644 src/app/api/reports/pdf/route.ts create mode 100644 src/app/api/reports/route.ts create mode 100644 src/app/api/route.ts create mode 100644 src/app/api/rubrics/[id]/duplicate/route.ts create mode 100644 src/app/api/rubrics/[id]/route.ts create mode 100644 src/app/api/rubrics/route.ts create mode 100644 src/app/api/school-years/route.ts create mode 100644 src/app/api/schools/route.ts create mode 100644 src/app/api/students/[id]/details/route.ts create mode 100644 src/app/api/students/bulk/route.ts create mode 100644 src/app/api/students/reorder/route.ts create mode 100644 src/app/api/students/route.ts create mode 100644 src/app/api/subjects/route.ts create mode 100644 src/app/api/teacher-notes/[id]/route.ts create mode 100644 src/app/api/teacher-notes/route.ts create mode 100644 src/app/api/users/[id]/classes/route.ts create mode 100644 src/app/api/users/[id]/route.ts create mode 100644 src/app/api/users/route.ts create mode 100644 src/app/globals.css create mode 100644 src/app/layout.tsx create mode 100644 src/app/page.tsx create mode 100644 src/components/analytics-view.tsx create mode 100644 src/components/app-layout.tsx create mode 100644 src/components/assessments-view.tsx create mode 100644 src/components/attendance-view.tsx create mode 100644 src/components/auth-view.tsx create mode 100644 src/components/behavior-tracking-view.tsx create mode 100644 src/components/calendar-view.tsx create mode 100644 src/components/classes-view.tsx create mode 100644 src/components/comment-bank-view.tsx create mode 100644 src/components/competence-flower-view.tsx create mode 100644 src/components/competency-grid-view.tsx create mode 100644 src/components/curriculum-coverage-view.tsx create mode 100644 src/components/dashboard-view.tsx create mode 100644 src/components/grading-view.tsx create mode 100644 src/components/keyboard-shortcuts-dialog.tsx create mode 100644 src/components/lesson-plans-view.tsx create mode 100644 src/components/mastery-matrix-view.tsx create mode 100644 src/components/onboarding-tour.tsx create mode 100644 src/components/parent-communication-view.tsx create mode 100644 src/components/progress-entries-view.tsx create mode 100644 src/components/reports-view.tsx create mode 100644 src/components/rubric-library-view.tsx create mode 100644 src/components/settings-view.tsx create mode 100644 src/components/student-detail-view.tsx create mode 100644 src/components/teacher-notes-section.tsx create mode 100644 src/components/ui/accordion.tsx create mode 100644 src/components/ui/alert-dialog.tsx create mode 100644 src/components/ui/alert.tsx create mode 100644 src/components/ui/aspect-ratio.tsx create mode 100644 src/components/ui/avatar.tsx create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/breadcrumb.tsx create mode 100644 src/components/ui/button.tsx create mode 100644 src/components/ui/calendar.tsx create mode 100644 src/components/ui/card.tsx create mode 100644 src/components/ui/carousel.tsx create mode 100644 src/components/ui/chart.tsx create mode 100644 src/components/ui/checkbox.tsx create mode 100644 src/components/ui/collapsible.tsx create mode 100644 src/components/ui/command.tsx create mode 100644 src/components/ui/context-menu.tsx create mode 100644 src/components/ui/dialog.tsx create mode 100644 src/components/ui/drawer.tsx create mode 100644 src/components/ui/dropdown-menu.tsx create mode 100644 src/components/ui/form.tsx create mode 100644 src/components/ui/hover-card.tsx create mode 100644 src/components/ui/input-otp.tsx create mode 100644 src/components/ui/input.tsx create mode 100644 src/components/ui/label.tsx create mode 100644 src/components/ui/menubar.tsx create mode 100644 src/components/ui/navigation-menu.tsx create mode 100644 src/components/ui/pagination.tsx create mode 100644 src/components/ui/popover.tsx create mode 100644 src/components/ui/progress.tsx create mode 100644 src/components/ui/radio-group.tsx create mode 100644 src/components/ui/resizable.tsx create mode 100644 src/components/ui/scroll-area.tsx create mode 100644 src/components/ui/select.tsx create mode 100644 src/components/ui/separator.tsx create mode 100644 src/components/ui/sheet.tsx create mode 100644 src/components/ui/sidebar.tsx create mode 100644 src/components/ui/skeleton.tsx create mode 100644 src/components/ui/slider.tsx create mode 100644 src/components/ui/sonner.tsx create mode 100644 src/components/ui/switch.tsx create mode 100644 src/components/ui/table.tsx create mode 100644 src/components/ui/tabs.tsx create mode 100644 src/components/ui/textarea.tsx create mode 100644 src/components/ui/toast.tsx create mode 100644 src/components/ui/toaster.tsx create mode 100644 src/components/ui/toggle-group.tsx create mode 100644 src/components/ui/toggle.tsx create mode 100644 src/components/ui/tooltip.tsx create mode 100644 src/hooks/use-mobile.ts create mode 100644 src/hooks/use-toast.ts create mode 100644 src/lib/api.ts create mode 100644 src/lib/auth.ts create mode 100644 src/lib/db.ts create mode 100644 src/lib/i18n.ts create mode 100644 src/lib/store.ts create mode 100644 src/lib/utils.ts create mode 100644 tailwind.config.ts create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6a15a84 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c7808d1 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..90d3b8c --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..785ba5b --- /dev/null +++ b/README.md @@ -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 diff --git a/components.json b/components.json new file mode 100644 index 0000000..ffe928f --- /dev/null +++ b/components.json @@ -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" +} \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..84578b3 --- /dev/null +++ b/eslint.config.mjs @@ -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; diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..0bd2f11 --- /dev/null +++ b/next.config.ts @@ -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; diff --git a/package.json b/package.json new file mode 100644 index 0000000..82dd542 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..c7bcb4b --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,5 @@ +const config = { + plugins: ["@tailwindcss/postcss"], +}; + +export default config; diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..edc9283 --- /dev/null +++ b/prisma/schema.prisma @@ -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]) +} diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..8578f2f --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,1545 @@ +/** + * CompetenceTrack — Prisma Seed Script + * Populates the database with realistic demo data for a Berlin elementary school. + * + * Usage: bun prisma/seed.ts + */ + +import { PrismaClient } from '@prisma/client' +import bcrypt from 'bcryptjs' + +const prisma = new PrismaClient() + +// ─── Helper: random date within last N days ────────────────────────────── +function randomDateWithinLast(days: number): Date { + const now = new Date() + const past = new Date(now.getTime() - days * 24 * 60 * 60 * 1000) + const diff = now.getTime() - past.getTime() + return new Date(past.getTime() + Math.random() * diff) +} + +// ─── Helper: random integer in range [min, max] ────────────────────────── +function randInt(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min +} + +// ─── Helper: pick random element from array ────────────────────────────── +function pick(arr: T[]): T { + return arr[Math.floor(Math.random() * arr.length)] +} + +async function main() { + console.log('🌱 Seeding CompetenceTrack database...\n') + + // Clean up existing data (order matters due to foreign keys) + console.log(' 🧹 Cleaning existing data...') + await prisma.auditLog.deleteMany() + await prisma.behaviorIncident.deleteMany() + await prisma.behaviorCategory.deleteMany() + await prisma.commentBankEntry.deleteMany() + await prisma.commentCategory.deleteMany() + await prisma.rubricLevel.deleteMany() + await prisma.rubricCriterion.deleteMany() + await prisma.rubric.deleteMany() + await prisma.parentMessage.deleteMany() + await prisma.parentContact.deleteMany() + await prisma.lessonPlan.deleteMany() + await prisma.teacherNote.deleteMany() + await prisma.attendanceRecord.deleteMany() + await prisma.attendanceSession.deleteMany() + await prisma.gradingWeightRule.deleteMany() + await prisma.gradingScheme.deleteMany() + await prisma.computedGrade.deleteMany() + await prisma.reportSection.deleteMany() + await prisma.report.deleteMany() + await prisma.dataExportRequest.deleteMany() + await prisma.assessmentResult.deleteMany() + await prisma.assessmentCompetencyLink.deleteMany() + await prisma.assessment.deleteMany() + await prisma.learningProgressEntry.deleteMany() + await prisma.masteryLevelDefinition.deleteMany() + await prisma.competency.deleteMany() + await prisma.competencyCategory.deleteMany() + await prisma.classCompetencyAssignment.deleteMany() + await prisma.competencyTemplate.deleteMany() + await prisma.enrollment.deleteMany() + await prisma.classGroupTeacher.deleteMany() + await prisma.classGroup.deleteMany() + await prisma.student.deleteMany() + await prisma.subject.deleteMany() + await prisma.schoolYear.deleteMany() + await prisma.user.deleteMany() + await prisma.school.deleteMany() + console.log(' ✅ Cleaned up.\n') + + // ─── 1. Demo School ─────────────────────────────────────────────────── + console.log(' 🏫 Creating school...') + const school = await prisma.school.create({ + data: { + name: 'Grundschule Am Park', + schoolType: 'ELEMENTARY', + country: 'DE', + timezone: 'Europe/Berlin', + }, + }) + + // ─── 2. Demo School Year ────────────────────────────────────────────── + console.log(' 📅 Creating school year...') + const schoolYear = await prisma.schoolYear.create({ + data: { + schoolId: school.id, + label: '2025/2026', + startDate: new Date('2025-08-01'), + endDate: new Date('2026-07-31'), + }, + }) + + // ─── 3. Demo Users ────────────────────────────────────────────────── + console.log(' 👩‍🏫 Creating demo users...') + const passwordHash = await bcrypt.hash('Demo2025!', 10) + const teacher = await prisma.user.create({ + data: { + schoolId: school.id, + email: 'demo@competencetrack.org', + passwordHash, + firstName: 'Anna', + lastName: 'Müller', + role: 'SCHOOL_ADMIN', + locale: 'de', + isDemo: true, + }, + }) + + // Demo Teacher (regular teacher role) + const demoTeacher = await prisma.user.create({ + data: { + schoolId: school.id, + email: 'demo.teacher@competencetrack.org', + passwordHash, + firstName: 'Max', + lastName: 'Lehrer', + role: 'TEACHER', + locale: 'de', + isDemo: true, + }, + }) + + // Demo Student (student user account) + const demoStudent = await prisma.user.create({ + data: { + schoolId: school.id, + email: 'demo.student@competencetrack.org', + passwordHash, + firstName: 'Lena', + lastName: 'Schüler', + role: 'STUDENT', + locale: 'de', + isDemo: true, + }, + }) + + // Demo Parent (parent user account) + const demoParent = await prisma.user.create({ + data: { + schoolId: school.id, + email: 'demo.parent@competencetrack.org', + passwordHash, + firstName: 'Thomas', + lastName: 'Elter', + role: 'PARENT', + locale: 'de', + isDemo: true, + }, + }) + + // ─── 4. Demo Classes ────────────────────────────────────────────────── + console.log(' 🏫 Creating classes...') + const class3a = await prisma.classGroup.create({ + data: { + schoolId: school.id, + schoolYearId: schoolYear.id, + name: '3a', + gradeLevel: 3, + schoolType: 'ELEMENTARY', + }, + }) + + const class3b = await prisma.classGroup.create({ + data: { + schoolId: school.id, + schoolYearId: schoolYear.id, + name: '3b', + gradeLevel: 3, + schoolType: 'ELEMENTARY', + }, + }) + + // Assign teacher roles + await prisma.classGroupTeacher.create({ + data: { + classGroupId: class3a.id, + userId: teacher.id, + role: 'HOMEROOM_TEACHER', + }, + }) + + await prisma.classGroupTeacher.create({ + data: { + classGroupId: class3b.id, + userId: teacher.id, + role: 'SUBJECT_TEACHER', + }, + }) + + // Assign demo teacher to class 3b as subject teacher + await prisma.classGroupTeacher.create({ + data: { + classGroupId: class3b.id, + userId: demoTeacher.id, + role: 'HOMEROOM_TEACHER', + }, + }) + + // ─── 5. Demo Students ───────────────────────────────────────────────── + console.log(' 👧🧒 Creating students...') + + // 3a students (8 students) + const students3aNames = [ + { firstName: 'Lena', lastName: 'Schmidt', dob: '2016-03-12' }, + { firstName: 'Maximilian', lastName: 'Weber', dob: '2016-07-22' }, + { firstName: 'Sophie', lastName: 'Schneider', dob: '2016-01-08' }, + { firstName: 'Felix', lastName: 'Fischer', dob: '2016-09-15' }, + { firstName: 'Emma', lastName: 'Meyer', dob: '2016-05-30' }, + { firstName: 'Lukas', lastName: 'Wagner', dob: '2016-11-04' }, + { firstName: 'Mia', lastName: 'Becker', dob: '2016-02-18' }, + { firstName: 'Jonas', lastName: 'Hoffmann', dob: '2016-08-27' }, + ] + + // 3b students (7 students) + const students3bNames = [ + { firstName: 'Hannah', lastName: 'Koch', dob: '2016-04-09' }, + { firstName: 'Leon', lastName: 'Richter', dob: '2016-12-01' }, + { firstName: 'Clara', lastName: 'Klein', dob: '2016-06-14' }, + { firstName: 'Tim', lastName: 'Schröder', dob: '2016-10-20' }, + { firstName: 'Anna-Lena', lastName: 'Braun', dob: '2016-03-25' }, + { firstName: 'David', lastName: 'Zimmermann', dob: '2016-07-03' }, + { firstName: 'Laura', lastName: 'Krüger', dob: '2016-01-30' }, + ] + + const students3a: { id: string; firstName: string; lastName: string }[] = [] + const students3b: { id: string; firstName: string; lastName: string }[] = [] + + for (const s of students3aNames) { + const student = await prisma.student.create({ + data: { + schoolId: school.id, + firstName: s.firstName, + lastName: s.lastName, + dateOfBirth: new Date(s.dob), + }, + }) + students3a.push(student) + + // Enroll in class 3a + await prisma.enrollment.create({ + data: { + studentId: student.id, + classGroupId: class3a.id, + schoolYearId: schoolYear.id, + startDate: new Date('2025-08-01'), + }, + }) + } + + for (const s of students3bNames) { + const student = await prisma.student.create({ + data: { + schoolId: school.id, + firstName: s.firstName, + lastName: s.lastName, + dateOfBirth: new Date(s.dob), + }, + }) + students3b.push(student) + + // Enroll in class 3b + await prisma.enrollment.create({ + data: { + studentId: student.id, + classGroupId: class3b.id, + schoolYearId: schoolYear.id, + startDate: new Date('2025-08-01'), + }, + }) + } + + console.log(` Created ${students3a.length} students in 3a, ${students3b.length} students in 3b`) + + // ─── 6. Subjects ────────────────────────────────────────────────────── + console.log(' 📚 Creating subjects...') + const mathSubject = await prisma.subject.create({ + data: { + schoolId: school.id, + name: 'Mathematik', + gradeLevelMin: 1, + gradeLevelMax: 4, + }, + }) + + const germanSubject = await prisma.subject.create({ + data: { + schoolId: school.id, + name: 'Deutsch', + gradeLevelMin: 1, + gradeLevelMax: 4, + }, + }) + + // ─── 7. Competency Templates ────────────────────────────────────────── + console.log(' 🎯 Creating competency templates...') + + // ─── Math Template ───────────────────────────────────────────────────── + const mathTemplate = await prisma.competencyTemplate.create({ + data: { + name: 'Mathematik Klasse 3', + description: 'Kompetenzraster für Mathematik in Klasse 3 gemäß Rahmenlehrplan Berlin-Brandenburg', + subjectId: mathSubject.id, + schoolType: 'ELEMENTARY', + gradeLevelMin: 3, + gradeLevelMax: 3, + isGlobalTemplate: false, + createdByUserId: teacher.id, + version: 1, + schoolId: school.id, + }, + }) + + // Math categories + const mathCatZahlen = await prisma.competencyCategory.create({ + data: { + competencyTemplateId: mathTemplate.id, + name: 'Zahlen und Operationen', + order: 0, + color: '#10b981', + }, + }) + + const mathCatGeometrie = await prisma.competencyCategory.create({ + data: { + competencyTemplateId: mathTemplate.id, + name: 'Geometrie', + order: 1, + color: '#f59e0b', + }, + }) + + const mathCatGroessen = await prisma.competencyCategory.create({ + data: { + competencyTemplateId: mathTemplate.id, + name: 'Größen und Messen', + order: 2, + color: '#8b5cf6', + }, + }) + + // Math competencies — Zahlen und Operationen + const mathCompZAH01 = await prisma.competency.create({ + data: { + categoryId: mathCatZahlen.id, + code: 'M.3.ZAH.01', + title: 'Zahlenraum bis 1000 erfassen', + description: 'Die Schülerinnen und Schüler können den Zahlenraum bis 1000 darstellen, ordnen und vergleichen.', + order: 0, + }, + }) + + const mathCompZAH02 = await prisma.competency.create({ + data: { + categoryId: mathCatZahlen.id, + code: 'M.3.ZAH.02', + title: 'Schriftliche Addition und Subtraktion', + description: 'Die Schülerinnen und Schüler können schriftliche Addition und Subtraktion im Zahlenraum bis 1000 durchführen.', + order: 1, + }, + }) + + const mathCompZAH03 = await prisma.competency.create({ + data: { + categoryId: mathCatZahlen.id, + code: 'M.3.ZAH.03', + title: 'Multiplikation und Division', + description: 'Die Schülerinnen und Schüler beherrschen das kleine Einmaleins und können einfache Multiplikations- und Divisionsaufgaben lösen.', + order: 2, + }, + }) + + // Math competencies — Geometrie + const mathCompGEO01 = await prisma.competency.create({ + data: { + categoryId: mathCatGeometrie.id, + code: 'M.3.GEO.01', + title: 'Ebenen Figuren erkennen und benennen', + description: 'Die Schülerinnen und Schüler können ebene Figuren wie Dreieck, Viereck, Kreis erkennen und benennen.', + order: 0, + }, + }) + + const mathCompGEO02 = await prisma.competency.create({ + data: { + categoryId: mathCatGeometrie.id, + code: 'M.3.GEO.02', + title: 'Umfang und Flächeninhalt vergleichen', + description: 'Die Schülerinnen und Schüler können den Umfang und den Flächeninhalt einfacher Figuren vergleichen und messen.', + order: 1, + }, + }) + + // Math competencies — Größen und Messen + const mathCompGRO01 = await prisma.competency.create({ + data: { + categoryId: mathCatGroessen.id, + code: 'M.3.GRO.01', + title: 'Längen messen und umrechnen', + description: 'Die Schülerinnen und Schüler können Längen mit appropriateen Maßeinheiten messen und zwischen m, dm, cm umrechnen.', + order: 0, + }, + }) + + const mathCompGRO02 = await prisma.competency.create({ + data: { + categoryId: mathCatGroessen.id, + code: 'M.3.GRO.02', + title: 'Geldbeträge berechnen', + description: 'Die Schülerinnen und Schüler können mit Geldbeträgen in Euro und Cent rechnen und diese vergleichen.', + order: 1, + }, + }) + + // Mastery level definitions for all Math competencies + const masteryLevels = [ + { levelValue: 1, label: 'noch nicht erreicht' }, + { levelValue: 2, label: 'entwickelt sich' }, + { levelValue: 3, label: 'kompetent' }, + { levelValue: 4, label: 'weit entwickelt' }, + ] + + const mathCompetencies = [mathCompZAH01, mathCompZAH02, mathCompZAH03, mathCompGEO01, mathCompGEO02, mathCompGRO01, mathCompGRO02] + + for (const comp of mathCompetencies) { + for (const ml of masteryLevels) { + await prisma.masteryLevelDefinition.create({ + data: { + competencyId: comp.id, + levelValue: ml.levelValue, + label: ml.label, + description: ml.levelValue === 1 + ? 'Die Kompetenz ist noch nicht erreicht.' + : ml.levelValue === 2 + ? 'Die Kompetenz entwickelt sich, erste Ansätze sind erkennbar.' + : ml.levelValue === 3 + ? 'Die Kompetenz wird sicher und selbstständig gezeigt.' + : 'Die Kompetenz wird weit über das erwartete Niveau hinaus gezeigt.', + }, + }) + } + } + + // ─── Deutsch Template ────────────────────────────────────────────────── + const germanTemplate = await prisma.competencyTemplate.create({ + data: { + name: 'Deutsch Klasse 3', + description: 'Kompetenzraster für Deutsch in Klasse 3 gemäß Rahmenlehrplan Berlin-Brandenburg', + subjectId: germanSubject.id, + schoolType: 'ELEMENTARY', + gradeLevelMin: 3, + gradeLevelMax: 3, + isGlobalTemplate: false, + createdByUserId: teacher.id, + version: 1, + schoolId: school.id, + }, + }) + + // German categories + const germanCatLesen = await prisma.competencyCategory.create({ + data: { + competencyTemplateId: germanTemplate.id, + name: 'Lesen', + order: 0, + color: '#3b82f6', + }, + }) + + const germanCatSchreiben = await prisma.competencyCategory.create({ + data: { + competencyTemplateId: germanTemplate.id, + name: 'Schreiben', + order: 1, + color: '#ef4444', + }, + }) + + const germanCatSprechen = await prisma.competencyCategory.create({ + data: { + competencyTemplateId: germanTemplate.id, + name: 'Sprechen und Zuhören', + order: 2, + color: '#06b6d4', + }, + }) + + // German competencies — Lesen + const germanCompLES01 = await prisma.competency.create({ + data: { + categoryId: germanCatLesen.id, + code: 'D.3.LES.01', + title: 'Altergerechte Texte sinnentnehmend lesen', + description: 'Die Schülerinnen und Schüler können altersgerechte Texte flüssig lesen und den Inhalt verstehen.', + order: 0, + }, + }) + + const germanCompLES02 = await prisma.competency.create({ + data: { + categoryId: germanCatLesen.id, + code: 'D.3.LES.02', + title: 'Texte zusammenfassen und interpretieren', + description: 'Die Schülerinnen und Schüler können gelesene Texte in eigenen Worten zusammenfassen und einfache Schlussfolgerungen ziehen.', + order: 1, + }, + }) + + // German competencies — Schreiben + const germanCompSCH01 = await prisma.competency.create({ + data: { + categoryId: germanCatSchreiben.id, + code: 'D.3.SCH.01', + title: 'Texte verfassen (Erzählung, Beschreibung)', + description: 'Die Schülerinnen und Schüler können kurze Erzählungen und Beschreibungen verfassen.', + order: 0, + }, + }) + + const germanCompSCH02 = await prisma.competency.create({ + data: { + categoryId: germanCatSchreiben.id, + code: 'D.3.SCH.02', + title: 'Rechtschreibung anwenden', + description: 'Die Schülerinnen und Schüler können grundlegende Rechtschreibregeln anwenden.', + order: 1, + }, + }) + + const germanCompSCH03 = await prisma.competency.create({ + data: { + categoryId: germanCatSchreiben.id, + code: 'D.3.SCH.03', + title: 'Sätze und Texte gliedern', + description: 'Die Schülerinnen und Schüler können Sätze sinnvoll gliedern und Texte strukturieren.', + order: 2, + }, + }) + + // German competencies — Sprechen und Zuhören + const germanCompSPR01 = await prisma.competency.create({ + data: { + categoryId: germanCatSprechen.id, + code: 'D.3.SPR.01', + title: 'Zuhören und verstehen', + description: 'Die Schülerinnen und Schüler können aufmerksam zuhören und Gehörtes wiedergeben.', + order: 0, + }, + }) + + const germanCompSPR02 = await prisma.competency.create({ + data: { + categoryId: germanCatSprechen.id, + code: 'D.3.SPR.02', + title: 'Sachgerecht sprechen und erzählen', + description: 'Die Schülerinnen und Schüler können sich sachgerecht ausdrücken und Erlebnisse zusammenhängend erzählen.', + order: 1, + }, + }) + + // Mastery level definitions for all German competencies + const germanCompetencies = [germanCompLES01, germanCompLES02, germanCompSCH01, germanCompSCH02, germanCompSCH03, germanCompSPR01, germanCompSPR02] + + for (const comp of germanCompetencies) { + for (const ml of masteryLevels) { + await prisma.masteryLevelDefinition.create({ + data: { + competencyId: comp.id, + levelValue: ml.levelValue, + label: ml.label, + description: ml.levelValue === 1 + ? 'Die Kompetenz ist noch nicht erreicht.' + : ml.levelValue === 2 + ? 'Die Kompetenz entwickelt sich, erste Ansätze sind erkennbar.' + : ml.levelValue === 3 + ? 'Die Kompetenz wird sicher und selbstständig gezeigt.' + : 'Die Kompetenz wird weit über das erwartete Niveau hinaus gezeigt.', + }, + }) + } + } + + // ─── 8. ClassCompetencyAssignment ────────────────────────────────────── + console.log(' 🔗 Assigning competency templates to class 3a...') + const mathAssignment = await prisma.classCompetencyAssignment.create({ + data: { + classGroupId: class3a.id, + subjectId: mathSubject.id, + competencyTemplateId: mathTemplate.id, + schoolYearId: schoolYear.id, + }, + }) + + const germanAssignment = await prisma.classCompetencyAssignment.create({ + data: { + classGroupId: class3a.id, + subjectId: germanSubject.id, + competencyTemplateId: germanTemplate.id, + schoolYearId: schoolYear.id, + }, + }) + + // ─── 9. Learning Progress Entries ────────────────────────────────────── + console.log(' 📊 Creating learning progress entries...') + + const allCompetencies = [...mathCompetencies, ...germanCompetencies] + const progressNotes = [ + 'Zeigt gute Fortschritte im Unterricht.', + 'Braucht noch zusätzliche Übung.', + 'Hat das Thema sicher verstanden.', + 'Kann das Gelernte selbstständig anwenden.', + 'Hat in der Gruppenarbeit gut mitgemacht.', + 'Benötigt noch Unterstützung bei komplexeren Aufgaben.', + 'Hervorragende Mitarbeit heute.', + 'Hat Schwierigkeiten mit dem Thema, braucht Förderung.', + null, + null, + ] + + let progressCount = 0 + for (let i = 0; i < 30; i++) { + const student = pick(students3a) + const competency = pick(allCompetencies) + const date = randomDateWithinLast(60) + // Weighted mastery: most students are 2-3, fewer at 1 or 4 + const mastery = pick([1, 2, 2, 2, 3, 3, 3, 3, 4]) + + await prisma.learningProgressEntry.create({ + data: { + studentId: student.id, + competencyId: competency.id, + teacherId: teacher.id, + classGroupId: class3a.id, + date, + masteryLevelValue: mastery, + note: pick(progressNotes), + }, + }) + progressCount++ + } + console.log(` Created ${progressCount} learning progress entries`) + + // ─── 10. Assessment ──────────────────────────────────────────────────── + console.log(' 📝 Creating assessment...') + const assessment = await prisma.assessment.create({ + data: { + classGroupId: class3a.id, + subjectId: mathSubject.id, + teacherId: teacher.id, + title: 'Mathematik Test 1', + date: new Date('2025-10-15'), + type: 'TEST', + maxScore: 20, + weight: 2.0, + }, + }) + + // Link assessment to Math competencies (ZAH ones primarily) + const mathAssessmentCompetencies = [mathCompZAH01, mathCompZAH02, mathCompZAH03] + for (const comp of mathAssessmentCompetencies) { + await prisma.assessmentCompetencyLink.create({ + data: { + assessmentId: assessment.id, + competencyId: comp.id, + weight: 1.0, + }, + }) + } + + // Create assessment results for all students in 3a + for (const student of students3a) { + // Realistic score distribution: mean around 14/20, some variation + const score = Math.min(20, Math.max(4, Math.round(randInt(8, 19) + randInt(-3, 3)))) + const clampedScore = Math.min(20, Math.max(0, score)) + + // Map score to mastery level (rough mapping) + const masteryFromScore = clampedScore >= 17 ? 4 : clampedScore >= 13 ? 3 : clampedScore >= 8 ? 2 : 1 + + await prisma.assessmentResult.create({ + data: { + assessmentId: assessment.id, + studentId: student.id, + score: clampedScore, + masteryLevelValue: masteryFromScore, + }, + }) + } + console.log(` Created assessment with ${students3a.length} results`) + + // ─── 11. Grading Scheme ──────────────────────────────────────────────── + console.log(' 📋 Creating grading scheme...') + const gradingScheme = await prisma.gradingScheme.create({ + data: { + classGroupId: class3a.id, + subjectId: mathSubject.id, + schoolId: school.id, + name: 'Noten 1-6 Mathematik 3a', + type: 'NUMERIC_GRADE', + scaleDefinition: JSON.stringify({ + min: 1, + max: 6, + labels: { + '1': 'sehr gut', + '2': 'gut', + '3': 'befriedigend', + '4': 'ausreichend', + '5': 'mangelhaft', + '6': 'ungenügend', + }, + }), + }, + }) + + // Grading weight rules + await prisma.gradingWeightRule.create({ + data: { + gradingSchemeId: gradingScheme.id, + sourceType: 'LEARNING_PROGRESS', + weightPercent: 50.0, + }, + }) + + await prisma.gradingWeightRule.create({ + data: { + gradingSchemeId: gradingScheme.id, + sourceType: 'ASSESSMENT', + weightPercent: 50.0, + }, + }) + + // ─── 12. Audit Log Entries ───────────────────────────────────────────── + console.log(' 📝 Creating audit log entries...') + const auditEntries = [ + { + action: 'CREATE', + entityType: 'CompetencyTemplate', + entityId: mathTemplate.id, + metadata: JSON.stringify({ templateName: 'Mathematik Klasse 3' }), + }, + { + action: 'CREATE', + entityType: 'CompetencyTemplate', + entityId: germanTemplate.id, + metadata: JSON.stringify({ templateName: 'Deutsch Klasse 3' }), + }, + { + action: 'CREATE', + entityType: 'Assessment', + entityId: assessment.id, + metadata: JSON.stringify({ title: 'Mathematik Test 1', class: '3a' }), + }, + { + action: 'ASSIGN', + entityType: 'ClassCompetencyAssignment', + entityId: mathAssignment.id, + metadata: JSON.stringify({ class: '3a', subject: 'Mathematik' }), + }, + { + action: 'ASSIGN', + entityType: 'ClassCompetencyAssignment', + entityId: germanAssignment.id, + metadata: JSON.stringify({ class: '3a', subject: 'Deutsch' }), + }, + ] + + for (const entry of auditEntries) { + await prisma.auditLog.create({ + data: { + userId: teacher.id, + schoolId: school.id, + action: entry.action, + entityType: entry.entityType, + entityId: entry.entityId, + timestamp: randomDateWithinLast(30), + metadata: entry.metadata, + }, + }) + } + + // ─── 13. Lesson Plans ────────────────────────────────────────────────── + console.log(' 📅 Creating lesson plans...') + + // Helper: get a date in the current week (Mon-Fri) at a given hour + function dateThisWeek(dayOffset: number, hour: number, minute = 0): Date { + const now = new Date() + const day = now.getDay() // 0 = Sunday, 1 = Monday + const diffToMonday = day === 0 ? -6 : 1 - day + const monday = new Date(now) + monday.setDate(now.getDate() + diffToMonday) + monday.setHours(hour, minute, 0, 0) + const result = new Date(monday) + result.setDate(monday.getDate() + dayOffset) + return result + } + + function dateLastWeek(dayOffset: number, hour: number, minute = 0): Date { + const d = dateThisWeek(dayOffset, hour, minute) + d.setDate(d.getDate() - 7) + return d + } + + // Lesson 1: draft, scheduled for later this week + await prisma.lessonPlan.create({ + data: { + teacherId: teacher.id, + classGroupId: class3a.id, + subjectId: mathSubject.id, + title: 'Schriftliche Addition bis 1000', + description: 'Einführung in die schriftliche Addition im Zahlenraum bis 1000 mit Übertrag.', + date: dateThisWeek(2, 9, 0), // Wednesday this week, 09:00 + durationMin: 45, + status: 'draft', + objectives: JSON.stringify([ + 'SuS können die schriftliche Addition sachgerecht durchführen.', + 'SuS beherrschen den Übertrag korrekt.', + 'SuS können eigene Aufgaben lösen und vergleichen.', + ]), + materials: JSON.stringify([ + 'Schulbuch S. 42–43', + 'Arbeitsblatt Addition', + 'Plättchen als Anschauungsmaterial', + ]), + homework: 'Arbeitsblatt Aufgabe 1–3', + reflection: null, + linkedCompetencyIds: JSON.stringify([mathCompZAH02.id, mathCompZAH01.id]), + }, + }) + + // Lesson 2: scheduled, this week Tuesday + await prisma.lessonPlan.create({ + data: { + teacherId: teacher.id, + classGroupId: class3a.id, + subjectId: germanSubject.id, + title: 'Leseverstehen: Märchen untersuchen', + description: 'Gemeinsames Lesen eines Märchens, Erarbeitung von Merkmalen.', + date: dateThisWeek(1, 10, 30), // Tuesday this week, 10:30 + durationMin: 45, + status: 'scheduled', + objectives: JSON.stringify([ + 'SuS können Merkmale eines Märchens nennen.', + 'SuS können die Handlung in eigenen Worten wiedergeben.', + ]), + materials: JSON.stringify([ + 'Märchen "Rotkäppchen" (Kopiervorlage)', + 'Lesezeichen mit Märchenmerkmalen', + 'Interaktives Tafelbild', + ]), + homework: 'Märchen zu Ende lesen und eine Frage notieren.', + reflection: null, + linkedCompetencyIds: JSON.stringify([germanCompLES01.id, germanCompLES02.id]), + }, + }) + + // Lesson 3: scheduled, this week Thursday + await prisma.lessonPlan.create({ + data: { + teacherId: teacher.id, + classGroupId: class3a.id, + subjectId: mathSubject.id, + title: 'Geometrische Figuren — Vierecke', + description: 'Erkennen und Benennen von Vierecken (Quadrat, Rechteck, Raute, Parallelogramm).', + date: dateThisWeek(3, 9, 0), // Thursday this week, 09:00 + durationMin: 45, + status: 'scheduled', + objectives: JSON.stringify([ + 'SuS können verschiedene Vierecke benennen.', + 'SuS können Eigenschaften der Vierecke beschreiben.', + ]), + materials: JSON.stringify([ + 'Geometrische Formen aus Pappe', + 'Arbeitsblatt "Vierecke sortieren"', + 'Geodreieck', + ]), + homework: 'Arbeitsblatt "Finde Vierecke in der Klasse"', + reflection: null, + linkedCompetencyIds: JSON.stringify([mathCompGEO01.id]), + }, + }) + + // Lesson 4: completed last week + await prisma.lessonPlan.create({ + data: { + teacherId: teacher.id, + classGroupId: class3a.id, + subjectId: mathSubject.id, + title: 'Multiplikation — Einmaleins wiederholen', + description: 'Wiederholung des kleinen Einmaleins mit Spielen und Übungen.', + date: dateLastWeek(1, 9, 0), // Last week Tuesday, 09:00 + durationMin: 45, + status: 'completed', + objectives: JSON.stringify([ + 'SuS können das kleine Einmaleins sicher anwenden.', + 'SuS können Aufgaben in Partnerarbeit lösen.', + ]), + materials: JSON.stringify([ + 'Einmaleins-Plakat', + 'Karten für "Male-Mal-Mir-Nach"', + ]), + homework: 'Einmaleins-Trainingsheft S. 8', + reflection: 'Die SuS waren sehr motiviert. Besonders die Partnerarbeit hat gut funktioniert. Einige SuS benötigen noch Übung mit dem 7er und 8er Einmaleins.', + linkedCompetencyIds: JSON.stringify([mathCompZAH03.id]), + }, + }) + + console.log(' Created 4 lesson plans (1 draft, 2 scheduled, 1 completed)') + + // ─── 14. Parent Communication ────────────────────────────────────── + console.log(' 👨‍👩‍👧 Creating parent contacts & messages...') + + // Clean previous parent data (so re-seeding works) + await prisma.parentMessage.deleteMany() + await prisma.parentContact.deleteMany() + + // Pick a few students to create parent contacts for + const parentSeedStudents = [ + students3a[0], // Lena Schmidt + students3a[1], // Maximilian Weber + students3a[3], // Felix Fischer + students3a[5], // Lukas Wagner + students3b[0], // Hannah Koch + ] + + const parentContacts: { id: string; firstName: string; lastName: string; studentId: string; studentFirstName: string; studentLastName: string }[] = [] + + const contactDefs = [ + { firstName: 'Maria', lastName: 'Schmidt', email: 'maria.schmidt@example.org', phone: '+49 30 12345601', relationship: 'parent', preferredContact: 'email', preferredLanguage: 'de', notes: 'Erreichbar ab 16 Uhr.' }, + { firstName: 'Thomas', lastName: 'Weber', email: 't.weber@example.org', phone: '+49 30 12345602', relationship: 'parent', preferredContact: 'phone', preferredLanguage: 'de', notes: null }, + { firstName: 'Petra', lastName: 'Fischer', email: 'petra.fischer@example.org', phone: '+49 30 12345603', relationship: 'guardian', preferredContact: 'both', preferredLanguage: 'de', notes: 'Großmutter, Haupterziehungsberechtigte.' }, + { firstName: 'Andreas', lastName: 'Wagner', email: 'a.wagner@example.org', phone: '+49 30 12345604', relationship: 'parent', preferredContact: 'email', preferredLanguage: 'en', notes: 'Speaks English at home.' }, + { firstName: 'Julia', lastName: 'Koch', email: 'julia.koch@example.org', phone: '+49 30 12345605', relationship: 'parent', preferredContact: 'email', preferredLanguage: 'de', notes: null }, + ] + + for (let i = 0; i < parentSeedStudents.length; i++) { + const stu = parentSeedStudents[i] + const def = contactDefs[i] + const created = await prisma.parentContact.create({ + data: { + studentId: stu.id, + firstName: def.firstName, + lastName: def.lastName, + email: def.email, + phone: def.phone, + relationship: def.relationship, + preferredContact: def.preferredContact, + preferredLanguage: def.preferredLanguage, + notes: def.notes, + }, + }) + parentContacts.push({ + id: created.id, + firstName: created.firstName, + lastName: created.lastName, + studentId: stu.id, + studentFirstName: stu.firstName, + studentLastName: stu.lastName, + }) + } + + // Create 8 messages across categories, priorities, statuses + const now = new Date() + + function daysAgo(days: number, hour = 10, minute = 0): Date { + const d = new Date(now) + d.setDate(d.getDate() - days) + d.setHours(hour, minute, 0, 0) + return d + } + + type MsgDef = { + parentIdx: number + subject: string + body: string + category: string + priority: string + status: string + createdAt: Date + readAt?: Date + reply?: string + replyAt?: Date + } + + const msgDefs: MsgDef[] = [ + { + parentIdx: 0, + subject: 'Positive Rückmeldung zu Lenas Lernfortschritt', + body: 'Sehr geehrte Frau Schmidt,\n\nich möchte Ihnen eine positive Rückmeldung zu Lenas Lernfortschritt im Fach Mathematik geben. In den letzten Wochen hat sie sich besonders im Bereich der schriftlichen Addition deutlich verbessert. Sie arbeitet konzentriert mit und hilft gerne anderen Kindern.\n\nMit freundlichen Grüßen\nAnna Müller', + category: 'progress', + priority: 'normal', + status: 'read', + createdAt: daysAgo(12, 9, 30), + readAt: daysAgo(11, 18, 0), + }, + { + parentIdx: 0, + subject: 'Elternsprechtag — Terminvereinbarung', + body: 'Sehr geehrte Frau Schmidt,\n\nam kommenden Freitag findet der Elternsprechtag statt. Ich würde Sie gerne zu einem Gespräch über Lenas Entwicklung einladen. Bitte melden Sie sich mit einem Wunschtermin.\n\nMit freundlichen Grüßen\nAnna Müller', + category: 'event', + priority: 'high', + status: 'replied', + createdAt: daysAgo(8, 14, 0), + readAt: daysAgo(8, 19, 30), + reply: 'Sehr geehrte Frau Müller,\n\nvielen Dank für die Nachricht. Mir würde Freitag um 15:30 Uhr passen.\n\nHerzliche Grüße\nMaria Schmidt', + replyAt: daysAgo(7, 8, 15), + }, + { + parentIdx: 1, + subject: 'Überprüfung im Fach Deutsch — Erinnerung', + body: 'Sehr geehrter Herr Weber,\n\nnächste Woche schreiben wir eine kurze Leseverständnis-Überprüfung. Bitte unterstützen Sie Maximilian bei der Vorbereitung, indem Sie mit ihm die Märchen-Lesezeichen durchgehen.\n\nMit freundlichen Grüßen\nAnna Müller', + category: 'assessment', + priority: 'high', + status: 'delivered', + createdAt: daysAgo(4, 8, 0), + }, + { + parentIdx: 2, + subject: 'Verhalten im Unterricht — Gesprächsbedarf', + body: 'Sehr geehrte Frau Fischer,\n\nin den letzten Tagen ist mir aufgefallen, dass Felix im Unterricht häufiger unaufmerksam war und Mitschüler ablenkte. Ich würde gerne zeitnah ein Gespräch mit Ihnen führen, um gemeinsam zu überlegen, wie wir Felix unterstützen können.\n\nMit freundlichen Grüßen\nAnna Müller', + category: 'behavior', + priority: 'urgent', + status: 'sent', + createdAt: daysAgo(2, 11, 45), + }, + { + parentIdx: 2, + subject: 'Positives Feedback zum Arbeitsverhalten', + body: 'Sehr geehrte Frau Fischer,\n\nich möchte Ihnen mitteilen, dass Felix in dieser Woche sehr konzentriert gearbeitet hat und die Aufgaben eigenständig gelöst hat. Das ist ein großer Fortschritt!\n\nMit freundlichen Grüßen\nAnna Müller', + category: 'progress', + priority: 'normal', + status: 'sent', + createdAt: daysAgo(1, 9, 15), + }, + { + parentIdx: 3, + subject: 'Anwesenheit — Entschuldigung', + body: 'Dear Mr. Wagner,\n\nI would like to inform you that Lukas was marked absent yesterday without prior notice. Please send a written excuse at your earliest convenience.\n\nBest regards\nAnna Müller', + category: 'attendance', + priority: 'normal', + status: 'delivered', + createdAt: daysAgo(3, 13, 0), + }, + { + parentIdx: 4, + subject: 'Schulfest — Mitwirkung der Klasse 3b', + body: 'Sehr geehrte Frau Koch,\n\nam Ende des Schuljahres findet unser Schulfest statt. Die Klasse 3b wird einen kleinen Beitrag leisten. Ich würde mich freuen, wenn Hannah daran teilnimmt. Nähere Informationen folgen.\n\nMit freundlichen Grüßen\nAnna Müller', + category: 'event', + priority: 'low', + status: 'draft', + createdAt: daysAgo(0, 8, 30), + }, + { + parentIdx: 0, + subject: 'Allgemeine Informationen zur Klassenfahrt', + body: 'Sehr geehrte Frau Schmidt,\n\nin Vorbereitung auf die Klassenfahrt im nächsten Monat sende ich Ihnen heute die Informationsmappe. Bitte geben Sie das ausgefüllte Anmeldeformular bis nächste Woche ab.\n\nMit freundlichen Grüßen\nAnna Müller', + category: 'general', + priority: 'normal', + status: 'replied', + createdAt: daysAgo(15, 16, 0), + readAt: daysAgo(14, 9, 0), + reply: 'Sehr geehrte Frau Müller,\n\ndas Formular liegt morgen früh in der Mappe bei. Vielen Dank für die Organisation!\n\nHerzliche Grüße\nMaria Schmidt', + replyAt: daysAgo(14, 17, 30), + }, + ] + + let msgCount = 0 + for (const def of msgDefs) { + const pc = parentContacts[def.parentIdx] + if (!pc) continue + await prisma.parentMessage.create({ + data: { + parentId: pc.id, + teacherId: teacher.id, + studentId: pc.studentId, + subject: def.subject, + body: def.body, + category: def.category, + priority: def.priority, + status: def.status, + readAt: def.readAt ?? null, + reply: def.reply ?? null, + replyAt: def.replyAt ?? null, + createdAt: def.createdAt, + updatedAt: def.createdAt, + }, + }) + msgCount++ + } + + console.log(` Created ${parentContacts.length} parent contacts and ${msgCount} messages`) + + // ─── 15. Behavior Tracking ───────────────────────────────────────── + console.log(' 🛡️ Creating behavior categories & incidents...') + + // Clean previous behavior data (so re-seeding works) + await prisma.behaviorIncident.deleteMany() + await prisma.behaviorCategory.deleteMany() + + const behaviorCategoryDefs = [ + { key: 'helpfulness', name: 'Hilfsbereitschaft', color: '#10b981', valence: 'positive', icon: '🤝' }, + { key: 'participation', name: 'Mitarbeit', color: '#14b8a6', valence: 'positive', icon: '✋' }, + { key: 'disruption', name: 'Störung', color: '#f43f5e', valence: 'negative', icon: '⚠️' }, + { key: 'conflict', name: 'Konflikt', color: '#f59e0b', valence: 'negative', icon: '😡' }, + { key: 'tardiness', name: 'Verspätung', color: '#64748b', valence: 'neutral', icon: '⏰' }, + { key: 'praise', name: 'Lob', color: '#8b5cf6', valence: 'positive', icon: '⭐' }, + ] + + const behaviorCategoryIds: Record = {} + for (const def of behaviorCategoryDefs) { + const created = await prisma.behaviorCategory.create({ + data: { + schoolId: school.id, + name: def.name, + color: def.color, + valence: def.valence, + icon: def.icon, + }, + }) + behaviorCategoryIds[def.key] = created.id + } + console.log(` Created ${behaviorCategoryDefs.length} behavior categories`) + + // Pick 4 target students across the two classes + const behaviorTargetStudents = [ + students3a[0], // Lena Schmidt + students3a[1], // Maximilian Weber + students3a[3], // Felix Fischer + students3b[0], // Hannah Koch + ] + + // Behavior incident definitions — mix of categories, severities, locations, follow-ups, resolved status + type IncidentDef = { + studentIdx: number + categoryId: string + classGroupId: string + daysAgo: number + hour: number + severity: 'minor' | 'moderate' | 'major' + description: string + location: string + followUpAction: string | null + resolved: boolean + resolvedDaysAgo?: number + } + + const incidentDefs: IncidentDef[] = [ + { + studentIdx: 0, + categoryId: behaviorCategoryIds.praise, + classGroupId: class3a.id, + daysAgo: 2, + hour: 9, + severity: 'minor', + description: 'Lena hat heute einer Mitschülerin beim Aufräumen geholfen, ohne darum gebeten zu werden.', + location: 'classroom', + followUpAction: 'praise_circle', + resolved: true, + resolvedDaysAgo: 1, + }, + { + studentIdx: 1, + categoryId: behaviorCategoryIds.disruption, + classGroupId: class3a.id, + daysAgo: 5, + hour: 10, + severity: 'moderate', + description: 'Maximilian hat den Unterricht mehrfach durch laute Bemerkungen unterbrochen und Mitschüler abgelenkt.', + location: 'classroom', + followUpAction: 'warning', + resolved: false, + }, + { + studentIdx: 1, + categoryId: behaviorCategoryIds.tardiness, + classGroupId: class3a.id, + daysAgo: 7, + hour: 8, + severity: 'minor', + description: 'Maximilian kam 10 Minuten zu spät zum Unterricht.', + location: 'classroom', + followUpAction: null, + resolved: true, + resolvedDaysAgo: 6, + }, + { + studentIdx: 2, + categoryId: behaviorCategoryIds.conflict, + classGroupId: class3a.id, + daysAgo: 10, + hour: 11, + severity: 'major', + description: 'Felix hat sich in der Pause mit einem Mitschüler gestritten und ihn geschubst. Ein Elterngespräch ist erforderlich.', + location: 'playground', + followUpAction: 'parent_talk', + resolved: false, + }, + { + studentIdx: 2, + categoryId: behaviorCategoryIds.disruption, + classGroupId: class3a.id, + daysAgo: 12, + hour: 9, + severity: 'moderate', + description: 'Felix hat während der Stillarbeit mehrfach Bücher geklatscht und andere Kinder gestört.', + location: 'classroom', + followUpAction: 'warning', + resolved: true, + resolvedDaysAgo: 11, + }, + { + studentIdx: 2, + categoryId: behaviorCategoryIds.helpfulness, + classGroupId: class3a.id, + daysAgo: 3, + hour: 10, + severity: 'minor', + description: 'Felix hat einer neuen Schülerin die Schulwege gezeigt und sie in die Klassengemeinschaft integriert.', + location: 'hallway', + followUpAction: 'praise_circle', + resolved: true, + resolvedDaysAgo: 2, + }, + { + studentIdx: 0, + categoryId: behaviorCategoryIds.participation, + classGroupId: class3a.id, + daysAgo: 1, + hour: 9, + severity: 'minor', + description: 'Lena hat sich im Sachunterricht besonders aktiv gemeldet und ein Referat vorbereitet.', + location: 'classroom', + followUpAction: 'prize', + resolved: false, + }, + { + studentIdx: 3, + categoryId: behaviorCategoryIds.praise, + classGroupId: class3b.id, + daysAgo: 4, + hour: 11, + severity: 'minor', + description: 'Hannah hat beim Lesen einer Mitschülerin geduldig geholfen und ihr Mut zugesprochen.', + location: 'library', + followUpAction: 'praise_circle', + resolved: true, + resolvedDaysAgo: 3, + }, + { + studentIdx: 3, + categoryId: behaviorCategoryIds.tardiness, + classGroupId: class3b.id, + daysAgo: 8, + hour: 8, + severity: 'minor', + description: 'Hannah kam nach der großen Pause 5 Minuten zu spät zurück in den Unterricht.', + location: 'playground', + followUpAction: null, + resolved: true, + resolvedDaysAgo: 7, + }, + { + studentIdx: 1, + categoryId: behaviorCategoryIds.participation, + classGroupId: class3a.id, + daysAgo: 14, + hour: 10, + severity: 'moderate', + description: 'Maximilian hat im Mathematikunterricht eine schwierige Aufgabe an der Tafel eigenständig gelöst und sein Vorgehen der Klasse erklärt.', + location: 'classroom', + followUpAction: 'prize', + resolved: true, + resolvedDaysAgo: 13, + }, + ] + + let incidentCount = 0 + for (const def of incidentDefs) { + const stu = behaviorTargetStudents[def.studentIdx] + if (!stu) continue + const date = new Date(now) + date.setDate(date.getDate() - def.daysAgo) + date.setHours(def.hour, 0, 0, 0) + const resolvedAt = def.resolved && def.resolvedDaysAgo !== undefined ? new Date(now.getTime() - def.resolvedDaysAgo * 24 * 60 * 60 * 1000) : null + await prisma.behaviorIncident.create({ + data: { + studentId: stu.id, + teacherId: teacher.id, + classGroupId: def.classGroupId, + schoolId: school.id, + categoryId: def.categoryId, + date, + severity: def.severity, + description: def.description, + location: def.location, + followUpAction: def.followUpAction, + resolved: def.resolved, + resolvedAt, + resolvedById: def.resolved ? teacher.id : null, + }, + }) + incidentCount++ + } + console.log(` Created ${incidentCount} behavior incidents`) + + // ─── 16. Rubric Library ──────────────────────────────────────────────── + console.log(' 📋 Creating rubrics...') + + // Rubric 1: Aufsatzbewertung Deutsch (ANALYTIC) + const rubric1 = await prisma.rubric.create({ + data: { + schoolId: school.id, + teacherId: teacher.id, + subjectId: germanSubject.id, + title: 'Aufsatzbewertung Deutsch', + description: 'Bewertungsraster für schriftliche Aufsätze in der Grundschule', + type: 'ANALYTIC', + maxPoints: 60, + isPublic: true, + version: 1, + criteria: { + create: [ + { + name: 'Inhalt', + description: 'Inhaltliche Qualität und Gedankenführung', + weight: 2.0, + maxPoints: 20, + order: 0, + levels: { + create: [ + { label: 'Sehr gut', description: 'Der Text ist inhaltlich sehr reichhaltig, gut strukturiert und zeigt eigene Gedanken.', points: 20, order: 0 }, + { label: 'Gut', description: 'Der Text ist inhaltlich angemessen, gut nachvollziehbar und zeigt Ansätze eigener Gedanken.', points: 16, order: 1 }, + { label: 'Befriedigend', description: 'Der Text ist inhaltlich verständlich, aber eher oberflächlich und wenig eigenständig.', points: 11, order: 2 }, + { label: 'Ausreichend', description: 'Der Text ist inhaltlich dürftig, schwer nachvollziehbar und ohne eigene Gedanken.', points: 6, order: 3 }, + ], + }, + }, + { + name: 'Sprache', + description: 'Sprachliche Richtigkeit und Ausdrucksfähigkeit', + weight: 2.0, + maxPoints: 20, + order: 1, + levels: { + create: [ + { label: 'Sehr gut', description: 'Sehr fehlerfreie Sprache, treffender Ausdruck, abwechslungsreiche Sätze.', points: 20, order: 0 }, + { label: 'Gut', description: 'Wenige Fehler, guter Ausdruck, überwiegend abwechslungsreiche Sätze.', points: 16, order: 1 }, + { label: 'Befriedigend', description: 'Einige Fehler, einfacher Ausdruck, ähnliche Satzstrukturen.', points: 11, order: 2 }, + { label: 'Ausreichend', description: 'Viele Fehler, eingeschränkter Ausdruck, sehr einfache Sätze.', points: 6, order: 3 }, + ], + }, + }, + { + name: 'Darstellung', + description: 'Äußere Form, Übersichtlichkeit und Lesbarkeit', + weight: 1.0, + maxPoints: 20, + order: 2, + levels: { + create: [ + { label: 'Sehr gut', description: 'Sehr übersichtlich, saubere Schrift, klare Absatzstruktur.', points: 20, order: 0 }, + { label: 'Gut', description: 'Übersichtlich, gut lesbar, angemessene Absatzstruktur.', points: 16, order: 1 }, + { label: 'Befriedigend', description: 'Eher unübersichtlich, noch lesbar, unklare Absatzstruktur.', points: 11, order: 2 }, + { label: 'Ausreichend', description: 'Unübersichtlich, schwer lesbar, keine Absatzstruktur.', points: 6, order: 3 }, + ], + }, + }, + ], + }, + }, + }) + + // Rubric 2: Mündliche Prüfung Mathe (ANALYTIC) + const rubric2 = await prisma.rubric.create({ + data: { + schoolId: school.id, + teacherId: teacher.id, + subjectId: mathSubject.id, + title: 'Mündliche Prüfung Mathe', + description: 'Bewertungsraster für mündliche Mathematikprüfungen', + type: 'ANALYTIC', + maxPoints: 40, + isPublic: true, + version: 1, + criteria: { + create: [ + { + name: 'Fachwissen', + description: 'Beherrschung mathematischer Begriffe und Verfahren', + weight: 2.0, + maxPoints: 20, + order: 0, + levels: { + create: [ + { label: 'Sehr gut', description: 'Sichere Beherrschung aller Fachbegriffe, kann Verfahren selbstständig erklären.', points: 20, order: 0 }, + { label: 'Gut', description: 'Gute Beherrschung der Fachbegriffe, kann Verfahren mit Hilfestellung erklären.', points: 16, order: 1 }, + { label: 'Befriedigend', description: 'Grundlegende Fachbegriffe bekannt, Erklärungen sind lückenhaft.', points: 11, order: 2 }, + { label: 'Ausreichend', description: 'Wenige Fachbegriffe bekannt, keine zusammenhängenden Erklärungen möglich.', points: 6, order: 3 }, + ], + }, + }, + { + name: 'Problemlösung', + description: 'Fähigkeit, mathematische Probleme selbstständig zu lösen', + weight: 2.0, + maxPoints: 20, + order: 1, + levels: { + create: [ + { label: 'Sehr gut', description: 'Löst Probleme selbstständig und findet eigene Lösungswege.', points: 20, order: 0 }, + { label: 'Gut', description: 'Löst Probleme mit wenig Hilfestellung, findet angemessene Lösungswege.', points: 16, order: 1 }, + { label: 'Befriedigend', description: 'Löst Probleme nur mit Hilfestellung, Lösungswege sind nachvollziehbar.', points: 11, order: 2 }, + { label: 'Ausreichend', description: 'Kann Probleme auch mit Hilfestellung kaum lösen.', points: 6, order: 3 }, + ], + }, + }, + ], + }, + }, + }) + + // Rubric 3: Projektpräsentation (HOLISTIC) + const rubric3 = await prisma.rubric.create({ + data: { + schoolId: school.id, + teacherId: teacher.id, + subjectId: null, + title: 'Projektpräsentation', + description: 'Ganzheitliche Bewertung von Projektpräsentationen', + type: 'HOLISTIC', + maxPoints: 15, + isPublic: false, + version: 1, + criteria: { + create: [ + { + name: 'Gesamteindruck', + description: 'Gesamteindruck der Präsentation inklusive Inhalt, Vortrag und Visualisierung', + weight: 1.0, + maxPoints: 15, + order: 0, + levels: { + create: [ + { label: 'Sehr gut', description: 'Hervorragende Präsentation mit klarer Struktur, überzeugendem Vortrag und ansprechender Visualisierung.', points: 15, order: 0 }, + { label: 'Gut', description: 'Gute Präsentation mit erkennbarer Struktur, solidem Vortrag und angemessener Visualisierung.', points: 12, order: 1 }, + { label: 'Befriedigend', description: 'Ausreichende Präsentation mit Schwächen in Struktur, Vortrag oder Visualisierung.', points: 8, order: 2 }, + { label: 'Ausreichend', description: 'Schwache Präsentation mit deutlichen Mängeln in Struktur, Vortrag und Visualisierung.', points: 5, order: 3 }, + ], + }, + }, + ], + }, + }, + }) + + console.log(` Created 3 rubrics: Aufsatzbewertung Deutsch, Mündliche Prüfung Mathe, Projektpräsentation`) + + // ─── 16. Comment Categories & Bank Entries ──────────────────────────── + console.log(' 💬 Creating comment categories and bank entries...') + + const commentCatSozial = await prisma.commentCategory.create({ + data: { schoolId: school.id, name: 'Sozialverhalten', color: '#10b981', icon: '🤝' }, + }) + const commentCatArbeit = await prisma.commentCategory.create({ + data: { schoolId: school.id, name: 'Arbeitsverhalten', color: '#14b8a6', icon: '✋' }, + }) + const commentCatFach = await prisma.commentCategory.create({ + data: { schoolId: school.id, name: 'Fachliche Kompetenz', color: '#f59e0b', icon: '📚' }, + }) + const commentCatMotiv = await prisma.commentCategory.create({ + data: { schoolId: school.id, name: 'Motivation', color: '#8b5cf6', icon: '💪' }, + }) + const commentCatGesamt = await prisma.commentCategory.create({ + data: { schoolId: school.id, name: 'Gesamteindruck', color: '#f43f5e', icon: '⭐' }, + }) + + const commentEntries = [ + { cat: commentCatSozial, title: 'Aktive Mitarbeit', text: 'X beteiligt sich aktiv und konstruktiv am Unterrichtsgeschehen.', subjectId: null, gradeLevel: 'all', isPublic: true, usageCount: 12, tags: 'sozial,mitarbeit,aktiv' }, + { cat: commentCatSozial, title: 'Hilfsbereitschaft', text: 'X zeigt große Hilfsbereitschaft gegenüber Mitschülern und unterstützt andere gerne.', subjectId: null, gradeLevel: 'all', isPublic: true, usageCount: 8, tags: 'sozial,hilfe,team' }, + { cat: commentCatSozial, title: 'Respektvoller Umgang', text: 'X geht respektvoll und rücksichtsvoll mit anderen um.', subjectId: null, gradeLevel: 'all', isPublic: false, usageCount: 5, tags: 'sozial,respekt' }, + { cat: commentCatArbeit, title: 'Zuverlässige Hausaufgaben', text: 'X erledigt die Hausaufgaben regelmäßig und sorgfältig.', subjectId: null, gradeLevel: '3-4', isPublic: true, usageCount: 15, tags: 'arbeit,hausaufgaben,zuverlässig' }, + { cat: commentCatArbeit, title: 'Sorgfältiges Arbeiten', text: 'X arbeitet konzentriert und sorgfältig an den gestellten Aufgaben.', subjectId: null, gradeLevel: 'all', isPublic: true, usageCount: 10, tags: 'arbeit,konzentration,sorgfalt' }, + { cat: commentCatArbeit, title: 'Selbstständige Organisation', text: 'X kann Arbeitsmaterialien selbstständig organisieren und den Arbeitsplatz ordentlich halten.', subjectId: null, gradeLevel: '3', isPublic: false, usageCount: 3, tags: 'arbeit,organisation' }, + { cat: commentCatFach, title: 'Gute Lesekompetenz', text: 'X liest flüssig und versteht auch komplexe Texte.', subjectId: germanSubject.id, gradeLevel: '3-4', isPublic: true, usageCount: 9, tags: 'fach,lesen,deutsch' }, + { cat: commentCatFach, title: 'Sicheres Rechnen', text: 'X beherrscht die Grundrechenarten sicher und kann sie in Sachaufgaben anwenden.', subjectId: mathSubject.id, gradeLevel: '3', isPublic: true, usageCount: 7, tags: 'fach,rechnen,mathe' }, + { cat: commentCatFach, title: 'Gute Schreibkompetenz', text: 'X verfasst strukturierte und verständliche Texte mit angemessenem Wortschatz.', subjectId: germanSubject.id, gradeLevel: '3-4', isPublic: false, usageCount: 6, tags: 'fach,schreiben,deutsch' }, + { cat: commentCatMotiv, title: 'Hohe Motivation', text: 'X zeigt große Begeisterung und Engagement im Unterricht.', subjectId: null, gradeLevel: 'all', isPublic: true, usageCount: 11, tags: 'motivation,engagement' }, + { cat: commentCatMotiv, title: 'Neugier und Lernfreude', text: 'X ist neugierig und zeigt Freude am Lernen neuer Inhalte.', subjectId: null, gradeLevel: 'all', isPublic: false, usageCount: 4, tags: 'motivation,neugier,lernfreude' }, + { cat: commentCatGesamt, title: 'Positiver Gesamteindruck', text: 'X hat sich in diesem Schuljahr insgesamt sehr positiv entwickelt und ist eine Bereicherung für die Klasse.', subjectId: null, gradeLevel: 'all', isPublic: true, usageCount: 14, tags: 'gesamt,positiv,entwicklung' }, + ] + + for (const ce of commentEntries) { + await prisma.commentBankEntry.create({ + data: { + schoolId: school.id, + teacherId: teacher.id, + categoryId: ce.cat.id, + subjectId: ce.subjectId, + title: ce.title, + text: ce.text, + gradeLevel: ce.gradeLevel, + schoolType: 'ELEMENTARY', + isPublic: ce.isPublic, + usageCount: ce.usageCount, + tags: ce.tags, + }, + }) + } + + console.log(` Created 5 comment categories, 12 comment bank entries`) + + // ─── Summary ─────────────────────────────────────────────────────────── + console.log('\n ✅ Seed completed successfully!\n') + console.log(' 📊 Summary:') + console.log(' ─────────────────────────────────────────') + console.log(` School: ${school.name}`) + console.log(` School Year: ${schoolYear.label}`) + console.log(` Teacher: ${teacher.firstName} ${teacher.lastName} (${teacher.email})`) + console.log(` Classes: 3a (8 students), 3b (7 students)`) + console.log(` Students: ${students3a.length + students3b.length} total`) + console.log(` Subjects: Mathematik, Deutsch`) + console.log(` Templates: Mathematik Klasse 3, Deutsch Klasse 3`) + console.log(` Categories: 6 (3 Math + 3 German)`) + console.log(` Competencies: ${mathCompetencies.length + germanCompetencies.length} (7 Math + 7 German)`) + console.log(` Progress: ${progressCount} entries`) + console.log(` Assessment: 1 with ${students3a.length} results`) + console.log(` Grading: 1 scheme (Noten 1-6)`) + console.log(` Audit Logs: ${auditEntries.length}`) + console.log(` Parent Contacts: ${parentContacts.length}, Parent Messages: ${msgCount}`) + console.log(` Behavior: ${behaviorCategoryDefs.length} categories, ${incidentCount} incidents`) + console.log(` Rubrics: 3 (Aufsatzbewertung, Mündliche Prüfung, Projektpräsentation)`) + console.log(` Comments: 5 categories, 12 bank entries`) + console.log(' ─────────────────────────────────────────') + console.log('\n 🔑 Login: demo@competencetrack.org / Demo2025!') +} + +main() + .catch((e) => { + console.error('❌ Seed failed:', e) + process.exit(1) + }) + .finally(async () => { + await prisma.$disconnect() + }) diff --git a/public/logo.svg b/public/logo.svg new file mode 100644 index 0000000..d93ac8f --- /dev/null +++ b/public/logo.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..6018e70 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,14 @@ +User-agent: Googlebot +Allow: / + +User-agent: Bingbot +Allow: / + +User-agent: Twitterbot +Allow: / + +User-agent: facebookexternalhit +Allow: / + +User-agent: * +Allow: / diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts new file mode 100644 index 0000000..c91e75e --- /dev/null +++ b/src/app/api/analytics/route.ts @@ -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 = {}; + + if (classGroupId) { + where.classGroupId = classGroupId; + } else { + // Scope by school via classGroup relation + const classWhere: Record = {}; + 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 = {}; + 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(); + 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 }>(); + 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() }; + 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([[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(); + 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(); + 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; + dateMs: number; + classAgg: Map; + }; + const trendWeekMap = new Map(); + for (const e of entries) { + const wk = isoWeekKey(new Date(e.date)); + const cur = trendWeekMap.get(wk) ?? { + total: 0, + count: 0, + students: new Set(), + dateMs: 0, + classAgg: new Map(), + }; + 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 = { endDate: null }; + const enrClassWhere: Record = {}; + 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(); + 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>(); + 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(); + 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 }); + } +} diff --git a/src/app/api/assessments/[id]/results/route.ts b/src/app/api/assessments/[id]/results/route.ts new file mode 100644 index 0000000..696b3ee --- /dev/null +++ b/src/app/api/assessments/[id]/results/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/assessments/route.ts b/src/app/api/assessments/route.ts new file mode 100644 index 0000000..8f18251 --- /dev/null +++ b/src/app/api/assessments/route.ts @@ -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 = {}; + 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 } + ); + } +} diff --git a/src/app/api/attendance/[id]/route.ts b/src/app/api/attendance/[id]/route.ts new file mode 100644 index 0000000..3438a89 --- /dev/null +++ b/src/app/api/attendance/[id]/route.ts @@ -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 = {}; + 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 }); + } +} diff --git a/src/app/api/attendance/route.ts b/src/app/api/attendance/route.ts new file mode 100644 index 0000000..ff7b268 --- /dev/null +++ b/src/app/api/attendance/route.ts @@ -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 = { classGroupId }; + if (dateFrom || dateTo) { + const dateFilter: Record = {}; + 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 = {}; + 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 }); + } +} diff --git a/src/app/api/audit-log/route.ts b/src/app/api/audit-log/route.ts new file mode 100644 index 0000000..3975139 --- /dev/null +++ b/src/app/api/audit-log/route.ts @@ -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 = {}; + + // 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 }); + } +} diff --git a/src/app/api/auth/route.ts b/src/app/api/auth/route.ts new file mode 100644 index 0000000..4100423 --- /dev/null +++ b/src/app/api/auth/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/behavior-categories/[id]/route.ts b/src/app/api/behavior-categories/[id]/route.ts new file mode 100644 index 0000000..33cc916 --- /dev/null +++ b/src/app/api/behavior-categories/[id]/route.ts @@ -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 = {}; + 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 }); + } +} diff --git a/src/app/api/behavior-categories/route.ts b/src/app/api/behavior-categories/route.ts new file mode 100644 index 0000000..ff96bb6 --- /dev/null +++ b/src/app/api/behavior-categories/route.ts @@ -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 }); + } +} diff --git a/src/app/api/behavior-incidents/[id]/route.ts b/src/app/api/behavior-incidents/[id]/route.ts new file mode 100644 index 0000000..79a5e80 --- /dev/null +++ b/src/app/api/behavior-incidents/[id]/route.ts @@ -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 = {}; + 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 }); + } +} diff --git a/src/app/api/behavior-incidents/route.ts b/src/app/api/behavior-incidents/route.ts new file mode 100644 index 0000000..6727522 --- /dev/null +++ b/src/app/api/behavior-incidents/route.ts @@ -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 = { 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 = {}; + 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 }); + } +} diff --git a/src/app/api/calendar/route.ts b/src/app/api/calendar/route.ts new file mode 100644 index 0000000..085d79b --- /dev/null +++ b/src/app/api/calendar/route.ts @@ -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; +} + +/** 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 = { + 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 }>(); + for (const e of progressEntries) { + const key = toDayKey(e.createdAt); + if (!key) continue; + const slot = progressByDay.get(key) ?? { count: 0, classGroups: new Set() }; + 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) => Promise>>; + }; + }).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 = { + 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'; +} diff --git a/src/app/api/class-competency-assignments/route.ts b/src/app/api/class-competency-assignments/route.ts new file mode 100644 index 0000000..3ca4bf8 --- /dev/null +++ b/src/app/api/class-competency-assignments/route.ts @@ -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 = {}; + 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 } + ); + } +} diff --git a/src/app/api/classes/[id]/students/route.ts b/src/app/api/classes/[id]/students/route.ts new file mode 100644 index 0000000..da0a8bb --- /dev/null +++ b/src/app/api/classes/[id]/students/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/classes/route.ts b/src/app/api/classes/route.ts new file mode 100644 index 0000000..69f28e0 --- /dev/null +++ b/src/app/api/classes/route.ts @@ -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 = {}; + 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 } + ); + } +} diff --git a/src/app/api/comment-bank/[id]/route.ts b/src/app/api/comment-bank/[id]/route.ts new file mode 100644 index 0000000..8322af2 --- /dev/null +++ b/src/app/api/comment-bank/[id]/route.ts @@ -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 = {}; + 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 }); + } +} diff --git a/src/app/api/comment-bank/route.ts b/src/app/api/comment-bank/route.ts new file mode 100644 index 0000000..38bae88 --- /dev/null +++ b/src/app/api/comment-bank/route.ts @@ -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 = { 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 }); + } +} diff --git a/src/app/api/comment-categories/[id]/route.ts b/src/app/api/comment-categories/[id]/route.ts new file mode 100644 index 0000000..65a3282 --- /dev/null +++ b/src/app/api/comment-categories/[id]/route.ts @@ -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 = {}; + 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 }); + } +} diff --git a/src/app/api/comment-categories/route.ts b/src/app/api/comment-categories/route.ts new file mode 100644 index 0000000..1ada505 --- /dev/null +++ b/src/app/api/comment-categories/route.ts @@ -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 }); + } +} diff --git a/src/app/api/competence-flower/route.ts b/src/app/api/competence-flower/route.ts new file mode 100644 index 0000000..910bdee --- /dev/null +++ b/src/app/api/competence-flower/route.ts @@ -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 + >(); // 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 } + ); + } +} diff --git a/src/app/api/competency-templates/[id]/route.ts b/src/app/api/competency-templates/[id]/route.ts new file mode 100644 index 0000000..3814892 --- /dev/null +++ b/src/app/api/competency-templates/[id]/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/competency-templates/route.ts b/src/app/api/competency-templates/route.ts new file mode 100644 index 0000000..83db2af --- /dev/null +++ b/src/app/api/competency-templates/route.ts @@ -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 = {}; + + 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 } + ); + } +} diff --git a/src/app/api/curriculum-coverage/route.ts b/src/app/api/curriculum-coverage/route.ts new file mode 100644 index 0000000..5f97063 --- /dev/null +++ b/src/app/api/curriculum-coverage/route.ts @@ -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(); + const categoryMap = new Map(); + + 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; lastDate: Date | null; lastMastery: number | null }>(); + for (const entry of progressEntries) { + const cur = progressByCompetency.get(entry.competencyId) ?? { count: 0, students: new Set(), 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 }>(); + for (const link of classAssessmentLinks) { + const cur = assessmentByCompetency.get(link.competencyId) ?? { count: 0, students: new Set() }; + 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(); + 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(); + 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 }); + } +} diff --git a/src/app/api/dashboard/route.ts b/src/app/api/dashboard/route.ts new file mode 100644 index 0000000..65fe074 --- /dev/null +++ b/src/app/api/dashboard/route.ts @@ -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 = { 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(); + 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 } + ); + } +} diff --git a/src/app/api/data-export/csv/route.ts b/src/app/api/data-export/csv/route.ts new file mode 100644 index 0000000..9564271 --- /dev/null +++ b/src/app/api/data-export/csv/route.ts @@ -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 = { 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 = {}; + 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 = {}; + 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 = {}; + 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 }); + } +} diff --git a/src/app/api/data-export/route.ts b/src/app/api/data-export/route.ts new file mode 100644 index 0000000..9de00c5 --- /dev/null +++ b/src/app/api/data-export/route.ts @@ -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 = { 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 = {}; + + 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 } + ); + } +} diff --git a/src/app/api/demo-accounts/[id]/route.ts b/src/app/api/demo-accounts/[id]/route.ts new file mode 100644 index 0000000..3a16fb6 --- /dev/null +++ b/src/app/api/demo-accounts/[id]/route.ts @@ -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 }); + } +} diff --git a/src/app/api/demo-accounts/route.ts b/src/app/api/demo-accounts/route.ts new file mode 100644 index 0000000..a22e931 --- /dev/null +++ b/src/app/api/demo-accounts/route.ts @@ -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 }); + } +} diff --git a/src/app/api/grading/route.ts b/src/app/api/grading/route.ts new file mode 100644 index 0000000..42406ab --- /dev/null +++ b/src/app/api/grading/route.ts @@ -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 = {}; + 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 = { + 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(); + 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 = { + 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 } + ); + } +} diff --git a/src/app/api/learning-progress/batch/route.ts b/src/app/api/learning-progress/batch/route.ts new file mode 100644 index 0000000..9d74b55 --- /dev/null +++ b/src/app/api/learning-progress/batch/route.ts @@ -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 }); + } +} diff --git a/src/app/api/learning-progress/route.ts b/src/app/api/learning-progress/route.ts new file mode 100644 index 0000000..e56d1f8 --- /dev/null +++ b/src/app/api/learning-progress/route.ts @@ -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 = {}; + if (studentId) where.studentId = studentId; + if (classGroupId) where.classGroupId = classGroupId; + if (competencyId) where.competencyId = competencyId; + + if (startDate || endDate) { + const dateFilter: Record = {}; + 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 } + ); + } +} diff --git a/src/app/api/lesson-plans/[id]/route.ts b/src/app/api/lesson-plans/[id]/route.ts new file mode 100644 index 0000000..bb522f6 --- /dev/null +++ b/src/app/api/lesson-plans/[id]/route.ts @@ -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 = {}; + 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 }); + } +} diff --git a/src/app/api/lesson-plans/route.ts b/src/app/api/lesson-plans/route.ts new file mode 100644 index 0000000..69c18e9 --- /dev/null +++ b/src/app/api/lesson-plans/route.ts @@ -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 = {}; + if (classGroupId && classGroupId !== 'all') { + where.classGroupId = classGroupId; + } else { + where.classGroupId = { in: schoolClassIds }; + } + if (dateFrom || dateTo) { + const dateFilter: Record = {}; + 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 }); + } +} diff --git a/src/app/api/mastery-matrix/route.ts b/src/app/api/mastery-matrix/route.ts new file mode 100644 index 0000000..b046bc3 --- /dev/null +++ b/src/app/api/mastery-matrix/route.ts @@ -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(); + 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 }); + } +} diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts new file mode 100644 index 0000000..6790478 --- /dev/null +++ b/src/app/api/notifications/route.ts @@ -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(); + +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 { + const dict: Record = { + '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; +} diff --git a/src/app/api/parent-messages/[id]/route.ts b/src/app/api/parent-messages/[id]/route.ts new file mode 100644 index 0000000..494d500 --- /dev/null +++ b/src/app/api/parent-messages/[id]/route.ts @@ -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 = {}; + 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 }); + } +} diff --git a/src/app/api/parent-messages/route.ts b/src/app/api/parent-messages/route.ts new file mode 100644 index 0000000..2a4bd59 --- /dev/null +++ b/src/app/api/parent-messages/route.ts @@ -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 = {}; + 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 }); + } +} diff --git a/src/app/api/parents/[id]/route.ts b/src/app/api/parents/[id]/route.ts new file mode 100644 index 0000000..cc2e793 --- /dev/null +++ b/src/app/api/parents/[id]/route.ts @@ -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 = {}; + 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 }); + } +} diff --git a/src/app/api/parents/route.ts b/src/app/api/parents/route.ts new file mode 100644 index 0000000..36cdbb6 --- /dev/null +++ b/src/app/api/parents/route.ts @@ -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 = {}; + + 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 }); + } +} diff --git a/src/app/api/reports/pdf/route.ts b/src/app/api/reports/pdf/route.ts new file mode 100644 index 0000000..282e3b2 --- /dev/null +++ b/src/app/api/reports/pdf/route.ts @@ -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 { + 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 ` +
+ ${catName ? `

${catName}

` : ''} +

${s.generatedText}

+
+ `; + }).join('\n'); + + return ` + + + + Kompetenzbericht — ${student.firstName} ${student.lastName} + + + + + +
+

Kompetenzbericht

+

${student.firstName} ${student.lastName}

+ ${String(report.status)} +
+ +
+
Klasse:
${classGroup.name} (Jahrgang ${classGroup.gradeLevel})
+
Schuljahr:
${schoolYear.label}
+
Zeitraum:
${String(report.period)}
+
Erstellt von:
${generatedByUser.firstName} ${generatedByUser.lastName}
+
Datum:
${new Date(String(report.generatedAt)).toLocaleDateString()}
+
Noten inklusive:
${String(report.includesGrades) === 'true' ? 'Ja' : 'Nein'}
+
+ + ${sectionsHtml} + + + +`; +} diff --git a/src/app/api/reports/route.ts b/src/app/api/reports/route.ts new file mode 100644 index 0000000..0453ff9 --- /dev/null +++ b/src/app/api/reports/route.ts @@ -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 = {}; + 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 } + ); + } +} diff --git a/src/app/api/route.ts b/src/app/api/route.ts new file mode 100644 index 0000000..0277710 --- /dev/null +++ b/src/app/api/route.ts @@ -0,0 +1,5 @@ +import { NextResponse } from "next/server"; + +export async function GET() { + return NextResponse.json({ message: "Hello, world!" }); +} \ No newline at end of file diff --git a/src/app/api/rubrics/[id]/duplicate/route.ts b/src/app/api/rubrics/[id]/duplicate/route.ts new file mode 100644 index 0000000..04ba2c3 --- /dev/null +++ b/src/app/api/rubrics/[id]/duplicate/route.ts @@ -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 }); + } +} diff --git a/src/app/api/rubrics/[id]/route.ts b/src/app/api/rubrics/[id]/route.ts new file mode 100644 index 0000000..65a9cc0 --- /dev/null +++ b/src/app/api/rubrics/[id]/route.ts @@ -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 = {}; + 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 }); + } +} diff --git a/src/app/api/rubrics/route.ts b/src/app/api/rubrics/route.ts new file mode 100644 index 0000000..99c3e6e --- /dev/null +++ b/src/app/api/rubrics/route.ts @@ -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 = { + 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 = { 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 }); + } +} diff --git a/src/app/api/school-years/route.ts b/src/app/api/school-years/route.ts new file mode 100644 index 0000000..621ee45 --- /dev/null +++ b/src/app/api/school-years/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/schools/route.ts b/src/app/api/schools/route.ts new file mode 100644 index 0000000..bbcb058 --- /dev/null +++ b/src/app/api/schools/route.ts @@ -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 = {}; + 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 } + ); + } +} diff --git a/src/app/api/students/[id]/details/route.ts b/src/app/api/students/[id]/details/route.ts new file mode 100644 index 0000000..de530cd --- /dev/null +++ b/src/app/api/students/[id]/details/route.ts @@ -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(); + 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 } + ); + } +} diff --git a/src/app/api/students/bulk/route.ts b/src/app/api/students/bulk/route.ts new file mode 100644 index 0000000..fcd314d --- /dev/null +++ b/src/app/api/students/bulk/route.ts @@ -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> = 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 } + ); + } +} diff --git a/src/app/api/students/reorder/route.ts b/src/app/api/students/reorder/route.ts new file mode 100644 index 0000000..a3df2fe --- /dev/null +++ b/src/app/api/students/reorder/route.ts @@ -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 }); + } +} diff --git a/src/app/api/students/route.ts b/src/app/api/students/route.ts new file mode 100644 index 0000000..4cd7c5e --- /dev/null +++ b/src/app/api/students/route.ts @@ -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 = { 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 } + ); + } +} diff --git a/src/app/api/subjects/route.ts b/src/app/api/subjects/route.ts new file mode 100644 index 0000000..1a35377 --- /dev/null +++ b/src/app/api/subjects/route.ts @@ -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 = {}; + 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 } + ); + } +} diff --git a/src/app/api/teacher-notes/[id]/route.ts b/src/app/api/teacher-notes/[id]/route.ts new file mode 100644 index 0000000..92663a1 --- /dev/null +++ b/src/app/api/teacher-notes/[id]/route.ts @@ -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 = {}; + 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 }); + } +} diff --git a/src/app/api/teacher-notes/route.ts b/src/app/api/teacher-notes/route.ts new file mode 100644 index 0000000..525b184 --- /dev/null +++ b/src/app/api/teacher-notes/route.ts @@ -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 }); + } +} diff --git a/src/app/api/users/[id]/classes/route.ts b/src/app/api/users/[id]/classes/route.ts new file mode 100644 index 0000000..cf631c8 --- /dev/null +++ b/src/app/api/users/[id]/classes/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/users/[id]/route.ts b/src/app/api/users/[id]/route.ts new file mode 100644 index 0000000..8b84e7d --- /dev/null +++ b/src/app/api/users/[id]/route.ts @@ -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; + 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; + 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 } + ); + } +} diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts new file mode 100644 index 0000000..570858b --- /dev/null +++ b/src/app/api/users/route.ts @@ -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 = { 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; + return NextResponse.json(safeUser, { status: 201 }); + } catch (error) { + console.error('Users POST error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..d0a20e4 --- /dev/null +++ b/src/app/globals.css @@ -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; } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..395a373 --- /dev/null +++ b/src/app/layout.tsx @@ -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 ( + + + + {children} + + + + + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..260cc57 --- /dev/null +++ b/src/app/page.tsx @@ -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 ( +
+
+
+ Loading... +
+
+ ); + } + + if (!currentUser) { + return ; + } + + return ; +} diff --git a/src/components/analytics-view.tsx b/src/components/analytics-view.tsx new file mode 100644 index 0000000..af9fc38 --- /dev/null +++ b/src/components/analytics-view.tsx @@ -0,0 +1,1492 @@ +'use client'; + +import React, { useEffect, useMemo, useState } from 'react'; +import { motion } from 'framer-motion'; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip as RTooltip, + ResponsiveContainer, + BarChart, + Bar, + PieChart, + Pie, + Cell, + Legend, + ComposedChart, + ReferenceLine, +} from 'recharts'; +import { + TrendingUp, + BarChart3, + PieChart as PieChartIcon, + Flame, + ArrowUp, + ArrowDown, + RefreshCw, + Users, + PenLine, + Activity, + Calendar, + Layers, + Sparkles, + AlertTriangle, + ShieldAlert, + TrendingDown, + CalendarClock, + Eye, + ChevronRight, + TrendingDown as TrendingDownIcon, + Hourglass, + CalendarDays, + ArrowDownRight, + ArrowRight, + Trophy, + TreePine, + Flame as FlameIcon, + AlertCircle, + PartyPopper, +} 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 { ScrollArea } from '@/components/ui/scroll-area'; +import { Avatar, AvatarFallback } from '@/components/ui/avatar'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Tooltip as ShadTooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { useAppStore } from '@/lib/store'; +import { t } from '@/lib/i18n'; +import { + fetchAnalytics, + fetchClasses, + fetchSubjects, + type AnalyticsData, + type AnalyticsAtRiskStudent, + type AnalyticsRiskLevel, + type AnalyticsRiskSignal, + type ClassGroup, + type Subject, +} from '@/lib/api'; +import { toast } from 'sonner'; + +const MASTERY_COLORS: Record = { + 1: '#ef4444', // rose + 2: '#f59e0b', // amber + 3: '#10b981', // emerald + 4: '#14b8a6', // teal +}; + +const HEATMAP_LEVELS = [ + { max: 0, bg: 'bg-gray-100 dark:bg-gray-800/60', label: '0' }, + { max: 1, bg: 'bg-emerald-200/60 dark:bg-emerald-900/30', label: '1' }, + { max: 3, bg: 'bg-emerald-300/70 dark:bg-emerald-700/40', label: '1-3' }, + { max: 6, bg: 'bg-emerald-400/80 dark:bg-emerald-600/60', label: '4-6' }, + { max: 12, bg: 'bg-emerald-500 dark:bg-emerald-500/80', label: '7-12' }, + { max: Infinity, bg: 'bg-teal-600 dark:bg-teal-500/90', label: '13+' }, +]; + +function heatmapClass(count: number): string { + return HEATMAP_LEVELS.find((l) => count <= l.max)?.bg ?? 'bg-gray-100 dark:bg-gray-800'; +} + +function formatRelativeDate(iso: string, locale: string): string { + const d = new Date(iso); + return d.toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US', { + month: 'short', + day: 'numeric', + }); +} + +function formatRelativeTime(iso: string, locale: string): string { + const d = new Date(iso); + const now = Date.now(); + const diffMs = now - d.getTime(); + const dayMs = 86400000; + const days = Math.floor(diffMs / dayMs); + if (locale === 'de') { + if (days < 1) return 'heute'; + if (days < 2) return 'gestern'; + if (days < 7) return `vor ${days} Tagen`; + if (days < 30) return `vor ${Math.floor(days / 7)} Wochen`; + if (days < 365) return `vor ${Math.floor(days / 30)} Monaten`; + return `vor ${Math.floor(days / 365)} Jahren`; + } + if (days < 1) return 'today'; + if (days < 2) return 'yesterday'; + if (days < 7) return `${days} days ago`; + if (days < 30) return `${Math.floor(days / 7)} weeks ago`; + if (days < 365) return `${Math.floor(days / 30)} months ago`; + return `${Math.floor(days / 365)} years ago`; +} + +function initials(name: string): string { + const parts = name.split(' ').filter(Boolean); + if (parts.length === 0) return '?'; + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); +} + +const RISK_LEVEL_STYLES: Record< + AnalyticsRiskLevel, + { badge: string; bar: string; dot: string; pulse?: boolean } +> = { + low: { + badge: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300', + bar: 'bg-emerald-500', + dot: 'bg-emerald-500', + }, + medium: { + badge: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300', + bar: 'bg-amber-500', + dot: 'bg-amber-500', + }, + high: { + badge: 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300', + bar: 'bg-rose-500', + dot: 'bg-rose-500', + }, + critical: { + badge: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300', + bar: 'bg-red-600', + dot: 'bg-red-600', + pulse: true, + }, +}; + +const SIGNAL_META: Record< + AnalyticsRiskSignal, + { icon: React.ReactNode; key: string; chip: string } +> = { + low_mastery: { + icon: , + key: 'analytics.signal_low_mastery', + chip: 'bg-rose-50 text-rose-700 dark:bg-rose-900/20 dark:text-rose-300', + }, + no_recent_progress: { + icon: , + key: 'analytics.signal_no_progress', + chip: 'bg-amber-50 text-amber-700 dark:bg-amber-900/20 dark:text-amber-300', + }, + low_attendance: { + icon: , + key: 'analytics.signal_low_attendance', + chip: 'bg-violet-50 text-violet-700 dark:bg-violet-900/20 dark:text-violet-300', + }, + declining: { + icon: , + key: 'analytics.signal_declining', + chip: 'bg-orange-50 text-orange-700 dark:bg-orange-900/20 dark:text-orange-300', + }, + }; + +export default function AnalyticsView() { + const currentUser = useAppStore((s) => s.currentUser); + const schoolYearId = useAppStore((s) => s.schoolYearId); + const locale = useAppStore((s) => s.locale); + const navigateToStudentDetail = useAppStore((s) => s.navigateToStudentDetail); + + const [classes, setClasses] = useState([]); + const [subjects, setSubjects] = useState([]); + const [selectedClassId, setSelectedClassId] = useState('all'); + const [selectedSubjectId, setSelectedSubjectId] = useState('all'); + const [dateRange, setDateRange] = useState('365'); + const [gradeTrendRange, setGradeTrendRange] = useState<'12' | '24' | 'all'>('12'); + const [riskFilter, setRiskFilter] = useState<'all' | AnalyticsRiskLevel>('all'); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // 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); + } catch { + // ignore + } + } + load(); + }, [currentUser?.schoolId, schoolYearId]); + + const { startDate, endDate } = useMemo(() => { + const end = new Date(); + const start = new Date(); + if (dateRange === 'all') { + return { startDate: undefined, endDate: undefined }; + } + const days = parseInt(dateRange, 10); + start.setDate(start.getDate() - days); + return { + startDate: start.toISOString().slice(0, 10), + endDate: end.toISOString().slice(0, 10), + }; + }, [dateRange]); + + const loadAnalytics = React.useCallback(async () => { + setLoading(true); + setError(null); + try { + const d = await fetchAnalytics({ + classGroupId: selectedClassId === 'all' ? undefined : selectedClassId, + subjectId: selectedSubjectId === 'all' ? undefined : selectedSubjectId, + schoolYearId: schoolYearId ?? undefined, + schoolId: currentUser?.schoolId ?? undefined, + startDate, + endDate, + }); + setData(d); + } catch (err) { + const msg = err instanceof Error ? err.message : t('error.generic'); + setError(msg); + toast.error(msg); + } finally { + setLoading(false); + } + }, [selectedClassId, selectedSubjectId, schoolYearId, currentUser?.schoolId, startDate, endDate]); + + useEffect(() => { + loadAnalytics(); + }, [loadAnalytics]); + + const trendData = useMemo(() => { + if (!data) return []; + return data.masteryTrend.map((p) => ({ + ...p, + label: formatRelativeDate(p.date, locale), + })); + }, [data, locale]); + + const distData = useMemo(() => { + if (!data) return []; + return data.masteryDistribution.map((d) => ({ + ...d, + name: `${t('analytics.level')} ${d.level}`, + fill: MASTERY_COLORS[d.level], + })); + }, [data]); + + const heatmapWeeks = useMemo(() => { + if (!data) return []; + // Group by week (7-day columns) for calendar layout + type HeatDay = { date: string; count: number; dow: number }; + type HeatWeek = { days: HeatDay[]; weekStart: string }; + const weeks: HeatWeek[] = []; + let currentWeek: HeatWeek | null = null; + for (const p of data.activityHeatmap) { + const d = new Date(p.date); + const dow = d.getUTCDay(); // 0=Sun + if (currentWeek === null || dow === 0) { + if (currentWeek) weeks.push(currentWeek); + currentWeek = { days: [], weekStart: p.date }; + } + currentWeek.days.push({ date: p.date, count: p.count, dow }); + } + if (currentWeek) weeks.push(currentWeek); + return weeks; + }, [data]); + + const maxHeatCount = useMemo(() => { + if (!data) return 0; + return Math.max(1, ...data.activityHeatmap.map((p) => p.count)); + }, [data]); + + // Grade Trend (sliced by selected range) + const gradeTrendData = useMemo(() => { + if (!data) return []; + const all = data.gradeTrend ?? []; + if (gradeTrendRange === 'all') return all; + const n = gradeTrendRange === '12' ? 12 : 24; + return all.slice(-n); + }, [data, gradeTrendRange]); + + // At-risk students (filtered by risk level) + const filteredAtRisk = useMemo(() => { + if (!data) return []; + const all = data.atRiskStudents ?? []; + if (riskFilter === 'all') return all; + return all.filter((s) => s.riskLevel === riskFilter); + }, [data, riskFilter]); + + const riskSummary = useMemo(() => { + const all = data?.atRiskStudents ?? []; + const critical = all.filter((s) => s.riskLevel === 'critical').length; + const high = all.filter((s) => s.riskLevel === 'high').length; + const avg = all.length > 0 + ? Math.round((all.reduce((s, x) => s + x.riskScore, 0) / all.length) * 10) / 10 + : 0; + return { total: all.length, critical, high, avg }; + }, [data]); + + return ( +
+ {/* Header */} + + + {/* Decorative gradient banner background */} +
+
+ +
+
+ +
+
+

{t('analytics.title')}

+

{t('analytics.subtitle')}

+
+ +
+ + {/* Filters */} +
+
+ + +
+
+ + +
+
+ + +
+
+ + {/* Quick stats */} + {data && ( +
+ + +

{data.totalEntries}

+

{t('analytics.entry_count')}

+
+ + +

{data.overallAvgMastery.toFixed(2)}

+

{t('analytics.avg_mastery')} {t('analytics.level_range')}

+
+ + +

{data.totalStudents}

+

{t('analytics.student_count')}

+
+ + + + + +
+

{data.masteryTrend.length}

+

{t('analytics.week')}

+
+
+ + {t('analytics.calendar_week_full')} + +
+
+
+
+ )} +
+ + + + {loading && !data ? ( +
+
+
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+ +
+ + +
+
+ ) : error ? ( + + +

{error}

+ +
+
+ ) : data && data.totalEntries === 0 ? ( + + + + + + +

{t('polish.empty_title_no_data')}

+

{t('analytics.no_data_hint')}

+
+
+
+ ) : data ? ( + <> + {/* Insights */} + {(() => { + const insights: Array<{ icon: string; text: string; tone: 'emerald' | 'amber' | 'violet' | 'rose' | 'teal' }> = []; + // Trend insight: compare first vs last mastery data point + if (data.masteryTrend.length >= 2) { + const first = data.masteryTrend[0].avgMastery; + const last = data.masteryTrend[data.masteryTrend.length - 1].avgMastery; + const delta = last - first; + const deltaPct = first > 0 ? Math.round((delta / first) * 100) : 0; + if (delta > 0.05) { + insights.push({ + icon: , + text: t('polish.insight_mastery_up', { pct: deltaPct }), + tone: 'emerald', + }); + } else if (delta < -0.05) { + insights.push({ + icon: , + text: t('polish.insight_mastery_down', { pct: Math.abs(deltaPct) }), + tone: 'amber', + }); + } else { + insights.push({ + icon: , + text: t('polish.insight_mastery_stable'), + tone: 'teal', + }); + } + } + // Top class insight + if (data.classComparison.length > 0) { + const top = [...data.classComparison].sort((a, b) => b.avgMastery - a.avgMastery)[0]; + insights.push({ + icon: , + text: t('polish.insight_top_class', { class: top.className, avg: top.avgMastery.toFixed(2) }), + tone: 'violet', + }); + } + // Distribution insight: highest level + if (data.masteryDistribution.length > 0) { + const total = data.masteryDistribution.reduce((s, d) => s + d.count, 0); + if (total > 0) { + const mastered = data.masteryDistribution.filter((d) => d.level >= 3).reduce((s, d) => s + d.count, 0); + const pct = Math.round((mastered / total) * 100); + insights.push({ + icon: , + text: t('polish.insight_mastered_pct', { pct }), + tone: 'emerald', + }); + } + } + // Activity insight: most active day from heatmap + if (data.activityHeatmap.length > 0) { + const mostActive = [...data.activityHeatmap].sort((a, b) => b.count - a.count)[0]; + if (mostActive.count > 0) { + const date = new Date(mostActive.date).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US', { month: 'short', day: 'numeric' }); + insights.push({ + icon: , + text: t('polish.insight_peak_day', { date, count: mostActive.count }), + tone: 'rose', + }); + } + } + // Needs-attention insight: lowest competency + if (data.bottomCompetencies.length > 0) { + const lowest = data.bottomCompetencies[0]; + insights.push({ + icon: , + text: t('polish.insight_focus', { comp: lowest.code, avg: lowest.avgMastery.toFixed(2) }), + tone: 'amber', + }); + } + + const toneClasses: Record = { + emerald: { card: 'bg-emerald-50/60 dark:bg-emerald-900/15 border-emerald-200/50 dark:border-emerald-900/30 text-emerald-700 dark:text-emerald-300', border: 'border-l-emerald-500', iconBg: 'bg-gradient-to-br from-emerald-400 to-teal-500' }, + amber: { card: 'bg-amber-50/60 dark:bg-amber-900/15 border-amber-200/50 dark:border-amber-900/30 text-amber-700 dark:text-amber-300', border: 'border-l-amber-500', iconBg: 'bg-gradient-to-br from-amber-400 to-orange-500' }, + violet: { card: 'bg-violet-50/60 dark:bg-violet-900/15 border-violet-200/50 dark:border-violet-900/30 text-violet-700 dark:text-violet-300', border: 'border-l-violet-500', iconBg: 'bg-gradient-to-br from-violet-400 to-purple-500' }, + rose: { card: 'bg-rose-50/60 dark:bg-rose-900/15 border-rose-200/50 dark:border-rose-900/30 text-rose-700 dark:text-rose-300', border: 'border-l-rose-500', iconBg: 'bg-gradient-to-br from-rose-400 to-pink-500' }, + teal: { card: 'bg-teal-50/60 dark:bg-teal-900/15 border-teal-200/50 dark:border-teal-900/30 text-teal-700 dark:text-teal-300', border: 'border-l-teal-500', iconBg: 'bg-gradient-to-br from-teal-400 to-cyan-500' }, + }; + + if (insights.length === 0) return null; + + return ( + + + + +
+ +
+ {t('polish.insights_title')} + + {insights.length} + +
+
+ + +
+ {insights.map((insight, i) => ( + + {insight.icon} +

{insight.text}

+
+ ))} +
+
+
+
+
+ ); + })()} + + {/* Mastery trend */} + + + +
+ +
+ {t('analytics.mastery_trend')} + + · {t('analytics.mastery_trend_desc')} + +
+
+ + {trendData.length === 0 ? ( +

{t('analytics.no_data')}

+ ) : ( + + + + + + + + + + + + { + const p = (item?.payload ?? {}) as { avgMastery?: number }; + return [ + `${(p.avgMastery ?? value).toFixed(2)}`, + t('analytics.avg_mastery'), + ]; + }} + labelFormatter={(label, payload) => { + const p = (payload?.[0]?.payload ?? {}) as { count?: number }; + return `${label} · ${p.count ?? 0} ${t('analytics.entry_count')}`; + }} + /> + {/* Area fill under the line */} + + + + )} +
+
+ + {/* Grade Trend Over Time (ComposedChart) */} + + + +
+ +
+ +
+ {t('analytics.grade_trend')} + + · {t('analytics.grade_trend_desc')} + +
+ {/* Time range selector */} +
+ {(['12', '24', 'all'] as const).map((opt) => ( + + ))} +
+
+
+ + {gradeTrendData.length === 0 ? ( +

{t('analytics.no_data')}

+ ) : ( + <> +
+ + + + + + + + + + + + + { + const p = (item?.payload ?? {}) as { + avgMastery?: number; + classAvg?: number; + entryCount?: number; + uniqueStudents?: number; + week?: string; + }; + if (name === t('analytics.entry_count')) { + return [ + `${p.entryCount ?? 0} · ${p.uniqueStudents ?? 0} ${t('analytics.unique_students')}`, + t('analytics.entry_count'), + ]; + } + if (name === t('analytics.school_avg')) { + return [`${(p.avgMastery ?? value).toFixed(2)}`, t('analytics.school_avg')]; + } + if (name === t('analytics.class_avg')) { + return [`${(p.classAvg ?? value).toFixed(2)}`, t('analytics.class_avg')]; + } + return [String(value), name]; + }} + labelFormatter={(_label, payload) => { + const p = (payload?.[0]?.payload ?? {}) as { week?: string }; + return p.week ?? ''; + }} + /> + + + + + + + +
+ {/* Legend extras */} +
+ + + {t('analytics.entry_count')} + + + + {t('analytics.school_avg')} + + + + {t('analytics.class_avg')} + + + + {t('analytics.min_standard')} (2.0) + +
+ + )} +
+
+
+ + {/* Risk Level Distribution mini-widget (below grade-trend chart) */} + + + + +
+ +
+ {t('analytics.risk_distribution_title')} + + · {t('analytics.risk_distribution_desc')} + +
+
+ + {(() => { + const all = data.atRiskStudents ?? []; + const total = all.length; + const counts: Record = { + low: all.filter((s) => s.riskLevel === 'low').length, + medium: all.filter((s) => s.riskLevel === 'medium').length, + high: all.filter((s) => s.riskLevel === 'high').length, + critical: all.filter((s) => s.riskLevel === 'critical').length, + }; + const max = Math.max(1, ...Object.values(counts)); + const rows: { level: AnalyticsRiskLevel; key: string; bar: string; text: string }[] = [ + { level: 'low', key: 'analytics.risk_low', bar: 'bg-emerald-500', text: 'text-emerald-700 dark:text-emerald-300' }, + { level: 'medium', key: 'analytics.risk_medium', bar: 'bg-amber-500', text: 'text-amber-700 dark:text-amber-300' }, + { level: 'high', key: 'analytics.risk_high', bar: 'bg-rose-500', text: 'text-rose-700 dark:text-rose-300' }, + { level: 'critical', key: 'analytics.risk_critical', bar: 'bg-red-600', text: 'text-red-700 dark:text-red-300' }, + ]; + if (total === 0) { + return ( + +
+ +
+

+ {t('analytics.all_good')} +

+
+ ); + } + return ( +
+ {rows.map((row) => { + const count = counts[row.level]; + const pct = total > 0 ? Math.round((count / total) * 100) : 0; + const width = Math.round((count / max) * 100); + return ( +
+
+ + {t(row.key)} +
+
+
+
+
+ {count} + ({pct}%) +
+
+ ); + })} +
+ + {t('analytics.total_at_risk')} + + {total} +
+
+ ); + })()} + + + + +
+ {/* Class comparison */} + + + +
+ +
+ {t('analytics.class_comparison')} + + · {t('analytics.class_comparison_desc')} + +
+
+ + {data.classComparison.length === 0 ? ( +

{t('analytics.no_classes')}

+ ) : ( + + + + + + { + const p = (item?.payload ?? {}) as { avgMastery?: number }; + return [ + `${(p.avgMastery ?? value).toFixed(2)}`, + t('analytics.avg_mastery'), + ]; + }} + labelFormatter={(label, payload) => { + const p = (payload?.[0]?.payload ?? {}) as { studentCount?: number }; + return `${label} · ${p.studentCount ?? 0} ${t('analytics.student_count')}`; + }} + /> + + {data.classComparison.map((entry, idx) => ( + = 3 ? '#10b981' : entry.avgMastery >= 2 ? '#f59e0b' : '#ef4444'} + /> + ))} + + + + )} +
+
+ + {/* Mastery distribution (donut) */} + + + +
+ +
+ {t('analytics.mastery_distribution')} + + · {t('analytics.mastery_distribution_desc')} + +
+
+ + {distData.length === 0 ? ( +

{t('analytics.no_data')}

+ ) : ( + + + + {distData.map((entry, idx) => ( + + ))} + + + { + const p = item?.payload as { percentage?: number; level?: number } | undefined; + return [`${value} (${p?.percentage ?? 0}%)`, `${t('analytics.level')} ${p?.level ?? ''}`]; + }} + /> + + + )} +
+
+
+ + {/* Top / Bottom competencies */} +
+ + + +
+ +
+ {t('analytics.top_competencies')} +
+
+ + {data.topCompetencies.length === 0 ? ( +
+
+ +
+

{t('analytics.no_competencies')}

+
+ ) : ( +
+ {data.topCompetencies.map((c, i) => ( +
+ + #{i + 1} + +
+

+ {c.code} — {c.title} +

+

+ {c.entryCount} {t('analytics.entry_count')} +

+
+ + {c.avgMastery.toFixed(2)} + +
+ ))} +
+ )} +
+
+ + + + +
+ +
+ {t('analytics.bottom_competencies')} +
+
+ + {data.bottomCompetencies.length === 0 ? ( +
+
+ +
+

{t('analytics.no_competencies')}

+
+ ) : ( +
+ {data.bottomCompetencies.map((c, i) => ( +
+ + #{i + 1} + +
+

+ {c.code} — {c.title} +

+

+ {c.entryCount} {t('analytics.entry_count')} +

+
+ + {c.avgMastery.toFixed(2)} + +
+ ))} +
+ )} +
+
+
+ + {/* Activity heatmap */} + + + +
+ +
+ {t('analytics.activity_heatmap')} + + · {t('analytics.activity_heatmap_desc')} + +
+
+ +
+
+ {/* Month labels */} +
+
+ {heatmapWeeks.map((wk, idx) => { + const firstDay = wk.days[0]; + if (!firstDay) { + return
; + } + const date = new Date(firstDay.date); + const showMonthLabel = idx === 0 || (idx > 0 && new Date(heatmapWeeks[idx - 1].days[0]?.date ?? firstDay.date).getUTCMonth() !== date.getUTCMonth()); + return ( +
+ {showMonthLabel ? date.toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US', { month: 'short' }) : ''} +
+ ); + })} +
+ {/* Heatmap grid */} + {Array.from({ length: 7 }).map((_, dowIdx) => ( +
+
+ {['', 'M', '', 'W', '', 'F', ''][dowIdx]} +
+ {heatmapWeeks.map((wk, widx) => { + const day = wk.days.find((d) => d.dow === dowIdx); + if (!day) { + return
; + } + return ( +
+ ); + })} +
+ ))} + {/* Legend */} +
+ {t('analytics.less')} +
+
+
+
+
+
+ {t('analytics.more')} + + + max {maxHeatCount} / Tag + +
+
+
+ + + + {/* Risk Analysis: Students Needing Support */} + + + +
+ +
+ +
+ {t('analytics.risk_analysis')} + + · {t('analytics.risk_analysis_desc')} + +
+ {/* Risk level filter */} + +
+
+ + {/* Summary stats */} +
+
+ +

{riskSummary.total}

+

+ {t('analytics.total_at_risk')} +

+
+
+ + + + +

{riskSummary.critical}

+

+ {t('analytics.critical_count')} +

+
+
+ +

{riskSummary.high}

+

+ {t('analytics.high_count')} +

+
+
+ +

{riskSummary.avg}

+

+ {t('analytics.avg_risk_score')} +

+
+
+ + {/* Table or empty state */} + {filteredAtRisk.length === 0 ? ( + +
+ +
+

+ {t('analytics.all_good')} +

+
+ ) : ( +
+ + + + + {t('analytics.student_count')} + + + {t('analytics.class_comparison')} + + + {t('analytics.risk_level')} + + + {t('analytics.risk_score')} + + + {t('analytics.signals')} + + + {t('analytics.latest_mastery')} + + + {t('analytics.last_activity')} + + + + + + {filteredAtRisk.map((s, i) => { + const style = RISK_LEVEL_STYLES[s.riskLevel]; + return ( + + +
+ + + {initials(s.studentName)} + + +
+

+ {s.studentName} +

+

+ {s.className || '—'} +

+
+
+
+ + {s.className ? ( + + {s.className} + + ) : ( + — + )} + + + + + {t(`analytics.risk_${s.riskLevel}`)} + + + +
+
+
+
+ + {s.riskScore} + +
+ + +
+ {s.signals.map((sig) => { + const meta = SIGNAL_META[sig]; + return ( + + {meta.icon} + {t(meta.key)} + + ); + })} +
+
+ + {s.latestMastery > 0 ? ( + + {s.latestMastery.toFixed(2)} + + ) : ( + {t('analytics.no_recent_activity')} + )} + + + + + {formatRelativeTime(s.latestEntryDate, locale)} + + + + + + + ); + })} + +
+
+ )} +
+
+
+ + ) : null} +
+ ); +} diff --git a/src/components/app-layout.tsx b/src/components/app-layout.tsx new file mode 100644 index 0000000..29d713b --- /dev/null +++ b/src/components/app-layout.tsx @@ -0,0 +1,1072 @@ +'use client'; + +import React, { useEffect, useState, useCallback, useMemo } from 'react'; +import { useTheme } from 'next-themes'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + LayoutDashboard, + Users, + Grid3X3, + PenLine, + Flower2, + ClipboardCheck, + Calculator, + FileText, + BookOpen, + Sun, + Moon, + Globe, + LogOut, + ChevronRight, + Heart, + Bell, + GraduationCap, + Settings, + Search, + HelpCircle, + Command, + CornerDownLeft, + User as UserIcon, + FolderSearch, + X, + TrendingUp, + CalendarCheck, + CalendarDays, + Calendar as CalendarIconNav, + Mail, + Shield, + Target, + Ruler, + MessageSquareText, + Pencil, + Plus, +} from 'lucide-react'; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarProvider, + SidebarInset, + SidebarTrigger, + SidebarRail, +} from '@/components/ui/sidebar'; +import { Button } from '@/components/ui/button'; +import { Avatar, AvatarFallback } from '@/components/ui/avatar'; +import { Separator } from '@/components/ui/separator'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from '@/components/ui/breadcrumb'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { useAppStore, type ViewName } from '@/lib/store'; +import { t } from '@/lib/i18n'; +import { fetchSchoolYears, fetchStudents, fetchClasses, fetchNotifications, markServerNotificationsRead, type SchoolYear, type Student, type ClassGroup, type NotificationData, type AssessmentNotification, type MissingObservationNotification } from '@/lib/api'; +import OnboardingTour, { isOnboardingCompleted } from '@/components/onboarding-tour'; +import KeyboardShortcutsDialog from '@/components/keyboard-shortcuts-dialog'; +import { + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandSeparator, + CommandShortcut, +} from '@/components/ui/command'; +import { toast } from 'sonner'; + +import { AlertTriangle, Info, ArrowRight } from 'lucide-react'; +import DashboardView from './dashboard-view'; +import ClassesView from './classes-view'; +import CompetencyGridView from './competency-grid-view'; +import ProgressEntriesView from './progress-entries-view'; +import CompetenceFlowerView from './competence-flower-view'; +import AssessmentsView from './assessments-view'; +import GradingView from './grading-view'; +import ReportsView from './reports-view'; +import SettingsView from './settings-view'; +import StudentDetailView from './student-detail-view'; +import AnalyticsView from './analytics-view'; +import MasteryMatrixView from './mastery-matrix-view'; +import AttendanceView from './attendance-view'; +import LessonPlansView from './lesson-plans-view'; +import CalendarView from './calendar-view'; +import ParentCommunicationView from './parent-communication-view'; +import BehaviorTrackingView from './behavior-tracking-view'; +import CurriculumCoverageView from './curriculum-coverage-view'; +import RubricLibraryView from './rubric-library-view'; +import CommentBankView from './comment-bank-view'; + +type NavItem = { key: ViewName; icon: React.ElementType; labelKey: string }; +type NavSection = { id: string; labelKey: string; items: NavItem[] }; + +const navSections: NavSection[] = [ + { + id: 'analysis', + labelKey: 'polish.nav_analysis', + items: [ + { key: 'dashboard', icon: LayoutDashboard, labelKey: 'nav.dashboard' }, + { key: 'calendar', icon: CalendarIconNav, labelKey: 'nav.calendar' }, + { key: 'analytics', icon: TrendingUp, labelKey: 'nav.analytics' }, + { key: 'reports', icon: FileText, labelKey: 'nav.reports' }, + { key: 'coverage', icon: Target, labelKey: 'nav.coverage' }, + ], + }, + { + id: 'teaching', + labelKey: 'polish.nav_teaching', + items: [ + { key: 'classes', icon: Users, labelKey: 'nav.classes' }, + { key: 'progress', icon: PenLine, labelKey: 'nav.progress' }, + { key: 'flower', icon: Flower2, labelKey: 'nav.flower' }, + { key: 'matrix', icon: Grid3X3, labelKey: 'nav.matrix' }, + { key: 'assessments', icon: ClipboardCheck, labelKey: 'nav.assessments' }, + { key: 'grading', icon: Calculator, labelKey: 'nav.grading' }, + { key: 'attendance', icon: CalendarCheck, labelKey: 'nav.attendance' }, + { key: 'lesson-plans', icon: CalendarDays, labelKey: 'nav.lesson_plans' }, + { key: 'parents', icon: Mail, labelKey: 'nav.parents' }, + { key: 'behavior', icon: Shield, labelKey: 'nav.behavior' }, + { key: 'rubrics', icon: Ruler, labelKey: 'nav.rubrics' }, + { key: 'comments', icon: MessageSquareText, labelKey: 'nav.comments' }, + ], + }, + { + id: 'setup', + labelKey: 'polish.nav_setup', + items: [ + { key: 'competencies', icon: BookOpen, labelKey: 'nav.competencies' }, + { key: 'settings', icon: Settings, labelKey: 'nav.settings' }, + ], + }, +]; + +function renderView(view: ViewName) { + switch (view) { + case 'dashboard': return ; + case 'classes': return ; + case 'competencies': return ; + case 'progress': return ; + case 'flower': return ; + case 'matrix': return ; + case 'analytics': return ; + case 'assessments': return ; + case 'grading': return ; + case 'reports': return ; + case 'settings': return ; + case 'student-detail': return ; + case 'attendance': return ; + case 'lesson-plans': return ; + case 'calendar': return ; + case 'parents': return ; + case 'behavior': return ; + case 'coverage': return ; + case 'rubrics': return ; + case 'comments': return ; + default: return ; + } +} + +export default function AppLayout() { + const { theme, setTheme } = useTheme(); + const currentView = useAppStore((s) => s.currentView); + const setCurrentView = useAppStore((s) => s.setCurrentView); + const currentUser = useAppStore((s) => s.currentUser); + const logout = useAppStore((s) => s.logout); + const locale = useAppStore((s) => s.locale); + const setLocale = useAppStore((s) => s.setLocale); + const storeSchoolYearId = useAppStore((s) => s.schoolYearId); + const setSchoolYearId = useAppStore((s) => s.setSchoolYearId); + const navigateToStudentDetail = useAppStore((s) => s.navigateToStudentDetail); + const setCurrentClass = useAppStore((s) => s.setCurrentClass); + const [schoolYears, setSchoolYears] = useState([]); + const [selectedYearId, setSelectedYearId] = useState(storeSchoolYearId ?? ''); + + // Quick search (Cmd+K) state + const [searchOpen, setSearchOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [searchStudents, setSearchStudents] = useState([]); + const [searchClasses, setSearchClasses] = useState([]); + const [searchLoading, setSearchLoading] = useState(false); + const [helpOpen, setHelpOpen] = useState(false); + const [notifOpen, setNotifOpen] = useState(false); + const [notifData, setNotifData] = useState(null); + const [notifLoading, setNotifLoading] = useState(false); + const [onboardingOpen, setOnboardingOpen] = useState(false); + const [shortcutsOpen, setShortcutsOpen] = useState(false); + const [recentActions, setRecentActions] = useState<{ key: string; label: string; view?: ViewName; timestamp: number }[]>([]); + + const initials = currentUser + ? `${currentUser.firstName[0]}${currentUser.lastName[0]}`.toUpperCase() + : '?'; + + const displayName = currentUser + ? `${currentUser.firstName} ${currentUser.lastName}` + : ''; + + const roleKey = currentUser?.role === 'TEACHER' ? 'role.teacher' : currentUser?.role === 'SCHOOL_ADMIN' ? 'role.school_admin' : 'role.super_admin'; + + const isDemoUser = currentUser?.isDemo === true || (currentUser?.email?.endsWith('@competencetrack.org') && currentUser?.email?.startsWith('demo')); + + const handleLogout = async () => { + await logout(); + toast.success(t('auth.logout')); + }; + + const toggleLocale = () => { + setLocale(locale === 'de' ? 'en' : 'de'); + }; + + // Track recent actions for command palette + const trackAction = useCallback((key: string, label: string, view?: ViewName) => { + setRecentActions((prev) => { + const filtered = prev.filter((a) => a.key !== key); + return [{ key, label, view, timestamp: Date.now() }, ...filtered].slice(0, 5); + }); + }, []); + + // View shortcut mapping: Ctrl/Cmd + number -> view + const viewShortcuts: Record = { + 1: 'dashboard', + 2: 'classes', + 3: 'progress', + 4: 'flower', + 5: 'assessments', + 6: 'grading', + 7: 'reports', + 8: 'competencies', + 9: 'settings', + }; + + // Global keyboard shortcuts + useEffect(() => { + function onKey(e: KeyboardEvent) { + const isMod = e.metaKey || e.ctrlKey; + + // Ctrl/Cmd + K: Open command palette + if (isMod && e.key.toLowerCase() === 'k') { + e.preventDefault(); + setSearchOpen((v) => !v); + return; + } + + // Ctrl/Cmd + /: Show keyboard shortcuts help + if (isMod && e.key.toLowerCase() === '/') { + e.preventDefault(); + setShortcutsOpen((v) => !v); + return; + } + + // Ctrl/Cmd + N: Create new entry (navigate to progress) + if (isMod && e.key.toLowerCase() === 'n') { + e.preventDefault(); + setSearchOpen(false); + setCurrentView('progress'); + trackAction('new-entry', t('shortcuts.new_entry'), 'progress'); + toast.info(t('shortcuts.new_entry')); + return; + } + + // Ctrl/Cmd + 1-9: Switch to specific view + if (isMod && e.key >= '1' && e.key <= '9') { + e.preventDefault(); + const view = viewShortcuts[Number(e.key)]; + if (view) { + setCurrentView(view); + trackAction(`view-${view}`, t(`nav.${view}`), view); + } + return; + } + + // Escape: Close any open dialog + if (e.key === 'Escape') { + if (searchOpen) { + setSearchOpen(false); + } else if (shortcutsOpen) { + setShortcutsOpen(false); + } else if (helpOpen) { + setHelpOpen(false); + } + } + } + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [searchOpen, shortcutsOpen, helpOpen, setCurrentView, trackAction]); + + // Debounced search + useEffect(() => { + if (!searchOpen) { + setSearchQuery(''); + setSearchStudents([]); + setSearchClasses([]); + return; + } + if (!searchQuery.trim()) { + setSearchStudents([]); + setSearchClasses([]); + return; + } + const q = searchQuery.trim(); + const handle = setTimeout(async () => { + setSearchLoading(true); + try { + const [students, classes] = await Promise.all([ + fetchStudents(currentUser?.schoolId ?? undefined, undefined, q).catch(() => []), + fetchClasses(currentUser?.schoolId ?? undefined, undefined).catch(() => []), + ]); + const lowerQ = q.toLowerCase(); + setSearchStudents(students.slice(0, 6)); + setSearchClasses(classes.filter((c) => c.name.toLowerCase().includes(lowerQ)).slice(0, 4)); + } finally { + setSearchLoading(false); + } + }, 220); + return () => clearTimeout(handle); + }, [searchQuery, searchOpen, currentUser?.schoolId]); + + const breadcrumbLabel = useMemo(() => { + if (currentView === 'student-detail') return t('nav.student-detail'); + return t(`nav.${currentView}`); + }, [currentView]); + + const handleSearchPickStudent = useCallback((s: Student) => { + setSearchOpen(false); + navigateToStudentDetail(s.id, currentView); + }, [navigateToStudentDetail, currentView]); + + const handleSearchPickClass = useCallback((c: ClassGroup) => { + setSearchOpen(false); + setCurrentClass(c.id); + setCurrentView('classes'); + }, [setCurrentClass, setCurrentView]); + + // Check onboarding status on mount + useEffect(() => { + if (!isOnboardingCompleted()) { + setOnboardingOpen(true); + } + }, []); + + // Fetch notifications + const loadNotifications = useCallback(async () => { + if (!currentUser?.schoolId) return; + setNotifLoading(true); + try { + const data = await fetchNotifications(); + setNotifData(data); + } catch { + // ignore + } finally { + setNotifLoading(false); + } + }, [currentUser?.schoolId]); + + useEffect(() => { + loadNotifications(); + }, [loadNotifications]); + + const handleMarkAllRead = useCallback(async () => { + if (!notifData) return; + const allIds = [ + ...notifData.upcomingAssessments.map((n) => n.id), + ...notifData.missingObservations.map((n) => n.id), + ]; + if (allIds.length === 0) return; + try { + await markServerNotificationsRead(allIds); + setNotifData((prev) => prev ? { + ...prev, + upcomingAssessments: prev.upcomingAssessments.map((n) => ({ ...n, isRead: true })), + missingObservations: prev.missingObservations.map((n) => ({ ...n, isRead: true })), + unreadCount: 0, + } : prev); + } catch { + // ignore + } + }, [notifData]); + + useEffect(() => { + async function loadYears() { + if (!currentUser?.schoolId) return; + try { + const years = await fetchSchoolYears(currentUser.schoolId); + setSchoolYears(years); + if (years.length > 0 && !selectedYearId) { + setSelectedYearId(years[0].id); + setSchoolYearId(years[0].id); + } + } catch { + // ignore + } + } + loadYears(); + }, [currentUser?.schoolId]); + + return ( + + + + +
+ +
+
+

CompetenceTrack

+

{t('app.subtitle')}

+
+
+
+ + + {navSections.map((section) => ( + + + {section.id === 'analysis' && } + {section.id === 'teaching' && } + {section.id === 'setup' && } + {t(section.labelKey)} + + + + {section.items.map((item) => ( + + setCurrentView(item.key)} + tooltip={t(item.labelKey)} + className={`group relative transition-all duration-200 ${ + currentView === item.key + ? 'bg-gradient-to-r from-emerald-100/90 via-emerald-50/70 to-teal-50/50 dark:from-emerald-900/40 dark:via-emerald-900/25 dark:to-teal-900/15 text-emerald-700 dark:text-emerald-300 font-semibold border-l-3 border-emerald-500 rounded-l-none shadow-sm shadow-emerald-100/40 dark:shadow-emerald-900/20' + : 'text-gray-600 dark:text-gray-400 hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-emerald-50/50 dark:hover:bg-emerald-900/10' + }`} + > + {currentView === item.key && ( + + )} + + {t(item.labelKey)} + + + ))} + + + + ))} + + + + +
+ + + {initials} + + +
+

+ {displayName} + {isDemoUser && ( + + {t('badge.demo')} + + )} +

+

{t(roleKey)}

+
+
+
+ + + +
+
+ +
+ + +
+ + + + + + setCurrentView('dashboard')} + > + {t('polish.breadcrumb_home')} + + + + + + {breadcrumbLabel} + + + + + {/* Right-aligned action group: Search | Help | School Year | Bell | Lang | Avatar */} +
+ {/* Quick Search (Cmd+K) */} + + + {/* Help button — opens help dialog, long-press/Ctrl+click opens onboarding tour */} + + {/* School Year Selector */} + {schoolYears.length > 0 && ( + + )} + {/* Notification Bell with Popover */} + + + + + + {/* Header */} +
+

+ {t('notifications.title')} +

+ {notifData && notifData.unreadCount > 0 && ( + + )} +
+ {/* Content */} + + {notifLoading && ( +
+ +

Loading…

+
+ )} + {!notifLoading && notifData && notifData.upcomingAssessments.length === 0 && notifData.missingObservations.length === 0 && ( +
+ +

{t('notifications.empty')}

+
+ )} + {!notifLoading && notifData && notifData.upcomingAssessments.length > 0 && ( +
+

+ + {t('notifications.upcoming_assessments')} +

+ {notifData.upcomingAssessments.map((n: AssessmentNotification, i: number) => ( + +
+ +
+
+

{n.title}

+

{n.description}

+ + {n.timestamp} + +
+ +
+ ))} +
+ )} + {!notifLoading && notifData && notifData.missingObservations.length > 0 && ( +
+

+ + {t('notifications.missing_observations')} +

+ {notifData.missingObservations.map((n: MissingObservationNotification, i: number) => ( + +
+ +
+
+

{n.title}

+

{n.description}

+ + {n.timestamp} + +
+ +
+ ))} +
+ )} + {/* Quick Actions footer */} + {!notifLoading && notifData && (notifData.upcomingAssessments.length > 0 || notifData.missingObservations.length > 0) && ( +
+ + + +
+ )} +
+
+
+ {/* Language toggle (mobile: just flag) */} + + {/* User avatar */} + + + {initials} + + +
+
+ +
+ + + {renderView(currentView)} + + +
+ +
+
+ + + CompetenceTrack — {t('footer.copyright')} + + + {t('footer.version')} · {t('footer.oss')} · 🇩🇪 🇬🇧 + {t('footer.version')} + +
+
+
+ + {/* ─── Command Palette (Cmd+K) ─────────────────────────── */} + + + + +
+ +

{t('polish.no_results')}

+
+
+ + {/* Recent actions */} + {!searchQuery.trim() && recentActions.length > 0 && ( + + {recentActions.map((action) => ( + { + if (action.view) { + setCurrentView(action.view); + trackAction(action.key, action.label, action.view); + } + setSearchOpen(false); + }} + > + + {action.label} + + {t('shortcuts.recent_actions')} + + + ))} + + )} + + {/* Quick actions (shown when no search query) */} + {!searchQuery.trim() && ( + + { + setCurrentView('progress'); + trackAction('new-entry', t('shortcuts.new_entry'), 'progress'); + setSearchOpen(false); + }} + > + + {t('shortcuts.new_entry')} + + + N + + + + { + setCurrentView('assessments'); + trackAction('new-assessment', t('action.create_assessment'), 'assessments'); + setSearchOpen(false); + }} + > + + {t('action.create_assessment')} + + + 5 + + + + { + setCurrentView('reports'); + trackAction('new-report', t('action.generate_report'), 'reports'); + setSearchOpen(false); + }} + > + + {t('action.generate_report')} + + + 7 + + + + { + setShortcutsOpen(true); + setSearchOpen(false); + }} + > + + {t('shortcuts.show_shortcuts')} + + + / + + + + + )} + + {/* Available views (shown when no search query) */} + {!searchQuery.trim() && ( + + {navSections.flatMap((section) => section.items).map((item, idx) => ( + { + setCurrentView(item.key); + trackAction(`view-${item.key}`, t(item.labelKey), item.key); + setSearchOpen(false); + }} + > + + {t(item.labelKey)} + {idx < 9 && ( + + + {idx + 1} + + + )} + + ))} + + )} + + {/* Search results: Students */} + {searchStudents.length > 0 && ( + + {searchStudents.map((s) => ( + handleSearchPickStudent(s)} + > +
+ {s.firstName[0]}{s.lastName[0]} +
+ {s.firstName} {s.lastName} + + {s.enrollments?.[0]?.classGroup?.name ?? ''} + +
+ ))} +
+ )} + + {/* Search results: Classes */} + {searchClasses.length > 0 && ( + + {searchClasses.map((c) => ( + handleSearchPickClass(c)} + > + + {c.name} + + {t('label.grade')} {c.gradeLevel} + + + ))} + + )} +
+
+ + {/* ─── Keyboard Shortcuts Dialog ────────────────────── */} + + + {/* ─── Help Modal ─────────────────────────────────────────── */} + + + + + + {t('polish.help')} + + + CompetenceTrack · {t('footer.version')} + + +
+ {[ + { kbd: '⌘K', label: t('shortcuts.command_palette') }, + { kbd: '⌘/', label: t('shortcuts.show_shortcuts') }, + { kbd: '⌘N', label: t('shortcuts.new_entry') }, + { kbd: '⌘1-9', label: t('shortcuts.switch_view') }, + { kbd: 'Esc', label: t('shortcuts.close_dialog') }, + ].map((row) => ( +
+ {row.label} + + {row.kbd} + +
+ ))} +

+ {t('app.tagline')} +

+ {/* Restart onboarding tour */} +
+ + +
+
+
+
+ + {/* ─── Onboarding Tour ─────────────────────────────────────── */} + setOnboardingOpen(false)} /> +
+ ); +} diff --git a/src/components/assessments-view.tsx b/src/components/assessments-view.tsx new file mode 100644 index 0000000..f2d9985 --- /dev/null +++ b/src/components/assessments-view.tsx @@ -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, + 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 = { + 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 ( +
+
+

+ + {t('assessments.score_distribution')} +

+

+ {t('assessments.average')}: {avg.toFixed(1)} · {t('assessments.median')}: {median.toFixed(1)} · {t('assessments.range')}: {min.toFixed(0)}-{max.toFixed(0)} +

+
+
+ + + + + + + + + + + + [value, t('assessments.score_distribution')]} + /> + + {buckets.map((_, index) => ( + + ))} + + + + +
+
+ ); +}; + +export default function AssessmentsView() { + const currentUser = useAppStore((s) => s.currentUser); + const currentClassId = useAppStore((s) => s.currentClassId); + + const [classes, setClasses] = useState([]); + const [selectedClass, setSelectedClass] = useState(null); + const [subjects, setSubjects] = useState([]); + const [selectedSubjectId, setSelectedSubjectId] = useState(''); + const [assessments, setAssessments] = useState([]); + const [students, setStudents] = useState([]); + 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(null); + const [gradingResults, setGradingResults] = useState([]); + const [gradingScores, setGradingScores] = useState>({}); + 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 = {}; + 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
; + } + + return ( +
+ {/* Selection header */} + + +
+
+ + +
+ {selectedClass && ( +
+ + +
+ )} + {selectedClass && selectedSubjectId && ( + + )} +
+
+
+ + {/* Assessments list */} + {!selectedClass || !selectedSubjectId ? ( + + + + + + +

{t('polish.empty_title_assessments')}

+

{t('polish.empty_subtitle_assessments')}

+
+ + {t('assessments.select_subject')} +
+
+
+
+ ) : assessments.length === 0 ? ( + + + + + + +

{t('polish.empty_title_no_data')}

+

{t('assessments.no_assessments')}

+ +
+
+
+ ) : ( + + + +
+ +
+ {t('assessments.list')} + {assessments.length} +
+
+ +
+ + + + {t('label.name')} + {t('assessments.assessment_type')} + {t('label.date')} + {t('assessments.max_score')} + {t('assessments.weight')} + {t('assessments.results_count')} + {t('polish.score_distribution')} + {t('assessments.enter_scores')} + + + + {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 ( + + +
+
+ +
+ {a.title} +
+
+ + + + {t(`assessment_type.${a.type.toLowerCase()}`)} + + + +
+ {relativeDate(a.date)} +
+
+ {new Date(a.date).toLocaleDateString()} +
+
+ + {a.maxScore ?? '—'} + + + {a.weight} + + +
+ + {a._count.assessmentResults} + + {classAvg !== null && ( + + + Ø {classAvg} + + )} +
+
+ +
+ {dist.map((v, i) => ( +
+ ))} +
+ + + + + + ); + })} + +
+
+
+
+ )} + + {/* Create assessment dialog */} + + + + +
+ {(() => { const Icon = typeConfig[newType]?.icon ?? ClipboardCheck; return ; })()} +
+ {t('assessments.create_title')} +
+
+ {/* Step indicator */} +
+
+
1
+ {t('label.name')} +
+
+
+
2
+ {t('assessments.assessment_type')} +
+
+
+
3
+ {t('assessments.max_score')} +
+
+
+
+ + setNewTitle(e.target.value)} placeholder={t('assessments.create_title')} className="rounded-xl border-emerald-200/50 dark:border-emerald-900/30" /> +
+
+ + +
+
+ + setNewDate(e.target.value)} className="rounded-xl border-emerald-200/50 dark:border-emerald-900/30" /> +
+
+
+ + setNewMaxScore(e.target.value)} className="rounded-xl border-emerald-200/50 dark:border-emerald-900/30" /> +
+
+ + setNewWeight(e.target.value)} className="rounded-xl border-emerald-200/50 dark:border-emerald-900/30" /> +
+
+
+ + + + + +
+ + {/* Grading dialog */} + + + + {t('assessments.bulk_grading_title', { title: gradingAssessment?.title ?? '' })} + {gradingAssessment && ( + + + + {t('polish.class_avg')}: {pseudoClassAvg(gradingAssessment) ?? '—'} + + + + {t('label.max_score')}: {gradingAssessment.maxScore ?? '—'} + + + )} + +
+ + + + {t('grading.student')} + {gradingAssessment?.maxScore && ( + {t('label.score')} (max {gradingAssessment.maxScore}) + )} + {t('label.level')} (1–4) + {t('label.note')} + + + + {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 ( + + +
+
+ {s.firstName[0]}{s.lastName[0]} +
+ {s.firstName} {s.lastName} + {isBest && ( + + {t('polish.best_performer')} + + )} + {needsSupport && ( + + {t('polish.needs_support')} + + )} +
+
+ {gradingAssessment?.maxScore && ( + + setGradingScores({ + ...gradingScores, + [s.id]: { ...gradingScores[s.id], score: e.target.value }, + })} + /> + + )} + + + + + setGradingScores({ + ...gradingScores, + [s.id]: { ...gradingScores[s.id], note: e.target.value }, + })} + /> + +
+ ); + })} +
+
+
+ {/* Score Distribution Histogram */} + {gradingAssessment && gradingResults.length > 0 && ( + + )} + + + + +
+
+
+ ); +} diff --git a/src/components/attendance-view.tsx b/src/components/attendance-view.tsx new file mode 100644 index 0000000..ee55c79 --- /dev/null +++ b/src/components/attendance-view.tsx @@ -0,0 +1,1290 @@ +'use client'; + +import React, { useEffect, useState, useCallback, useMemo } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + CalendarCheck, + Plus, + Check, + Clock, + FileText, + Trash2, + RotateCcw, + Save, + ChevronDown, + ChevronRight, + Loader2, + Users, + TrendingUp, + AlertCircle, + CheckCircle2, + XCircle, + ClipboardList, + Timer, + BarChart3, + LucideIcon, + FilePenLine, +} 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 { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { useAppStore } from '@/lib/store'; +import { t } from '@/lib/i18n'; +import { + fetchAttendanceSessions, + createAttendanceSession, + updateAttendanceSession, + updateAttendanceRecord, + deleteAttendanceSession, + fetchClasses, + fetchSubjects, + type AttendanceSession, + type AttendanceRecord, + type AttendanceStatus, + type ClassGroup, + type Subject, +} from '@/lib/api'; + +/* ── Status helpers ────────────────────────────────────────────────── */ + +const STATUS_CONFIG: Record = { + PRESENT: { + icon: CheckCircle2, + labelKey: 'attendance.present', + bgClass: 'bg-emerald-100', + bgDarkClass: 'dark:bg-emerald-900/40', + borderClass: 'border-emerald-300', + borderDarkClass: 'dark:border-emerald-700', + textClass: 'text-emerald-700', + textDarkClass: 'dark:text-emerald-300', + dotClass: 'bg-emerald-500', + heatLevel: 4, + }, + ABSENT: { + icon: XCircle, + labelKey: 'attendance.absent', + bgClass: 'bg-rose-100', + bgDarkClass: 'dark:bg-rose-900/40', + borderClass: 'border-rose-300', + borderDarkClass: 'dark:border-rose-700', + textClass: 'text-rose-700', + textDarkClass: 'dark:text-rose-300', + dotClass: 'bg-rose-500', + heatLevel: 0, + }, + EXCUSED: { + icon: ClipboardList, + labelKey: 'attendance.excused', + bgClass: 'bg-amber-100', + bgDarkClass: 'dark:bg-amber-900/40', + borderClass: 'border-amber-300', + borderDarkClass: 'dark:border-amber-700', + textClass: 'text-amber-700', + textDarkClass: 'dark:text-amber-300', + dotClass: 'bg-amber-500', + heatLevel: 2, + }, + LATE: { + icon: Timer, + labelKey: 'attendance.late', + bgClass: 'bg-orange-100', + bgDarkClass: 'dark:bg-orange-900/40', + borderClass: 'border-orange-300', + borderDarkClass: 'dark:border-orange-700', + textClass: 'text-orange-700', + textDarkClass: 'dark:text-orange-300', + dotClass: 'bg-orange-500', + heatLevel: 3, + }, +}; + +function StatusBadge({ status }: { status: AttendanceStatus }) { + const cfg = STATUS_CONFIG[status]; + return ( + + + {t(cfg.labelKey)} + + ); +} + +/* ── Status Toggle Button ──────────────────────────────────────────── */ + +function StatusToggle({ + currentStatus, + targetStatus, + onChange, + disabled, +}: { + currentStatus: AttendanceStatus; + targetStatus: AttendanceStatus; + onChange: (status: AttendanceStatus) => void; + disabled?: boolean; +}) { + const cfg = STATUS_CONFIG[targetStatus]; + const isActive = currentStatus === targetStatus; + + return ( + + + + onChange(targetStatus)} + disabled={disabled} + className={` + relative flex items-center justify-center w-9 h-9 rounded-lg border-2 transition-all duration-200 + ${isActive + ? `${cfg.bgClass} ${cfg.bgDarkClass} ${cfg.borderClass} ${cfg.borderDarkClass} shadow-md` + : 'bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600' + } + ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'} + `} + > + + {isActive && ( + + )} + + + +

{t(cfg.labelKey)}

+
+
+
+ ); +} + +/* ── Monthly Heatmap ───────────────────────────────────────────────── */ + +function MonthlyHeatmap({ sessions }: { sessions: AttendanceSession[] }) { + const now = new Date(); + const year = now.getFullYear(); + const month = now.getMonth(); + + // Build a map of date -> attendance rate + const dateMap = useMemo(() => { + const map = new Map(); + for (const session of sessions) { + const d = new Date(session.date); + const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + const existing = map.get(key) || { present: 0, total: 0 }; + for (const record of session.records) { + existing.total++; + if (record.status === 'PRESENT' || record.status === 'LATE') { + existing.present++; + } + } + map.set(key, existing); + } + return map; + }, [sessions]); + + // Generate days of the current month + const daysInMonth = new Date(year, month + 1, 0).getDate(); + const firstDayOfWeek = new Date(year, month, 1).getDay(); // 0=Sun + const adjustedFirstDay = firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1; // Mon=0 + + const dayLabels = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']; + const monthNames = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', + ]; + + const cells: Array<{ day: number | null; dateKey: string | null }> = []; + // Empty cells for alignment + for (let i = 0; i < adjustedFirstDay; i++) { + cells.push({ day: null, dateKey: null }); + } + for (let d = 1; d <= daysInMonth; d++) { + const dateKey = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`; + cells.push({ day: d, dateKey }); + } + + function getHeatColor(day: number | null, dateKey: string | null): string { + if (!day || !dateKey) return 'bg-transparent'; + const data = dateMap.get(dateKey); + if (!data || data.total === 0) return 'bg-gray-100 dark:bg-gray-800'; + const rate = data.present / data.total; + if (rate >= 0.9) return 'bg-emerald-500 dark:bg-emerald-600'; + if (rate >= 0.7) return 'bg-emerald-400 dark:bg-emerald-500'; + if (rate >= 0.5) return 'bg-emerald-300 dark:bg-emerald-700'; + if (rate >= 0.3) return 'bg-emerald-200 dark:bg-emerald-800'; + return 'bg-rose-300 dark:bg-rose-700'; + } + + return ( + + + + + {t('attendance.calendar')} — {monthNames[month]} {year} + + + +
+ {dayLabels.map((d) => ( +
+ {d} +
+ ))} +
+
+ {cells.map((cell, i) => ( + + + +
+ {cell.day && ( + + {cell.day} + + )} +
+
+ {cell.dateKey && cell.day && ( + + {(() => { + const data = dateMap.get(cell.dateKey); + if (!data || data.total === 0) return

{t('attendance.no_sessions')}

; + const rate = Math.round((data.present / data.total) * 100); + return ( +

+ {t('attendance.rate')}: {rate}% ({data.present}/{data.total}) +

+ ); + })()} +
+ )} +
+
+ ))} +
+ {/* Legend */} +
+ {t('attendance.absent')} +
+
+
+
+
+
+
+
+ {t('attendance.present')} +
+ + + ); +} + +/* ── Stats Cards ───────────────────────────────────────────────────── */ + +function StatsCards({ sessions }: { sessions: AttendanceSession[] }) { + const stats = useMemo(() => { + let total = 0; + let present = 0; + let absent = 0; + let excused = 0; + let late = 0; + + for (const session of sessions) { + for (const record of session.records) { + total++; + if (record.status === 'PRESENT') present++; + else if (record.status === 'ABSENT') absent++; + else if (record.status === 'EXCUSED') excused++; + else if (record.status === 'LATE') late++; + } + } + + const rate = total > 0 ? Math.round(((present + late) / total) * 100) : 0; + + return { total, present, absent, excused, late, rate }; + }, [sessions]); + + const cards = [ + { + label: t('attendance.rate'), + value: `${stats.rate}%`, + icon: BarChart3, + gradient: 'from-emerald-500 to-teal-600', + border: 'border-emerald-200 dark:border-emerald-800', + }, + { + label: t('attendance.present_count'), + value: stats.present, + icon: CheckCircle2, + gradient: 'from-emerald-400 to-green-500', + border: 'border-emerald-200 dark:border-emerald-800', + }, + { + label: t('attendance.absent_count'), + value: stats.absent, + icon: XCircle, + gradient: 'from-rose-400 to-red-500', + border: 'border-rose-200 dark:border-rose-800', + }, + { + label: t('attendance.excused_count'), + value: stats.excused, + icon: ClipboardList, + gradient: 'from-amber-400 to-yellow-500', + border: 'border-amber-200 dark:border-amber-800', + }, + { + label: t('attendance.late_count_short'), + value: stats.late, + icon: Timer, + gradient: 'from-orange-400 to-amber-500', + border: 'border-orange-200 dark:border-orange-800', + }, + ]; + + return ( +
+ {cards.map((card) => ( + + + +
+ + {card.label} +
+
+ {card.value} +
+
+
+
+ ))} +
+ ); +} + +/* ── Session Detail (Attendance Grid) ──────────────────────────────── */ + +function SessionDetail({ + session, + onBack, + onUpdate, + onDelete, + onComplete, + onReopen, +}: { + session: AttendanceSession; + onBack: () => void; + onUpdate: (session: AttendanceSession) => void; + onDelete: (id: string) => void; + onComplete: (id: string) => void; + onReopen: (id: string) => void; +}) { + const [localRecords, setLocalRecords] = useState(session.records); + const [saving, setSaving] = useState(false); + + useEffect(() => { + setLocalRecords(session.records); + }, [session.records]); + + const handleStatusChange = useCallback((recordId: string, newStatus: AttendanceStatus) => { + setLocalRecords((prev) => + prev.map((r) => (r.id === recordId ? { ...r, status: newStatus } : r)) + ); + }, []); + + const handleCommentChange = useCallback((recordId: string, comment: string) => { + setLocalRecords((prev) => + prev.map((r) => (r.id === recordId ? { ...r, comment } : r)) + ); + }, []); + + const handleArrivalTimeChange = useCallback((recordId: string, arrivalTime: string) => { + setLocalRecords((prev) => + prev.map((r) => (r.id === recordId ? { ...r, arrivalTime } : r)) + ); + }, []); + + const handleSaveAll = async () => { + setSaving(true); + try { + const changedRecords = localRecords.filter((localRecord) => { + const original = session.records.find((r) => r.id === localRecord.id); + if (!original) return true; + return ( + localRecord.status !== original.status || + localRecord.comment !== original.comment || + localRecord.arrivalTime !== original.arrivalTime + ); + }); + + if (changedRecords.length > 0) { + const updated = await updateAttendanceSession(session.id, { + records: changedRecords.map((r) => ({ + id: r.id, + status: r.status, + arrivalTime: r.arrivalTime ?? undefined, + comment: r.comment ?? undefined, + })), + }); + onUpdate(updated); + } + } catch (error) { + console.error('Save error:', error); + } finally { + setSaving(false); + } + }; + + const isOpen = session.status === 'OPEN'; + + // Session stats + const sessionStats = useMemo(() => { + const total = localRecords.length; + const present = localRecords.filter((r) => r.status === 'PRESENT').length; + const absent = localRecords.filter((r) => r.status === 'ABSENT').length; + const excused = localRecords.filter((r) => r.status === 'EXCUSED').length; + const late = localRecords.filter((r) => r.status === 'LATE').length; + const rate = total > 0 ? Math.round(((present + late) / total) * 100) : 0; + return { total, present, absent, excused, late, rate }; + }, [localRecords]); + + const sessionDate = new Date(session.date); + + return ( +
+ {/* Header */} +
+
+ +
+

+ {t('attendance.session_for')} {sessionDate.toLocaleDateString()} + {session.period && ` — ${session.period}`} + {session.subject && ` — ${session.subject.name}`} +

+
+ + {isOpen ? t('attendance.open') : t('attendance.completed')} + + + {t('attendance.rate')}: {sessionStats.rate}% + +
+
+
+
+ {isOpen && ( + <> + + + + )} + {!isOpen && ( + + )} + +
+
+ + {/* Mini stats */} +
+ {[ + { label: t('attendance.present_count'), value: sessionStats.present, color: 'text-emerald-600 dark:text-emerald-400' }, + { label: t('attendance.absent_count'), value: sessionStats.absent, color: 'text-rose-600 dark:text-rose-400' }, + { label: t('attendance.excused_count'), value: sessionStats.excused, color: 'text-amber-600 dark:text-amber-400' }, + { label: t('attendance.late_count_short'), value: sessionStats.late, color: 'text-orange-600 dark:text-orange-400' }, + ].map((s) => ( +
+
{s.value}
+
{s.label}
+
+ ))} +
+ + {/* Attendance Grid */} + + +
+ + + + # + {t('attendance.total_students')} + {t('attendance.status')} + {t('attendance.arrival_time')} + {t('attendance.comment')} + + + + {localRecords.map((record, idx) => ( + + + {idx + 1} + +
+ + {record.student.lastName}, {record.student.firstName} + + +
+
+ +
+ {(['PRESENT', 'ABSENT', 'EXCUSED', 'LATE'] as AttendanceStatus[]).map( + (status) => ( + handleStatusChange(record.id, s)} + disabled={!isOpen} + /> + ) + )} +
+
+ + {record.status === 'LATE' ? ( + handleArrivalTimeChange(record.id, e.target.value)} + disabled={!isOpen} + className="h-8 text-sm" + /> + ) : ( + — + )} + + + handleCommentChange(record.id, e.target.value)} + disabled={!isOpen} + placeholder={t('attendance.comment')} + className="h-8 text-sm" + /> + +
+
+ ))} +
+
+
+
+
+
+ ); +} + +/* ── Main Attendance View ──────────────────────────────────────────── */ + +export default function AttendanceView() { + const currentClassId = useAppStore((s) => s.currentClassId); + const setCurrentClass = useAppStore((s) => s.setCurrentClass); + + const [classes, setClasses] = useState([]); + const [subjects, setSubjects] = useState([]); + const [sessions, setSessions] = useState([]); + const [selectedSessionId, setSelectedSessionId] = useState(null); + const [loading, setLoading] = useState(false); + const [createDialogOpen, setCreateDialogOpen] = useState(false); + + // Quick stats for the empty state (loaded only when no class is selected) + const [quickStats, setQuickStats] = useState<{ + totalSessionsMonth: number; + avgRate: number | null; + commonReason: string | null; + loading: boolean; + hasClasses: boolean; + }>({ totalSessionsMonth: 0, avgRate: null, commonReason: null, loading: false, hasClasses: false }); + + // New session form + const [newDate, setNewDate] = useState(new Date().toISOString().split('T')[0]); + const [newSubjectId, setNewSubjectId] = useState(''); + const [newPeriod, setNewPeriod] = useState(''); + const [creating, setCreating] = useState(false); + + // Load classes + useEffect(() => { + fetchClasses() + .then((data) => setClasses(data)) + .catch(console.error); + }, []); + + // Load subjects when class is selected + useEffect(() => { + if (currentClassId) { + fetchSubjects() + .then((data) => setSubjects(data)) + .catch(console.error); + } + }, [currentClassId]); + + // Load school-wide attendance quick stats when no class is selected. + // We fetch sessions for the current month for each class in parallel (capped at 8 classes). + useEffect(() => { + if (currentClassId) return; + if (classes.length === 0) { + setQuickStats({ totalSessionsMonth: 0, avgRate: null, commonReason: null, loading: false, hasClasses: false }); + return; + } + if (quickStats.loading || quickStats.hasClasses) return; + + let cancelled = false; + setQuickStats((s) => ({ ...s, loading: true, hasClasses: true })); + + const now = new Date(); + const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); + const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59); + const from = monthStart.toISOString(); + const to = monthEnd.toISOString(); + + const targetClasses = classes.slice(0, 8); + Promise.all( + targetClasses.map((c) => + fetchAttendanceSessions(c.id, from, to).catch(() => [] as AttendanceSession[]) + ) + ) + .then((results) => { + if (cancelled) return; + const allSessions = results.flat(); + const totalSessionsMonth = allSessions.length; + let presentCount = 0; + let totalCount = 0; + const reasonCounts: Record = { ABSENT: 0, EXCUSED: 0, LATE: 0 }; + for (const session of allSessions) { + for (const rec of session.records) { + totalCount += 1; + if (rec.status === 'PRESENT') presentCount += 1; + else if (rec.status in reasonCounts) reasonCounts[rec.status] += 1; + } + } + const avgRate = totalCount > 0 ? Math.round((presentCount / totalCount) * 100) : null; + let commonReason: string | null = null; + let maxReasonCount = 0; + for (const [reason, count] of Object.entries(reasonCounts)) { + if (count > maxReasonCount) { + maxReasonCount = count; + commonReason = reason; + } + } + if (maxReasonCount === 0) commonReason = null; + setQuickStats({ totalSessionsMonth, avgRate, commonReason, loading: false, hasClasses: true }); + }) + .catch(() => { + if (!cancelled) setQuickStats((s) => ({ ...s, loading: false })); + }); + + return () => { + cancelled = true; + }; + }, [currentClassId, classes, quickStats.loading, quickStats.hasClasses]); + + const reasonLabel = (reason: string | null) => { + if (reason === 'ABSENT') return t('attendance.absent'); + if (reason === 'EXCUSED') return t('attendance.excused'); + if (reason === 'LATE') return t('attendance.late'); + return t('polish.attendance_no_absences'); + }; + const reasonIcon = (reason: string | null) => { + if (reason === 'ABSENT') return ; + if (reason === 'EXCUSED') return ; + if (reason === 'LATE') return ; + return ; + }; + + // Load sessions when class changes + const loadSessions = useCallback(async () => { + if (!currentClassId) return; + setLoading(true); + try { + const data = await fetchAttendanceSessions(currentClassId); + setSessions(data); + } catch (error) { + console.error('Failed to load sessions:', error); + } finally { + setLoading(false); + } + }, [currentClassId]); + + useEffect(() => { + loadSessions(); + }, [loadSessions]); + + const handleCreateSession = async () => { + if (!currentClassId) return; + setCreating(true); + try { + const newSession = await createAttendanceSession({ + classGroupId: currentClassId, + date: newDate, + subjectId: newSubjectId || undefined, + period: newPeriod || undefined, + }); + setSessions((prev) => [newSession, ...prev]); + setCreateDialogOpen(false); + setNewDate(new Date().toISOString().split('T')[0]); + setNewSubjectId(''); + setNewPeriod(''); + // Auto-select the new session + setSelectedSessionId(newSession.id); + } catch (error) { + console.error('Create session error:', error); + } finally { + setCreating(false); + } + }; + + const handleUpdateSession = useCallback((updated: AttendanceSession) => { + setSessions((prev) => + prev.map((s) => (s.id === updated.id ? updated : s)) + ); + }, []); + + const handleDeleteSession = async (id: string) => { + try { + await deleteAttendanceSession(id); + setSessions((prev) => prev.filter((s) => s.id !== id)); + if (selectedSessionId === id) setSelectedSessionId(null); + } catch (error) { + console.error('Delete error:', error); + } + }; + + const handleCompleteSession = async (id: string) => { + try { + const updated = await updateAttendanceSession(id, { status: 'COMPLETED' }); + handleUpdateSession(updated); + } catch (error) { + console.error('Complete error:', error); + } + }; + + const handleReopenSession = async (id: string) => { + try { + const updated = await updateAttendanceSession(id, { status: 'OPEN' }); + handleUpdateSession(updated); + } catch (error) { + console.error('Reopen error:', error); + } + }; + + const selectedSession = sessions.find((s) => s.id === selectedSessionId); + + // If viewing a specific session, show the detail view + if (selectedSession) { + return ( +
+ setSelectedSessionId(null)} + onUpdate={handleUpdateSession} + onDelete={handleDeleteSession} + onComplete={handleCompleteSession} + onReopen={handleReopenSession} + /> +
+ ); + } + + return ( +
+ {/* Header */} + +
+

+ + {t('attendance.title')} +

+

{t('attendance.subtitle')}

+
+ +
+ {/* Class selector */} + + + {/* New session button */} + + + + + + + {t('attendance.new_session')} + + {t('attendance.subtitle')} + + +
+
+ + setNewDate(e.target.value)} + /> +
+
+ + setNewPeriod(e.target.value)} + placeholder="z.B. 1. Stunde" + /> +
+
+ + +
+ +
+
+
+
+
+ + {/* No class selected */} + {!currentClassId && ( + +
+
+ +
+

+ {t('polish.empty_title_attendance')} +

+

+ {t('polish.empty_subtitle_attendance')} +

+
+ + {/* Quick Stats card — fills the empty space with school-wide attendance stats */} + + + +
+ +
+ {t('polish.attendance_quick_stats_title')} + + · {t('polish.attendance_quick_stats_subtitle')} + +
+
+ + {classes.length === 0 ? ( +
+

+ {t('polish.attendance_no_classes_hint')} +

+
+ ) : quickStats.loading ? ( +
+ + + +
+ ) : ( +
+ {/* Total sessions this month */} +
+
+
+ +
+

+ {t('polish.attendance_total_sessions_month')} +

+
+

+ {quickStats.totalSessionsMonth} +

+
+ {/* Average attendance rate */} +
+
+
+ +
+

+ {t('polish.attendance_avg_rate')} +

+
+

+ {quickStats.avgRate === null ? '—' : `${quickStats.avgRate}%`} +

+
+ {/* Most common absence reason */} +
+
+
+ +
+

+ {t('polish.attendance_common_reason')} +

+
+

+ {reasonIcon(quickStats.commonReason)} + {reasonLabel(quickStats.commonReason)} +

+
+
+ )} +
+
+
+ )} + + {/* Loading */} + {currentClassId && loading && ( +
+ +
+ )} + + {/* Content */} + {currentClassId && !loading && ( + + + + {t('attendance.status')} + + + {t('attendance.stats')} + + + {t('attendance.calendar')} + + + + {/* Sessions List Tab */} + + {sessions.length === 0 ? ( + +
+ +
+

{t('polish.empty_title_no_data')}

+

{t('attendance.empty_action_hint')}

+
+ ) : ( +
+ + {sessions.map((session, idx) => { + const sessionDate = new Date(session.date); + const totalRecords = session.records.length; + const presentCount = session.records.filter((r) => r.status === 'PRESENT').length; + const absentCount = session.records.filter((r) => r.status === 'ABSENT').length; + const excusedCount = session.records.filter((r) => r.status === 'EXCUSED').length; + const lateCount = session.records.filter((r) => r.status === 'LATE').length; + const rate = totalRecords > 0 ? Math.round(((presentCount + lateCount) / totalRecords) * 100) : 0; + + return ( + + setSelectedSessionId(session.id)} + > + +
+
+
+ + {sessionDate.getDate()} + + + {sessionDate.toLocaleDateString(undefined, { month: 'short' })} + +
+
+
+ {sessionDate.toLocaleDateString()} + {session.period && ( + + {session.period} + + )} + {session.subject && ( + + {session.subject.name} + + )} +
+
+ {session.teacher.firstName} {session.teacher.lastName} +
+
+
+ +
+ {/* Mini status bars */} +
+ + + +
+ + {presentCount} +
+
+ {t('attendance.present')} +
+
+ + + +
+ + {absentCount} +
+
+ {t('attendance.absent')} +
+
+ + + +
+ + {excusedCount} +
+
+ {t('attendance.excused')} +
+
+ + + +
+ + {lateCount} +
+
+ {t('attendance.late')} +
+
+
+ + {/* Rate badge */} +
= 90 + ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300' + : rate >= 70 + ? 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300' + : 'bg-rose-100 text-rose-700 dark:bg-rose-900 dark:text-rose-300' + }`} + > + {rate}% +
+ + {/* Session status */} + + {session.status === 'OPEN' ? t('attendance.open') : t('attendance.completed')} + + + +
+
+
+
+
+ ); + })} +
+
+ )} +
+ + {/* Stats Tab */} + + + + + {/* Calendar Tab */} + + + +
+ )} +
+ ); +} diff --git a/src/components/auth-view.tsx b/src/components/auth-view.tsx new file mode 100644 index 0000000..df79735 --- /dev/null +++ b/src/components/auth-view.tsx @@ -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 ( +
+ {/* Animated floating geometric shapes background */} +
+ {/* Large emerald circle */} +
+ {/* Medium teal square */} +
+ {/* Small amber circle */} +
+ {/* Tiny emerald dot */} +
+ {/* Medium violet square */} +
+ {/* Small teal circle */} +
+ {/* Dot grid overlay */} +
+
+ {/* Left illustration side (hidden on small screens) */} + +
+ {/* Decorative circles */} + + + + {/* Main icon */} + + + +
+ {/* Feature highlights */} + +

{t('auth.mission_title')}

+
+
+
+ +
+

{t('auth.feature_competencies')}

+
+
+
+ +
+

{t('auth.feature_student_centered')}

+
+
+
+ +
+

{t('auth.feature_open_source')}

+
+
+
+ +
+

{t('auth.feature_reports')}

+
+
+
+ +
+

{t('auth.feature_flower')}

+
+
+ + {/* Mockup dashboard preview */} + +
+
+
+
+
+
+
+ {[0, 1, 2, 3, 4, 5].map((i) => ( +
+ ))} +
+
+
+
+
+
+ + + + + {/* Auth form */} + + {/* Branding */} +
+ + + +

+ {t('app.name')} +

+

+ {t('app.subtitle')} +

+
+ + {/* Auth Card */} + + + + {mode === 'login' ? t('auth.login') : t('auth.register_title')} + + + +
+ + {mode === 'register' && ( + +
+
+ +
+ + 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 + /> +
+
+
+ + 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 + /> +
+
+
+ )} +
+ +
+ +
+ + 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 + /> +
+
+ +
+
+ + {mode === 'login' && ( + + )} +
+
+ + 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 + /> + +
+
+ + {/* Remember me (login mode only) */} + {mode === 'login' && ( +
+ setRememberMe(v === true)} + className="border-emerald-300 dark:border-emerald-700 data-[state=checked]:bg-emerald-500 data-[state=checked]:border-emerald-500" + /> + +
+ )} + + +
+ + {/* Role-specific demo login buttons */} + {mode === 'login' && ( + +
+
+ +

{t('auth.demo_section_title')}

+ {t('badge.demo')} +
+

{t('auth.demo_section_subtitle')}

+
+
+ {DEMO_ACCOUNTS.map((demo) => ( + + ))} +
+
+ )} + +
+ +
+
+
+ + {/* Footer */} +

+ {t('app.tagline')} +

+
+
+ ); +} diff --git a/src/components/behavior-tracking-view.tsx b/src/components/behavior-tracking-view.tsx new file mode 100644 index 0000000..6a15432 --- /dev/null +++ b/src/components/behavior-tracking-view.tsx @@ -0,0 +1,1855 @@ +'use client'; + +import React, { useEffect, useState, useCallback, useMemo } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + BarChart, + Bar, + PieChart, + Pie, + Cell, + XAxis, + YAxis, + Tooltip as RTooltip, + ResponsiveContainer, + Legend, + CartesianGrid, +} from 'recharts'; +import { + Shield, + ShieldAlert, + Plus, + Pencil, + Trash2, + CheckCircle2, + Circle, + Loader2, + Filter, + X, + Calendar as CalendarIcon, + MapPin, + UserCheck, + Award, + PieChart as PieChartIcon, + BarChart3, + ListChecks, + Smile, + Frown, + Meh, + 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 { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { toast } from 'sonner'; +import { useAppStore } from '@/lib/store'; +import { t } from '@/lib/i18n'; +import { + fetchBehaviorCategories, + createBehaviorCategory, + updateBehaviorCategory, + deleteBehaviorCategory, + fetchBehaviorIncidents, + createBehaviorIncident, + updateBehaviorIncident, + deleteBehaviorIncident, + fetchClasses, + fetchStudents, + type BehaviorCategory, + type BehaviorIncident, + type BehaviorValence, + type BehaviorSeverity, + type BehaviorIncidentInput, + type BehaviorIncidentUpdate, + type BehaviorCategoryInput, + type ClassGroup, + type Student, +} from '@/lib/api'; + +/* ── Constants ─────────────────────────────────────────────────────── */ + +const VALENCES: BehaviorValence[] = ['positive', 'negative', 'neutral']; +const SEVERITIES: BehaviorSeverity[] = ['minor', 'moderate', 'major']; + +const SEVERITY_CONFIG: Record< + BehaviorSeverity, + { dot: string; badge: string; bar: string } +> = { + minor: { + dot: 'bg-emerald-500', + badge: + 'bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-950/60 dark:text-emerald-300 dark:border-emerald-800', + bar: 'bg-emerald-500', + }, + moderate: { + dot: 'bg-amber-500', + badge: + 'bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-950/60 dark:text-amber-300 dark:border-amber-800', + bar: 'bg-amber-500', + }, + major: { + dot: 'bg-rose-500', + badge: + 'bg-rose-100 text-rose-700 border-rose-200 dark:bg-rose-950/60 dark:text-rose-300 dark:border-rose-800', + bar: 'bg-rose-500', + }, +}; + +const VALENCE_CONFIG: Record< + BehaviorValence, + { gradient: string; ring: string; text: string; bg: string; icon: React.ReactNode } +> = { + positive: { + gradient: 'from-emerald-500 to-teal-500', + ring: 'ring-emerald-200 dark:ring-emerald-800/50', + text: 'text-emerald-700 dark:text-emerald-300', + bg: 'bg-emerald-50 dark:bg-emerald-950/30', + icon: , + }, + negative: { + gradient: 'from-rose-500 to-amber-500', + ring: 'ring-rose-200 dark:ring-rose-800/50', + text: 'text-rose-700 dark:text-rose-300', + bg: 'bg-rose-50 dark:bg-rose-950/30', + icon: , + }, + neutral: { + gradient: 'from-slate-400 to-slate-500', + ring: 'ring-slate-200 dark:ring-slate-700/50', + text: 'text-slate-700 dark:text-slate-300', + bg: 'bg-slate-50 dark:bg-slate-900/30', + icon: , + }, +}; + +const COLOR_PRESETS = [ + '#10b981', // emerald + '#14b8a6', // teal + '#f59e0b', // amber + '#8b5cf6', // violet + '#f43f5e', // rose + '#64748b', // slate + '#84cc16', // lime + '#ec4899', // pink +]; + +const LOCATION_OPTIONS = [ + 'classroom', + 'playground', + 'hallway', + 'gym', + 'auditorium', + 'library', + 'cafeteria', + 'other', +]; + +const FOLLOWUP_OPTIONS = [ + 'none', + 'parent_talk', + 'detention', + 'praise_circle', + 'note_home', + 'warning', + 'mediation', + 'prize', +]; + +/* ── Helpers ───────────────────────────────────────────────────────── */ + +function pad(n: number): string { + return String(n).padStart(2, '0'); +} + +function toDateInputValue(d: Date): string { + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +function formatDateShort(iso: string, locale: string): string { + const d = new Date(iso); + return d.toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }); +} + +function formatDateTime(iso: string, locale: string): string { + const d = new Date(iso); + return d.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +function getInitials(first: string, last: string): string { + return `${first[0] ?? ''}${last[0] ?? ''}`.toUpperCase(); +} + +function hexToRgba(hex: string, alpha: number): string { + const m = hex.replace('#', ''); + if (m.length !== 6) return hex; + const r = parseInt(m.slice(0, 2), 16); + const g = parseInt(m.slice(2, 4), 16); + const b = parseInt(m.slice(4, 6), 16); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + +/** Translate a location value, falling back to a raw string for unknown values. */ +function locationLabel(loc: string): string { + if (LOCATION_OPTIONS.includes(loc as typeof LOCATION_OPTIONS[number])) { + return t(`behavior.location.${loc}`); + } + return loc; +} + +/** Translate a follow-up action value, falling back to a raw string for unknown values. */ +function followupLabel(fu: string): string { + if (FOLLOWUP_OPTIONS.includes(fu as typeof FOLLOWUP_OPTIONS[number])) { + return t(`behavior.followup.${fu}`); + } + return fu; +} + +/* ── Form state ────────────────────────────────────────────────────── */ + +interface IncidentFormState { + id?: string; + studentId: string; + categoryId: string; + classGroupId: string; + date: string; + severity: BehaviorSeverity; + description: string; + location: string; + followUpAction: string; + resolved: boolean; +} + +interface CategoryFormState { + id?: string; + name: string; + color: string; + valence: BehaviorValence; + icon: string; +} + +function emptyIncidentForm(studentId = '', classGroupId = '', categoryId = ''): IncidentFormState { + const today = new Date(); + return { + studentId, + categoryId, + classGroupId, + date: toDateInputValue(today), + severity: 'minor', + description: '', + location: 'classroom', + followUpAction: 'none', + resolved: false, + }; +} + +function emptyCategoryForm(): CategoryFormState { + return { + name: '', + color: COLOR_PRESETS[0], + valence: 'positive', + icon: '', + }; +} + +function incidentToForm(inc: BehaviorIncident): IncidentFormState { + return { + id: inc.id, + studentId: inc.studentId, + categoryId: inc.categoryId, + classGroupId: inc.classGroupId ?? '', + date: toDateInputValue(new Date(inc.date)), + severity: inc.severity, + description: inc.description, + location: inc.location ?? 'classroom', + followUpAction: inc.followUpAction ?? 'none', + resolved: inc.resolved, + }; +} + +function categoryToForm(cat: BehaviorCategory): CategoryFormState { + return { + id: cat.id, + name: cat.name, + color: cat.color, + valence: cat.valence, + icon: cat.icon ?? '', + }; +} + +/* ── Sub-components ────────────────────────────────────────────────── */ + +function StudentAvatar({ first, last, size = 'md' }: { first: string; last: string; size?: 'sm' | 'md' | 'lg' }) { + const sizeCls = size === 'sm' ? 'w-8 h-8 text-xs' : size === 'lg' ? 'w-14 h-14 text-lg' : 'w-10 h-10 text-sm'; + return ( +
+ {getInitials(first, last)} +
+ ); +} + +function ValenceBadge({ valence }: { valence: BehaviorValence }) { + const cfg = VALENCE_CONFIG[valence]; + return ( + + {cfg.icon} + {t(`behavior.valence.${valence}`)} + + ); +} + +function SeverityBadge({ severity }: { severity: BehaviorSeverity }) { + const cfg = SEVERITY_CONFIG[severity]; + return ( + + + {t(`behavior.severity.${severity}`)} + + ); +} + +function CategoryBadge({ category }: { category: BehaviorIncident['category'] }) { + return ( + + {category.icon && {category.icon}} + {category.name} + + ); +} + +/* ── Main component ────────────────────────────────────────────────── */ + +export default function BehaviorTrackingView() { + const currentUser = useAppStore((s) => s.currentUser); + const locale = useAppStore((s) => s.locale); + const schoolYearId = useAppStore((s) => s.schoolYearId); + + const [classes, setClasses] = useState([]); + const [students, setStudents] = useState([]); + const [categories, setCategories] = useState([]); + const [incidents, setIncidents] = useState([]); + const [classesLoading, setClassesLoading] = useState(true); + const [categoriesLoading, setCategoriesLoading] = useState(true); + const [incidentsLoading, setIncidentsLoading] = useState(true); + const [tab, setTab] = useState<'incidents' | 'categories' | 'statistics'>('incidents'); + + // Filters + const [filterClassId, setFilterClassId] = useState('all'); + const [filterCategoryId, setFilterCategoryId] = useState('all'); + const [filterSeverity, setFilterSeverity] = useState('all'); + const [filterResolved, setFilterResolved] = useState('all'); + const [filterStudentId, setFilterStudentId] = useState('all'); + const [filterDateFrom, setFilterDateFrom] = useState(''); + const [filterDateTo, setFilterDateTo] = useState(''); + + // Dialogs + const [incidentDialogOpen, setIncidentDialogOpen] = useState(false); + const [incidentForm, setIncidentForm] = useState(null); + const [incidentDetailOpen, setIncidentDetailOpen] = useState(false); + const [detailIncident, setDetailIncident] = useState(null); + const [savingIncident, setSavingIncident] = useState(false); + const [deleteIncidentId, setDeleteIncidentId] = useState(null); + + const [categoryDialogOpen, setCategoryDialogOpen] = useState(false); + const [categoryForm, setCategoryForm] = useState(null); + const [savingCategory, setSavingCategory] = useState(false); + const [deleteCategoryId, setDeleteCategoryId] = useState(null); + + const schoolId = currentUser?.schoolId ?? ''; + + /* ── Load classes & students ─────────────────────────────────────── */ + useEffect(() => { + setClassesLoading(true); + fetchClasses(schoolId || undefined, schoolYearId ?? undefined) + .then((c) => setClasses(c)) + .catch(() => setClasses([])) + .finally(() => setClassesLoading(false)); + }, [schoolId, schoolYearId]); + + useEffect(() => { + fetchStudents(schoolId || undefined, filterClassId !== 'all' ? filterClassId : undefined) + .then(setStudents) + .catch(() => setStudents([])); + }, [schoolId, filterClassId]); + + /* ── Load categories ─────────────────────────────────────────────── */ + const loadCategories = useCallback(async () => { + if (!schoolId) { + setCategories([]); + return; + } + setCategoriesLoading(true); + try { + const data = await fetchBehaviorCategories(schoolId); + setCategories(data); + } catch (err) { + toast.error(err instanceof Error ? err.message : t('behavior.error_load')); + setCategories([]); + } finally { + setCategoriesLoading(false); + } + }, [schoolId]); + + useEffect(() => { + loadCategories(); + }, [loadCategories]); + + /* ── Load incidents ──────────────────────────────────────────────── */ + const loadIncidents = useCallback(async () => { + if (!schoolId) { + setIncidents([]); + return; + } + setIncidentsLoading(true); + try { + const data = await fetchBehaviorIncidents({ + schoolId, + classGroupId: filterClassId !== 'all' ? filterClassId : undefined, + studentId: filterStudentId !== 'all' ? filterStudentId : undefined, + categoryId: filterCategoryId !== 'all' ? filterCategoryId : undefined, + severity: filterSeverity !== 'all' ? filterSeverity : undefined, + resolved: filterResolved !== 'all' ? filterResolved : undefined, + dateFrom: filterDateFrom || undefined, + dateTo: filterDateTo || undefined, + }); + setIncidents(data); + } catch (err) { + toast.error(err instanceof Error ? err.message : t('behavior.error_load')); + setIncidents([]); + } finally { + setIncidentsLoading(false); + } + }, [schoolId, filterClassId, filterStudentId, filterCategoryId, filterSeverity, filterResolved, filterDateFrom, filterDateTo]); + + useEffect(() => { + loadIncidents(); + }, [loadIncidents]); + + /* ── Incident actions ────────────────────────────────────────────── */ + const openCreateIncident = () => { + if (categories.length === 0) { + toast.error(t('behavior.empty_categories_desc')); + return; + } + const presetStudent = filterStudentId !== 'all' ? filterStudentId : ''; + const presetClass = filterClassId !== 'all' ? filterClassId : ''; + setIncidentForm(emptyIncidentForm(presetStudent, presetClass, categories[0]?.id ?? '')); + setIncidentDialogOpen(true); + }; + + const openEditIncident = (inc: BehaviorIncident) => { + setIncidentForm(incidentToForm(inc)); + setIncidentDetailOpen(false); + setIncidentDialogOpen(true); + }; + + const openDetail = (inc: BehaviorIncident) => { + setDetailIncident(inc); + setIncidentDetailOpen(true); + }; + + const closeIncidentDialog = () => { + if (savingIncident) return; + setIncidentDialogOpen(false); + setIncidentForm(null); + }; + + const handleSaveIncident = async () => { + if (!incidentForm) return; + if (!incidentForm.studentId) { + toast.error(t('behavior.required_student')); + return; + } + if (!incidentForm.categoryId) { + toast.error(t('behavior.required_category')); + return; + } + if (!incidentForm.description.trim()) { + toast.error(t('behavior.required_description')); + return; + } + setSavingIncident(true); + try { + const payload: IncidentFormState = incidentForm; + const base: Omit = { + studentId: payload.studentId, + classGroupId: payload.classGroupId || null, + categoryId: payload.categoryId, + date: new Date(payload.date).toISOString(), + severity: payload.severity, + description: payload.description.trim(), + location: payload.location || null, + followUpAction: payload.followUpAction && payload.followUpAction !== 'none' ? payload.followUpAction : null, + resolved: payload.resolved, + }; + if (payload.id) { + const update: BehaviorIncidentUpdate = { ...base }; + await updateBehaviorIncident(payload.id, update); + } else { + await createBehaviorIncident({ ...base, schoolId }); + } + toast.success(t('behavior.saved_incident')); + setIncidentDialogOpen(false); + setIncidentForm(null); + await loadIncidents(); + } catch (err) { + toast.error(err instanceof Error ? err.message : t('behavior.error_save')); + } finally { + setSavingIncident(false); + } + }; + + const handleToggleResolved = async (inc: BehaviorIncident) => { + try { + await updateBehaviorIncident(inc.id, { resolved: !inc.resolved }); + toast.success(inc.resolved ? t('behavior.marked_unresolved') : t('behavior.marked_resolved')); + await loadIncidents(); + // If detail dialog is open, update it too + if (detailIncident?.id === inc.id) { + setDetailIncident({ ...inc, resolved: !inc.resolved }); + } + } catch (err) { + toast.error(err instanceof Error ? err.message : t('behavior.error_save')); + } + }; + + const handleDeleteIncident = async () => { + if (!deleteIncidentId) return; + try { + await deleteBehaviorIncident(deleteIncidentId); + toast.success(t('behavior.deleted_incident')); + setDeleteIncidentId(null); + if (detailIncident?.id === deleteIncidentId) { + setIncidentDetailOpen(false); + setDetailIncident(null); + } + await loadIncidents(); + } catch (err) { + toast.error(err instanceof Error ? err.message : t('behavior.error_delete')); + } + }; + + /* ── Category actions ────────────────────────────────────────────── */ + const openCreateCategory = () => { + setCategoryForm(emptyCategoryForm()); + setCategoryDialogOpen(true); + }; + + const openEditCategory = (cat: BehaviorCategory) => { + setCategoryForm(categoryToForm(cat)); + setCategoryDialogOpen(true); + }; + + const closeCategoryDialog = () => { + if (savingCategory) return; + setCategoryDialogOpen(false); + setCategoryForm(null); + }; + + const handleSaveCategory = async () => { + if (!categoryForm) return; + if (!categoryForm.name.trim()) { + toast.error(t('behavior.field.name')); + return; + } + setSavingCategory(true); + try { + const payload: Omit = { + name: categoryForm.name.trim(), + color: categoryForm.color, + valence: categoryForm.valence, + icon: categoryForm.icon.trim() || null, + }; + if (categoryForm.id) { + await updateBehaviorCategory(categoryForm.id, payload); + } else { + await createBehaviorCategory({ ...payload, schoolId }); + } + toast.success(t('behavior.saved_category')); + setCategoryDialogOpen(false); + setCategoryForm(null); + await loadCategories(); + } catch (err) { + const msg = err instanceof Error ? err.message : t('behavior.error_save'); + if (msg.includes('already exists')) { + toast.error(t('behavior.duplicate_category')); + } else { + toast.error(msg); + } + } finally { + setSavingCategory(false); + } + }; + + const handleDeleteCategory = async () => { + if (!deleteCategoryId) return; + try { + await deleteBehaviorCategory(deleteCategoryId); + toast.success(t('behavior.deleted_category')); + setDeleteCategoryId(null); + await loadCategories(); + } catch (err) { + const msg = err instanceof Error ? err.message : t('behavior.error_delete'); + if (msg.includes('Cannot delete') || msg.includes('referenced')) { + toast.error(t('behavior.delete_category_blocked')); + } else { + toast.error(msg); + } + } + }; + + /* ── Statistics computations ─────────────────────────────────────── */ + const stats = useMemo(() => { + const now = new Date(); + const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + const thisMonth = incidents.filter((i) => new Date(i.date) >= startOfMonth); + const totalThisMonth = thisMonth.length; + const positiveCount = thisMonth.filter((i) => i.category.valence === 'positive').length; + const negativeCount = thisMonth.filter((i) => i.category.valence === 'negative').length; + const resolvedCount = thisMonth.filter((i) => i.resolved).length; + const resolutionRate = totalThisMonth > 0 ? Math.round((resolvedCount / totalThisMonth) * 100) : 0; + + // By category (use ALL incidents for richer chart) + const byCatMap = new Map(); + for (const inc of incidents) { + const existing = byCatMap.get(inc.categoryId); + if (existing) existing.count += 1; + else byCatMap.set(inc.categoryId, { name: inc.category.name, count: 1, color: inc.category.color, valence: inc.category.valence }); + } + const byCategory = Array.from(byCatMap.values()).sort((a, b) => b.count - a.count); + + // By valence distribution (use ALL incidents) + const valenceCounts = { positive: 0, negative: 0, neutral: 0 }; + for (const inc of incidents) valenceCounts[inc.category.valence] += 1; + const byValence = [ + { name: t('behavior.valence.positive'), value: valenceCounts.positive, color: '#10b981' }, + { name: t('behavior.valence.negative'), value: valenceCounts.negative, color: '#f43f5e' }, + { name: t('behavior.valence.neutral'), value: valenceCounts.neutral, color: '#64748b' }, + ].filter((v) => v.value > 0); + + // Top 5 students by incident count (split positive/negative) + const byStudentMap = new Map< + string, + { + studentId: string; + firstName: string; + lastName: string; + positive: number; + negative: number; + neutral: number; + total: number; + } + >(); + for (const inc of incidents) { + const existing = byStudentMap.get(inc.studentId); + if (existing) { + existing[inc.category.valence] += 1; + existing.total += 1; + } else { + byStudentMap.set(inc.studentId, { + studentId: inc.studentId, + firstName: inc.student.firstName, + lastName: inc.student.lastName, + positive: inc.category.valence === 'positive' ? 1 : 0, + negative: inc.category.valence === 'negative' ? 1 : 0, + neutral: inc.category.valence === 'neutral' ? 1 : 0, + total: 1, + }); + } + } + const topStudents = Array.from(byStudentMap.values()).sort((a, b) => b.total - a.total).slice(0, 5); + + return { + totalThisMonth, + positiveCount, + negativeCount, + resolutionRate, + byCategory, + byValence, + topStudents, + totalAll: incidents.length, + }; + }, [incidents]); + + /* ── Derived ─────────────────────────────────────────────────────── */ + const isFiltered = + filterClassId !== 'all' || + filterStudentId !== 'all' || + filterCategoryId !== 'all' || + filterSeverity !== 'all' || + filterResolved !== 'all' || + filterDateFrom !== '' || + filterDateTo !== ''; + + const clearFilters = () => { + setFilterClassId('all'); + setFilterStudentId('all'); + setFilterCategoryId('all'); + setFilterSeverity('all'); + setFilterResolved('all'); + setFilterDateFrom(''); + setFilterDateTo(''); + }; + + /* ── Render ──────────────────────────────────────────────────────── */ + return ( +
+ {/* Header */} + +
+
+

+ + {t('behavior.title')} +

+

{t('behavior.subtitle')}

+
+ + {/* Class selector */} +
+ +
+
+
+ + {/* Tabs */} + setTab(v as typeof tab)}> + + + + {t('behavior.tab_incidents')} + {incidents.length > 0 && ( + + {incidents.length} + + )} + + + + {t('behavior.tab_categories')} + + + + {t('behavior.tab_statistics')} + + + + {/* ── Tab: Incidents ─────────────────────────────────────────── */} + + + + {/* Filter row */} + + +
+
+ + {t('behavior.filter_section')} +
+
+ {isFiltered && ( + + )} + +
+
+
+
+ + setFilterDateFrom(e.target.value)} + className="mt-1 h-9 text-sm" + /> +
+
+ + setFilterDateTo(e.target.value)} + className="mt-1 h-9 text-sm" + /> +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + {/* Incidents list */} + {incidentsLoading ? ( +
+ +
+ ) : incidents.length === 0 ? ( + + +
+ +
+

+ {isFiltered ? t('behavior.empty_incidents_filtered') : t('behavior.empty_incidents')} +

+

+ {isFiltered ? t('behavior.empty_incidents_filtered_desc') : t('behavior.empty_incidents_desc')} +

+ {!isFiltered && ( + + )} +
+
+ ) : ( +
+ {incidents.map((inc, idx) => ( + + openDetail(inc)} + > + +
+ +
+
+
+
+ {inc.student.firstName} {inc.student.lastName} +
+
+ + {formatDateShort(inc.date, locale)} + {inc.classGroup && ( + <> + · + {inc.classGroup.name} + + )} +
+
+
+ {inc.resolved ? ( + + + {t('behavior.resolved')} + + ) : ( + + + {t('behavior.unresolved')} + + )} +
+
+
+ + + {inc.location && ( + + + {locationLabel(inc.location)} + + )} +
+

+ {inc.description} +

+ {inc.followUpAction && inc.followUpAction !== 'none' && ( +
+ + {followupLabel(inc.followUpAction)} +
+ )} +
+
+ {t('behavior.by_teacher')}:{' '} + + {inc.teacher.firstName} {inc.teacher.lastName} + +
+
e.stopPropagation()} + > + + + +
+
+
+
+
+
+
+ ))} +
+ )} +
+
+
+ + {/* ── Tab: Categories ────────────────────────────────────────── */} + + + +
+ +
+ + {categoriesLoading ? ( +
+ +
+ ) : categories.length === 0 ? ( + + +
+ +
+

{t('behavior.empty_categories')}

+

{t('behavior.empty_categories_desc')}

+ +
+
+ ) : ( +
+ {categories.map((cat, idx) => { + const cfg = VALENCE_CONFIG[cat.valence]; + return ( + + +
+ +
+
+
+ {cat.icon || cat.name[0]} +
+
+
{cat.name}
+ +
+
+
+ + +
+
+
+ + {t('behavior.incidents_count')}: {cat._count?.incidents ?? 0} + + +
+
+ + + ); + })} +
+ )} +
+ + + + {/* ── Tab: Statistics ────────────────────────────────────────── */} + + + + {/* KPI tiles */} +
+ } + gradient="from-emerald-500 to-teal-500" + /> + } + gradient="from-teal-500 to-emerald-500" + /> + } + gradient="from-rose-500 to-amber-500" + /> + } + gradient="from-violet-500 to-emerald-500" + /> +
+ + {/* Charts */} +
+ {/* Bar chart by category */} + + + +
+ +
+ {t('behavior.stat.by_category')} +
+
+ + {stats.byCategory.length === 0 ? ( +

{t('behavior.stat.no_data')}

+ ) : ( + + + + + + + + {stats.byCategory.map((entry, idx) => ( + + ))} + + + + )} +
+
+ + {/* Pie chart by valence */} + + + +
+ +
+ {t('behavior.stat.by_valence')} +
+
+ + {stats.byValence.length === 0 ? ( +

{t('behavior.stat.no_data')}

+ ) : ( + + + `${name}: ${value}`} + labelLine={false} + > + {stats.byValence.map((entry, idx) => ( + + ))} + + + + + + )} +
+
+
+ + {/* Top 5 students */} + + + +
+ +
+ {t('behavior.stat.top_students')} +
+
+ + {stats.topStudents.length === 0 ? ( +

{t('behavior.stat.no_data')}

+ ) : ( +
+ {stats.topStudents.map((s, idx) => { + const max = stats.topStudents[0].total || 1; + const posPct = (s.positive / max) * 100; + const negPct = (s.negative / max) * 100; + const neuPct = (s.neutral / max) * 100; + return ( +
+
+ {idx + 1} +
+ +
+
+
+ {s.firstName} {s.lastName} +
+
+ + {s.positive} + + + {s.negative} + + · {s.total} +
+
+
+
+
+
+
+
+
+ ); + })} +
+ )} + + + + + + + + {/* ── Incident Form Dialog ─────────────────────────────────────── */} + { if (!o) closeIncidentDialog(); }}> + + + + + {incidentForm?.id ? t('behavior.edit_incident') : t('behavior.add_incident')} + + {t('behavior.subtitle')} + + + {incidentForm && ( +
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + setIncidentForm({ ...incidentForm, date: e.target.value })} + className="mt-1" + /> +
+
+ + +
+
+ +
+ +