diff --git a/.env.example b/.env.example index 93d1691..ede6073 100644 --- a/.env.example +++ b/.env.example @@ -12,4 +12,6 @@ NEXT_PUBLIC_CLERK_AFTER_SIGN_OUT_URL=/sign-in # Cloudinary NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=your_cloud_name -NEXT_PUBLIC_CLOUDINARY_API_KEY=your_api_key \ No newline at end of file +CLOUDINARY_API_KEY=your_api_key +CLOUDINARY_API_SECRET=your_api_secret +CRON_SECRET=your_api_secret \ No newline at end of file diff --git a/README.md b/README.md index 4eb67d7..1e05ced 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,150 @@ # Academix -**Academix** is a full-stack school management and assessment platform designed to centralize academic workflows such as attendance tracking, scheduling, assessments, and grading. +Academix is a modern **School Management and Assessment System** designed to streamline academic operations and improve collaboration between administrators, teachers, students, and parents. It provides a centralized platform for managing users, classes, assessments, attendance, communication, and academic performance through dedicated role-based portals. + +

+ Academix Dashboard +

+ +--- + +## 🚀 Live Demo + +🔗 https://academix-edu.vercel.app --- -## Overview +## ✨ Features + +### Administration + +- Manage students, teachers, parents, and classes +- Organize academic years, subjects, and schedules +- School-wide analytics and reporting +- User and role management + +### Teachers + +- Create and manage assignments +- Create and manage online examinations +- Record attendance +- Grade submissions +- Publish results +- Manage announcements -Academix is a web-based system that helps educational institutions manage academic operations through a centralized platform. +### Students -The system provides role-based access for administrators, teachers, and students, enabling structured and transparent interaction with academic processes. +- Access assignments and online exams +- Submit coursework +- View grades and academic performance +- Access schedules and announcements + +### Parents + +- Monitor student progress +- Track attendance records +- View grades +- Stay updated with school announcements + +### Assessment System + +- Multiple Choice Questions +- True / False Questions +- Text Answer Questions +- File Upload Questions +- Automatic Grading +- Manual Review +- AI-Assisted Evaluation +- Performance Tracking --- -## Tech Stack +## 👥 User Roles -- Next.js (App Router) -- React -- TypeScript -- Tailwind CSS -- Prisma ORM -- PostgreSQL -- Docker +- 👨‍💼 Administrator +- 👨‍🏫 Teacher +- 🎓 Student +- 👨‍👩‍👧 Parent --- -## Core Features +## 🛠️ Tech Stack + +- **Frontend:** Next.js, React, TypeScript +- **Styling:** Tailwind CSS +- **Backend:** Next.js Server Actions +- **Database:** PostgreSQL, Prisma ORM +- **Authentication:** Clerk +- **Forms & Validation:** React Hook Form, Zod +- **Internationalization:** next-intl +- **AI Integration:** Google Gemini API +- **File Storage:** Cloudinary +- **Email:** Nodemailer +- **Data Visualization:** Recharts +- **Scheduling:** React Big Calendar +- **Deployment:** Vercel -- Role-based dashboards (Admin, Teacher, Student) -- Student and class management -- Attendance tracking -- Exams and assignments management -- Grade and results system -- Scheduling and notifications +--- + +## 📸 Screenshots + +### Admin & Student Portals + +

+ Admin Dashboard + Student Dashboard +

+ +Dedicated dashboards tailored to each role, providing quick access to courses, assignments, examinations, grades, and classroom management tools. + +--- + +### Assessment System + +

+ Assessment Management + Online Examination +

+ +Manage assessments, create online exams, and review student submissions through a streamlined workflow. + +--- + +### Subject Virtual Classroom + +

+ Subject Virtual Classroom +

+ +A dedicated workspace for each subject where teachers and students can collaborate through announcements, assignments, learning materials, examinations, and discussions. --- -## Getting Started +### AI-Assisted Evaluation + +

+ AI-Assisted Evaluation +

-### 1. Clone the repository +Teachers can leverage AI-generated grading suggestions and feedback for written responses while maintaining full control over the final evaluation. + +--- -```bash -git clone https://github.com/academix-platform/academix.git -cd academix -2. Setup environment variables -cp .env.example .env -3. Start the database -docker compose up -d -4. Install dependencies -npm install -5. Setup the database -npx prisma db push -npx prisma db seed -6. Run the app -npm run dev +## 🌟 Project Highlights + +- Multi-tenant school architecture +- Role-based authentication and authorization +- Complete online examination workflow +- Assignment management +- Attendance tracking +- AI-assisted assessment evaluation +- Academic performance analytics +- Responsive design +- Secure file uploads +- Modern and intuitive user interface + +--- -App runs at: +## 📄 License -http://localhost:3000 -``` +This project is intended for educational and demonstration purposes. diff --git a/assets/admin-dashboard.png b/assets/admin-dashboard.png new file mode 100644 index 0000000..4113c88 Binary files /dev/null and b/assets/admin-dashboard.png differ diff --git a/assets/ai-evaluation.jpeg b/assets/ai-evaluation.jpeg new file mode 100644 index 0000000..d5d8b37 Binary files /dev/null and b/assets/ai-evaluation.jpeg differ diff --git a/assets/assessment-management.png b/assets/assessment-management.png new file mode 100644 index 0000000..e3e6907 Binary files /dev/null and b/assets/assessment-management.png differ diff --git a/assets/hero-dashboard.png b/assets/hero-dashboard.png new file mode 100644 index 0000000..68d1a1f Binary files /dev/null and b/assets/hero-dashboard.png differ diff --git a/assets/image.png b/assets/image.png new file mode 100644 index 0000000..a10e97c Binary files /dev/null and b/assets/image.png differ diff --git a/assets/online-exam.png b/assets/online-exam.png new file mode 100644 index 0000000..3ea5947 Binary files /dev/null and b/assets/online-exam.png differ diff --git a/assets/student-dashboard.png b/assets/student-dashboard.png new file mode 100644 index 0000000..8409f6d Binary files /dev/null and b/assets/student-dashboard.png differ diff --git a/assets/virtual-classroom.png b/assets/virtual-classroom.png new file mode 100644 index 0000000..67369b9 Binary files /dev/null and b/assets/virtual-classroom.png differ diff --git a/docker-compose.yml b/docker-compose.yml index 7b92ba5..383a6db 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,6 @@ services: image: postgres:15 container_name: postgres restart: always - environment: POSTGRES_USER: dev POSTGRES_PASSWORD: dev123 diff --git a/messages/ar.json b/messages/ar.json new file mode 100644 index 0000000..a0af475 --- /dev/null +++ b/messages/ar.json @@ -0,0 +1,1451 @@ +{ + "common": { + "brand": "أكاديميكس", + "dashboard": "لوحة التحكم", + "search": "بحث..." + }, + "states": { + "loading": "جار التحميل...", + "errorTitle": "حدث خطأ ما", + "errorDescription": "تعذر تحميل هذا المحتوى. يرجى المحاولة مرة أخرى.", + "tryAgain": "حاول مرة أخرى", + "retrying": "جار إعادة المحاولة...", + "applicationErrorTitle": "خطأ في التطبيق", + "applicationErrorDescription": "حدث خطأ غير متوقع أثناء تحميل التطبيق.", + "pageErrorTitle": "تعذر تحميل هذه الصفحة", + "pageErrorDescription": "يرجى التحديث أو المحاولة مرة أخرى.", + "unauthorizedTitle": "وصول غير مصرح", + "unauthorizedDescription": "ليس لديك صلاحية للوصول إلى هذه الصفحة بدورك الحالي أو حالة حسابك.", + "noDataYet": "لا توجد بيانات بعد", + "academicYearNotStarted": "لم تبدأ السنة الأكاديمية بعد.", + "noCurrentYear": "لم يتم اختيار سنة حالية", + "selectCurrentYear": "اختر سنة أكاديمية حالية لعرض البيانات.", + "goToSettings": "الانتقال إلى الإعدادات", + "noStudentsLinked": "لم يتم العثور على طلاب", + "noStudentsLinkedDescription": "لا يوجد طلاب مرتبطون بحساب ولي الأمر هذا بعد.", + "noClassAssigned": "لا يوجد صف معين", + "selectedStudentNoClass": "الطالب المحدد غير معين إلى صف بعد." + }, + "sidebar": { + "sections": { + "main": "القائمة", + "other": "أخرى" + }, + "items": { + "dashboard": "لوحة التحكم", + "schools": "المدارس", + "teachers": "المعلمون", + "students": "الطلاب", + "parents": "أولياء الأمور", + "subjects": "المواد", + "classes": "الصفوف", + "grades": "المراحل", + "schedules": "الجداول", + "exams": "الامتحانات", + "assignments": "الواجبات", + "results": "النتائج", + "attendance": "الحضور", + "events": "الأحداث", + "messages": "الرسائل", + "announcements": "الإعلانات", + "notifications": "الإشعارات", + "archive": "الأرشيف", + "feedback": "الملاحظات", + "feedbacks": "الملاحظات", + "profile": "الملف الشخصي", + "settings": "الإعدادات", + "logout": "تسجيل الخروج", + "finalResults": "المعدلات النهائية" + } + }, + "navbar": { + "openMenu": "فتح القائمة", + "closeMenu": "إغلاق القائمة", + "expandMenu": "توسيع القائمة", + "collapseMenu": "طي القائمة", + "messages": "الرسائل", + "userFallback": "مستخدم", + "language": { + "label": "اللغة", + "en": "English", + "ar": "العربية" + }, + "userMenu": { + "open": "فتح قائمة المستخدم", + "avatarAlt": "صورة المستخدم", + "noRole": "لا يوجد دور" + }, + "notifications": { + "label": "الإشعارات", + "newCount": "{count} جديد", + "markAllRead": "تعليم الكل كمقروء", + "allRead": "الكل مقروء", + "empty": "لا توجد إشعارات بعد", + "viewAll": "عرض كل الإشعارات", + "close": "إغلاق" + } + }, + "home": { + "nav": { + "home": "الرئيسية", + "about": "عن المنصة", + "features": "المزايا", + "users": "المستخدمون", + "howItWorks": "كيف تعمل", + "signIn": "تسجيل الدخول", + "signUp": "إنشاء حساب", + "openMenu": "فتح القائمة", + "closeMenu": "إغلاق القائمة" + }, + "hero": { + "badge": "الآن مع مزايا الذكاء الاصطناعي الجديدة", + "title": "مرحبا بك في أكاديميكس", + "highlight": "أكاديميكس", + "description": "منصة حديثة لإدارة المدارس صممت لتبسيط سير العمل الأكاديمي للمديرين والمعلمين والطلاب وأولياء الأمور.", + "signIn": "تسجيل الدخول", + "signUp": "إنشاء حساب" + }, + "about": { + "title": "عن أكاديميكس", + "description": "يربط أكاديميكس كل أجزاء الإدارة المدرسية في نظام موحد، من التسجيل والحضور إلى التقييمات والدرجات والتواصل.", + "highlights": { + "multilingual": { + "title": "دعم متعدد اللغات", + "description": "محسن للإنجليزية والعربية مع تخطيطات مناسبة لاتجاه RTL ومحتوى مترجم." + }, + "roleAccess": { + "title": "وصول حسب الدور", + "description": "لوحات آمنة وصلاحيات مخصصة لمديري المدارس والمعلمين والطلاب وأولياء الأمور." + }, + "insights": { + "title": "رؤى فورية", + "description": "تابع الأداء والحضور والتفاعل من خلال لوحات مباشرة وتصدير البيانات." + } + } + }, + "features": { + "title": "المزايا الرئيسية", + "description": "كل ما تحتاجه مؤسستك لتعمل بذكاء أكبر كل يوم.", + "items": { + "students": { + "title": "إدارة الطلاب", + "description": "نظم ملفات الطلاب وسجل التسجيل والبيانات الأكاديمية في مكان واحد." + }, + "courses": { + "title": "إدارة المواد", + "description": "خطط المواد، وعيّن المعلمين، ونظم جداول الصفوف لكل مرحلة." + }, + "assessments": { + "title": "التقييمات", + "description": "أنشئ الواجبات والامتحانات، ثم تابع التسليمات والتقييم." + }, + "attendance": { + "title": "تتبع الحضور", + "description": "حوّل الحضور اليومي إلى نظام رقمي مع ملخصات وتقارير عملية." + }, + "reports": { + "title": "الدرجات والتقارير", + "description": "أنشئ كشوف العلامات والتحليلات لدعم قرارات أكاديمية أفضل." + }, + "learning": { + "title": "مساحة تعلم افتراضية", + "description": "شارك الموارد والإعلانات ومحتوى المواد ضمن تجربة موحدة للطلاب." + } + } + }, + "roles": { + "title": "مصمم لكل دور", + "description": "تجارب مخصصة للمديرين والمعلمين والطلاب.", + "items": { + "administrator": { + "title": "المدير", + "description": "أدر المعلمين والصفوف والمواد والعمليات المدرسية من لوحة واحدة.", + "actions": { + "users": "إدارة جميع المستخدمين", + "settings": "ضبط إعدادات المدرسة", + "reports": "إنشاء التقارير", + "operations": "متابعة العمليات" + } + }, + "teacher": { + "title": "المعلم", + "description": "خطط الدروس، وأنشئ التقييمات، وتابع التسليمات وتقدم الصف.", + "actions": { + "assessments": "إنشاء التقييمات", + "attendance": "تسجيل الحضور", + "grading": "تقييم التسليمات", + "resources": "إدارة موارد الصف" + } + }, + "student": { + "title": "الطالب", + "description": "ادخل إلى المواد، وأكمل الواجبات، وتابع الدرجات والإعلانات.", + "actions": { + "courses": "عرض المواد المسجلة", + "assignments": "تسليم الواجبات", + "grades": "متابعة الدرجات", + "attendance": "مراجعة الحضور" + } + } + } + }, + "howItWorks": { + "title": "كيف تعمل", + "description": "ابدأ مساحة مدرستك بخطوات موجهة قليلة.", + "steps": { + "account": { + "title": "إنشاء حساب", + "description": "سجل مدرستك وادع فريقك خلال دقائق." + }, + "setup": { + "title": "إعداد المدرسة", + "description": "اضبط الصفوف والمواد والأدوار والفترات الأكاديمية." + }, + "operations": { + "title": "تشغيل العمليات", + "description": "أدر الحضور والامتحانات والتواصل وسير العمل اليومي." + }, + "outcomes": { + "title": "تحسين النتائج", + "description": "استخدم التقارير والتحليلات لاتخاذ قرارات أذكى." + } + } + }, + "cta": { + "title": "هل أنت جاهز لتطوير مدرستك؟", + "description": "انضم إلى أكاديميكس وبسّط عمليات المدرسة بمنصة واحدة للتعلم والتقييم والإدارة.", + "button": "ابدأ مجانا" + }, + "footer": { + "copyright": "© 2026 أكاديميكس. جميع الحقوق محفوظة." + } + }, + "roles": { + "admin": "مدير", + "teacher": "معلم", + "student": "طالب", + "parent": "ولي أمر", + "superAdmin": "مدير عام" + }, + "actions": { + "add": "إضافة", + "create": "إنشاء", + "update": "تعديل", + "edit": "تعديل", + "delete": "حذف", + "deleting": "جار الحذف...", + "saving": "جار الحفظ...", + "uploading": "جار الرفع...", + "adding": "جار الإضافة...", + "creating": "جار الإنشاء...", + "updating": "جار التحديث...", + "close": "إغلاق", + "yes": "نعم", + "no": "لا", + "somethingWentWrong": "حدث خطأ ما!", + "deleteConfirmation": "سيتم فقدان جميع البيانات. هل أنت متأكد من حذف {entity}؟", + "deleteSuccess": "تم حذف {entity} بنجاح", + "exportCsv": "تصدير CSV", + "filter": "تصفية", + "sort": "ترتيب", + "apply": "تطبيق", + "clear": "مسح", + "markAllRead": "تعليم الكل كمقروء", + "deleteAll": "حذف الكل", + "signOut": "تسجيل الخروج", + "signingOut": "جار تسجيل الخروج...", + "formNotFound": "لم يتم العثور على النموذج!" + }, + "tables": { + "grade": "المرحلة", + "subject": "المادة", + "class": "الصف", + "teacher": "المعلم", + "student": "الطالب", + "parent": "ولي الأمر", + "exam": "الامتحان", + "assignment": "الواجب", + "result": "النتيجة", + "lesson": "الحصة", + "attendance": "الحضور", + "event": "الحدث", + "announcement": "الإعلان", + "message": "الرسالة" + }, + "tableHeaders": { + "info": "المعلومات", + "studentId": "معرف الطالب", + "student": "الطالب", + "teacherId": "معرف المعلم", + "parent": "ولي الأمر", + "studentNames": "أسماء الطلاب", + "title": "العنوان", + "content": "المحتوى", + "subject": "المادة", + "subjects": "المواد", + "class": "الصف", + "classes": "الصفوف", + "className": "اسم الصف", + "grade": "المرحلة", + "gradeLevel": "مستوى المرحلة", + "teacher": "المعلم", + "phone": "الهاتف", + "address": "العنوان", + "actions": "الإجراءات", + "submission": "التسليم", + "score": "الدرجة", + "endDate": "تاريخ الانتهاء", + "startTime": "وقت البدء", + "endTime": "وقت الانتهاء", + "status": "الحالة", + "date": "التاريخ", + "time": "الوقت", + "sentTo": "مرسل إلى", + "assessment": "التقييم", + "capacity": "السعة", + "supervisor": "المشرف", + "school": "المدرسة", + "adminUsername": "اسم مستخدم المدير", + "pauseReason": "سبب الإيقاف", + "file": "الملف", + "submitted": "تم التسليم", + "submittedAt": "وقت التسليم", + "name": "الاسم", + "username": "اسم المستخدم", + "email": "البريد الإلكتروني", + "statusValue": "الحالة / القيمة", + "range": "النطاق", + "current": "الحالي", + "type": "النوع", + "assessmentCount": "التقييمات", + "averageScore": "المعدل %", + "finalGrade": "الدرجة النهائية" + }, + "filters": { + "all": "الكل", + "allClasses": "كل الصفوف", + "allGrades": "كل المراحل", + "allSubjects": "كل المواد", + "gradeLevel": "المرحلة {level}", + "dataType": "نوع البيانات", + "academicYear": "السنة الأكاديمية", + "status": "الحالة", + "repeatedTimes": "مرات الإعادة", + "regular": "منتظم", + "repeated": "معاد", + "graduated": "متخرج", + "left": "منسحب", + "time": "مرة", + "times": "مرات", + "pending": "قيد الانتظار", + "reviewed": "تمت المراجعة", + "resolved": "تم الحل", + "suggestions": "اقتراحات", + "complaints": "شكاوى", + "active": "نشط", + "paused": "موقوف", + "students": "الطلاب", + "teachers": "المعلمون", + "parents": "أولياء الأمور", + "exams": "الامتحانات", + "assignments": "الواجبات", + "results": "النتائج", + "attendance": "الحضور", + "subjects": "المواد", + "classes": "الصفوف", + "grades": "المراحل", + "announcements": "الإعلانات", + "events": "الأحداث", + "messages": "الرسائل", + "feedback": "الملاحظات", + "allTypes": "كل الأنواع", + "assignmentUpdates": "تحديثات الواجبات", + "gradeUpdates": "تحديثات الدرجات", + "schedule": "الجدول", + "supervisor": "المشرف", + "newest": "الأحدث", + "oldest": "الأقدم", + "searchStudent": "بحث عن طالب..." + }, + "attendance": { + "filter": "تصفية", + "present": "حاضر", + "absent": "غائب", + "save": "حفظ الحضور", + "saved": "تم حفظ الحضور", + "noRecordsPage": "لا توجد سجلات في هذه الصفحة", + "noScopeFound": "لم يتم العثور على {scope}", + "noRecordsDate": "لا توجد سجلات حضور للتاريخ المحدد.", + "tryAnotherPage": "جرّب الانتقال إلى صفحة أخرى.", + "noClassesAssigned": "لا توجد صفوف معينة", + "noClassesAssignedDescription": "لم يتم تعيينك للإشراف على أي صف بعد.", + "dateOutsideAcademicYear": "هذا التاريخ خارج نطاق السنة الأكاديمية الحالية ({start} إلى {end})." + }, + "archivePage": { + "previewData": "معاينة البيانات", + "unknownStudent": "طالب غير معروف", + "present": "حاضر", + "absent": "غائب", + "noData": "لا توجد بيانات", + "showMore": "عرض المزيد" + }, + "feedbackPage": { + "pendingCount": "{count} قيد الانتظار", + "shownCount": "{count} معروض", + "noFeedbacks": "لم يتم العثور على ملاحظات.", + "resolvedLocked": "تم حل هذه الملاحظة وهي مقفلة.", + "markReviewed": "تعليم كمراجع", + "markResolved": "تعليم كمحلول", + "markPending": "تعليم كقيد الانتظار", + "historyTitle": "سجل ملاحظاتي", + "historyDescription": "تابع اقتراحاتك وشكاويك السابقة.", + "submittedCount": "{count} مرسلة", + "emptyHistory": "لم ترسل أي ملاحظات بعد.", + "submit": "إرسال ملاحظة", + "description": "أرسل اقتراحا أو شكوى إلى إدارة المدرسة.", + "type": "النوع", + "message": "الرسالة", + "messagePlaceholder": "اكتب رسالتك...", + "cancel": "إلغاء", + "send": "إرسال الملاحظة", + "sending": "جار الإرسال...", + "close": "إغلاق", + "unauthorized": "غير مصرح", + "unexpectedError": "حدث خطأ ما!", + "types": { + "suggestion": "اقتراح", + "complaint": "شكوى" + }, + "statuses": { + "pending": "قيد الانتظار", + "reviewed": "تمت المراجعة", + "resolved": "تم الحل" + } + }, + "examList": { + "addExam": "إضافة امتحان", + "submissions": "التسليمات", + "takeExam": "بدء الامتحان", + "continue": "متابعة", + "editWorkflow": "تعديل مسار الامتحان", + "noExams": "لم يتم العثور على امتحانات", + "emptyDescription": "جرّب تغيير عوامل التصفية أو كلمات البحث.", + "readyToStart": "هل أنت جاهز للبدء؟", + "instructions": "التعليمات", + "noInstructions": "لم تتم إضافة تعليمات خاصة لهذا الامتحان.", + "continueNotice": "عند المتابعة سيتم فتح جلسة الامتحان.", + "cancel": "إلغاء", + "startExam": "ابدأ الامتحان", + "starting": "جار البدء...", + "close": "إغلاق", + "statuses": { + "notStarted": "لم يبدأ", + "inProgress": "قيد التنفيذ", + "submitted": "تم التسليم", + "graded": "تم التقييم", + "published": "منشور" + } + }, + "examTaking": { + "pageLabel": "صفحة {current} من {total}", + "submitExam": "تسليم الامتحان", + "submitting": "جار التسليم...", + "saving": "جار الحفظ...", + "saved": "تم الحفظ", + "error": "خطأ", + "point": "{count} نقطة", + "fileUpload": "رفع ملف", + "previous": "السابق", + "next": "التالي", + "confirmEyebrow": "تسليم الامتحان", + "confirmTitle": "هل أنت متأكد؟", + "confirmBody": "بعد التسليم لن تتمكن من تعديل إجاباتك لهذا الامتحان.", + "confirmNotice": "سيتم تسليم إجاباتك المحفوظة للتقييم.", + "cancel": "إلغاء", + "close": "إغلاق", + "true": "صحيح", + "false": "خطأ", + "multiAnswerHint": "يمكنك اختيار أكثر من إجابة.", + "textPlaceholder": "اكتب إجابتك هنا...", + "toast": { + "saveFailed": "فشل حفظ الإجابة.", + "previousDisabled": "التنقل إلى الصفحات السابقة معطل.", + "answerBeforeProceeding": "يرجى الإجابة عن جميع الأسئلة في هذه الصفحة قبل المتابعة.", + "answerBeforeSubmitting": "يرجى الإجابة عن جميع الأسئلة في هذه الصفحة قبل التسليم.", + "submitted": "تم تسليم الامتحان بنجاح!", + "timeUp": "انتهى وقت الامتحان! جار التسليم التلقائي..." + }, + "uploading": "جار الرفع... {progress}%", + "uploadFailed": "فشل الرفع", + "failedToUpload": "فشل رفع \"{fileName}\"", + "tryAgain": "حاول مرة أخرى", + "metadataSaveFailed": "فشل حفظ بيانات الملف. تم حفظ ملفك محليا وستتم إعادة المحاولة.", + "removeFileFailed": "فشل إزالة الملف.", + "accepted": "المقبول:", + "maxSize": "الحجم الأقصى:", + "chooseFile": "اختر ملفا", + "replace": "استبدال", + "remove": "إزالة", + "removingFile": "جار إزالة الملف...", + "identicalFileConfirm": "يبدو أن هذا الملف مطابق للملف الحالي. هل تريد استبداله على أي حال؟", + "fileTypeBlocked": "نوع الملف \".{extension}\" غير مسموح.", + "allowedFilesOnly": "يسمح فقط بملفات {extensions}.", + "minFileSize": "يجب ألا يقل حجم الملف عن {size} MB.", + "maxFileSize": "يتجاوز حجم الملف الحد الأقصى {size} MB." + }, + "directMessage": { + "titleMessageRequired": "العنوان والرسالة مطلوبان", + "sendFailed": "فشل إرسال الرسالة", + "errorOccurred": "حدث خطأ", + "sentTo": "تم إرسال الرسالة إلى {name}", + "heading": "إرسال رسالة إلى {name}", + "title": "العنوان", + "titlePlaceholder": "أدخل عنوان الرسالة", + "message": "الرسالة", + "messagePlaceholder": "أدخل محتوى الرسالة", + "cancel": "إلغاء", + "send": "إرسال", + "sending": "جار الإرسال..." + }, + "submissionsModal": { + "open": "التسليمات", + "title": "تسليمات الطلاب", + "deadlinePassed": "انتهى الموعد النهائي - جاهز للتقييم", + "submitted": "{count} تم التسليم", + "pending": "{count} قيد الانتظار", + "graded": "{count} مقيمة", + "emptyTitle": "لا توجد تسليمات بعد", + "emptyDescription": "لم يسلم الطلاب أي شيء لهذا الواجب بعد.", + "scoreRequired": "الدرجة مطلوبة", + "gradesPublished": "تم نشر الدرجات بنجاح!", + "published": "منشور", + "draft": "مسودة", + "ungraded": "غير مقيم", + "late": "متأخر (+{time})", + "early": "{time} مبكر", + "downloadSubmission": "تنزيل التسليم", + "editGrade": "تعديل الدرجة", + "addGrade": "إضافة درجة", + "editFeedback": "تعديل الملاحظات", + "addFeedback": "إضافة ملاحظات", + "publishing": "جار النشر...", + "publishGrades": "نشر الدرجات ({published}/{graded})", + "close": "إغلاق", + "gradeTitle": "تقييم التسليم", + "student": "الطالب:", + "score": "الدرجة", + "gradeHelp": "حفظ الدرجة يبقيها مخفية حتى تنشر الدرجات.", + "cancel": "إلغاء", + "saving": "جار الحفظ...", + "saveGrade": "حفظ الدرجة", + "feedbackTitle": "ملاحظات المعلم", + "feedbackPlaceholder": "اكتب ملاحظاتك لهذا الطالب...", + "saveFeedback": "حفظ الملاحظات", + "day": "{count} يوم", + "hour": "{count} ساعة", + "minute": "{count} دقيقة" + }, + "schoolSignup": { + "statusTitle": "حالة الوصول للمدرسة", + "statusDescription": "طلب مدرستك بانتظار الموافقة. سيراجع فريق Academix الطلب ويفعل حسابك بعد اكتمال التحقق.", + "goToSignIn": "الانتقال إلى تسجيل الدخول", + "title": "إنشاء حساب مدرسة", + "description": "املأ بيانات المدرسة والمدير الأساسي كاملة.", + "step": "الخطوة {number}", + "schoolInfo": "معلومات المدرسة", + "adminInfo": "حساب المدير الأساسي", + "fields": { + "schoolName": "اسم المدرسة", + "registrationNumber": "رقم التسجيل", + "schoolEmail": "بريد المدرسة الإلكتروني", + "schoolPhone": "هاتف المدرسة", + "country": "الدولة", + "city": "المدينة", + "website": "الموقع الإلكتروني", + "schoolAddress": "عنوان المدرسة", + "adminName": "اسم المدير الكامل", + "adminEmailOptional": "بريد المدير الإلكتروني (اختياري)", + "adminUsername": "اسم مستخدم المدير", + "adminPassword": "كلمة مرور المدير" + }, + "validation": { + "schoolNameRequired": "اسم المدرسة مطلوب.", + "schoolEmailRequired": "بريد المدرسة الإلكتروني مطلوب.", + "schoolPhoneRequired": "هاتف المدرسة مطلوب.", + "countryRequired": "الدولة مطلوبة.", + "cityRequired": "المدينة مطلوبة.", + "addressRequired": "عنوان المدرسة مطلوب.", + "adminNameRequired": "اسم المدير الكامل مطلوب.", + "adminUsernameRequired": "اسم مستخدم المدير مطلوب.", + "adminPasswordRequired": "كلمة مرور المدير مطلوبة." + }, + "submitting": "جار الإرسال...", + "submit": "إرسال", + "page": { + "goBack": "رجوع", + "titleStart": "تسجيل", + "titleHighlight": "المدرسة", + "description": "سجل بيانات مدرستك وأنشئ حساب المدير الأساسي. بعد المراجعة، سيتم تفعيل المدرسة بواسطة أكاديميكس.", + "steps": { + "profile": "إرسال ملف المدرسة الكامل", + "verification": "التحقق بواسطة أكاديميكس", + "access": "منح الوصول إلى لوحة التحكم" + } + } + }, + "auth": { + "login": { + "welcomeBack": "مرحبا بعودتك", + "redirecting": "جار تحويلك إلى لوحة التحكم...", + "goHome": "العودة للرئيسية", + "title": "تسجيل الدخول إلى حسابك", + "identifier": "اسم المستخدم أو البريد الإلكتروني", + "password": "كلمة المرور", + "showPassword": "إظهار كلمة المرور", + "hidePassword": "إخفاء كلمة المرور", + "signingIn": "جار تسجيل الدخول...", + "signIn": "تسجيل الدخول", + "newSchool": "مدرسة جديدة؟", + "signupRequest": "إرسال طلب تسجيل" + }, + "signOut": { + "signingOut": "جار تسجيل الخروج...", + "backToSignIn": "العودة لتسجيل الدخول" + }, + "schoolAccess": { + "title": "حالة الوصول للمدرسة", + "pending": "طلب مدرستك بانتظار الموافقة. سيراجع فريق أكاديميكس الطلب ويفعل حسابك بعد اكتمال التحقق.", + "paused": "مدرستك موقوفة حاليا.", + "reason": "السبب:", + "noReason": "لم يتم تقديم سبب.", + "active": "حساب مدرستك نشط." + } + }, + "schoolStatus": { + "alreadyActive": "هذه المدرسة نشطة بالفعل.", + "alreadyPaused": "هذه المدرسة موقوفة بالفعل.", + "activated": "تم تفعيل المدرسة بنجاح.", + "activateFailed": "فشل تفعيل المدرسة.", + "pauseReasonRequired": "سبب الإيقاف مطلوب.", + "paused": "تم إيقاف المدرسة بنجاح.", + "pauseFailed": "فشل إيقاف المدرسة.", + "activate": "تفعيل", + "pause": "إيقاف", + "confirmActivation": "تأكيد التفعيل", + "confirmPause": "تأكيد الإيقاف", + "activateConfirm": "هل أنت متأكد من تفعيل {name}؟", + "pauseConfirm": "أدخل سببا قبل إيقاف {name}.", + "pauseReason": "سبب الإيقاف", + "cancel": "إلغاء", + "confirm": "تأكيد", + "activating": "جار التفعيل...", + "pausing": "جار الإيقاف...", + "closeModal": "إغلاق النافذة" + }, + "promotion": { + "button": "تحديث مراحل الطلاب", + "academicYear": "السنة الأكاديمية", + "status": "الحالة", + "stillInProgress": "السنة الأكاديمية لا تزال قيد التنفيذ", + "confirmTitle": "تأكيد ترقية الطلاب", + "completed": "اكتملت الترقية", + "failed": "تعذّر إكمال الترقية", + "notFinishedBody": "تنتهي السنة الأكاديمية {year} في {date}. يمكنك تحديث الطلاب بعد مرور آخر يوم.", + "confirmBody": "The academic year {year} has finished. Confirm this action to update students based on their final average. Students with 60% or higher pass.", + "promoted": "تمت الترقية", + "graduated": "متخرجون", + "repeated": "معادون", + "successFallback": "تمت ترقية الطلاب بنجاح.", + "confirm": "تأكيد", + "updating": "جار التحديث...", + "ok": "حسنا", + "close": "إغلاق نافذة ترقية الطلاب" + }, + "messageView": { + "view": "عرض الرسالة", + "close": "إغلاق عرض الرسالة", + "title": "العنوان", + "body": "المحتوى", + "date": "التاريخ", + "time": "الوقت" + }, + "pages": { + "archive": "الأرشيف", + "settings": "إعدادات المدرسة", + "announcements": "الإعلانات", + "events": "الأحداث", + "feedbacks": "الملاحظات", + "feedbackHistory": "سجل ملاحظاتي", + "allStudents": "كل الطلاب", + "allTeachers": "كل المعلمين", + "allParents": "كل أولياء الأمور", + "allSubjects": "كل المواد", + "allClasses": "كل الصفوف", + "allGrades": "كل المراحل", + "allExams": "كل الامتحانات", + "allAssignments": "كل الواجبات", + "allResults": "كل النتائج", + "allEvents": "كل الأحداث", + "allAnnouncements": "كل الإعلانات", + "allMessages": "كل الرسائل", + "allNotifications": "كل الإشعارات", + "attendance": "الحضور", + "messages": "الرسائل", + "notifications": "الإشعارات", + "finalResults": "المعدلات النهائية", + "finalAverages": "المعدلات النهائية" + }, + "dashboardCards": { + "admin": "المديرون", + "teacher": "المعلمون", + "student": "الطلاب", + "parent": "أولياء الأمور" + }, + "profile": { + "avatarAlt": "الملف الشخصي", + "notFound": "لم يتم العثور على الملف الشخصي", + "notProvided": "غير متوفر", + "fields": { + "adminUsername": "اسم مستخدم المدير", + "schoolName": "اسم المدرسة", + "schoolId": "رقم المدرسة", + "createdAt": "تاريخ الإنشاء", + "updatedAt": "تاريخ التحديث", + "name": "الاسم", + "username": "اسم المستخدم", + "email": "البريد الإلكتروني", + "phone": "الهاتف", + "address": "العنوان", + "bloodType": "فصيلة الدم", + "gender": "الجنس", + "birthday": "تاريخ الميلاد", + "subjects": "المواد", + "classes": "الصفوف", + "school": "المدرسة", + "grade": "المرحلة", + "class": "الصف", + "status": "الحالة", + "parent": "ولي الأمر", + "children": "الأبناء" + } + }, + "settings": { + "description": "اضبط إعدادات الجدول الأسبوعي المستخدمة في الحصص والتقويم.", + "workingDaysDescription": "حدد أيام العمل المدرسية وأضف عطلات أو أيام توقف حسب التاريخ.", + "sections": { + "scheduleDefaults": "إعدادات الجدول", + "workingDaysHolidays": "أيام العمل والعطلات", + "academicYears": "السنوات الأكاديمية" + }, + "scheduleForm": { + "title": "إعدادات الجدول", + "workDayStart": "بداية يوم العمل", + "workDayEnd": "نهاية يوم العمل", + "lessonDuration": "مدة الحصة (بالدقائق)", + "lessonsPerDay": "عدد الحصص في اليوم", + "save": "حفظ الإعدادات", + "updated": "تم تحديث إعدادات المدرسة." + }, + "academicYears": { + "title": "السنوات الأكاديمية", + "description": "أنشئ وحافظ على نطاقات السنوات الأكاديمية.", + "add": "إضافة سنة أكاديمية", + "editTitle": "تعديل السنة الأكاديمية", + "create": "إنشاء سنة أكاديمية", + "update": "تحديث السنة الأكاديمية", + "startDate": "تاريخ البداية", + "endDate": "تاريخ النهاية", + "markCurrent": "تعيين كسنة أكاديمية حالية", + "empty": "لا توجد سنوات أكاديمية بعد.", + "created": "تم إنشاء السنة الأكاديمية.", + "updated": "تم تحديث السنة الأكاديمية." + }, + "workingDays": { + "weeklyWorkingDays": "أيام العمل الأسبوعية", + "saveWorkingDays": "حفظ أيام العمل", + "holidaysOffDays": "العطلات وأيام التوقف", + "addException": "إضافة استثناء", + "addDayException": "إضافة استثناء يوم", + "nameOptional": "الاسم (اختياري)", + "namePlaceholder": "مثال: عطلة العيد", + "notesOptional": "ملاحظات (اختياري)", + "empty": "لا توجد استثناءات أيام بعد.", + "workingDaysUpdated": "تم تحديث أيام العمل.", + "exceptionSaved": "تم حفظ استثناء اليوم.", + "exceptionRemoved": "تم حذف استثناء اليوم.", + "days": { + "SATURDAY": "السبت", + "SUNDAY": "الأحد", + "MONDAY": "الاثنين", + "TUESDAY": "الثلاثاء", + "WEDNESDAY": "الأربعاء", + "THURSDAY": "الخميس", + "FRIDAY": "الجمعة" + }, + "types": { + "HOLIDAY": "عطلة", + "OFF_DAY": "يوم توقف", + "WORKING_OVERRIDE": "يوم عمل استثنائي" + } + } + }, + "profilePages": { + "cards": { + "attendance": "الحضور", + "grade": "المرحلة", + "lessons": "الحصص", + "class": "الصف", + "subjects": "المواد", + "classes": "الصفوف" + }, + "shortcuts": { + "title": "الاختصارات" + }, + "student": { + "schedule": "جدول الطالب", + "lessons": "حصص الطالب", + "teachers": "معلمو الطالب", + "exams": "امتحانات الطالب", + "assignments": "واجبات الطالب", + "results": "نتائج الطالب" + }, + "teacher": { + "schedule": "جدول المعلم", + "classes": "صفوف المعلم", + "students": "طلاب المعلم", + "exams": "امتحانات المعلم", + "assignments": "واجبات المعلم" + } + }, + "lessonsPage": { + "title": "جدول الحصص", + "grade": "المرحلة", + "classes": "الصفوف", + "scheduleFor": "جدول المرحلة {grade} - {className}", + "empty": { + "noClassesTitle": "لم يتم العثور على صفوف", + "noClassesDescription": "لا توجد صفوف لديها حصص متاحة لهذا العرض.", + "noClassesForGrade": "لم يتم العثور على صفوف للمرحلة المحددة." + } + }, + "forms": { + "common": { + "submitting": "جار الإرسال...", + "selected": "{count} محدد", + "optional": "اختياري", + "searchTeachers": "بحث عن معلمين...", + "searchParents": "بحث عن أولياء أمور...", + "searchStudents": "بحث عن طلاب...", + "searchSubjects": "بحث عن مواد...", + "noTeachersFound": "لم يتم العثور على معلمين.", + "noParentsFound": "لم يتم العثور على أولياء أمور.", + "noStudentsFound": "لم يتم العثور على طلاب.", + "noSubjectsFound": "لم يتم العثور على مواد.", + "noResultsFound": "لم يتم العثور على نتائج", + "gradeLevel": "المرحلة {level}", + "selectGrade": "اختر المرحلة", + "selectSex": "اختر الجنس", + "somethingWentWrong": "حدث خطأ ما!", + "authenticationInfo": "معلومات تسجيل الدخول", + "personalInfo": "المعلومات الشخصية", + "username": "اسم المستخدم", + "email": "البريد الإلكتروني", + "password": "كلمة المرور", + "showPassword": "إظهار كلمة المرور", + "hidePassword": "إخفاء كلمة المرور", + "fullName": "الاسم الكامل", + "name": "الاسم", + "phone": "الهاتف", + "address": "العنوان", + "bloodType": "فصيلة الدم", + "birthday": "تاريخ الميلاد", + "sex": "الجنس", + "male": "ذكر", + "female": "أنثى", + "status": "الحالة", + "regular": "منتظم", + "repeated": "معاد", + "graduated": "متخرج", + "left": "منسحب", + "photoUploaded": "تم رفع الصورة بنجاح", + "removePhoto": "إزالة الصورة", + "selectAll": "تحديد الكل", + "selectedClasses": "تم تحديد {count} صف", + "description": "الوصف", + "date": "التاريخ", + "startDate": "تاريخ البداية", + "endDate": "تاريخ النهاية", + "title": "العنوان", + "classes": "الصفوف" + }, + "grade": { + "createTitle": "إنشاء مرحلة جديدة", + "level": "مستوى المرحلة", + "created": "تم إنشاء المرحلة بنجاح!" + }, + "class": { + "createTitle": "إنشاء صف جديد", + "updateTitle": "تحديث الصف", + "name": "اسم الصف", + "capacity": "السعة", + "grade": "المرحلة", + "supervisor": "المشرف", + "created": "تم إنشاء الصف بنجاح!", + "updated": "تم تحديث الصف بنجاح!" + }, + "subject": { + "createTitle": "إنشاء مادة جديدة", + "updateTitle": "تحديث المادة", + "name": "اسم المادة", + "grade": "المرحلة", + "teachers": "المعلمون", + "teachersHelp": "اختر معلما واحدا أو أكثر لتعيينهم لهذه المادة.", + "noTeachersSelected": "لم يتم اختيار أي معلم بعد.", + "created": "تم إنشاء المادة بنجاح!", + "updated": "تم تحديث المادة بنجاح!" + }, + "student": { + "createTitle": "إنشاء طالب جديد", + "updateTitle": "تحديث الطالب", + "parent": "ولي الأمر", + "grade": "المرحلة", + "class": "الصف", + "classCapacity": "{className} - {students}/{capacity} السعة", + "uploadPhoto": "رفع صورة الطالب", + "previewAlt": "معاينة الطالب", + "create": "إنشاء طالب", + "update": "تحديث الطالب", + "created": "تم إنشاء الطالب!", + "updated": "تم تحديث الطالب!" + }, + "teacher": { + "createTitle": "إنشاء معلم", + "updateTitle": "تحديث المعلم", + "subjects": "المواد", + "subjectsHelp": "اختر مادة واحدة أو أكثر سيقوم هذا المعلم بتدريسها.", + "noSubjectsSelected": "لم يتم اختيار أي مادة بعد.", + "removeSubject": "إزالة {subject}", + "uploadPhoto": "رفع صورة المعلم", + "previewAlt": "معاينة المعلم", + "create": "إنشاء معلم", + "update": "تحديث المعلم", + "created": "تم إنشاء المعلم بنجاح!", + "updated": "تم تحديث المعلم بنجاح!" + }, + "parent": { + "createTitle": "إنشاء ولي أمر جديد", + "updateTitle": "تحديث ولي الأمر", + "students": "الطلاب", + "selectStudentRequired": "اختر طالبا واحدا على الأقل.", + "selectStudentBeforeCreate": "اختر طالبا واحدا على الأقل قبل إنشاء ولي الأمر.", + "create": "إنشاء ولي أمر", + "update": "تحديث ولي الأمر", + "created": "تم إنشاء ولي الأمر!", + "updated": "تم تحديث ولي الأمر!" + }, + "event": { + "createTitle": "إنشاء حدث جديد", + "updateTitle": "تحديث الحدث", + "title": "عنوان الحدث", + "titlePlaceholder": "مثال: اليوم الرياضي", + "descriptionPlaceholder": "وصف الحدث", + "created": "تم إنشاء الحدث!", + "updated": "تم تحديث الحدث!" + }, + "announcement": { + "createTitle": "إنشاء إعلان جديد", + "updateTitle": "تحديث الإعلان", + "title": "عنوان الإعلان", + "titlePlaceholder": "مثال: اجتماع أولياء الأمور", + "descriptionPlaceholder": "تفاصيل الإعلان", + "created": "تم إنشاء الإعلان!", + "updated": "تم تحديث الإعلان!" + }, + "message": { + "createTitle": "إنشاء رسالة جديدة", + "updateTitle": "تحديث الرسالة", + "recipients": "المستلمون", + "content": "محتوى الرسالة", + "title": "عنوان الرسالة", + "titlePlaceholder": "مثال: تذكير", + "descriptionPlaceholder": "تفاصيل الرسالة", + "studentRecipients": "مستلمو الطلاب", + "parentRecipients": "مستلمو أولياء الأمور", + "teacherRecipients": "مستلمو المعلمين", + "classesOptional": "الصفوف (اختياري)", + "searchRecipients": "بحث في {label}...", + "openRecipientSearch": "فتح نتائج بحث {label}", + "created": "تم إنشاء الرسالة!", + "updated": "تم تحديث الرسالة!" + }, + "result": { + "createTitle": "إنشاء نتيجة جديدة", + "updateTitle": "تحديث النتيجة", + "student": "الطالب", + "assessmentType": "نوع التقييم", + "assessment": "التقييم", + "selectAssessment": "اختر التقييم", + "exam": "امتحان", + "assignment": "واجب", + "score": "الدرجة", + "created": "تم إنشاء النتيجة!", + "updated": "تم تحديث النتيجة!" + }, + "lesson": { + "createTitle": "إنشاء جدول حصص أسبوعي", + "updateTitle": "تحديث جدول الحصص الأسبوعي", + "class": "الصف", + "selectClass": "اختر الصف", + "lesson": "الحصة {slot}", + "noSubject": "لا توجد مادة", + "selectTeacher": "اختر المعلم", + "teacherRequired": "اختر معلما", + "saved": "تم حفظ جدول الحصص الأسبوعي.", + "save": "حفظ الجدول الأسبوعي", + "days": { + "SATURDAY": "السبت", + "SUNDAY": "الأحد", + "MONDAY": "الاثنين", + "TUESDAY": "الثلاثاء", + "WEDNESDAY": "الأربعاء", + "THURSDAY": "الخميس", + "FRIDAY": "الجمعة" + } + }, + "exam": { + "createTitle": "إنشاء امتحان جديد", + "updateTitle": "تحديث الامتحان", + "title": "عنوان الامتحان", + "titlePlaceholder": "مثال: اختبار منتصف الفصل", + "subject": "المادة", + "selectSubject": "اختر المادة", + "noClassesAvailable": "لا توجد صفوف متاحة.", + "created": "تم إنشاء الامتحان!", + "updated": "تم تحديث الامتحان!" + }, + "assignment": { + "createTitle": "إنشاء واجب جديد", + "updateTitle": "تحديث الواجب", + "title": "عنوان الواجب", + "titlePlaceholder": "مثال: واجب الفصل الرابع", + "marks": "العلامات", + "assignedTeacher": "المعلم المسؤول", + "assignedTeacherHelp": "سيتمكن هذا المعلم من الوصول إلى التسليمات وتقييمها.", + "subject": "المادة", + "subjectFallback": "مادة رقم {id}", + "searchSubject": "بحث عن مادة...", + "clearSubject": "مسح المادة", + "noClassesForSubject": "لم يتم العثور على صفوف لهذه المادة.", + "selectSubjectFirst": "اختر مادة أولا لعرض الصفوف المتاحة.", + "rubric": "الإجابة النموذجية / سلم التقييم", + "rubricPlaceholder": "اكتب الإجابة المتوقعة أو وضح كيفية توزيع العلامات...", + "rubricHelp": "يستخدم لتوجيه تقييم الواجب والتقييم الذكي.", + "fileOptional": "ملف الواجب (اختياري)", + "currentFile": "الملف الحالي", + "downloadCurrentFile": "تحميل الملف الحالي", + "removeCurrentFile": "إزالة الملف الحالي", + "changeFile": "تغيير الملف", + "replaceFile": "استبدال الملف", + "uploadFile": "رفع ملف", + "removeSelected": "إزالة المحدد", + "noFileAttached": "لا يوجد ملف مرفق بعد. ارفع ملفا عند الحاجة.", + "currentFileWillBeReplaced": "سيتم استبدال الملف الحالي.", + "allowLateSubmission": "السماح بالتسليم المتأخر", + "lateAllowed": "يمكن للطلاب التسليم بعد الموعد النهائي", + "lateNotAllowed": "لا يمكن للطلاب التسليم بعد الموعد النهائي", + "created": "تم إنشاء الواجب!", + "updated": "تم تحديث الواجب!" + }, + "studentDelete": { + "title": "حذف الطالب", + "confirm": "هل أنت متأكد من حذف {name}؟", + "fallbackName": "هذا الطالب", + "linkedParent": "هذا الطالب مرتبط بولي الأمر {parent}.", + "canDeleteBoth": "يمكنك حذف الطالب فقط، أو حذف الطالب وولي الأمر معا.", + "parentHasMoreStudents": "لدى ولي الأمر أكثر من طالب، لذلك يمكن حذف الطالب فقط.", + "deleteStudent": "حذف الطالب", + "deleteBoth": "حذف الاثنين", + "studentDeleted": "تم حذف الطالب بنجاح", + "studentAndParentDeleted": "تم حذف الطالب وولي الأمر بنجاح" + }, + "gradeDelete": { + "title": "حذف المرحلة", + "deleted": "تم حذف المرحلة بنجاح!", + "warning": "تحذير: هذه المرحلة مرتبطة بـ {count} صف.", + "cascadeWarning": "حذف هذه المرحلة سيؤدي أيضا إلى حذف تلك الصفوف والسجلات المرتبطة بجدولها.", + "classes": "الصفوف: {classes}", + "noClasses": "المرحلة {level} لا تحتوي على صفوف ويمكن حذفها.", + "confirm": "هل أنت متأكد من حذف المرحلة {level}؟", + "delete": "حذف المرحلة" + }, + "classDelete": { + "title": "حذف الصف", + "deleted": "تم حذف الصف بنجاح!", + "deletedAndReassigned": "تم حذف الصف وإعادة تعيين الطلاب بنجاح!", + "hasStudents": "هذا الصف يحتوي على {count} طالب. انقلهم إلى صف آخر أو أنشئ صفا جديدا قبل حذف هذا الصف.", + "noStudents": "لا يوجد طلاب في هذا الصف، لذلك يمكن حذفه الآن.", + "moveExisting": "نقل إلى صف موجود", + "createNew": "إنشاء صف جديد", + "targetClass": "الصف الهدف", + "selectExisting": "اختر صفا موجودا", + "newClassName": "اسم الصف الجديد", + "newClassPlaceholder": "مثال: 3B", + "capacity": "السعة", + "grade": "المرحلة", + "supervisor": "المشرف", + "noSupervisor": "لا يوجد مشرف", + "selectTargetError": "اختر صفا لنقل الطلاب إليه.", + "nameRequired": "اسم الصف الجديد مطلوب.", + "capacityRequired": "يجب أن تكون سعة الصف الجديد 1 على الأقل.", + "gradeRequired": "اختر مرحلة للصف الجديد.", + "delete": "حذف الصف" + }, + "examWorkflow": { + "basicInfo": "1. المعلومات الأساسية", + "settings": "2. الإعدادات", + "instructions": "3. التعليمات", + "questions": "4. الأسئلة", + "examTitle": "عنوان الامتحان", + "startTime": "وقت البداية", + "endTime": "وقت النهاية", + "assignedTeacher": "المعلم المسؤول", + "assignedTeacherHelp": "سيتمكن هذا المعلم من الوصول إلى التسليمات وتقييمها.", + "subject": "المادة", + "selectSubject": "اختر مادة...", + "classes": "الصفوف", + "noClassesForSubject": "لم يتم العثور على صفوف لهذه المادة.", + "selectSubjectFirst": "اختر مادة أولا لعرض الصفوف المتاحة.", + "showTimer": "إظهار المؤقت", + "autoSave": "الحفظ التلقائي", + "navigation": "التنقل إلى الصفحة السابقة", + "autoSubmit": "التسليم التلقائي عند انتهاء الوقت", + "duration": "المدة (بالدقائق)", + "questionsPerPage": "عدد الأسئلة في الصفحة", + "studentInstructions": "تعليمات الطالب", + "instructionsPlaceholder": "أضف أي تعليمات يجب أن يقرأها الطلاب قبل البدء...", + "addQuestion": "+ إضافة سؤال", + "createExam": "إنشاء امتحان", + "updateExam": "تحديث الامتحان", + "created": "تم إنشاء الامتحان بنجاح!", + "updated": "تم تحديث الامتحان بنجاح!", + "unexpectedError": "حدث خطأ غير متوقع.", + "emptyOptionsForQuestion": "السؤال رقم {number} يحتوي على خيارات فارغة. يرجى تعبئة كل الخيارات.", + "selectCorrectForQuestion": "يرجى اختيار إجابة صحيحة واحدة على الأقل للسؤال رقم {number}.", + "question": { + "text": "نص السؤال", + "type": "النوع", + "points": "النقاط", + "textAnswer": "إجابة نصية", + "mcq": "اختيار من متعدد", + "trueFalse": "صح / خطأ", + "file": "رفع ملف", + "allowMultiple": "السماح بأكثر من إجابة صحيحة", + "options": "الخيارات", + "addOption": "+ إضافة خيار", + "optionPlaceholder": "الخيار {number}", + "correct": "صحيح", + "remove": "إزالة", + "emptyOptions": "يجب تعبئة كل حقول الخيارات. لا تترك خيارات فارغة.", + "optionTextRequired": "اكتب نص الخيار قبل تحديده كإجابة صحيحة!", + "answerHelp": "اختر إجابة صحيحة واحدة، أو أكثر إذا كان مسموحا بتعدد الإجابات.", + "correctAnswer": "الإجابة الصحيحة", + "true": "صح", + "false": "خطأ", + "rubric": "الإجابة النموذجية / سلم التقييم", + "rubricPlaceholder": "اكتب الإجابة المتوقعة أو وضح كيفية توزيع العلامات...", + "rubricHelp": "يستخدم لتوجيه التقييم اليدوي والتقييم الذكي.", + "fileSettings": "إعدادات رفع الملف", + "allowedExtensions": "امتدادات الملفات المسموحة", + "allowedExtensionsPlaceholder": "مثال: pdf, docx, jpg", + "allowedExtensionsHelp": "اتركه فارغا لقبول كل أنواع الملفات (اختياري)", + "minFileSize": "أقل حجم للملف (MB)", + "maxFileSize": "أكبر حجم للملف (MB)", + "systemMax": "الحد الأقصى للنظام: 10 MB", + "fileInstructions": "تعليمات للطالب (اختياري)", + "fileInstructionsPlaceholder": "مثال: ارفع مقالك بصيغة PDF...", + "pdfAiHelp": "يمكن تقييم ملفات PDF بالذكاء الاصطناعي باستخدام الإجابة النموذجية / سلم التقييم أعلاه." + } + } + }, + "assignmentSubmissions": { + "breadcrumb": "الواجبات", + "title": "تسليمات الواجب", + "subjectFallback": "لا توجد مادة", + "marks": "{count} علامة", + "stats": { + "visibleSubmissions": "التسليمات المعروضة", + "graded": "مقيّمة", + "draftGrades": "درجات مسودة", + "published": "منشورة" + }, + "status": { + "published": "منشور", + "draftGrade": "درجة مسودة", + "submitted": "تم التسليم" + }, + "actions": { + "publishGrades": "نشر الدرجات", + "gradesPublished": "تم نشر الدرجات", + "increaseAll": "زيادة الكل", + "decreaseAll": "إنقاص الكل", + "view": "عرض", + "download": "تنزيل" + }, + "fields": { + "marks": "علامات", + "scoreAdjustmentAmount": "قيمة تعديل الدرجة" + }, + "empty": { + "title": "لم يتم العثور على تسليمات", + "description": "لا توجد تسليمات طلاب تطابق عوامل التصفية الحالية.", + "previewUnavailable": "المعاينة غير متاحة" + }, + "validation": { + "positiveAmount": "أدخل قيمة تعديل موجبة.", + "maxAmount": "لا يمكن أن تكون القيمة أكبر من {maxScore}." + } + }, + "examSubmissions": { + "breadcrumb": "الامتحانات", + "title": "تسليمات الامتحان", + "gradeTitle": "تقييم التسليم", + "subjectFallback": "لا توجد مادة", + "marks": "{count} علامة", + "gradedCount": "{graded} من {total} مقيّمة", + "stats": { + "visible": "المعروضة", + "submitted": "تم التسليم", + "graded": "مقيّمة", + "inProgress": "قيد الحل", + "published": "منشورة" + }, + "status": { + "inProgress": "قيد الحل", + "submitted": "تم التسليم", + "graded": "مقيّم", + "saving": "جار الحفظ...", + "saved": "تم الحفظ", + "error": "خطأ", + "allGraded": "تم تقييم الكل", + "autoGraded": "مقيّم تلقائيا" + }, + "actions": { + "publishGrades": "نشر الدرجات", + "gradesPublished": "تم نشر الدرجات", + "view": "عرض", + "grade": "تقييم", + "downloadFile": "تنزيل الملف", + "backToSubmissions": "العودة إلى التسليمات", + "finalizing": "جار الإنهاء...", + "finalizedReapprove": "تم إنهاء التقييم (إعادة اعتماد)", + "approveFinalize": "اعتماد وإنهاء التقييم" + }, + "answers": { + "noAnswer": "لم يتم تقديم إجابة", + "studentAnswer": "إجابة الطالب:", + "correctAnswer": "الإجابة الصحيحة:", + "score": "الدرجة:", + "uploadedFile": "ملف مرفوع", + "file": "ملف", + "uploaded": "تم الرفع", + "noFile": "لم يتم تسليم ملف.", + "previewUnavailable": "المعاينة غير متاحة" + }, + "empty": { + "title": "لا توجد تسليمات بعد", + "description": "لم يقم الطلاب بتسليم هذا الامتحان بعد." + }, + "toast": { + "publishedSynced": "تمت مزامنة الدرجات المنشورة مع النتائج.", + "gradesPublished": "تم نشر الدرجات للطلاب.", + "publishFailed": "تعذر نشر الدرجات.", + "scorePositive": "يجب أن تكون الدرجة رقما موجبا.", + "scoreMax": "لا يمكن أن تتجاوز الدرجة {maxPoints} نقطة.", + "somethingWrong": "حدث خطأ ما.", + "autoSaveFailed": "فشل الحفظ التلقائي للدرجة.", + "scoreRequired": "يرجى إدخال درجة.", + "scoreSaved": "تم حفظ الدرجة!", + "finalized": "تم اعتماد التقييم وإنهاؤه بنجاح!", + "finalizeError": "حدث خطأ أثناء إنهاء التقييم." + }, + "ai": { + "pdfOnly": "يدعم التقييم الذكي إجابات ملفات PDF فقط." + } + }, + "aiEvaluation": { + "title": "التقييم الذكي", + "evaluate": "قيّم بالذكاء الاصطناعي", + "saved": "تم حفظ التقييم الذكي.", + "failed": "فشل التقييم الذكي.", + "manualFallback": "فشل التقييم الذكي. لا يزال التقييم اليدوي متاحا.", + "appliedDraftScore": "درجة المسودة المطبقة: {score} / {maxScore}", + "needsReview": "تحتاج مراجعة", + "noFeedback": "لم يتم تقديم ملاحظات.", + "strengths": "نقاط القوة", + "weaknesses": "نقاط الضعف", + "pdfSubmissionsOnly": "يدعم التقييم الذكي تسليمات PDF فقط." + }, + "assignmentsPage": { + "actions": { + "submit": "تسليم", + "submitLate": "تسليم متأخر", + "submitLateShort": "تسليم متأخر", + "replaceFile": "استبدال الملف", + "viewSubmission": "عرض التسليم", + "deadlinePassed": "انتهى الموعد", + "download": "تنزيل", + "submissions": "التسليمات", + "cancel": "إلغاء", + "close": "إغلاق", + "processing": "جار المعالجة..." + }, + "status": { + "gradePublished": "تم نشر الدرجة", + "feedbackReady": "الملاحظات جاهزة", + "submitted": "تم التسليم", + "notSubmitted": "لم يتم التسليم", + "assignmentSubmitted": "تم تسليم الواجب", + "gradePublishedMessage": "تم نشر درجتك أدناه.", + "waitingReview": "بانتظار مراجعة المعلم." + }, + "modal": { + "mySubmission": "تسليمي", + "replaceSubmittedFile": "استبدال الملف المسلّم", + "submitAssignment": "تسليم الواجب", + "due": "الموعد: {date}", + "lateWarning": "أنت تسلّم بعد الموعد النهائي. التسليم المتأخر مسموح لهذا الواجب.", + "submittedFile": "الملف المسلّم", + "submittedDate": "تم التسليم: {date}", + "replaceWarning": "رفع ملف جديد سيستبدل الملف الحالي نهائيا.", + "publishedGrade": "الدرجة المنشورة", + "newFile": "ملف جديد", + "file": "ملف", + "maxFileSize": "(الحد الأقصى 20MB)", + "chooseFile": "اختر ملفا", + "noFileChosen": "لم يتم اختيار ملف", + "pdfHint": "ارفع ملف PDF إن أمكن للحصول على أفضل تجربة مراجعة.", + "note": "ملاحظة", + "optional": "(اختياري)", + "notePlaceholder": "أضف ملاحظة للمعلم..." + }, + "empty": { + "title": "لم يتم العثور على واجبات", + "description": "جرّب تغيير عوامل التصفية أو كلمات البحث." + } + }, + "widgets": { + "viewAll": "عرض الكل", + "noEventsForDay": "لا توجد أحداث لهذا اليوم." + }, + "emptyStates": { + "defaultTitle": "لا توجد بيانات", + "defaultDescription": "لا يوجد شيء لعرضه الآن.", + "noResults": "لم يتم العثور على نتائج", + "filterDescription": "جرّب تغيير عوامل التصفية أو كلمات البحث.", + "searchDescription": "جرّب تغيير كلمات البحث.", + "announcements": "لم يتم العثور على إعلانات", + "classes": "لم يتم العثور على صفوف", + "events": "لم يتم العثور على أحداث", + "grades": "لم يتم العثور على مراحل", + "teachers": "لم يتم العثور على معلمين", + "results": "لم يتم العثور على نتائج", + "messages": "لم يتم العثور على رسائل", + "students": "لم يتم العثور على طلاب", + "parents": "لم يتم العثور على أولياء أمور", + "subjects": "لم يتم العثور على مواد", + "notifications": "لم يتم العثور على إشعارات", + "studyMaterials": "لا توجد مواد دراسية بعد", + "uploadFirstMaterial": "ارفع أول مادة باستخدام زر الرفع.", + "noStudyMaterialsForStudent": "لم يرفع معلمك أي مواد بعد.", + "uploadMaterial": "رفع مادة", + "uploadStudyMaterial": "رفع مادة دراسية", + "assignments": "لا توجد واجبات بعد.", + "teachersAssigned": "لا يوجد معلمون معينون بعد.", + "finalResults": "لا توجد معدلات نهائية", + "finalAverages": "لا توجد معدلات نهائية" + }, + "finalResultStatus": { + "PASS": "ناجح", + "FAIL": "راسب", + "NO_RESULTS": "لا توجد نتائج", + "NOT_UPDATED": "لم يتم التحديث" + }, + "subjectDetails": { + "subjects": "المواد", + "grade": "الصف", + "gradeLevel": "الصف {level}", + "teacherCount": "{count, plural, one {# معلم} other {# معلمين}}", + "teachers": "المعلمون", + "classes": "الصفوف", + "assignments": "الواجبات", + "materials": "المواد الدراسية", + "exams": "الامتحانات", + "subjectInfo": "معلومات المادة", + "subjectTeacher": "معلم المادة", + "yourTeachers": "{count, plural, one {معلمك} other {معلموك}}", + "createExam": "إنشاء امتحان", + "manageAssignments": "إدارة الواجبات", + "manageExams": "إدارة الامتحانات", + "class": "الصف", + "due": "الموعد", + "overdue": "متأخر", + "download": "تنزيل", + "downloadAssignmentFile": "تنزيل ملف الواجب", + "noExams": "لا توجد امتحانات مجدولة.", + "studyMaterials": "المواد الدراسية", + "upload": "رفع", + "delete": "حذف", + "tabs": { + "materials": "المواد", + "assignments": "الواجبات", + "exams": "الامتحانات" + }, + "editor": { + "trigger": "تعديل الصفحة", + "title": "تعديل صفحة المادة", + "bannerImage": "صورة الغلاف", + "bannerPreviewAlt": "معاينة الغلاف", + "uploadBanner": "اضغط لرفع صورة الغلاف", + "bannerHint": "الحد الأقصى 5MB - JPG, PNG, WebP", + "height": "الارتفاع:", + "heights": { + "sm": "صغير", + "md": "متوسط", + "lg": "كبير" + }, + "announcement": "إعلان", + "announcementHint": "(يظهر أعلى الصفحة للطلاب)", + "announcementPlaceholder": "مثال: \"مرحبا بكم في الرياضيات!\"", + "courseDescription": "وصف المادة", + "descriptionPlaceholder": "وصف مختصر لهذه المادة...", + "wordCount": "{count}/{max} كلمة", + "cancel": "إلغاء", + "saveChanges": "حفظ التغييرات" + }, + "materialUpload": { + "title": "رفع مادة دراسية", + "submit": "رفع المادة", + "maxSize": "(الحد الأقصى 20MB)", + "fields": { + "title": "العنوان", + "description": "الوصف", + "file": "الملف" + }, + "placeholders": { + "title": "مثال: ملاحظات الفصل 3", + "description": "وصف اختياري..." + }, + "chooseFile": "اختر ملفا", + "noFileChosen": "لم يتم اختيار ملف" + } + }, + "finalGradeEdit": { + "edit": "تعديل الدرجة النهائية", + "title": "تعديل الدرجة النهائية", + "finalGrade": "الدرجة النهائية (%)", + "cancel": "إلغاء", + "save": "حفظ", + "saving": "جار الحفظ...", + "updated": "تم تحديث الدرجة النهائية." + } +} diff --git a/messages/en.json b/messages/en.json new file mode 100644 index 0000000..7098c43 --- /dev/null +++ b/messages/en.json @@ -0,0 +1,1451 @@ +{ + "common": { + "brand": "ACADEMIX", + "dashboard": "Dashboard", + "search": "Search..." + }, + "states": { + "loading": "Loading...", + "errorTitle": "Something went wrong", + "errorDescription": "We could not load this content. Please try again.", + "tryAgain": "Try again", + "retrying": "Retrying...", + "applicationErrorTitle": "Application error", + "applicationErrorDescription": "An unexpected error happened while loading the app.", + "pageErrorTitle": "Could not load this page", + "pageErrorDescription": "Please refresh or try again.", + "unauthorizedTitle": "Unauthorized Access", + "unauthorizedDescription": "You do not have permission to access this page with your current role or account status.", + "noDataYet": "No data yet", + "academicYearNotStarted": "Academic year did not begin yet.", + "noCurrentYear": "No current year selected", + "selectCurrentYear": "Select a current academic year to view data.", + "goToSettings": "Go to settings", + "noStudentsLinked": "No students found", + "noStudentsLinkedDescription": "There are no linked students on this parent account yet.", + "noClassAssigned": "No class assigned", + "selectedStudentNoClass": "The selected student is not assigned to a class yet." + }, + "sidebar": { + "sections": { + "main": "Menu", + "other": "Other" + }, + "items": { + "dashboard": "Dashboard", + "schools": "Schools", + "teachers": "Teachers", + "students": "Students", + "parents": "Parents", + "subjects": "Subjects", + "classes": "Classes", + "grades": "Grades", + "schedules": "Schedules", + "exams": "Exams", + "assignments": "Assignments", + "results": "Results", + "finalResults": "Final Averages", + "attendance": "Attendance", + "events": "Events", + "messages": "Messages", + "announcements": "Announcements", + "notifications": "Notifications", + "archive": "Archive", + "feedback": "Feedback", + "feedbacks": "Feedbacks", + "profile": "Profile", + "settings": "Settings", + "logout": "Logout" + } + }, + "navbar": { + "openMenu": "Open menu", + "closeMenu": "Close menu", + "expandMenu": "Expand menu", + "collapseMenu": "Collapse menu", + "messages": "Messages", + "userFallback": "User", + "language": { + "label": "Language", + "en": "English", + "ar": "العربية" + }, + "userMenu": { + "open": "Open user menu", + "avatarAlt": "User avatar", + "noRole": "No role" + }, + "notifications": { + "label": "Notifications", + "newCount": "{count} new", + "markAllRead": "Mark all as read", + "allRead": "All read", + "empty": "No notifications yet", + "viewAll": "View all notifications", + "close": "Close" + } + }, + "home": { + "nav": { + "home": "Home", + "about": "About", + "features": "Features", + "users": "Users", + "howItWorks": "How It Works", + "signIn": "Sign In", + "signUp": "Sign Up", + "openMenu": "Open menu", + "closeMenu": "Close menu" + }, + "hero": { + "badge": "Powered with new AI features", + "title": "Welcome to Academix", + "highlight": "Academix", + "description": "A modern school management platform designed to streamline academic workflows for administrators, teachers, students, and parents.", + "signIn": "Sign In", + "signUp": "Sign Up" + }, + "about": { + "title": "About Academix", + "description": "Academix connects every part of school administration into one unified system, from enrollment and attendance to assessments, grading, and communication.", + "highlights": { + "multilingual": { + "title": "Multilingual Support", + "description": "Optimized for English and Arabic with RTL-friendly layouts and localized content." + }, + "roleAccess": { + "title": "Role-Based Access", + "description": "Secure dashboards and permissions for school admins, teachers, students, and guardians." + }, + "insights": { + "title": "Real-Time Insights", + "description": "Track performance, attendance, and engagement with live dashboards and exports." + } + } + }, + "features": { + "title": "Key Features", + "description": "Everything your institution needs to run smarter every day.", + "items": { + "students": { + "title": "Student Management", + "description": "Keep student profiles, enrollment history, and academic records organized in one place." + }, + "courses": { + "title": "Course Management", + "description": "Plan subjects, assign teachers, and structure class schedules for every grade level." + }, + "assessments": { + "title": "Assessments", + "description": "Create assignments and exams, then monitor submissions and grading progress." + }, + "attendance": { + "title": "Attendance Tracking", + "description": "Digitize daily attendance with automated summaries and actionable reports." + }, + "reports": { + "title": "Grades & Reports", + "description": "Generate report cards and analytics to support better academic decisions." + }, + "learning": { + "title": "Virtual Learning Space", + "description": "Share resources, announcements, and course content in a unified student experience." + } + } + }, + "roles": { + "title": "Designed for Every Role", + "description": "Tailored experiences for administrators, teachers, and students.", + "items": { + "administrator": { + "title": "Administrator", + "description": "Manage teachers, classes, subjects, and school-wide operations from one dashboard.", + "actions": { + "users": "Manage all users", + "settings": "Configure school settings", + "reports": "Generate reports", + "operations": "Oversee operations" + } + }, + "teacher": { + "title": "Teacher", + "description": "Plan lessons, create assessments, track submissions, and monitor classroom progress.", + "actions": { + "assessments": "Create assessments", + "attendance": "Take attendance", + "grading": "Grade submissions", + "resources": "Manage class resources" + } + }, + "student": { + "title": "Student", + "description": "Access courses, complete assignments, track grades, and stay updated with announcements.", + "actions": { + "courses": "View enrolled courses", + "assignments": "Submit assignments", + "grades": "Track grades", + "attendance": "Review attendance" + } + } + } + }, + "howItWorks": { + "title": "How It Works", + "description": "Launch your school workspace in a few guided steps.", + "steps": { + "account": { + "title": "Create Account", + "description": "Sign up your school and invite your team in minutes." + }, + "setup": { + "title": "Set Up School", + "description": "Configure classes, subjects, roles, and academic terms." + }, + "operations": { + "title": "Run Operations", + "description": "Handle attendance, exams, communication, and daily workflows." + }, + "outcomes": { + "title": "Improve Outcomes", + "description": "Use reports and analytics to drive smarter decisions." + } + } + }, + "cta": { + "title": "Ready to Transform Your School?", + "description": "Join Academix and simplify school operations with one platform for learning, assessment, and administration.", + "button": "Get Started Free" + }, + "footer": { + "copyright": "© 2026 Academix. All rights reserved." + } + }, + "roles": { + "admin": "Admin", + "teacher": "Teacher", + "student": "Student", + "parent": "Parent", + "superAdmin": "Super Admin" + }, + "actions": { + "add": "Add", + "create": "Create", + "update": "Edit", + "edit": "Edit", + "delete": "Delete", + "deleting": "Deleting...", + "saving": "Saving...", + "uploading": "Uploading...", + "adding": "Adding...", + "creating": "Creating...", + "updating": "Updating...", + "close": "Close", + "yes": "Yes", + "no": "No", + "somethingWentWrong": "Something went wrong!", + "deleteConfirmation": "All data will be lost. Are you sure you want to delete this {entity}?", + "deleteSuccess": "{entity} deleted successfully", + "exportCsv": "Export CSV", + "filter": "Filter", + "sort": "Sort", + "apply": "Apply", + "clear": "Clear", + "markAllRead": "Mark all read", + "deleteAll": "Delete all", + "signOut": "Sign out", + "signingOut": "Signing out...", + "formNotFound": "Form not found!" + }, + "tables": { + "grade": "grade", + "subject": "subject", + "class": "class", + "teacher": "teacher", + "student": "student", + "parent": "parent", + "exam": "exam", + "assignment": "assignment", + "result": "result", + "lesson": "lesson", + "attendance": "attendance", + "event": "event", + "announcement": "announcement", + "message": "message" + }, + "tableHeaders": { + "info": "Info", + "studentId": "Student ID", + "student": "Student", + "teacherId": "Teacher ID", + "parent": "Parent", + "studentNames": "Student Names", + "title": "Title", + "content": "Content", + "subject": "Subject", + "subjects": "Subjects", + "class": "Class", + "classes": "Classes", + "className": "Class Name", + "grade": "Grade", + "finalGrade": "Final Grade", + "gradeLevel": "Grade Level", + "teacher": "Teacher", + "phone": "Phone", + "address": "Address", + "actions": "Actions", + "submission": "Submission", + "score": "Score", + "endDate": "End Date", + "startTime": "Start Time", + "endTime": "End Time", + "status": "Status", + "date": "Date", + "time": "Time", + "sentTo": "Sent To", + "assessment": "Assessment", + "assessmentCount": "Assessments", + "averageScore": "Average %", + "capacity": "Capacity", + "supervisor": "Supervisor", + "school": "School", + "adminUsername": "Admin Username", + "pauseReason": "Pause Reason", + "file": "File", + "submitted": "Submitted", + "submittedAt": "Submitted At", + "name": "Name", + "username": "Username", + "email": "Email", + "statusValue": "Status / Value", + "range": "Range", + "current": "Current", + "type": "Type" + }, + "filters": { + "all": "All", + "allClasses": "All Classes", + "allGrades": "All Grades", + "allSubjects": "All Subjects", + "gradeLevel": "Grade {level}", + "dataType": "Data Type", + "academicYear": "Academic Year", + "status": "Status", + "repeatedTimes": "Repeated Times", + "regular": "Regular", + "repeated": "Repeated", + "graduated": "Graduated", + "left": "Left", + "time": "time", + "times": "times", + "pending": "Pending", + "reviewed": "Reviewed", + "resolved": "Resolved", + "suggestions": "Suggestions", + "complaints": "Complaints", + "active": "Active", + "paused": "Paused", + "students": "Students", + "teachers": "Teachers", + "parents": "Parents", + "exams": "Exams", + "assignments": "Assignments", + "results": "Results", + "attendance": "Attendance", + "subjects": "Subjects", + "classes": "Classes", + "grades": "Grades", + "announcements": "Announcements", + "events": "Events", + "messages": "Messages", + "feedback": "Feedback", + "allTypes": "All Types", + "assignmentUpdates": "Assignment Updates", + "gradeUpdates": "Grade Updates", + "schedule": "Schedule", + "supervisor": "Supervisor", + "newest": "Newest", + "oldest": "Oldest", + "searchStudent": "Search student..." + }, + "finalResultStatus": { + "PASS": "Pass", + "FAIL": "Fail", + "NO_RESULTS": "No results", + "NOT_UPDATED": "Not updated" + }, + "attendance": { + "filter": "Filter", + "present": "Present", + "absent": "Absent", + "save": "Save Attendance", + "saved": "Attendance saved", + "noRecordsPage": "No records on this page", + "noScopeFound": "No {scope} found", + "noRecordsDate": "There are no attendance records for the selected date.", + "tryAnotherPage": "Try moving to another page.", + "noClassesAssigned": "No classes assigned", + "noClassesAssignedDescription": "You are not assigned to supervise any class yet.", + "dateOutsideAcademicYear": "This date is outside the current academic year ({start} to {end})." + }, + "archivePage": { + "previewData": "Preview Data", + "unknownStudent": "Unknown student", + "present": "Present", + "absent": "Absent", + "noData": "No data found", + "showMore": "Show More" + }, + "feedbackPage": { + "pendingCount": "{count} pending", + "shownCount": "{count} shown", + "noFeedbacks": "No feedbacks found.", + "resolvedLocked": "This feedback is resolved and locked.", + "markReviewed": "Mark Reviewed", + "markResolved": "Mark Resolved", + "markPending": "Mark Pending", + "historyTitle": "My Feedback History", + "historyDescription": "Track your previous suggestions and complaints.", + "submittedCount": "{count} submitted", + "emptyHistory": "You have not submitted any feedback yet.", + "submit": "Submit Feedback", + "description": "Send a suggestion or complaint to the school administration.", + "type": "Type", + "message": "Message", + "messagePlaceholder": "Write your message...", + "cancel": "Cancel", + "send": "Send Feedback", + "sending": "Sending...", + "close": "Close", + "unauthorized": "Unauthorized", + "unexpectedError": "Something went wrong!", + "types": { + "suggestion": "Suggestion", + "complaint": "Complaint" + }, + "statuses": { + "pending": "Pending", + "reviewed": "Reviewed", + "resolved": "Resolved" + } + }, + "examList": { + "addExam": "Add Exam", + "submissions": "Submissions", + "takeExam": "Take Exam", + "continue": "Continue", + "editWorkflow": "Edit exam workflow", + "noExams": "No exams found", + "emptyDescription": "Try changing your filters or search terms.", + "readyToStart": "Ready to start?", + "instructions": "Instructions", + "noInstructions": "No special instructions were added for this exam.", + "continueNotice": "Once you continue, the exam session will open.", + "cancel": "Cancel", + "startExam": "Start Exam", + "starting": "Starting...", + "close": "Close", + "statuses": { + "notStarted": "Not Started", + "inProgress": "In Progress", + "submitted": "Submitted", + "graded": "Graded", + "published": "Published" + } + }, + "examTaking": { + "pageLabel": "Page {current} of {total}", + "submitExam": "Submit Exam", + "submitting": "Submitting...", + "saving": "Saving...", + "saved": "Saved", + "error": "Error", + "point": "{count, plural, one {# point} other {# points}}", + "fileUpload": "File upload", + "previous": "Previous", + "next": "Next", + "confirmEyebrow": "Submit exam", + "confirmTitle": "Are you sure?", + "confirmBody": "Once you submit, you cannot change your answers for this exam.", + "confirmNotice": "Your saved answers will be submitted for grading.", + "cancel": "Cancel", + "close": "Close", + "true": "True", + "false": "False", + "multiAnswerHint": "You may select more than one answer.", + "textPlaceholder": "Write your answer here...", + "toast": { + "saveFailed": "Failed to save answer.", + "previousDisabled": "Navigation to previous pages is disabled.", + "answerBeforeProceeding": "Please answer all questions on this page before proceeding.", + "answerBeforeSubmitting": "Please answer all questions on this page before submitting.", + "submitted": "Exam submitted successfully!", + "timeUp": "Exam time is up! Auto-submitting..." + }, + "uploading": "Uploading... {progress}%", + "uploadFailed": "Upload failed", + "failedToUpload": "Failed to upload \"{fileName}\"", + "tryAgain": "Try again", + "metadataSaveFailed": "Failed to save file metadata. Your file is saved locally and will be retried.", + "removeFileFailed": "Failed to remove file.", + "accepted": "Accepted:", + "maxSize": "Max size:", + "chooseFile": "Choose File", + "replace": "Replace", + "remove": "Remove", + "removingFile": "Removing file...", + "identicalFileConfirm": "This file appears identical to the current upload. Replace anyway?", + "fileTypeBlocked": "File type \".{extension}\" is not allowed.", + "allowedFilesOnly": "Only {extensions} files are allowed.", + "minFileSize": "File size must be at least {size} MB.", + "maxFileSize": "File size exceeds {size} MB limit." + }, + "directMessage": { + "titleMessageRequired": "Title and message are required", + "sendFailed": "Failed to send message", + "errorOccurred": "An error occurred", + "sentTo": "Message sent to {name}", + "heading": "Send Message to {name}", + "title": "Title", + "titlePlaceholder": "Enter message title", + "message": "Message", + "messagePlaceholder": "Enter message body", + "cancel": "Cancel", + "send": "Send", + "sending": "Sending..." + }, + "submissionsModal": { + "open": "Submissions", + "title": "Student Submissions", + "deadlinePassed": "Deadline passed - ready for grading", + "submitted": "{count} Submitted", + "pending": "{count} Pending", + "graded": "{count} Graded", + "emptyTitle": "No submissions yet", + "emptyDescription": "Students haven't submitted anything for this assignment.", + "scoreRequired": "Score is required", + "gradesPublished": "Grades published successfully!", + "published": "Published", + "draft": "Draft", + "ungraded": "Ungraded", + "late": "Late (+{time})", + "early": "{time} early", + "downloadSubmission": "Download submission", + "editGrade": "Edit grade", + "addGrade": "Add grade", + "editFeedback": "Edit feedback", + "addFeedback": "Add feedback", + "publishing": "Publishing...", + "publishGrades": "Publish Grades ({published}/{graded})", + "close": "Close", + "gradeTitle": "Grade Submission", + "student": "Student:", + "score": "Score", + "gradeHelp": "Saving a grade keeps it hidden until you publish grades.", + "cancel": "Cancel", + "saving": "Saving...", + "saveGrade": "Save Grade", + "feedbackTitle": "Teacher Feedback", + "feedbackPlaceholder": "Write your feedback for this student...", + "saveFeedback": "Save Feedback", + "day": "{count, plural, one {# day} other {# days}}", + "hour": "{count, plural, one {# hour} other {# hours}}", + "minute": "{count, plural, one {# minute} other {# minutes}}" + }, + "schoolSignup": { + "statusTitle": "School Access Status", + "statusDescription": "Your school is pending approval. Academix team will review your request and activate your account once verification is complete.", + "goToSignIn": "Go to sign-in", + "title": "Create School Account", + "description": "Fill in complete school and primary admin details.", + "step": "Step {number}", + "schoolInfo": "School Information", + "adminInfo": "Primary Admin Account", + "fields": { + "schoolName": "School Name", + "registrationNumber": "Registration Number", + "schoolEmail": "School Email", + "schoolPhone": "School Phone", + "country": "Country", + "city": "City", + "website": "Website", + "schoolAddress": "School Address", + "adminName": "Admin Full Name", + "adminEmailOptional": "Admin Email (Optional)", + "adminUsername": "Admin Username", + "adminPassword": "Admin Password" + }, + "validation": { + "schoolNameRequired": "School name is required.", + "schoolEmailRequired": "School email is required.", + "schoolPhoneRequired": "School phone is required.", + "countryRequired": "Country is required.", + "cityRequired": "City is required.", + "addressRequired": "School address is required.", + "adminNameRequired": "Admin full name is required.", + "adminUsernameRequired": "Admin username is required.", + "adminPasswordRequired": "Admin password is required." + }, + "submitting": "Submitting...", + "submit": "Submit", + "page": { + "goBack": "Go back", + "titleStart": "School", + "titleHighlight": "Signup", + "description": "Register your school details and create your primary admin account. After review, the school will be activated by Academix.", + "steps": { + "profile": "Submit complete school profile", + "verification": "Verification by Academix", + "access": "Access granted to dashboard" + } + } + }, + "auth": { + "login": { + "welcomeBack": "Welcome back", + "redirecting": "Redirecting to your dashboard...", + "goHome": "Go home", + "title": "Sign in to your account", + "identifier": "Username or Email", + "password": "Password", + "showPassword": "Show password", + "hidePassword": "Hide password", + "signingIn": "Signing in...", + "signIn": "Sign In", + "newSchool": "New school?", + "signupRequest": "Submit signup request" + }, + "signOut": { + "signingOut": "Signing out...", + "backToSignIn": "Back to sign-in" + }, + "schoolAccess": { + "title": "School Access Status", + "pending": "Your school is pending approval. Academix team will review your request and activate your account once verification is complete.", + "paused": "Your school is currently paused.", + "reason": "Reason:", + "noReason": "No reason provided.", + "active": "Your school account is active." + } + }, + "schoolStatus": { + "alreadyActive": "This school is already active.", + "alreadyPaused": "This school is already paused.", + "activated": "School activated successfully.", + "activateFailed": "Failed to activate school.", + "pauseReasonRequired": "Pause reason is required.", + "paused": "School paused successfully.", + "pauseFailed": "Failed to pause school.", + "activate": "Activate", + "pause": "Pause", + "confirmActivation": "Confirm Activation", + "confirmPause": "Confirm Pause", + "activateConfirm": "Are you sure you want to activate {name}?", + "pauseConfirm": "Enter a reason before pausing {name}.", + "pauseReason": "Pause reason", + "cancel": "Cancel", + "confirm": "Confirm", + "activating": "Activating...", + "pausing": "Pausing...", + "closeModal": "Close modal" + }, + "promotion": { + "button": "Update Students Grades", + "academicYear": "Academic Year", + "status": "Status", + "stillInProgress": "Academic year is still in progress", + "confirmTitle": "Confirm student promotion", + "completed": "Promotion completed", + "failed": "Promotion could not be completed", + "notFinishedBody": "The academic year {year} ends on {date}. You can update the students once the last day has passed.", + "confirmBody": "The academic year {year} has finished. Confirm this action to update students based on their final average. Students with 60% or higher pass.", + "promoted": "Promoted", + "graduated": "Graduated", + "repeated": "Repeated", + "successFallback": "Students promoted successfully.", + "confirm": "Confirm", + "updating": "Updating...", + "ok": "Ok", + "close": "Close promotion dialog" + }, + "messageView": { + "view": "View message", + "close": "Close message view", + "title": "Title", + "body": "Body", + "date": "Date", + "time": "Time" + }, + "pages": { + "archive": "Archive", + "settings": "School Settings", + "announcements": "Announcements", + "events": "Events", + "feedbacks": "Feedbacks", + "feedbackHistory": "My Feedback History", + "allStudents": "All Students", + "allTeachers": "All Teachers", + "allParents": "All Parents", + "allSubjects": "All Subjects", + "allClasses": "All Classes", + "allGrades": "All Grades", + "allExams": "All Exams", + "allAssignments": "All Assignments", + "allResults": "All Results", + "finalResults": "Final Averages", + "finalAverages": "Final Averages", + "allEvents": "All Events", + "allAnnouncements": "All Announcements", + "allMessages": "All Messages", + "allNotifications": "All Notifications", + "attendance": "Attendance", + "messages": "Messages", + "notifications": "Notifications" + }, + "dashboardCards": { + "admin": "Admins", + "teacher": "Teachers", + "student": "Students", + "parent": "Parents" + }, + "profile": { + "avatarAlt": "Profile", + "notFound": "Profile not found", + "notProvided": "Not provided", + "fields": { + "adminUsername": "Admin Username", + "schoolName": "School Name", + "schoolId": "School ID", + "createdAt": "Created At", + "updatedAt": "Updated At", + "name": "Name", + "username": "Username", + "email": "Email", + "phone": "Phone", + "address": "Address", + "bloodType": "Blood Type", + "gender": "Gender", + "birthday": "Birthday", + "subjects": "Subjects", + "classes": "Classes", + "school": "School", + "grade": "Grade", + "class": "Class", + "status": "Status", + "parent": "Parent", + "children": "Children" + } + }, + "settings": { + "description": "Configure weekly schedule defaults used by lessons and calendar rendering.", + "workingDaysDescription": "Define school working days and add date-specific holidays or off-day overrides.", + "sections": { + "scheduleDefaults": "Schedule Defaults", + "workingDaysHolidays": "Working Days & Holidays", + "academicYears": "Academic Years" + }, + "scheduleForm": { + "title": "Schedule Defaults", + "workDayStart": "Work Day Start", + "workDayEnd": "Work Day End", + "lessonDuration": "Lesson Duration (minutes)", + "lessonsPerDay": "Lessons Per Day", + "save": "Save Settings", + "updated": "School settings updated." + }, + "academicYears": { + "title": "Academic Years", + "description": "Create and maintain academic year ranges.", + "add": "Add Academic Year", + "editTitle": "Edit Academic Year", + "create": "Create Academic Year", + "update": "Update Academic Year", + "startDate": "Start Date", + "endDate": "End Date", + "markCurrent": "Mark as current academic year", + "empty": "No academic years yet.", + "created": "Academic year created.", + "updated": "Academic year updated." + }, + "workingDays": { + "weeklyWorkingDays": "Weekly Working Days", + "saveWorkingDays": "Save Working Days", + "holidaysOffDays": "Holidays and Off Days", + "addException": "Add Exception", + "addDayException": "Add Day Exception", + "nameOptional": "Name (optional)", + "namePlaceholder": "e.g. Eid Holiday", + "notesOptional": "Notes (optional)", + "empty": "No day exceptions yet.", + "workingDaysUpdated": "Working days updated.", + "exceptionSaved": "Day exception saved.", + "exceptionRemoved": "Day exception removed.", + "days": { + "SATURDAY": "Saturday", + "SUNDAY": "Sunday", + "MONDAY": "Monday", + "TUESDAY": "Tuesday", + "WEDNESDAY": "Wednesday", + "THURSDAY": "Thursday", + "FRIDAY": "Friday" + }, + "types": { + "HOLIDAY": "Holiday", + "OFF_DAY": "Off Day", + "WORKING_OVERRIDE": "Working Override" + } + } + }, + "profilePages": { + "cards": { + "attendance": "Attendance", + "grade": "Grade", + "lessons": "Lessons", + "class": "Class", + "subjects": "Subjects", + "classes": "Classes" + }, + "shortcuts": { + "title": "Shortcuts" + }, + "student": { + "schedule": "Student's Schedule", + "lessons": "Student's Lessons", + "teachers": "Student's Teachers", + "exams": "Student's Exams", + "assignments": "Student's Assignments", + "results": "Student's Results" + }, + "teacher": { + "schedule": "Teacher's Schedule", + "classes": "Teacher's Classes", + "students": "Teacher's Students", + "exams": "Teacher's Exams", + "assignments": "Teacher's Assignments" + } + }, + "lessonsPage": { + "title": "Lessons Calendar", + "grade": "Grade", + "classes": "Classes", + "scheduleFor": "Schedule for Grade {grade} - {className}", + "empty": { + "noClassesTitle": "No classes found", + "noClassesDescription": "No classes with lessons are available for this view.", + "noClassesForGrade": "No classes were found for the selected grade." + } + }, + "forms": { + "common": { + "submitting": "Submitting...", + "selected": "{count} selected", + "optional": "optional", + "searchTeachers": "Search teachers...", + "searchParents": "Search parents...", + "searchStudents": "Search students...", + "searchSubjects": "Search subjects...", + "noTeachersFound": "No teachers found.", + "noParentsFound": "No parents found.", + "noStudentsFound": "No students found.", + "noSubjectsFound": "No subjects found.", + "noResultsFound": "No results found", + "gradeLevel": "Grade {level}", + "selectGrade": "Select a grade", + "selectSex": "Select sex", + "somethingWentWrong": "Something went wrong!", + "authenticationInfo": "Authentication Information", + "personalInfo": "Personal Information", + "username": "Username", + "email": "Email", + "password": "Password", + "showPassword": "Show password", + "hidePassword": "Hide password", + "fullName": "Full Name", + "name": "Name", + "phone": "Phone", + "address": "Address", + "bloodType": "Blood Type", + "birthday": "Birthday", + "sex": "Sex", + "male": "Male", + "female": "Female", + "status": "Status", + "regular": "Regular", + "repeated": "Repeated", + "graduated": "Graduated", + "left": "Left", + "photoUploaded": "Photo uploaded successfully", + "removePhoto": "Remove photo", + "selectAll": "Select all", + "selectedClasses": "{count} {count, plural, one {class is} other {classes are}} selected", + "description": "Description", + "date": "Date", + "startDate": "Start Date", + "endDate": "End Date", + "title": "Title", + "classes": "Classes" + }, + "grade": { + "createTitle": "Create a new grade", + "level": "Grade Level", + "created": "Grade created successfully!" + }, + "class": { + "createTitle": "Create a new class", + "updateTitle": "Update the class", + "name": "Class Name", + "capacity": "Capacity", + "grade": "Grade", + "supervisor": "Supervisor", + "created": "Class created successfully!", + "updated": "Class updated successfully!" + }, + "subject": { + "createTitle": "Create a new subject", + "updateTitle": "Update the subject", + "name": "Subject Name", + "grade": "Grade", + "teachers": "Teachers", + "teachersHelp": "Choose one or more teachers who will be assigned to this subject.", + "noTeachersSelected": "No teachers selected yet.", + "created": "Subject created successfully!", + "updated": "Subject updated successfully!" + }, + "student": { + "createTitle": "Create a new student", + "updateTitle": "Update the student", + "parent": "Parent", + "grade": "Grade", + "class": "Class", + "classCapacity": "{className} - {students}/{capacity} Capacity", + "uploadPhoto": "Upload Student Photo", + "previewAlt": "Student preview", + "create": "Create Student", + "update": "Update Student", + "created": "Student has been created!", + "updated": "Student has been updated!" + }, + "teacher": { + "createTitle": "Create Teacher", + "updateTitle": "Update Teacher", + "subjects": "Subjects", + "subjectsHelp": "Choose one or more subjects this teacher will teach.", + "noSubjectsSelected": "No subjects selected yet.", + "removeSubject": "Remove {subject}", + "uploadPhoto": "Upload Teacher Photo", + "previewAlt": "Teacher preview", + "create": "Create Teacher", + "update": "Update Teacher", + "created": "Teacher created successfully!", + "updated": "Teacher updated successfully!" + }, + "parent": { + "createTitle": "Create a new parent", + "updateTitle": "Update the parent", + "students": "Students", + "selectStudentRequired": "Select at least one student.", + "selectStudentBeforeCreate": "Select at least one student before creating a parent.", + "create": "Create Parent", + "update": "Update Parent", + "created": "Parent has been created!", + "updated": "Parent has been updated!" + }, + "event": { + "createTitle": "Create a new event", + "updateTitle": "Update the event", + "title": "Event Title", + "titlePlaceholder": "e.g. Sports Day", + "descriptionPlaceholder": "Event description", + "created": "Event has been created!", + "updated": "Event has been updated!" + }, + "announcement": { + "createTitle": "Create a new announcement", + "updateTitle": "Update the announcement", + "title": "Announcement Title", + "titlePlaceholder": "e.g. Parent Meeting", + "descriptionPlaceholder": "Announcement details", + "created": "Announcement has been created!", + "updated": "Announcement has been updated!" + }, + "message": { + "createTitle": "Create a new message", + "updateTitle": "Update the message", + "recipients": "Recipients", + "content": "Message Content", + "title": "Message Title", + "titlePlaceholder": "e.g. Reminder", + "descriptionPlaceholder": "Message details", + "studentRecipients": "Student recipients", + "parentRecipients": "Parent recipients", + "teacherRecipients": "Teacher recipients", + "classesOptional": "Classes (optional)", + "searchRecipients": "Search {label}...", + "openRecipientSearch": "Open {label} search results", + "created": "Message has been created!", + "updated": "Message has been updated!" + }, + "result": { + "createTitle": "Create a new result", + "updateTitle": "Update the result", + "student": "Student", + "assessmentType": "Assessment Type", + "assessment": "Assessment", + "selectAssessment": "Select assessment", + "exam": "Exam", + "assignment": "Assignment", + "score": "Score", + "created": "Result has been created!", + "updated": "Result has been updated!" + }, + "lesson": { + "createTitle": "Create weekly lesson schedule", + "updateTitle": "Update weekly lesson schedule", + "class": "Class", + "selectClass": "Select class", + "lesson": "Lesson {slot}", + "noSubject": "No subject", + "selectTeacher": "Select teacher", + "teacherRequired": "Select a teacher", + "saved": "Weekly lesson schedule has been saved.", + "save": "Save Weekly Schedule", + "days": { + "SATURDAY": "SAT", + "SUNDAY": "SUN", + "MONDAY": "MON", + "TUESDAY": "TUE", + "WEDNESDAY": "WED", + "THURSDAY": "THU", + "FRIDAY": "FRI" + } + }, + "exam": { + "createTitle": "Create a new exam", + "updateTitle": "Update the exam", + "title": "Exam Title", + "titlePlaceholder": "e.g. Midterm Quiz", + "subject": "Subject", + "selectSubject": "Select subject", + "noClassesAvailable": "No classes available.", + "created": "Exam has been created!", + "updated": "Exam has been updated!" + }, + "assignment": { + "createTitle": "Create a new assignment", + "updateTitle": "Update the assignment", + "title": "Assignment Title", + "titlePlaceholder": "e.g. Chapter 4 Homework", + "marks": "Marks", + "assignedTeacher": "Assigned Teacher", + "assignedTeacherHelp": "This teacher will be able to access and evaluate submissions.", + "subject": "Subject", + "subjectFallback": "Subject #{id}", + "searchSubject": "Search subject...", + "clearSubject": "Clear subject", + "noClassesForSubject": "No classes found for this subject.", + "selectSubjectFirst": "Select a subject first to see available classes.", + "rubric": "Model Answer / Rubric", + "rubricPlaceholder": "Write the expected answer or explain how marks should be awarded...", + "rubricHelp": "Used to guide assignment grading and AI evaluation.", + "fileOptional": "Assignment File (optional)", + "currentFile": "Current file", + "downloadCurrentFile": "Download current file", + "removeCurrentFile": "Remove current file", + "changeFile": "Change File", + "replaceFile": "Replace File", + "uploadFile": "Upload File", + "removeSelected": "Remove selected", + "noFileAttached": "No file attached yet. Upload one if needed.", + "currentFileWillBeReplaced": "The current file will be replaced.", + "allowLateSubmission": "Allow Late Submission", + "lateAllowed": "Students can submit after the deadline", + "lateNotAllowed": "Students cannot submit after the deadline", + "created": "Assignment has been created!", + "updated": "Assignment has been updated!" + }, + "studentDelete": { + "title": "Delete student", + "confirm": "Are you sure you want to delete {name}?", + "fallbackName": "this student", + "linkedParent": "This student is linked to parent {parent}.", + "canDeleteBoth": "You can delete the student only, or delete both the student and the parent.", + "parentHasMoreStudents": "The parent has more than one student, so only the student can be deleted.", + "deleteStudent": "Delete Student", + "deleteBoth": "Delete both", + "studentDeleted": "Student deleted successfully", + "studentAndParentDeleted": "Student and parent deleted successfully" + }, + "gradeDelete": { + "title": "Delete grade", + "deleted": "Grade deleted successfully!", + "warning": "Warning: This grade has {count} {count, plural, one {class} other {classes}} attached.", + "cascadeWarning": "Deleting this grade will also delete those classes and their schedule-related records.", + "classes": "Classes: {classes}", + "noClasses": "Grade {level} has no classes attached and can be deleted.", + "confirm": "Are you sure you want to delete Grade {level}?", + "delete": "Delete grade" + }, + "classDelete": { + "title": "Delete class", + "deleted": "Class deleted successfully!", + "deletedAndReassigned": "Class deleted and students reassigned successfully!", + "hasStudents": "This class has {count} {count, plural, one {student} other {students}}. Move them to another class or create a new class before deleting this one.", + "noStudents": "This class has no students, so it can be deleted now.", + "moveExisting": "Move to existing class", + "createNew": "Create new class", + "targetClass": "Target class", + "selectExisting": "Select an existing class", + "newClassName": "New class name", + "newClassPlaceholder": "e.g. 3B", + "capacity": "Capacity", + "grade": "Grade", + "supervisor": "Supervisor", + "noSupervisor": "No supervisor", + "selectTargetError": "Please select a class to move the students to.", + "nameRequired": "New class name is required.", + "capacityRequired": "New class capacity must be at least 1.", + "gradeRequired": "Please select a grade for the new class.", + "delete": "Delete class" + }, + "examWorkflow": { + "basicInfo": "1. Basic Information", + "settings": "2. Settings", + "instructions": "3. Instructions", + "questions": "4. Questions", + "examTitle": "Exam Title", + "startTime": "Start Time", + "endTime": "End Time", + "assignedTeacher": "Assigned Teacher", + "assignedTeacherHelp": "This teacher will be able to access and evaluate submissions.", + "subject": "Subject", + "selectSubject": "Select a subject...", + "classes": "Classes", + "noClassesForSubject": "No classes found for this subject.", + "selectSubjectFirst": "Select a subject first to see available classes.", + "showTimer": "Show Timer", + "autoSave": "Auto-Save", + "navigation": "Previous Page Navigation", + "autoSubmit": "Auto-Submit when time is up", + "duration": "Duration (minutes)", + "questionsPerPage": "Questions Per Page", + "studentInstructions": "Student Instructions", + "instructionsPlaceholder": "Add anything students should read before starting...", + "addQuestion": "+ Add Question", + "createExam": "Create Exam", + "updateExam": "Update Exam", + "created": "Exam created successfully!", + "updated": "Exam updated successfully!", + "unexpectedError": "An unexpected error occurred.", + "emptyOptionsForQuestion": "Question #{number} has empty options! Please fill in all option fields.", + "selectCorrectForQuestion": "Please select at least one correct answer for Question #{number}.", + "question": { + "text": "Question Text", + "type": "Type", + "points": "Points", + "textAnswer": "Text Answer", + "mcq": "Multiple Choice", + "trueFalse": "True / False", + "file": "File Upload", + "allowMultiple": "Allow multiple correct answers", + "options": "Options", + "addOption": "+ Add Option", + "optionPlaceholder": "Option {number}", + "correct": "Correct", + "remove": "Remove", + "emptyOptions": "All option fields must be filled. Please do not leave empty options.", + "optionTextRequired": "Please write the option text before marking it as correct!", + "answerHelp": "Select one correct answer, or more if multiple answers are allowed.", + "correctAnswer": "Correct Answer", + "true": "True", + "false": "False", + "rubric": "Model Answer / Rubric", + "rubricPlaceholder": "Write the expected answer or explain how marks should be awarded...", + "rubricHelp": "Used to guide manual grading and AI evaluation.", + "fileSettings": "File Upload Settings", + "allowedExtensions": "Allowed File Extensions", + "allowedExtensionsPlaceholder": "e.g. pdf, docx, jpg", + "allowedExtensionsHelp": "Leave empty to accept all file types (Optional)", + "minFileSize": "Min File Size (MB)", + "maxFileSize": "Max File Size (MB)", + "systemMax": "System max: 10 MB", + "fileInstructions": "Instructions for Student (optional)", + "fileInstructionsPlaceholder": "e.g. Upload your essay as a PDF file...", + "pdfAiHelp": "PDF submissions can be evaluated with AI using the model answer / rubric above." + } + } + }, + "assignmentSubmissions": { + "breadcrumb": "Assignments", + "title": "Assignment Submissions", + "subjectFallback": "No subject", + "marks": "{count} marks", + "stats": { + "visibleSubmissions": "Visible Submissions", + "graded": "Graded", + "draftGrades": "Draft Grades", + "published": "Published" + }, + "status": { + "published": "Published", + "draftGrade": "Draft Grade", + "submitted": "Submitted" + }, + "actions": { + "publishGrades": "Publish Grades", + "gradesPublished": "Grades Published", + "increaseAll": "Increase All", + "decreaseAll": "Decrease All", + "view": "View", + "download": "Download" + }, + "fields": { + "marks": "marks", + "scoreAdjustmentAmount": "Score adjustment amount" + }, + "empty": { + "title": "No submissions found", + "description": "No student submissions match the current filters.", + "previewUnavailable": "Preview unavailable" + }, + "validation": { + "positiveAmount": "Enter a positive adjustment amount.", + "maxAmount": "Amount cannot be greater than {maxScore}." + } + }, + "examSubmissions": { + "breadcrumb": "Exams", + "title": "Exam Submissions", + "gradeTitle": "Grade Submission", + "subjectFallback": "No subject", + "marks": "{count} marks", + "gradedCount": "{graded} of {total} graded", + "stats": { + "visible": "Visible", + "submitted": "Submitted", + "graded": "Graded", + "inProgress": "In Progress", + "published": "Published" + }, + "status": { + "inProgress": "In Progress", + "submitted": "Submitted", + "graded": "Graded", + "saving": "Saving...", + "saved": "Saved", + "error": "Error", + "allGraded": "All graded", + "autoGraded": "Auto-graded" + }, + "actions": { + "publishGrades": "Publish Grades", + "gradesPublished": "Grades Published", + "view": "View", + "grade": "Grade", + "downloadFile": "Download File", + "backToSubmissions": "Back to Submissions", + "finalizing": "Finalizing...", + "finalizedReapprove": "Grading Finalized (Re-Approve)", + "approveFinalize": "Approve & Finalize Grading" + }, + "answers": { + "noAnswer": "No answer provided", + "studentAnswer": "Student Answer:", + "correctAnswer": "Correct Answer:", + "score": "Score:", + "uploadedFile": "Uploaded file", + "file": "FILE", + "uploaded": "Uploaded", + "noFile": "No file submitted.", + "previewUnavailable": "Preview unavailable" + }, + "empty": { + "title": "No submissions yet", + "description": "Students have not submitted this exam yet." + }, + "toast": { + "publishedSynced": "Published grades synced to results.", + "gradesPublished": "Grades published to students.", + "publishFailed": "Could not publish grades.", + "scorePositive": "Score must be a positive number.", + "scoreMax": "Score cannot exceed {maxPoints} points.", + "somethingWrong": "Something went wrong.", + "autoSaveFailed": "Failed to auto-save score.", + "scoreRequired": "Please enter a score.", + "scoreSaved": "Score saved!", + "finalized": "Grading approved and finalized successfully!", + "finalizeError": "An error occurred while finalizing grading." + }, + "ai": { + "pdfOnly": "AI evaluation supports PDF file answers only." + } + }, + "aiEvaluation": { + "title": "AI Evaluation", + "evaluate": "Evaluate with AI", + "saved": "AI evaluation saved.", + "failed": "AI evaluation failed.", + "manualFallback": "AI evaluation failed. Manual grading still works.", + "appliedDraftScore": "Applied draft score: {score} / {maxScore}", + "needsReview": "Needs review", + "noFeedback": "No feedback provided.", + "strengths": "Strengths", + "weaknesses": "Weaknesses", + "pdfSubmissionsOnly": "AI evaluation supports PDF submissions only." + }, + "assignmentsPage": { + "actions": { + "submit": "Submit", + "submitLate": "Submit Late", + "submitLateShort": "Submit (Late)", + "replaceFile": "Replace File", + "viewSubmission": "View Submission", + "deadlinePassed": "Deadline Passed", + "download": "Download", + "submissions": "Submissions", + "cancel": "Cancel", + "close": "Close", + "processing": "Processing..." + }, + "status": { + "gradePublished": "Grade published", + "feedbackReady": "Feedback ready", + "submitted": "Submitted", + "notSubmitted": "Not submitted", + "assignmentSubmitted": "Assignment submitted", + "gradePublishedMessage": "Your grade is published below.", + "waitingReview": "Waiting for your teacher to review it." + }, + "modal": { + "mySubmission": "My Submission", + "replaceSubmittedFile": "Replace Submitted File", + "submitAssignment": "Submit Assignment", + "due": "Due: {date}", + "lateWarning": "You are submitting after the deadline. Late submission is allowed for this assignment.", + "submittedFile": "Submitted file", + "submittedDate": "Submitted: {date}", + "replaceWarning": "Uploading a new file will permanently replace the current one.", + "publishedGrade": "Published Grade", + "newFile": "New File", + "file": "File", + "maxFileSize": "(Max 20MB)", + "chooseFile": "Choose File", + "noFileChosen": "No file chosen", + "pdfHint": "Upload a PDF if possible for the best review experience.", + "note": "Note", + "optional": "(optional)", + "notePlaceholder": "Add a note for your teacher..." + }, + "empty": { + "title": "No assignments found", + "description": "Try changing your filters or search terms." + } + }, + "widgets": { + "viewAll": "View All", + "noEventsForDay": "No events for this day." + }, + "emptyStates": { + "defaultTitle": "No data found", + "defaultDescription": "There is nothing to display right now.", + "noResults": "No results found", + "filterDescription": "Try changing your filters or search terms.", + "searchDescription": "Try changing your search terms.", + "announcements": "No announcements found", + "classes": "No classes found", + "events": "No events found", + "grades": "No grades found", + "teachers": "No teachers found", + "results": "No results found", + "finalResults": "No final averages found", + "finalAverages": "No final averages found", + "messages": "No messages found", + "students": "No students found", + "parents": "No parents found", + "subjects": "No subjects found", + "notifications": "No notifications found", + "studyMaterials": "No study materials yet", + "uploadFirstMaterial": "Upload the first material using the upload button.", + "noStudyMaterialsForStudent": "Your teacher hasn't uploaded any materials yet.", + "uploadMaterial": "Upload Material", + "uploadStudyMaterial": "Upload Study Material", + "assignments": "No assignments yet.", + "teachersAssigned": "No teachers assigned yet." + }, + "subjectDetails": { + "subjects": "Subjects", + "grade": "Grade", + "gradeLevel": "Grade {level}", + "teacherCount": "{count, plural, one {# teacher} other {# teachers}}", + "teachers": "Teachers", + "classes": "Classes", + "assignments": "Assignments", + "materials": "Materials", + "exams": "Exams", + "subjectInfo": "Subject Info", + "subjectTeacher": "Subject Teacher", + "yourTeachers": "{count, plural, one {Your Teacher} other {Your Teachers}}", + "createExam": "Create Exam", + "manageAssignments": "Manage Assignments", + "manageExams": "Manage Exams", + "class": "Class", + "due": "Due", + "overdue": "Overdue", + "download": "Download", + "downloadAssignmentFile": "Download assignment file", + "noExams": "No exams scheduled.", + "studyMaterials": "Study Materials", + "upload": "Upload", + "delete": "Delete", + "tabs": { + "materials": "Materials", + "assignments": "Assignments", + "exams": "Exams" + }, + "editor": { + "trigger": "Edit Page", + "title": "Edit Subject Page", + "bannerImage": "Banner Image", + "bannerPreviewAlt": "Banner preview", + "uploadBanner": "Click to upload banner", + "bannerHint": "Max 5MB - JPG, PNG, WebP", + "height": "Height:", + "heights": { + "sm": "Small", + "md": "Medium", + "lg": "Large" + }, + "announcement": "Announcement", + "announcementHint": "(shown at top for students)", + "announcementPlaceholder": "e.g. \"Welcome to Mathematics! Please review chapter 3 before Monday.\"", + "courseDescription": "Course Description", + "descriptionPlaceholder": "Brief description of this subject...", + "wordCount": "{count}/{max} words", + "cancel": "Cancel", + "saveChanges": "Save Changes" + }, + "materialUpload": { + "title": "Upload Study Material", + "submit": "Upload Material", + "maxSize": "(Max 20MB)", + "fields": { + "title": "Title", + "description": "Description", + "file": "File" + }, + "placeholders": { + "title": "e.g. Chapter 3 Notes", + "description": "Optional description..." + }, + "chooseFile": "Choose File", + "noFileChosen": "No file chosen" + } + }, + "finalGradeEdit": { + "edit": "Edit final grade", + "title": "Edit Final Grade", + "finalGrade": "Final Grade (%)", + "cancel": "Cancel", + "save": "Save", + "saving": "Saving...", + "updated": "Final grade updated." + } +} diff --git a/next.config.mjs b/next.config.mjs index 0cfa573..b3c1b37 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,3 +1,7 @@ +import createNextIntlPlugin from "next-intl/plugin"; + +const withNextIntl = createNextIntlPlugin(); + /** @type {import('next').NextConfig} */ const nextConfig = { images: { @@ -7,6 +11,11 @@ const nextConfig = { { hostname: "img.clerk.com" }, ], }, + experimental: { + serverActions: { + bodySizeLimit: "25mb", + }, + }, }; -export default nextConfig; +export default withNextIntl(nextConfig); diff --git a/package-lock.json b/package-lock.json index e5ddbc6..ee71c54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,19 +7,24 @@ "": { "name": "academix-dev-next-dashboard", "version": "0.1.0", + "hasInstallScript": true, "dependencies": { "@clerk/nextjs": "^7.0.12", + "@google/genai": "^2.7.0", "@hookform/resolvers": "^3.9.0", "@prisma/adapter-pg": "^7.6.0", - "@prisma/client": "^7.6.0", + "@prisma/client": "^7.8.0", "@types/react-big-calendar": "^1.8.9", + "cloudinary": "^2.10.0", "date-fns": "^4.1.0", "dotenv": "^17.4.0", "lucide-react": "^1.7.0", "moment": "^2.30.1", "next": "16.2.3", "next-cloudinary": "^6.17.5", - "pg": "^8.20.0", + "next-intl": "^4.13.0", + "nodemailer": "^8.0.10", + "pg": "^8.21.0", "prisma": "^7.6.0", "react": "^19.2.3", "react-big-calendar": "^1.13.2", @@ -32,11 +37,13 @@ }, "devDependencies": { "@types/node": "^20", + "@types/nodemailer": "^8.0.0", "@types/react": "19.2.2", "@types/react-dom": "19.2.2", "eslint": "^9.39.0", "eslint-config-next": "16.2.3", "postcss": "^8", + "prisma": "^7.8.0", "tailwindcss": "^3.4.1", "tsx": "^4.21.0", "typescript": "^5" @@ -56,13 +63,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -71,9 +78,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -81,21 +88,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -112,14 +119,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -129,14 +136,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -146,9 +153,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -156,29 +163,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -188,9 +195,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -198,9 +205,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -208,9 +215,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -218,27 +225,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -248,42 +255,42 @@ } }, "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -291,26 +298,26 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@clerk/backend": { - "version": "3.2.8", - "resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-3.2.8.tgz", - "integrity": "sha512-N9yqCQCdkn/4XpfiVnaoS6wXDeC2yQf85ioHGolOIOCWXOPwMd63fONUfRhwOZSlXGTAsgyUNZu87Ps0tcVYsg==", + "version": "3.4.14", + "resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-3.4.14.tgz", + "integrity": "sha512-0iaMT7k4wDk31QVC3HMaoeVFttblwsCECTHKNQpbRzIyD8j2gHdKEw/FNjffoyqyBqPw869IQlk1YokUlwVAqQ==", "license": "MIT", "dependencies": { - "@clerk/shared": "^4.6.0", + "@clerk/shared": "^4.14.0", "standardwebhooks": "^1.0.0", "tslib": "2.8.1" }, @@ -319,14 +326,14 @@ } }, "node_modules/@clerk/nextjs": { - "version": "7.0.12", - "resolved": "https://registry.npmjs.org/@clerk/nextjs/-/nextjs-7.0.12.tgz", - "integrity": "sha512-dq4iOLKf5PImLlA2YAs7c2E+yBiOYlbMlnw386xw+sTe/kv4KpRQPk2g/7PeEvFxFaV1gY764iy2sr4jy2bdQA==", + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@clerk/nextjs/-/nextjs-7.4.2.tgz", + "integrity": "sha512-DpqljtLkTtMNsi03XRWYaWYyt0tb4S07HjS9fSmUtN//CKNqM7QAVMZzpgMAZz5yb2QhIVIVF1Uir2c7f72+MA==", "license": "MIT", "dependencies": { - "@clerk/backend": "^3.2.8", - "@clerk/react": "^6.2.1", - "@clerk/shared": "^4.6.0", + "@clerk/backend": "^3.4.14", + "@clerk/react": "^6.7.2", + "@clerk/shared": "^4.14.0", "server-only": "0.0.1", "tslib": "2.8.1" }, @@ -340,12 +347,12 @@ } }, "node_modules/@clerk/react": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/@clerk/react/-/react-6.2.1.tgz", - "integrity": "sha512-fZpCBHTRgxkLk1S7FPp8iDpl2ZoZ/VFKvQWs3CFRX4Z9FFEcC5eAMPYbEL3treUv3TzQxcGTSgp5gd9+mFx4LQ==", + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@clerk/react/-/react-6.7.2.tgz", + "integrity": "sha512-tgRWWTfW+ejXj6WiZqx7iMbtvh45h0445uEvbSNVCyJBEOaBlCF0/ZfAfXlWAQNbRZ+bPIphEfNUmatadZgGOQ==", "license": "MIT", "dependencies": { - "@clerk/shared": "^4.6.0", + "@clerk/shared": "^4.14.0", "tslib": "2.8.1" }, "engines": { @@ -357,16 +364,16 @@ } }, "node_modules/@clerk/shared": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-4.6.0.tgz", - "integrity": "sha512-dtbsO/+xK5e+qWuOAY1ekZ8BJxsB0F0LYDpXWjRON7JSoFKSaqqaH5cwBvdjbbWaehb6jz1YUQmWVV8gBOx6Ag==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-4.14.0.tgz", + "integrity": "sha512-StZCFJ2rg0ITE3fYjIN9CKuKq8ZACW6l2+5qGCFPPYd4hZphA+VDselmh/lHPsF1ex/WRVUSHvquykAHR8cQbQ==", "hasInstallScript": true, "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.90.16", + "@tanstack/query-core": "^5.100.6", "dequal": "2.0.3", "glob-to-regexp": "0.4.1", - "js-cookie": "3.0.5", + "js-cookie": "3.0.7", "std-env": "^3.9.0" }, "engines": { @@ -437,12 +444,14 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz", "integrity": "sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==", + "devOptional": true, "license": "Apache-2.0" }, "node_modules/@electric-sql/pglite-socket": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.1.tgz", "integrity": "sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw==", + "devOptional": true, "license": "Apache-2.0", "bin": { "pglite-server": "dist/scripts/server.js" @@ -455,15 +464,16 @@ "version": "0.3.1", "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.3.1.tgz", "integrity": "sha512-C+T3oivmy9bpQvSxVqXA1UDY8cB9Eb9vZHL9zxWwEUfDixbXv4G3r2LjoTdR33LD8aomR3O9ZXEO3XEwr/cUCA==", + "devOptional": true, "license": "Apache-2.0", "peerDependencies": { "@electric-sql/pglite": "0.4.1" } }, "node_modules/@emnapi/core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", - "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, "license": "MIT", "optional": true, @@ -473,9 +483,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", - "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "license": "MIT", "optional": true, "dependencies": { @@ -494,9 +504,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", "cpu": [ "ppc64" ], @@ -511,9 +521,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", "cpu": [ "arm" ], @@ -528,9 +538,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", "cpu": [ "arm64" ], @@ -545,9 +555,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", "cpu": [ "x64" ], @@ -562,9 +572,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", "cpu": [ "arm64" ], @@ -579,9 +589,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", "cpu": [ "x64" ], @@ -596,9 +606,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", "cpu": [ "arm64" ], @@ -613,9 +623,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", "cpu": [ "x64" ], @@ -630,9 +640,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", "cpu": [ "arm" ], @@ -647,9 +657,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", "cpu": [ "arm64" ], @@ -664,9 +674,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", "cpu": [ "ia32" ], @@ -681,9 +691,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", "cpu": [ "loong64" ], @@ -698,9 +708,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", "cpu": [ "mips64el" ], @@ -715,9 +725,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", "cpu": [ "ppc64" ], @@ -732,9 +742,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", "cpu": [ "riscv64" ], @@ -749,9 +759,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", "cpu": [ "s390x" ], @@ -766,9 +776,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", "cpu": [ "x64" ], @@ -783,9 +793,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", "cpu": [ "arm64" ], @@ -800,9 +810,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", "cpu": [ "x64" ], @@ -817,9 +827,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", "cpu": [ "arm64" ], @@ -834,9 +844,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", "cpu": [ "x64" ], @@ -851,9 +861,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", "cpu": [ "arm64" ], @@ -868,9 +878,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", "cpu": [ "x64" ], @@ -885,9 +895,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", "cpu": [ "arm64" ], @@ -902,9 +912,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", "cpu": [ "ia32" ], @@ -919,9 +929,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", "cpu": [ "x64" ], @@ -1079,10 +1089,65 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@formatjs/fast-memoize": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.6.tgz", + "integrity": "sha512-H5aexk1Le7T9TPmscacZ+1pR6CTa2n1wq+HDVGXhH8TzUlQQpeXzZs91dRtmFHrbeNbjPFPfQujUqm7MHgVoXQ==", + "license": "MIT" + }, + "node_modules/@formatjs/icu-messageformat-parser": { + "version": "3.5.11", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.11.tgz", + "integrity": "sha512-NVsuNsc2dUVG9+4HBJ/srScxtA/18LqGgwtop/tuN/OIBjVl6QA+0KhfZQddDD9sEh2LeVjLFPGVU3ixa3blcA==", + "license": "MIT", + "dependencies": { + "@formatjs/icu-skeleton-parser": "2.1.10" + } + }, + "node_modules/@formatjs/icu-skeleton-parser": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.10.tgz", + "integrity": "sha512-XuSva+8ZGawk8VnD5VD6UeH8KarQ/Z022zgjHDoHmlNiAewstXuuzXc0Hk5pGFSdG+nNw5bfJKXqj1ZXHn9yUA==", + "license": "MIT" + }, + "node_modules/@formatjs/intl-localematcher": { + "version": "0.8.10", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.10.tgz", + "integrity": "sha512-P/IC3qws3jH+1fEs+o0RIFgXKRaQlFehjS5W0FPAqdo6hgzawLl+eD0q0JjheQ3XtoOe5n8WSYfX06KQZI/QJA==", + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "3.1.6" + } + }, + "node_modules/@google/genai": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.7.0.tgz", + "integrity": "sha512-tv0DRtcndt2oEhBYy+5mA0TaXH98+L1Gt0AP9unBfH7DP20KhB7+O3QqAN1Lz+laMARGTHS7BFQSNpLbl4gm1g==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, "node_modules/@hono/node-server": { "version": "1.19.11", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=18.14.1" @@ -1101,29 +1166,43 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -1672,19 +1751,26 @@ "version": "0.3.4", "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "devOptional": true, "license": "MIT" }, "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", - "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.10.0" + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, "node_modules/@next/env": { @@ -1879,6 +1965,313 @@ "node": ">=12.4.0" } }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -1890,24 +2283,24 @@ } }, "node_modules/@prisma/adapter-pg": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.7.0.tgz", - "integrity": "sha512-q33Ta8sKbgzEpAy0lx45tAq//yMv0qcb+8nj+TCA3P4wiAY+OBFEFk/NDkZncAfHaNJeGo5WJpJdpbL+ijYx8g==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.8.0.tgz", + "integrity": "sha512-ygb3UkerK3v8MDpXVgCISdRNDozpxh6+JVJgiIGbSr5KBgz10LLf5ejUskPGoXlsIjxsOu6nuy1JVQr2EKGSlg==", "license": "Apache-2.0", "dependencies": { - "@prisma/driver-adapter-utils": "7.7.0", + "@prisma/driver-adapter-utils": "7.8.0", "@types/pg": "^8.16.0", "pg": "^8.16.3", "postgres-array": "3.0.4" } }, "node_modules/@prisma/client": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.7.0.tgz", - "integrity": "sha512-5Ar4OsZpJ54s21sy5oDNNW9gQtd4NuxCaiM7+JDTOU07D6VvlpLjYzAVCMB1+JzokN+08dAVomlx+b7bhJd3ww==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.8.0.tgz", + "integrity": "sha512-HFp3Dawv/3sU3JtlPha90IB+48lS7zHiH4LKZPjmcE8YH5P9DOXGPvo8dqOtO7MqLDd1p2hOWMcFlRT1DMblHw==", "license": "Apache-2.0", "dependencies": { - "@prisma/client-runtime-utils": "7.7.0" + "@prisma/client-runtime-utils": "7.8.0" }, "engines": { "node": "^20.19 || ^22.12 || >=24.0" @@ -1926,33 +2319,35 @@ } }, "node_modules/@prisma/client-runtime-utils": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.7.0.tgz", - "integrity": "sha512-BLyd0UpFYOtyJFTHm7jS9vesHW7P83abibodQMiIofqjBKzDHQ1VAsQkdfvXyYDkPlONPfOTz7/rv3x/+CQqvQ==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.8.0.tgz", + "integrity": "sha512-5NQZztQ0oY/ADFkmd9gPuweH5A1/CCY8YQPorLLO0Mu6a87mY5gsnDkzmFmIHs9NFaLnZojzgddFVN4RpKYrdw==", "license": "Apache-2.0" }, "node_modules/@prisma/config": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.7.0.tgz", - "integrity": "sha512-hmPI3tKLO2aP0Y5vugbjcnA9qqlfJndiT6ds4tw28U5hNHLWg+mHJEWAhjsSPgxjtmxhJ/EDIeIlyh+3Us0OPg==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.8.0.tgz", + "integrity": "sha512-HFESzd9rx2ZQxlK+TL7tu1HPvCqrHiL6LCxYykI2c34mvaUuIVVl3lYuicJD/MNnzgPnyeBEMlK4WTomJCV5jw==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { - "c12": "3.1.0", + "c12": "3.3.4", "deepmerge-ts": "7.1.5", "effect": "3.20.0", "empathic": "2.0.0" } }, "node_modules/@prisma/debug": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.7.0.tgz", - "integrity": "sha512-12J62XdqCmpiwJHhHdQxZeY3ckVCWIFmcJP8hg5dPTceeiQ0wiojXGFYTluKqFQfu46fRLgb/rLALZMAx3+dTA==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.8.0.tgz", + "integrity": "sha512-p+QZReysDUqXC+mk17q9a+Y/qzh4c2KYliDK30buYUyfrGeTGSyfmc0AIrJRhZJrLHhRiJa9Au/J72h3C+szvA==", "license": "Apache-2.0" }, "node_modules/@prisma/dev": { "version": "0.24.3", "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.3.tgz", "integrity": "sha512-ffHlQuKXZiaDt9Go0OnCTdJZrHxK0k7omJKNV86/VjpsXu5EIHZLK0T7JSWgvNlJwh56kW9JFu9v0qJciFzepg==", + "devOptional": true, "license": "ISC", "dependencies": { "@electric-sql/pglite": "0.4.1", @@ -1975,66 +2370,79 @@ } }, "node_modules/@prisma/driver-adapter-utils": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.7.0.tgz", - "integrity": "sha512-gZXREeu6mOk7zXfGFJgh86p7Vhj0sXNKp+4Cg1tWYo7V2dfncP2qxS2BiTmbIIha8xPqItkl0WSw38RuSq1HoQ==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.8.0.tgz", + "integrity": "sha512-/Q13o0ZT0rjc1Xk0Q9KhZYwuq2EW/vSbWUBKfgEKkaCuB/Sg6bqnjmTZqC5cD4d6y1vfFAEwBRzfzoSMIVJ55A==", "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.7.0" + "@prisma/debug": "7.8.0" } }, "node_modules/@prisma/engines": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.7.0.tgz", - "integrity": "sha512-7fmcbT7HHXBq/b+3h/dO1JI3fd8l8q7erf7xP7pRprh58hmSSnG8mg9K3yjW3h9WaHWUwngVFpSxxxivaitQ2w==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.8.0.tgz", + "integrity": "sha512-jx3rCnNNrt5uzbkKlegtQ2GZHxSlihMCzutgT/BP6UIDF1r9tDI39hV/0T/cHZgzJ3ELbuQPXlVZy+Y1n0pcgw==", + "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.7.0", - "@prisma/engines-version": "7.6.0-1.75cbdc1eb7150937890ad5465d861175c6624711", - "@prisma/fetch-engine": "7.7.0", - "@prisma/get-platform": "7.7.0" + "@prisma/debug": "7.8.0", + "@prisma/engines-version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", + "@prisma/fetch-engine": "7.8.0", + "@prisma/get-platform": "7.8.0" } }, "node_modules/@prisma/engines-version": { - "version": "7.6.0-1.75cbdc1eb7150937890ad5465d861175c6624711", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.6.0-1.75cbdc1eb7150937890ad5465d861175c6624711.tgz", - "integrity": "sha512-r51DLcJ8bDRSrBEJF3J4cinoWyGA7rfP2mG6lD90VqIbGNOkbfcLcXalSVjq5Y6brQS3vcjrq4GbyUb1Cb7vkw==", + "version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a.tgz", + "integrity": "sha512-fJPQxCkLgA5EayWaW8eArgCvjJ+N+Kz3VyeNKMEeYiQC4alNkxRKFVAGxv/ZUzuJISKqdw+zGeDbS6mn6RCPOA==", + "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.7.0.tgz", - "integrity": "sha512-MEUNzvKxvYnJ7kgvd6oNRnMmmiGNS9TYLB2weMeIXplnHdL/UWEGnvavYGnN7KLJ2n0iI4dDAyzSkHI3c7AscQ==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", + "integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.7.0" + "@prisma/debug": "7.8.0" } }, "node_modules/@prisma/fetch-engine": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.7.0.tgz", - "integrity": "sha512-TfyzveBQoK4xALzsTpVhB/0KG1N8zOK0ap+RnBMkzGUu3f98fnQ4QtXa2wlKPhsO2X8a3N5ugFQgcKNoHGmDfw==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.8.0.tgz", + "integrity": "sha512-gwB0Euiz/DDRyxFRpLXYlK3RfaZUj1c5dAYMuhZYfApg7arknJlcb9bIsOHDppJmbqYaVA+yBIiFMDBfprsNPQ==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.7.0", - "@prisma/engines-version": "7.6.0-1.75cbdc1eb7150937890ad5465d861175c6624711", - "@prisma/get-platform": "7.7.0" + "@prisma/debug": "7.8.0", + "@prisma/engines-version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", + "@prisma/get-platform": "7.8.0" } }, + "node_modules/@prisma/fetch-engine/node_modules/@prisma/debug": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.8.0.tgz", + "integrity": "sha512-p+QZReysDUqXC+mk17q9a+Y/qzh4c2KYliDK30buYUyfrGeTGSyfmc0AIrJRhZJrLHhRiJa9Au/J72h3C+szvA==", + "devOptional": true, + "license": "Apache-2.0" + }, "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.7.0.tgz", - "integrity": "sha512-MEUNzvKxvYnJ7kgvd6oNRnMmmiGNS9TYLB2weMeIXplnHdL/UWEGnvavYGnN7KLJ2n0iI4dDAyzSkHI3c7AscQ==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", + "integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.7.0" + "@prisma/debug": "7.8.0" } }, "node_modules/@prisma/get-platform": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz", "integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "7.2.0" @@ -2044,18 +2452,21 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz", "integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==", + "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/query-plan-executor": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz", "integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==", + "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/streams-local": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/@prisma/streams-local/-/streams-local-0.1.2.tgz", "integrity": "sha512-l49yTxKKF2odFxaAXTmwmkBKL3+bVQ1tFOooGifu4xkdb9NMNLxHj27XAhTylWZod8I+ISGM5erU1xcl/oBCtg==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { "ajv": "^8.12.0", @@ -2069,9 +2480,10 @@ } }, "node_modules/@prisma/streams-local/node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "devOptional": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -2088,12 +2500,14 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "devOptional": true, "license": "MIT" }, "node_modules/@prisma/studio-core": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.27.3.tgz", "integrity": "sha512-AADjNFPdsrglxHQVTmHFqv6DuKQZ5WY4p5/gVFY017twvNrSwpLJ9lqUbYYxEu2W7nbvVxTZA8deJ8LseNALsw==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@radix-ui/react-toggle": "1.1.10", @@ -2109,16 +2523,81 @@ "react-dom": "^18.0.0 || ^19.0.0" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "devOptional": true, "license": "MIT" }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -2134,6 +2613,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "devOptional": true, "license": "MIT", "dependencies": { "@radix-ui/react-slot": "1.2.3" @@ -2157,6 +2637,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "devOptional": true, "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" @@ -2175,6 +2656,7 @@ "version": "1.1.10", "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "devOptional": true, "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.3", @@ -2200,6 +2682,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "devOptional": true, "license": "MIT", "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", @@ -2219,6 +2702,7 @@ "version": "0.0.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "devOptional": true, "license": "MIT", "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" @@ -2237,6 +2721,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -2260,24 +2745,229 @@ "react": ">=16.8.0" } }, - "node_modules/@rtsao/scc": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", - "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", - "dev": true, - "license": "MIT" - }, - "node_modules/@stablelib/base64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", - "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", - "license": "MIT" + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@schummar/icu-type-parser": { + "version": "1.21.5", + "resolved": "https://registry.npmjs.org/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz", + "integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==", + "license": "MIT" + }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.15.40", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.40.tgz", + "integrity": "sha512-PaYyclfmQ++77D8ityYvmmVzHv9aG8ROwt2GfG6/ccloy4Hgf80qtOnzb9VYvPsUT7Ty1uhuDRhv3XYpf62qhQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.15.40", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.40.tgz", + "integrity": "sha512-HbbPzvfLBUXjIB1Ezks+//lNUjmLjfyd63XSwprJgrZaXYdm70kohXPJUWdqKZozolFxbPaO+xtBaiUp6BoueA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.15.40", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.40.tgz", + "integrity": "sha512-SlRZsCjOCPR2LvFs0Ri/Xrx/5o5TCt8vl4gW6mX1hEZOG0a625RxzRHpHdAQNGykmAN/7IeaFAJG+QnNmxlHcA==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.15.40", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.40.tgz", + "integrity": "sha512-Q8byxJt2fh8CR3EUX6snBpy47AoBVm+In/+Z3rjDHMjC38ZvR9/gtUUNCT0tfrn4EdVsO8/QPi59nxrxvqxvBQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.15.40", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.40.tgz", + "integrity": "sha512-4z0MgHU+7M0pZDqBN1El7mFXDI1SBwinfcUkAyA4v8QrhOIUOZltySt2aStQLZGrdXVXM4Y4ylfiTC04ED+MoQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-ppc64-gnu": { + "version": "1.15.40", + "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.40.tgz", + "integrity": "sha512-fLI4iUgeSZu0eRWUXwe6YzPFx9gHbFiPkl8Rp3mJfP8OpNR3nTQCGPvHdDh9xniW7mVvgMY4ni7A4VzqI1KrpA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-s390x-gnu": { + "version": "1.15.40", + "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.40.tgz", + "integrity": "sha512-YqeKMAb7d4nQSGMJQ454IlaCENpzcDqhvBE9+CPfdnYpnUXxd+BSrB6Xk0YjW8UyoEhUj4p6quATCxbsp6J3jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.15.40", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.40.tgz", + "integrity": "sha512-7HOuS1iGcme/j/TuL1TfmmLGiMQrjv/GmjyZeydl00FKPtpGXEldwqfI56xgd1YzrzoB2svWjxbGGyQ0TEASxg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.15.40", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.40.tgz", + "integrity": "sha512-h4kZYHc7dpc9P9u4brRJaS8Pl7tPVHAeiLSzw7T5RfIJgAoSdaCMKzI/2Uay9gFhaw8uyCDl0L5q37r0EpAfIA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.15.40", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.40.tgz", + "integrity": "sha512-+mQgKZXSj6mV38Zh05QaxSjUDmGP/R2JWlXZTDLSPkDzHU6p3GxN9eeSf5dfyDVU86946fmCvSzyl/ucImx8+A==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.15.40", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.40.tgz", + "integrity": "sha512-yvwdPLGd25mcj/mNatjNQ0lZujtQD6psH3v9PNmMb+fSzjbNG8KIDxjFWrcV+fsFVLOkyOmdJsFmX7NAFjVyPw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.15.40", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.40.tgz", + "integrity": "sha512-OXtKsLU1bVtInzzDEAY2sYiF/rl4tvAnLLLpuMp3HzAOQZ5A+i69AKDhA1YLQTaMAqO3vzyYNVAYVRMPtSYD4w==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT" + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" }, "node_modules/@swc/helpers": { "version": "0.5.15", @@ -2288,10 +2978,19 @@ "tslib": "^2.8.0" } }, + "node_modules/@swc/types": { + "version": "0.1.26", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz", + "integrity": "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==", + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, "node_modules/@tanstack/query-core": { - "version": "5.90.16", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.16.tgz", - "integrity": "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww==", + "version": "5.100.14", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.14.tgz", + "integrity": "sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==", "license": "MIT", "funding": { "type": "github", @@ -2299,9 +2998,9 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "dev": true, "license": "MIT", "optional": true, @@ -2379,9 +3078,9 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, @@ -2400,14 +3099,24 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.19.39", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", - "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "version": "20.19.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", + "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz", + "integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/pg": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", @@ -2449,12 +3158,18 @@ "version": "19.2.2", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz", "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" } }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, "node_modules/@types/warning": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.4.tgz", @@ -2462,17 +3177,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz", - "integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz", + "integrity": "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.58.1", - "@typescript-eslint/type-utils": "8.58.1", - "@typescript-eslint/utils": "8.58.1", - "@typescript-eslint/visitor-keys": "8.58.1", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/type-utils": "8.60.0", + "@typescript-eslint/utils": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -2485,7 +3200,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.58.1", + "@typescript-eslint/parser": "^8.60.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -2501,16 +3216,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.1.tgz", - "integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.0.tgz", + "integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.58.1", - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/typescript-estree": "8.58.1", - "@typescript-eslint/visitor-keys": "8.58.1", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", "debug": "^4.4.3" }, "engines": { @@ -2526,14 +3241,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.1.tgz", - "integrity": "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.0.tgz", + "integrity": "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.58.1", - "@typescript-eslint/types": "^8.58.1", + "@typescript-eslint/tsconfig-utils": "^8.60.0", + "@typescript-eslint/types": "^8.60.0", "debug": "^4.4.3" }, "engines": { @@ -2548,14 +3263,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.1.tgz", - "integrity": "sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz", + "integrity": "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/visitor-keys": "8.58.1" + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2566,9 +3281,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.1.tgz", - "integrity": "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz", + "integrity": "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==", "dev": true, "license": "MIT", "engines": { @@ -2583,15 +3298,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.1.tgz", - "integrity": "sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz", + "integrity": "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/typescript-estree": "8.58.1", - "@typescript-eslint/utils": "8.58.1", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -2608,9 +3323,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.1.tgz", - "integrity": "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz", + "integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==", "dev": true, "license": "MIT", "engines": { @@ -2622,16 +3337,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.1.tgz", - "integrity": "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz", + "integrity": "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.58.1", - "@typescript-eslint/tsconfig-utils": "8.58.1", - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/visitor-keys": "8.58.1", + "@typescript-eslint/project-service": "8.60.0", + "@typescript-eslint/tsconfig-utils": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -2660,9 +3375,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -2689,9 +3404,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "dev": true, "license": "ISC", "bin": { @@ -2702,16 +3417,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.1.tgz", - "integrity": "sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.0.tgz", + "integrity": "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.58.1", - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/typescript-estree": "8.58.1" + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2726,13 +3441,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.1.tgz", - "integrity": "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz", + "integrity": "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/types": "8.60.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -2757,9 +3472,9 @@ } }, "node_modules/@unrs/resolver-binding-android-arm-eabi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", - "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", "cpu": [ "arm" ], @@ -2771,9 +3486,9 @@ ] }, "node_modules/@unrs/resolver-binding-android-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", - "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", "cpu": [ "arm64" ], @@ -2785,9 +3500,9 @@ ] }, "node_modules/@unrs/resolver-binding-darwin-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", - "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", "cpu": [ "arm64" ], @@ -2799,9 +3514,9 @@ ] }, "node_modules/@unrs/resolver-binding-darwin-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", - "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", "cpu": [ "x64" ], @@ -2813,9 +3528,9 @@ ] }, "node_modules/@unrs/resolver-binding-freebsd-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", - "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", "cpu": [ "x64" ], @@ -2827,9 +3542,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", - "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", "cpu": [ "arm" ], @@ -2841,9 +3556,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", - "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", "cpu": [ "arm" ], @@ -2855,9 +3570,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", - "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", "cpu": [ "arm64" ], @@ -2869,9 +3584,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-arm64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", - "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", "cpu": [ "arm64" ], @@ -2882,10 +3597,38 @@ "linux" ] }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", - "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", "cpu": [ "ppc64" ], @@ -2897,9 +3640,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", - "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", "cpu": [ "riscv64" ], @@ -2911,9 +3654,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", - "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", "cpu": [ "riscv64" ], @@ -2925,9 +3668,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", - "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", "cpu": [ "s390x" ], @@ -2939,9 +3682,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-x64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", - "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", "cpu": [ "x64" ], @@ -2953,9 +3696,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-x64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", - "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", "cpu": [ "x64" ], @@ -2966,10 +3709,24 @@ "linux" ] }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, "node_modules/@unrs/resolver-binding-wasm32-wasi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", - "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", "cpu": [ "wasm32" ], @@ -2977,16 +3734,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^0.2.11" + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", - "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", "cpu": [ "arm64" ], @@ -2998,9 +3757,9 @@ ] }, "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", - "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", "cpu": [ "ia32" ], @@ -3012,9 +3771,9 @@ ] }, "node_modules/@unrs/resolver-binding-win32-x64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", - "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", "cpu": [ "x64" ], @@ -3057,10 +3816,19 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -3332,15 +4100,16 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "devOptional": true, "license": "MIT", "engines": { "node": ">= 6.0.0" } }, "node_modules/axe-core": { - "version": "4.11.2", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.2.tgz", - "integrity": "sha512-byD6KPdvo72y/wj2T/4zGEvvlis+PsZsn/yPS3pEO+sFpcrqRpX/TJCxvVaEsNeMrfQbCr7w163YqoD9IYwHXw==", + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz", + "integrity": "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==", "dev": true, "license": "MPL-2.0", "engines": { @@ -3364,10 +4133,30 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { - "version": "2.10.17", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.17.tgz", - "integrity": "sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==", + "version": "2.10.33", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", + "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -3377,11 +4166,21 @@ } }, "node_modules/better-result": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/better-result/-/better-result-2.8.2.tgz", - "integrity": "sha512-YOf0VSj5nUPI27doTtXF+BBnsiRq3qY7avHqfIWnppxTLGyvkLq1QV2RTxkwoZwJ60ywLfZ0raFF4J/G886i7A==", + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/better-result/-/better-result-2.9.2.tgz", + "integrity": "sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==", + "devOptional": true, "license": "MIT" }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -3396,9 +4195,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "license": "MIT", "dependencies": { @@ -3453,27 +4252,34 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/c12": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", - "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz", + "integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==", + "devOptional": true, "license": "MIT", "dependencies": { - "chokidar": "^4.0.3", - "confbox": "^0.2.2", - "defu": "^6.1.4", - "dotenv": "^16.6.1", - "exsolve": "^1.0.7", - "giget": "^2.0.0", - "jiti": "^2.4.2", + "chokidar": "^5.0.0", + "confbox": "^0.2.4", + "defu": "^6.1.6", + "dotenv": "^17.3.1", + "exsolve": "^1.0.8", + "giget": "^3.2.0", + "jiti": "^2.6.1", "ohash": "^2.0.11", "pathe": "^2.0.3", - "perfect-debounce": "^1.0.0", - "pkg-types": "^2.2.0", - "rc9": "^2.1.2" + "perfect-debounce": "^2.1.0", + "pkg-types": "^2.3.0", + "rc9": "^3.0.1" }, "peerDependencies": { - "magicast": "^0.3.5" + "magicast": "*" }, "peerDependenciesMeta": { "magicast": { @@ -3481,18 +4287,6 @@ } } }, - "node_modules/c12/node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -3564,9 +4358,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001787", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", - "integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==", + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", "funding": [ { "type": "opencollective", @@ -3604,6 +4398,7 @@ "version": "4.5.1", "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "devOptional": true, "license": "MIT", "dependencies": { "@kurkle/color": "^0.3.0" @@ -3613,35 +4408,39 @@ } }, "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "devOptional": true, "license": "MIT", "dependencies": { - "readdirp": "^4.0.1" + "readdirp": "^5.0.0" }, "engines": { - "node": ">= 14.16.0" + "node": ">= 20.19.0" }, "funding": { "url": "https://paulmillr.com/funding/" } }, - "node_modules/citty": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", - "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", - "license": "MIT", - "dependencies": { - "consola": "^3.2.3" - } - }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/cloudinary": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-2.10.0.tgz", + "integrity": "sha512-sY09kYg7wprkndAOjZBAYqFZqwL+SxnEGcAvksOvFA+5upnFn949UjkEkHKNSwkBtW/xRDd0p6NgbSXZcxkI3w==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.23" + }, + "engines": { + "node": ">=9" + } + }, "node_modules/clsx": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", @@ -3692,17 +4491,9 @@ "version": "0.2.4", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "devOptional": true, "license": "MIT" }, - "node_modules/consola": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", - "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "license": "MIT", - "engines": { - "node": "^14.18.0 || >=16.10.0" - } - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -3714,6 +4505,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "devOptional": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -3871,6 +4663,15 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -3932,9 +4733,9 @@ "license": "MIT" }, "node_modules/date-fns": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", - "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", "license": "MIT", "funding": { "type": "github", @@ -3942,16 +4743,15 @@ } }, "node_modules/dayjs": { - "version": "1.11.20", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", - "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", "license": "MIT" }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3982,6 +4782,7 @@ "version": "7.1.5", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=16.0.0" @@ -4027,12 +4828,14 @@ "version": "6.1.7", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "devOptional": true, "license": "MIT" }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=0.10" @@ -4051,6 +4854,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "devOptional": true, "license": "MIT" }, "node_modules/detect-libc": { @@ -4058,7 +4862,6 @@ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "license": "Apache-2.0", - "optional": true, "engines": { "node": ">=8" } @@ -4101,9 +4904,9 @@ } }, "node_modules/dotenv": { - "version": "17.4.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", - "integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==", + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -4127,10 +4930,20 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/effect": { "version": "3.20.0", "resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz", "integrity": "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==", + "devOptional": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -4138,9 +4951,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.334", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.334.tgz", - "integrity": "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==", + "version": "1.5.364", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz", + "integrity": "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==", "dev": true, "license": "ISC" }, @@ -4155,6 +4968,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=14" @@ -4164,6 +4978,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "devOptional": true, "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" @@ -4290,9 +5105,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "dev": true, "license": "MIT", "dependencies": { @@ -4350,9 +5165,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -4363,32 +5178,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" } }, "node_modules/escalade": { @@ -4572,9 +5387,9 @@ } }, "node_modules/eslint-module-utils": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", - "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.13.0.tgz", + "integrity": "sha512-bLohSkT6469rRs8czj0tLTD8vaeIS/whvPRJVjDr7IuoTT1k5DYDERlNycjDj/HkOlvQdYurmfZ/g3fG5bgeLQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4707,9 +5522,9 @@ } }, "node_modules/eslint-plugin-react-hooks": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", - "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", "dev": true, "license": "MIT", "dependencies": { @@ -4723,7 +5538,7 @@ "node": ">=18" }, "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "node_modules/eslint-scope": { @@ -4830,12 +5645,20 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, "node_modules/fast-check": { "version": "3.23.2", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "devOptional": true, "funding": [ { "type": "individual", @@ -4858,6 +5681,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "devOptional": true, "license": "MIT" }, "node_modules/fast-equals": { @@ -4920,9 +5744,10 @@ "license": "Unlicense" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "devOptional": true, "funding": [ { "type": "github", @@ -4945,6 +5770,29 @@ "reusify": "^1.0.4" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -5029,6 +5877,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "devOptional": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", @@ -5041,6 +5890,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -5097,10 +5958,39 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/generate-function": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "devOptional": true, "license": "MIT", "dependencies": { "is-property": "^1.0.2" @@ -5155,6 +6045,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz", "integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==", + "devOptional": true, "license": "MIT" }, "node_modules/get-proto": { @@ -5190,9 +6081,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.13.7", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", - "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", "dev": true, "license": "MIT", "dependencies": { @@ -5215,18 +6106,11 @@ } }, "node_modules/giget": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", - "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-3.2.0.tgz", + "integrity": "sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==", + "devOptional": true, "license": "MIT", - "dependencies": { - "citty": "^0.1.6", - "consola": "^3.4.0", - "defu": "^6.1.4", - "node-fetch-native": "^1.6.6", - "nypm": "^0.6.0", - "pathe": "^2.0.3" - }, "bin": { "giget": "dist/cli.mjs" } @@ -5285,6 +6169,32 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -5302,18 +6212,21 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "devOptional": true, "license": "ISC" }, "node_modules/grammex": { "version": "3.1.12", "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz", "integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==", + "devOptional": true, "license": "MIT" }, "node_modules/graphmatch": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.1.tgz", "integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==", + "devOptional": true, "license": "MIT" }, "node_modules/has-bigints": { @@ -5398,9 +6311,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "dev": true, "license": "MIT", "dependencies": { @@ -5428,9 +6341,10 @@ } }, "node_modules/hono": { - "version": "4.12.12", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz", - "integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==", + "version": "4.12.23", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", + "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=16.9.0" @@ -5440,12 +6354,27 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==", + "devOptional": true, "license": "MIT" }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "devOptional": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -5458,6 +6387,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/icu-minify": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.13.0.tgz", + "integrity": "sha512-SIFMeUHZJjzS5RvIGvybKvWoHjDm9cGVEs2EpJ8PmywOdJLWyblPm7TdPLLoUtkJtwQD7iGhl2WMptZ+N0on+w==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/amannn" + } + ], + "license": "MIT", + "dependencies": { + "@formatjs/icu-messageformat-parser": "^3.4.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5519,6 +6463,16 @@ "node": ">=12" } }, + "node_modules/intl-messageformat": { + "version": "11.2.8", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.2.8.tgz", + "integrity": "sha512-l323RCl3qJDVQ8U9j74ut/hVMdg3VPsOHpVMDvFfz9qiq4dPO5ooVYFNVUzzrpgG39a+RLzcXyJb8VFgIU+tUA==", + "license": "BSD-3-Clause", + "dependencies": { + "@formatjs/fast-memoize": "3.1.6", + "@formatjs/icu-messageformat-parser": "3.5.11" + } + }, "node_modules/invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -5623,9 +6577,9 @@ } }, "node_modules/is-bun-module/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "dev": true, "license": "ISC", "bin": { @@ -5649,13 +6603,13 @@ } }, "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", "dev": true, "license": "MIT", "dependencies": { - "hasown": "^2.0.2" + "hasown": "^2.0.3" }, "engines": { "node": ">= 0.4" @@ -5703,7 +6657,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5749,7 +6702,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -5815,6 +6767,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "devOptional": true, "license": "MIT" }, "node_modules/is-regex": { @@ -5973,6 +6926,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "devOptional": true, "license": "ISC" }, "node_modules/iterator.prototype": { @@ -5994,21 +6948,22 @@ } }, "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "devOptional": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" } }, "node_modules/js-cookie": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", - "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.7.tgz", + "integrity": "sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==", "license": "MIT", "engines": { - "node": ">=14" + "node": ">=20" } }, "node_modules/js-tokens": { @@ -6043,6 +6998,15 @@ "node": ">=6" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -6093,6 +7057,27 @@ "node": ">=4.0" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -6224,6 +7209,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "devOptional": true, "license": "MIT", "engines": { "bun": ">=1.0.0", @@ -6236,9 +7222,9 @@ } }, "node_modules/lucide-react": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.8.0.tgz", - "integrity": "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==", + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.17.0.tgz", + "integrity": "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -6378,13 +7364,13 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mysql2": { "version": "3.15.3", "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", + "devOptional": true, "license": "MIT", "dependencies": { "aws-ssl-profiles": "^1.1.1", @@ -6417,6 +7403,7 @@ "version": "1.1.6", "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "devOptional": true, "license": "MIT", "dependencies": { "lru.min": "^1.1.0" @@ -6426,9 +7413,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "funding": [ { "type": "github", @@ -6466,6 +7453,15 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/next": { "version": "16.2.3", "resolved": "https://registry.npmjs.org/next/-/next-16.2.3.tgz", @@ -6534,6 +7530,94 @@ "react": "^17 || ^18 || >=19.0.0-beta || ^19" } }, + "node_modules/next-intl": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.13.0.tgz", + "integrity": "sha512-OvNq2v5XLx4EkQOsAhVE9g+6zdb83XHusADCXXtIW4LILYnjEVaeINdr1lkVWKSjzwNUiMSlH5N4K0OQTRiv6A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/amannn" + } + ], + "license": "MIT", + "dependencies": { + "@formatjs/intl-localematcher": "^0.8.1", + "@parcel/watcher": "^2.4.1", + "@swc/core": "^1.15.2", + "icu-minify": "^4.13.0", + "negotiator": "^1.0.0", + "next-intl-swc-plugin-extractor": "^4.13.0", + "po-parser": "^2.1.1", + "use-intl": "^4.13.0" + }, + "peerDependencies": { + "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/next-intl-swc-plugin-extractor": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.13.0.tgz", + "integrity": "sha512-6S/fJI0KXvLCL8nhBo9P8eGaJPzmwJBTCzX0NaUIj0VyU8U89d//T+vjMLdNIXl5MlLaYH7B9MbAjb8Mvu+tqQ==", + "license": "MIT" + }, + "node_modules/next-intl/node_modules/@swc/core": { + "version": "1.15.40", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.40.tgz", + "integrity": "sha512-2kwzJikRvgtNAG7MwVZY2vEzZjTxKIq5jXOihuSV/8U+Hej8Va22t65aKnJZs3P+NwojZvR8Mf8kyM7O+V8sQg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.26" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.15.40", + "@swc/core-darwin-x64": "1.15.40", + "@swc/core-linux-arm-gnueabihf": "1.15.40", + "@swc/core-linux-arm64-gnu": "1.15.40", + "@swc/core-linux-arm64-musl": "1.15.40", + "@swc/core-linux-ppc64-gnu": "1.15.40", + "@swc/core-linux-s390x-gnu": "1.15.40", + "@swc/core-linux-x64-gnu": "1.15.40", + "@swc/core-linux-x64-musl": "1.15.40", + "@swc/core-win32-arm64-msvc": "1.15.40", + "@swc/core-win32-ia32-msvc": "1.15.40", + "@swc/core-win32-x64-msvc": "1.15.40" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/next-intl/node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -6562,6 +7646,32 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-exports-info": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", @@ -6581,18 +7691,42 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/node-fetch-native": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", - "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", - "license": "MIT" + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } }, "node_modules/node-releases": { - "version": "2.0.37", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", - "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", + "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nodemailer": { + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.10.tgz", + "integrity": "sha512-BLFuSth7QtHOkBzyqTehWWyub0NTRDuK2Q2SQfnGLsrJnzyU+Yeh4WpV1eZGuARFj1xQJHIdnTuJZLP+b9R1GQ==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } }, "node_modules/normalize-path": { "version": "3.0.0", @@ -6604,29 +7738,6 @@ "node": ">=0.10.0" } }, - "node_modules/nypm": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", - "integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==", - "license": "MIT", - "dependencies": { - "citty": "^0.2.0", - "pathe": "^2.0.3", - "tinyexec": "^1.0.2" - }, - "bin": { - "nypm": "dist/cli.mjs" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/nypm/node_modules/citty": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", - "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", - "license": "MIT" - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -6763,6 +7874,7 @@ "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "devOptional": true, "license": "MIT" }, "node_modules/optionator": { @@ -6842,6 +7954,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-retry/node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -6869,6 +8003,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -6885,23 +8020,25 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "devOptional": true, "license": "MIT" }, "node_modules/perfect-debounce": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", - "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "devOptional": true, "license": "MIT" }, "node_modules/pg": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", - "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", + "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", "license": "MIT", "dependencies": { - "pg-connection-string": "^2.12.0", - "pg-pool": "^3.13.0", - "pg-protocol": "^1.13.0", + "pg-connection-string": "^2.13.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.14.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, @@ -6909,7 +8046,7 @@ "node": ">= 16.0.0" }, "optionalDependencies": { - "pg-cloudflare": "^1.3.0" + "pg-cloudflare": "^1.4.0" }, "peerDependencies": { "pg-native": ">=3.0.1" @@ -6921,16 +8058,16 @@ } }, "node_modules/pg-cloudflare": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", - "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", "license": "MIT", "optional": true }, "node_modules/pg-connection-string": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", - "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz", + "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==", "license": "MIT" }, "node_modules/pg-int8": { @@ -6943,18 +8080,18 @@ } }, "node_modules/pg-pool": { - "version": "3.13.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", - "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", "license": "MIT", "peerDependencies": { "pg": ">=8.0" } }, "node_modules/pg-protocol": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", - "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", + "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==", "license": "MIT" }, "node_modules/pg-types": { @@ -7031,16 +8168,23 @@ } }, "node_modules/pkg-types": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", - "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "devOptional": true, "license": "MIT", "dependencies": { - "confbox": "^0.2.2", - "exsolve": "^1.0.7", + "confbox": "^0.2.4", + "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, + "node_modules/po-parser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz", + "integrity": "sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ==", + "license": "MIT" + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -7052,9 +8196,9 @@ } }, "node_modules/postcss": { - "version": "8.5.9", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", - "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -7072,7 +8216,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -7099,12 +8243,13 @@ } }, "node_modules/postcss-import/node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", "dev": true, "license": "MIT", "dependencies": { + "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" @@ -7239,6 +8384,7 @@ "version": "3.4.7", "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", + "devOptional": true, "license": "Unlicense", "engines": { "node": ">=12" @@ -7298,15 +8444,16 @@ } }, "node_modules/prisma": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.7.0.tgz", - "integrity": "sha512-HlgwRBt1uEFB9LStHL4HLYDvoi4BNu1rYA0hPG0zCAEyK9SaZBqp7E5Rjpc3Qh8Lex/ye/svoHZ0OWoFNhWxuQ==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.8.0.tgz", + "integrity": "sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw==", + "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@prisma/config": "7.7.0", + "@prisma/config": "7.8.0", "@prisma/dev": "0.24.3", - "@prisma/engines": "7.7.0", + "@prisma/engines": "7.8.0", "@prisma/studio-core": "0.27.3", "mysql2": "3.15.3", "postgres": "3.4.7" @@ -7345,6 +8492,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "devOptional": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -7356,8 +8504,33 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "devOptional": true, "license": "ISC" }, + "node_modules/protobufjs": { + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.2.tgz", + "integrity": "sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -7372,6 +8545,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "devOptional": true, "funding": [ { "type": "individual", @@ -7406,28 +8580,29 @@ "license": "MIT" }, "node_modules/rc9": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", - "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz", + "integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==", + "devOptional": true, "license": "MIT", "dependencies": { - "defu": "^6.1.4", - "destr": "^2.0.3" + "defu": "^6.1.6", + "destr": "^2.0.5" } }, "node_modules/react": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", - "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-big-calendar": { - "version": "1.19.4", - "resolved": "https://registry.npmjs.org/react-big-calendar/-/react-big-calendar-1.19.4.tgz", - "integrity": "sha512-FrvbDx2LF6JAWFD96LU1jjloppC5OgIvMYUYIPzAw5Aq+ArYFPxAjLqXc4DyxfsQDN0TJTMuS/BIbcSB7Pg0YA==", + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/react-big-calendar/-/react-big-calendar-1.19.6.tgz", + "integrity": "sha512-2vCG/LBuNOOok4Olet4S7M6l11DjCO6RyWwqGRkZcR0cTOWEKVCwpqe5r24NobmVKPQV6nU3/DZGoAkrOypRlw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.20.7", @@ -7487,21 +8662,21 @@ } }, "node_modules/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.3" + "react": "^19.2.6" } }, "node_modules/react-hook-form": { - "version": "7.72.1", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.72.1.tgz", - "integrity": "sha512-RhwBoy2ygeVZje+C+bwJ8g0NjTdBmDlJvAUHTxRjTmSUKPYsKfMphkS2sgEMotsY03bP358yEYlnUeZy//D9Ig==", + "version": "7.76.1", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.76.1.tgz", + "integrity": "sha512-rYM7tPiWlu3nZchkR/ex7piyzui2vFPyaLnXnI/RnblB/L4qfMmyses8llJVtF1NpE9WBBsJlGtcSZzPCXW1qQ==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -7562,9 +8737,9 @@ } }, "node_modules/react-toastify": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-11.0.5.tgz", - "integrity": "sha512-EpqHBGvnSTtHYhCPLxML05NLY2ZX0JURbAdNYa6BUkk+amz4wbKBQvoKQAB0ardvSarUBuY4Q4s1sluAzZwkmA==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-11.1.0.tgz", + "integrity": "sha512-e9h23x3phN0wbFeB6yovmWp7lobzV4CaCH0LO8nVP6H7Y+3GbcLpIzMm9dJhcp1RXbpyfvjgpfXqO80QAmn7sg==", "license": "MIT", "dependencies": { "clsx": "^2.1.1" @@ -7610,12 +8785,13 @@ } }, "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "devOptional": true, "license": "MIT", "engines": { - "node": ">= 14.18.0" + "node": ">= 20.19.0" }, "funding": { "type": "individual", @@ -7626,6 +8802,7 @@ "version": "2.15.4", "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide", "license": "MIT", "dependencies": { "clsx": "^2.0.0", @@ -7717,6 +8894,7 @@ "version": "2.33.4", "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", "integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==", + "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/remeda" @@ -7726,20 +8904,21 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/resolve": { - "version": "2.0.0-next.6", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", - "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "version": "2.0.0-next.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", + "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", + "is-core-module": "^2.16.2", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", @@ -7779,6 +8958,7 @@ "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "devOptional": true, "license": "MIT", "engines": { "node": ">= 4" @@ -7820,15 +9000,15 @@ } }, "node_modules/safe-array-concat": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", - "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" }, @@ -7839,6 +9019,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -7878,6 +9078,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "devOptional": true, "license": "MIT" }, "node_modules/scheduler": { @@ -7899,7 +9100,8 @@ "node_modules/seq-queue": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", - "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==", + "devOptional": true }, "node_modules/server-only": { "version": "0.0.1", @@ -8002,9 +9204,9 @@ } }, "node_modules/sharp/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "license": "ISC", "optional": true, "bin": { @@ -8018,6 +9220,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "devOptional": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -8030,6 +9233,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -8115,6 +9319,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "devOptional": true, "license": "ISC", "engines": { "node": ">=14" @@ -8145,6 +9350,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -8525,12 +9731,13 @@ } }, "node_modules/tailwindcss/node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", "dev": true, "license": "MIT", "dependencies": { + "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" @@ -8574,19 +9781,10 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, - "node_modules/tinyexec": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", - "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -8697,14 +9895,13 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", + "integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" + "esbuild": "~0.28.0" }, "bin": { "tsx": "dist/cli.mjs" @@ -8787,18 +9984,18 @@ } }, "node_modules/typed-array-length": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", - "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0", - "reflect.getprototypeof": "^1.0.6" + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" }, "engines": { "node": ">= 0.4" @@ -8822,16 +10019,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.1.tgz", - "integrity": "sha512-gf6/oHChByg9HJvhMO1iBexJh12AqqTfnuxscMDOVqfJW3htsdRJI/GfPpHTTcyeB8cSTUY2JcZmVgoyPqcrDg==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.0.tgz", + "integrity": "sha512-9f65qWLZdAW9m1JaxBDUHcqRUfL8bkxxXL7XxEfI+F09q56PkBvIfCjLF3yInsDM/BBmwkqmCQdCZe/RYlIWEw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.58.1", - "@typescript-eslint/parser": "8.58.1", - "@typescript-eslint/typescript-estree": "8.58.1", - "@typescript-eslint/utils": "8.58.1" + "@typescript-eslint/eslint-plugin": "8.60.0", + "@typescript-eslint/parser": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -8886,38 +10083,41 @@ "license": "MIT" }, "node_modules/unrs-resolver": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", - "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { - "napi-postinstall": "^0.3.0" + "napi-postinstall": "^0.3.4" }, "funding": { "url": "https://opencollective.com/unrs-resolver" }, "optionalDependencies": { - "@unrs/resolver-binding-android-arm-eabi": "1.11.1", - "@unrs/resolver-binding-android-arm64": "1.11.1", - "@unrs/resolver-binding-darwin-arm64": "1.11.1", - "@unrs/resolver-binding-darwin-x64": "1.11.1", - "@unrs/resolver-binding-freebsd-x64": "1.11.1", - "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", - "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", - "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", - "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", - "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", - "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-x64-musl": "1.11.1", - "@unrs/resolver-binding-wasm32-wasi": "1.11.1", - "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", - "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", - "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" } }, "node_modules/update-browserslist-db": { @@ -8961,6 +10161,27 @@ "punycode": "^2.1.0" } }, + "node_modules/use-intl": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.13.0.tgz", + "integrity": "sha512-fAFDrWaASxlhXOipcOyb5VDD+YONqj6+8O8EcG/J7RBoOUF3A8YahRWLN+mBxYMrlMQB8N6Voqk5X+YC+HSL0A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/amannn" + } + ], + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "^3.1.0", + "@schummar/icu-type-parser": "1.21.5", + "icu-minify": "^4.13.0", + "intl-messageformat": "^11.1.0" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -8972,6 +10193,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", + "devOptional": true, "license": "MIT", "peerDependencies": { "typescript": ">=5" @@ -9013,10 +10235,20 @@ "loose-envify": "^1.0.0" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "devOptional": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -9096,14 +10328,14 @@ } }, "node_modules/which-typed-array": { - "version": "1.1.20", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", - "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.21.tgz", + "integrity": "sha512-zbRA8cVm6io/d5W8uIe2hblzN76/Wm3v/yiythQvr+dpBWeqhPSWIDNj4zOyHi4zKbMK6DN34Xsr9jPHJERAEw==", "dev": true, "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", + "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", @@ -9127,6 +10359,27 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -9160,6 +10413,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz", "integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==", + "devOptional": true, "license": "MIT", "dependencies": { "grammex": "^3.1.11", diff --git a/package.json b/package.json index 8d62a7f..00fcf11 100644 --- a/package.json +++ b/package.json @@ -3,24 +3,29 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev --webpack", + "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint ." + "lint": "eslint .", + "postinstall": "prisma generate" }, "dependencies": { "@clerk/nextjs": "^7.0.12", + "@google/genai": "^2.7.0", "@hookform/resolvers": "^3.9.0", "@prisma/adapter-pg": "^7.6.0", - "@prisma/client": "^7.6.0", + "@prisma/client": "^7.8.0", "@types/react-big-calendar": "^1.8.9", + "cloudinary": "^2.10.0", "date-fns": "^4.1.0", "dotenv": "^17.4.0", "lucide-react": "^1.7.0", "moment": "^2.30.1", "next": "16.2.3", "next-cloudinary": "^6.17.5", - "pg": "^8.20.0", + "next-intl": "^4.13.0", + "nodemailer": "^8.0.10", + "pg": "^8.21.0", "prisma": "^7.6.0", "react": "^19.2.3", "react-big-calendar": "^1.13.2", @@ -33,11 +38,13 @@ }, "devDependencies": { "@types/node": "^20", + "@types/nodemailer": "^8.0.0", "@types/react": "19.2.2", "@types/react-dom": "19.2.2", "eslint": "^9.39.0", "eslint-config-next": "16.2.3", "postcss": "^8", + "prisma": "^7.8.0", "tailwindcss": "^3.4.1", "tsx": "^4.21.0", "typescript": "^5" diff --git a/prisma/migrations/20260510175534_exam_workflow/migration.sql b/prisma/migrations/20260510175534_exam_workflow/migration.sql deleted file mode 100644 index 10ca5b2..0000000 --- a/prisma/migrations/20260510175534_exam_workflow/migration.sql +++ /dev/null @@ -1,733 +0,0 @@ -/* - Warnings: - - - The values [FRIDAY] on the enum `Day` will be removed. If these variants are still used in the database, this will fail. - - You are about to drop the column `classId` on the `Announcement` table. All the data in the column will be lost. - - You are about to drop the column `lessonId` on the `Attendance` table. All the data in the column will be lost. - - You are about to drop the column `classId` on the `Event` table. All the data in the column will be lost. - - A unique constraint covering the columns `[schoolId,username]` on the table `Admin` will be added. If there are existing duplicate values, this will fail. - - A unique constraint covering the columns `[studentId,academicYearId,date]` on the table `Attendance` will be added. If there are existing duplicate values, this will fail. - - A unique constraint covering the columns `[teacherId,academicYearId,date]` on the table `Attendance` will be added. If there are existing duplicate values, this will fail. - - A unique constraint covering the columns `[schoolId,name]` on the table `Class` will be added. If there are existing duplicate values, this will fail. - - A unique constraint covering the columns `[schoolId,level]` on the table `Grade` will be added. If there are existing duplicate values, this will fail. - - A unique constraint covering the columns `[schoolId,username]` on the table `Parent` will be added. If there are existing duplicate values, this will fail. - - A unique constraint covering the columns `[schoolId,email]` on the table `Parent` will be added. If there are existing duplicate values, this will fail. - - A unique constraint covering the columns `[schoolId,phone]` on the table `Parent` will be added. If there are existing duplicate values, this will fail. - - A unique constraint covering the columns `[schoolId,username]` on the table `Student` will be added. If there are existing duplicate values, this will fail. - - A unique constraint covering the columns `[schoolId,email]` on the table `Student` will be added. If there are existing duplicate values, this will fail. - - A unique constraint covering the columns `[schoolId,phone]` on the table `Student` will be added. If there are existing duplicate values, this will fail. - - A unique constraint covering the columns `[schoolId,name]` on the table `Subject` will be added. If there are existing duplicate values, this will fail. - - A unique constraint covering the columns `[schoolId,username]` on the table `Teacher` will be added. If there are existing duplicate values, this will fail. - - A unique constraint covering the columns `[schoolId,email]` on the table `Teacher` will be added. If there are existing duplicate values, this will fail. - - A unique constraint covering the columns `[schoolId,phone]` on the table `Teacher` will be added. If there are existing duplicate values, this will fail. - - Added the required column `schoolId` to the `Admin` table without a default value. This is not possible if the table is not empty. - - Added the required column `academicYearId` to the `Announcement` table without a default value. This is not possible if the table is not empty. - - Added the required column `schoolId` to the `Announcement` table without a default value. This is not possible if the table is not empty. - - Added the required column `academicYearId` to the `Assignment` table without a default value. This is not possible if the table is not empty. - - Added the required column `schoolId` to the `Assignment` table without a default value. This is not possible if the table is not empty. - - Added the required column `academicYearId` to the `Attendance` table without a default value. This is not possible if the table is not empty. - - Added the required column `schoolId` to the `Attendance` table without a default value. This is not possible if the table is not empty. - - Added the required column `schoolId` to the `Class` table without a default value. This is not possible if the table is not empty. - - Added the required column `academicYearId` to the `Event` table without a default value. This is not possible if the table is not empty. - - Added the required column `schoolId` to the `Event` table without a default value. This is not possible if the table is not empty. - - Added the required column `academicYearId` to the `Exam` table without a default value. This is not possible if the table is not empty. - - Added the required column `schoolId` to the `Exam` table without a default value. This is not possible if the table is not empty. - - Added the required column `schoolId` to the `Grade` table without a default value. This is not possible if the table is not empty. - - Added the required column `academicYearId` to the `Lesson` table without a default value. This is not possible if the table is not empty. - - Added the required column `schoolId` to the `Lesson` table without a default value. This is not possible if the table is not empty. - - Added the required column `schoolId` to the `Parent` table without a default value. This is not possible if the table is not empty. - - Added the required column `academicYearId` to the `Result` table without a default value. This is not possible if the table is not empty. - - Added the required column `schoolId` to the `Result` table without a default value. This is not possible if the table is not empty. - - Added the required column `birthday` to the `Student` table without a default value. This is not possible if the table is not empty. - - Added the required column `schoolId` to the `Student` table without a default value. This is not possible if the table is not empty. - - Added the required column `gradeId` to the `Subject` table without a default value. This is not possible if the table is not empty. - - Added the required column `schoolId` to the `Subject` table without a default value. This is not possible if the table is not empty. - - Added the required column `birthday` to the `Teacher` table without a default value. This is not possible if the table is not empty. - - Added the required column `schoolId` to the `Teacher` table without a default value. This is not possible if the table is not empty. - -*/ --- CreateEnum -CREATE TYPE "QuestionType" AS ENUM ('TRUE_FALSE', 'MCQ', 'TEXT', 'FILE'); - --- CreateEnum -CREATE TYPE "SubmissionStatus" AS ENUM ('IN_PROGRESS', 'SUBMITTED', 'GRADED'); - --- CreateEnum -CREATE TYPE "StudentStatus" AS ENUM ('ACTIVE', 'REPEATED', 'GRADUATED', 'LEFT'); - --- CreateEnum -CREATE TYPE "PassFailStatus" AS ENUM ('PASS', 'FAIL'); - --- AlterEnum -BEGIN; -CREATE TYPE "Day_new" AS ENUM ('SATURDAY', 'SUNDAY', 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY'); -ALTER TABLE "Lesson" ALTER COLUMN "day" TYPE "Day_new" USING ("day"::text::"Day_new"); -ALTER TYPE "Day" RENAME TO "Day_old"; -ALTER TYPE "Day_new" RENAME TO "Day"; -DROP TYPE "public"."Day_old"; -COMMIT; - --- DropForeignKey -ALTER TABLE "Announcement" DROP CONSTRAINT "Announcement_classId_fkey"; - --- DropForeignKey -ALTER TABLE "Attendance" DROP CONSTRAINT "Attendance_lessonId_fkey"; - --- DropForeignKey -ALTER TABLE "Attendance" DROP CONSTRAINT "Attendance_studentId_fkey"; - --- DropForeignKey -ALTER TABLE "Class" DROP CONSTRAINT "Class_supervisorId_fkey"; - --- DropForeignKey -ALTER TABLE "Event" DROP CONSTRAINT "Event_classId_fkey"; - --- DropForeignKey -ALTER TABLE "Student" DROP CONSTRAINT "Student_parentId_fkey"; - --- DropIndex -DROP INDEX "Admin_username_key"; - --- DropIndex -DROP INDEX "Class_name_key"; - --- DropIndex -DROP INDEX "Grade_level_key"; - --- DropIndex -DROP INDEX "Parent_email_key"; - --- DropIndex -DROP INDEX "Parent_phone_key"; - --- DropIndex -DROP INDEX "Parent_username_key"; - --- DropIndex -DROP INDEX "Student_email_key"; - --- DropIndex -DROP INDEX "Student_phone_key"; - --- DropIndex -DROP INDEX "Student_username_key"; - --- DropIndex -DROP INDEX "Subject_name_key"; - --- DropIndex -DROP INDEX "Teacher_email_key"; - --- DropIndex -DROP INDEX "Teacher_phone_key"; - --- DropIndex -DROP INDEX "Teacher_username_key"; - --- AlterTable -ALTER TABLE "Admin" ADD COLUMN "schoolId" INTEGER NOT NULL; - --- AlterTable -ALTER TABLE "Announcement" DROP COLUMN "classId", -ADD COLUMN "academicYearId" INTEGER NOT NULL, -ADD COLUMN "schoolId" INTEGER NOT NULL; - --- AlterTable -ALTER TABLE "Assignment" ADD COLUMN "academicYearId" INTEGER NOT NULL, -ADD COLUMN "classId" INTEGER, -ADD COLUMN "schoolId" INTEGER NOT NULL, -ADD COLUMN "subjectId" INTEGER; - --- AlterTable -ALTER TABLE "Attendance" DROP COLUMN "lessonId", -ADD COLUMN "academicYearId" INTEGER NOT NULL, -ADD COLUMN "schoolId" INTEGER NOT NULL, -ADD COLUMN "teacherId" TEXT, -ALTER COLUMN "studentId" DROP NOT NULL; - --- AlterTable -ALTER TABLE "Class" ADD COLUMN "schoolId" INTEGER NOT NULL, -ALTER COLUMN "supervisorId" DROP NOT NULL; - --- AlterTable -ALTER TABLE "Event" DROP COLUMN "classId", -ADD COLUMN "academicYearId" INTEGER NOT NULL, -ADD COLUMN "schoolId" INTEGER NOT NULL; - --- AlterTable -ALTER TABLE "Exam" ADD COLUMN "academicYearId" INTEGER NOT NULL, -ADD COLUMN "autoSaveInterval" INTEGER NOT NULL DEFAULT 30, -ADD COLUMN "classId" INTEGER, -ADD COLUMN "duration" INTEGER, -ADD COLUMN "enableAutoSave" BOOLEAN NOT NULL DEFAULT true, -ADD COLUMN "enableAutoSubmit" BOOLEAN NOT NULL DEFAULT true, -ADD COLUMN "enableNavigation" BOOLEAN NOT NULL DEFAULT true, -ADD COLUMN "enableTimer" BOOLEAN NOT NULL DEFAULT true, -ADD COLUMN "questionsPerPage" INTEGER NOT NULL DEFAULT 1, -ADD COLUMN "schoolId" INTEGER NOT NULL, -ADD COLUMN "subjectId" INTEGER; - --- AlterTable -ALTER TABLE "Grade" ADD COLUMN "schoolId" INTEGER NOT NULL; - --- AlterTable -ALTER TABLE "Lesson" ADD COLUMN "academicYearId" INTEGER NOT NULL, -ADD COLUMN "schoolId" INTEGER NOT NULL; - --- AlterTable -ALTER TABLE "Parent" ADD COLUMN "schoolId" INTEGER NOT NULL; - --- AlterTable -ALTER TABLE "Result" ADD COLUMN "academicYearId" INTEGER NOT NULL, -ADD COLUMN "schoolId" INTEGER NOT NULL; - --- AlterTable -ALTER TABLE "Student" ADD COLUMN "birthday" TIMESTAMP(3) NOT NULL, -ADD COLUMN "repeatCount" INTEGER NOT NULL DEFAULT 0, -ADD COLUMN "schoolId" INTEGER NOT NULL, -ADD COLUMN "status" "StudentStatus" NOT NULL DEFAULT 'ACTIVE', -ALTER COLUMN "parentId" DROP NOT NULL; - --- AlterTable -ALTER TABLE "Subject" ADD COLUMN "gradeId" INTEGER NOT NULL, -ADD COLUMN "schoolId" INTEGER NOT NULL; - --- AlterTable -ALTER TABLE "Teacher" ADD COLUMN "birthday" TIMESTAMP(3) NOT NULL, -ADD COLUMN "schoolId" INTEGER NOT NULL; - --- CreateTable -CREATE TABLE "School" ( - "id" SERIAL NOT NULL, - "name" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "School_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "SchoolSettings" ( - "id" SERIAL NOT NULL, - "schoolId" INTEGER NOT NULL, - "workDayStart" TIME NOT NULL, - "workDayEnd" TIME NOT NULL, - "lessonDuration" INTEGER NOT NULL, - "lessonsPerDay" INTEGER NOT NULL, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "SchoolSettings_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "AcademicYear" ( - "id" SERIAL NOT NULL, - "schoolId" INTEGER NOT NULL, - "name" TEXT NOT NULL, - "startDate" DATE NOT NULL, - "endDate" DATE NOT NULL, - "isCurrent" BOOLEAN NOT NULL DEFAULT false, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "AcademicYear_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "StudentAcademicYear" ( - "id" SERIAL NOT NULL, - "studentId" TEXT NOT NULL, - "schoolId" INTEGER NOT NULL, - "academicYearId" INTEGER NOT NULL, - "gradeId" INTEGER NOT NULL, - "classId" INTEGER, - "performanceStatus" "PassFailStatus", - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "StudentAcademicYear_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "Message" ( - "id" SERIAL NOT NULL, - "schoolId" INTEGER NOT NULL, - "title" TEXT NOT NULL, - "description" TEXT NOT NULL, - "date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "academicYearId" INTEGER NOT NULL, - - CONSTRAINT "Message_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "Question" ( - "id" SERIAL NOT NULL, - "schoolId" INTEGER NOT NULL, - "examId" INTEGER NOT NULL, - "text" TEXT NOT NULL, - "type" "QuestionType" NOT NULL, - "points" INTEGER NOT NULL DEFAULT 1, - "order" INTEGER NOT NULL, - "options" JSONB, - "correctAnswer" TEXT[], - "allowMultiple" BOOLEAN NOT NULL DEFAULT false, - - CONSTRAINT "Question_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "Submission" ( - "id" SERIAL NOT NULL, - "schoolId" INTEGER NOT NULL, - "examId" INTEGER NOT NULL, - "studentId" TEXT NOT NULL, - "status" "SubmissionStatus" NOT NULL DEFAULT 'IN_PROGRESS', - "currentPage" INTEGER NOT NULL DEFAULT 1, - "startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "submittedAt" TIMESTAMP(3), - "autoSubmitted" BOOLEAN NOT NULL DEFAULT false, - "lastSyncedAt" TIMESTAMP(3), - "totalScore" DOUBLE PRECISION, - "extraTime" INTEGER, - "extendedBy" TEXT, - "extendedAt" TIMESTAMP(3), - "disconnectedAt" TIMESTAMP(3), - "reconnectedAt" TIMESTAMP(3), - "totalOfflineTime" INTEGER, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "Submission_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "Answer" ( - "id" SERIAL NOT NULL, - "schoolId" INTEGER NOT NULL, - "submissionId" INTEGER NOT NULL, - "questionId" INTEGER NOT NULL, - "textAnswer" TEXT, - "fileUrl" TEXT, - "isDraft" BOOLEAN NOT NULL DEFAULT true, - "savedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "score" DOUBLE PRECISION, - - CONSTRAINT "Answer_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "_TeacherClasses" ( - "A" INTEGER NOT NULL, - "B" TEXT NOT NULL, - - CONSTRAINT "_TeacherClasses_AB_pkey" PRIMARY KEY ("A","B") -); - --- CreateTable -CREATE TABLE "_ClassToEvent" ( - "A" INTEGER NOT NULL, - "B" INTEGER NOT NULL, - - CONSTRAINT "_ClassToEvent_AB_pkey" PRIMARY KEY ("A","B") -); - --- CreateTable -CREATE TABLE "_ClassToMessage" ( - "A" INTEGER NOT NULL, - "B" INTEGER NOT NULL, - - CONSTRAINT "_ClassToMessage_AB_pkey" PRIMARY KEY ("A","B") -); - --- CreateTable -CREATE TABLE "_AnnouncementToClass" ( - "A" INTEGER NOT NULL, - "B" INTEGER NOT NULL, - - CONSTRAINT "_AnnouncementToClass_AB_pkey" PRIMARY KEY ("A","B") -); - --- CreateTable -CREATE TABLE "_MessageToStudent" ( - "A" INTEGER NOT NULL, - "B" TEXT NOT NULL, - - CONSTRAINT "_MessageToStudent_AB_pkey" PRIMARY KEY ("A","B") -); - --- CreateTable -CREATE TABLE "_MessageToParent" ( - "A" INTEGER NOT NULL, - "B" TEXT NOT NULL, - - CONSTRAINT "_MessageToParent_AB_pkey" PRIMARY KEY ("A","B") -); - --- CreateTable -CREATE TABLE "_MessageToTeacher" ( - "A" INTEGER NOT NULL, - "B" TEXT NOT NULL, - - CONSTRAINT "_MessageToTeacher_AB_pkey" PRIMARY KEY ("A","B") -); - --- CreateIndex -CREATE UNIQUE INDEX "School_name_key" ON "School"("name"); - --- CreateIndex -CREATE UNIQUE INDEX "SchoolSettings_schoolId_key" ON "SchoolSettings"("schoolId"); - --- CreateIndex -CREATE INDEX "SchoolSettings_schoolId_idx" ON "SchoolSettings"("schoolId"); - --- CreateIndex -CREATE UNIQUE INDEX "AcademicYear_schoolId_name_key" ON "AcademicYear"("schoolId", "name"); - --- CreateIndex -CREATE INDEX "StudentAcademicYear_academicYearId_idx" ON "StudentAcademicYear"("academicYearId"); - --- CreateIndex -CREATE INDEX "StudentAcademicYear_gradeId_idx" ON "StudentAcademicYear"("gradeId"); - --- CreateIndex -CREATE INDEX "StudentAcademicYear_classId_idx" ON "StudentAcademicYear"("classId"); - --- CreateIndex -CREATE INDEX "StudentAcademicYear_schoolId_idx" ON "StudentAcademicYear"("schoolId"); - --- CreateIndex -CREATE UNIQUE INDEX "StudentAcademicYear_schoolId_studentId_academicYearId_key" ON "StudentAcademicYear"("schoolId", "studentId", "academicYearId"); - --- CreateIndex -CREATE INDEX "Message_schoolId_idx" ON "Message"("schoolId"); - --- CreateIndex -CREATE INDEX "Question_schoolId_idx" ON "Question"("schoolId"); - --- CreateIndex -CREATE INDEX "Question_examId_idx" ON "Question"("examId"); - --- CreateIndex -CREATE INDEX "Submission_schoolId_idx" ON "Submission"("schoolId"); - --- CreateIndex -CREATE INDEX "Submission_examId_idx" ON "Submission"("examId"); - --- CreateIndex -CREATE INDEX "Submission_studentId_idx" ON "Submission"("studentId"); - --- CreateIndex -CREATE UNIQUE INDEX "Submission_examId_studentId_key" ON "Submission"("examId", "studentId"); - --- CreateIndex -CREATE INDEX "Answer_schoolId_idx" ON "Answer"("schoolId"); - --- CreateIndex -CREATE INDEX "Answer_submissionId_idx" ON "Answer"("submissionId"); - --- CreateIndex -CREATE UNIQUE INDEX "Answer_submissionId_questionId_key" ON "Answer"("submissionId", "questionId"); - --- CreateIndex -CREATE INDEX "_TeacherClasses_B_index" ON "_TeacherClasses"("B"); - --- CreateIndex -CREATE INDEX "_ClassToEvent_B_index" ON "_ClassToEvent"("B"); - --- CreateIndex -CREATE INDEX "_ClassToMessage_B_index" ON "_ClassToMessage"("B"); - --- CreateIndex -CREATE INDEX "_AnnouncementToClass_B_index" ON "_AnnouncementToClass"("B"); - --- CreateIndex -CREATE INDEX "_MessageToStudent_B_index" ON "_MessageToStudent"("B"); - --- CreateIndex -CREATE INDEX "_MessageToParent_B_index" ON "_MessageToParent"("B"); - --- CreateIndex -CREATE INDEX "_MessageToTeacher_B_index" ON "_MessageToTeacher"("B"); - --- CreateIndex -CREATE INDEX "Admin_schoolId_idx" ON "Admin"("schoolId"); - --- CreateIndex -CREATE UNIQUE INDEX "Admin_schoolId_username_key" ON "Admin"("schoolId", "username"); - --- CreateIndex -CREATE INDEX "Announcement_schoolId_idx" ON "Announcement"("schoolId"); - --- CreateIndex -CREATE INDEX "Assignment_schoolId_idx" ON "Assignment"("schoolId"); - --- CreateIndex -CREATE INDEX "Attendance_academicYearId_idx" ON "Attendance"("academicYearId"); - --- CreateIndex -CREATE INDEX "Attendance_date_idx" ON "Attendance"("date"); - --- CreateIndex -CREATE INDEX "Attendance_studentId_idx" ON "Attendance"("studentId"); - --- CreateIndex -CREATE INDEX "Attendance_teacherId_idx" ON "Attendance"("teacherId"); - --- CreateIndex -CREATE INDEX "Attendance_schoolId_idx" ON "Attendance"("schoolId"); - --- CreateIndex -CREATE UNIQUE INDEX "Attendance_studentId_academicYearId_date_key" ON "Attendance"("studentId", "academicYearId", "date"); - --- CreateIndex -CREATE UNIQUE INDEX "Attendance_teacherId_academicYearId_date_key" ON "Attendance"("teacherId", "academicYearId", "date"); - --- CreateIndex -CREATE INDEX "Class_schoolId_idx" ON "Class"("schoolId"); - --- CreateIndex -CREATE UNIQUE INDEX "Class_schoolId_name_key" ON "Class"("schoolId", "name"); - --- CreateIndex -CREATE INDEX "Event_schoolId_idx" ON "Event"("schoolId"); - --- CreateIndex -CREATE INDEX "Exam_schoolId_idx" ON "Exam"("schoolId"); - --- CreateIndex -CREATE INDEX "Grade_schoolId_idx" ON "Grade"("schoolId"); - --- CreateIndex -CREATE UNIQUE INDEX "Grade_schoolId_level_key" ON "Grade"("schoolId", "level"); - --- CreateIndex -CREATE INDEX "Lesson_schoolId_idx" ON "Lesson"("schoolId"); - --- CreateIndex -CREATE INDEX "Parent_schoolId_idx" ON "Parent"("schoolId"); - --- CreateIndex -CREATE UNIQUE INDEX "Parent_schoolId_username_key" ON "Parent"("schoolId", "username"); - --- CreateIndex -CREATE UNIQUE INDEX "Parent_schoolId_email_key" ON "Parent"("schoolId", "email"); - --- CreateIndex -CREATE UNIQUE INDEX "Parent_schoolId_phone_key" ON "Parent"("schoolId", "phone"); - --- CreateIndex -CREATE INDEX "Result_schoolId_idx" ON "Result"("schoolId"); - --- CreateIndex -CREATE INDEX "Student_schoolId_idx" ON "Student"("schoolId"); - --- CreateIndex -CREATE UNIQUE INDEX "Student_schoolId_username_key" ON "Student"("schoolId", "username"); - --- CreateIndex -CREATE UNIQUE INDEX "Student_schoolId_email_key" ON "Student"("schoolId", "email"); - --- CreateIndex -CREATE UNIQUE INDEX "Student_schoolId_phone_key" ON "Student"("schoolId", "phone"); - --- CreateIndex -CREATE INDEX "Subject_schoolId_idx" ON "Subject"("schoolId"); - --- CreateIndex -CREATE UNIQUE INDEX "Subject_schoolId_name_key" ON "Subject"("schoolId", "name"); - --- CreateIndex -CREATE INDEX "Teacher_schoolId_idx" ON "Teacher"("schoolId"); - --- CreateIndex -CREATE UNIQUE INDEX "Teacher_schoolId_username_key" ON "Teacher"("schoolId", "username"); - --- CreateIndex -CREATE UNIQUE INDEX "Teacher_schoolId_email_key" ON "Teacher"("schoolId", "email"); - --- CreateIndex -CREATE UNIQUE INDEX "Teacher_schoolId_phone_key" ON "Teacher"("schoolId", "phone"); - --- AddForeignKey -ALTER TABLE "Admin" ADD CONSTRAINT "Admin_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "SchoolSettings" ADD CONSTRAINT "SchoolSettings_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "AcademicYear" ADD CONSTRAINT "AcademicYear_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Student" ADD CONSTRAINT "Student_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Student" ADD CONSTRAINT "Student_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Parent"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Teacher" ADD CONSTRAINT "Teacher_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Parent" ADD CONSTRAINT "Parent_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Grade" ADD CONSTRAINT "Grade_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Class" ADD CONSTRAINT "Class_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Class" ADD CONSTRAINT "Class_supervisorId_fkey" FOREIGN KEY ("supervisorId") REFERENCES "Teacher"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "StudentAcademicYear" ADD CONSTRAINT "StudentAcademicYear_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "Student"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "StudentAcademicYear" ADD CONSTRAINT "StudentAcademicYear_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "StudentAcademicYear" ADD CONSTRAINT "StudentAcademicYear_academicYearId_fkey" FOREIGN KEY ("academicYearId") REFERENCES "AcademicYear"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "StudentAcademicYear" ADD CONSTRAINT "StudentAcademicYear_gradeId_fkey" FOREIGN KEY ("gradeId") REFERENCES "Grade"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "StudentAcademicYear" ADD CONSTRAINT "StudentAcademicYear_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Subject" ADD CONSTRAINT "Subject_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Subject" ADD CONSTRAINT "Subject_gradeId_fkey" FOREIGN KEY ("gradeId") REFERENCES "Grade"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Lesson" ADD CONSTRAINT "Lesson_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Lesson" ADD CONSTRAINT "Lesson_academicYearId_fkey" FOREIGN KEY ("academicYearId") REFERENCES "AcademicYear"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Exam" ADD CONSTRAINT "Exam_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Exam" ADD CONSTRAINT "Exam_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Exam" ADD CONSTRAINT "Exam_subjectId_fkey" FOREIGN KEY ("subjectId") REFERENCES "Subject"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Exam" ADD CONSTRAINT "Exam_academicYearId_fkey" FOREIGN KEY ("academicYearId") REFERENCES "AcademicYear"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Assignment" ADD CONSTRAINT "Assignment_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Assignment" ADD CONSTRAINT "Assignment_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Assignment" ADD CONSTRAINT "Assignment_subjectId_fkey" FOREIGN KEY ("subjectId") REFERENCES "Subject"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Assignment" ADD CONSTRAINT "Assignment_academicYearId_fkey" FOREIGN KEY ("academicYearId") REFERENCES "AcademicYear"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Result" ADD CONSTRAINT "Result_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Result" ADD CONSTRAINT "Result_academicYearId_fkey" FOREIGN KEY ("academicYearId") REFERENCES "AcademicYear"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Attendance" ADD CONSTRAINT "Attendance_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Attendance" ADD CONSTRAINT "Attendance_academicYearId_fkey" FOREIGN KEY ("academicYearId") REFERENCES "AcademicYear"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Attendance" ADD CONSTRAINT "Attendance_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "Student"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Attendance" ADD CONSTRAINT "Attendance_teacherId_fkey" FOREIGN KEY ("teacherId") REFERENCES "Teacher"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Event" ADD CONSTRAINT "Event_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Event" ADD CONSTRAINT "Event_academicYearId_fkey" FOREIGN KEY ("academicYearId") REFERENCES "AcademicYear"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Announcement" ADD CONSTRAINT "Announcement_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Announcement" ADD CONSTRAINT "Announcement_academicYearId_fkey" FOREIGN KEY ("academicYearId") REFERENCES "AcademicYear"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Message" ADD CONSTRAINT "Message_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Message" ADD CONSTRAINT "Message_academicYearId_fkey" FOREIGN KEY ("academicYearId") REFERENCES "AcademicYear"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Question" ADD CONSTRAINT "Question_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Question" ADD CONSTRAINT "Question_examId_fkey" FOREIGN KEY ("examId") REFERENCES "Exam"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Submission" ADD CONSTRAINT "Submission_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Submission" ADD CONSTRAINT "Submission_examId_fkey" FOREIGN KEY ("examId") REFERENCES "Exam"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Submission" ADD CONSTRAINT "Submission_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "Student"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Answer" ADD CONSTRAINT "Answer_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Answer" ADD CONSTRAINT "Answer_submissionId_fkey" FOREIGN KEY ("submissionId") REFERENCES "Submission"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Answer" ADD CONSTRAINT "Answer_questionId_fkey" FOREIGN KEY ("questionId") REFERENCES "Question"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "_TeacherClasses" ADD CONSTRAINT "_TeacherClasses_A_fkey" FOREIGN KEY ("A") REFERENCES "Class"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "_TeacherClasses" ADD CONSTRAINT "_TeacherClasses_B_fkey" FOREIGN KEY ("B") REFERENCES "Teacher"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "_ClassToEvent" ADD CONSTRAINT "_ClassToEvent_A_fkey" FOREIGN KEY ("A") REFERENCES "Class"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "_ClassToEvent" ADD CONSTRAINT "_ClassToEvent_B_fkey" FOREIGN KEY ("B") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "_ClassToMessage" ADD CONSTRAINT "_ClassToMessage_A_fkey" FOREIGN KEY ("A") REFERENCES "Class"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "_ClassToMessage" ADD CONSTRAINT "_ClassToMessage_B_fkey" FOREIGN KEY ("B") REFERENCES "Message"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "_AnnouncementToClass" ADD CONSTRAINT "_AnnouncementToClass_A_fkey" FOREIGN KEY ("A") REFERENCES "Announcement"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "_AnnouncementToClass" ADD CONSTRAINT "_AnnouncementToClass_B_fkey" FOREIGN KEY ("B") REFERENCES "Class"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "_MessageToStudent" ADD CONSTRAINT "_MessageToStudent_A_fkey" FOREIGN KEY ("A") REFERENCES "Message"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "_MessageToStudent" ADD CONSTRAINT "_MessageToStudent_B_fkey" FOREIGN KEY ("B") REFERENCES "Student"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "_MessageToParent" ADD CONSTRAINT "_MessageToParent_A_fkey" FOREIGN KEY ("A") REFERENCES "Message"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "_MessageToParent" ADD CONSTRAINT "_MessageToParent_B_fkey" FOREIGN KEY ("B") REFERENCES "Parent"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "_MessageToTeacher" ADD CONSTRAINT "_MessageToTeacher_A_fkey" FOREIGN KEY ("A") REFERENCES "Message"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "_MessageToTeacher" ADD CONSTRAINT "_MessageToTeacher_B_fkey" FOREIGN KEY ("B") REFERENCES "Teacher"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260509100045_init/migration.sql b/prisma/migrations/20260516145158_merge_submissions/migration.sql similarity index 75% rename from prisma/migrations/20260509100045_init/migration.sql rename to prisma/migrations/20260516145158_merge_submissions/migration.sql index e167685..3709771 100644 --- a/prisma/migrations/20260509100045_init/migration.sql +++ b/prisma/migrations/20260516145158_merge_submissions/migration.sql @@ -1,3 +1,9 @@ +-- CreateEnum +CREATE TYPE "QuestionType" AS ENUM ('TRUE_FALSE', 'MCQ', 'TEXT', 'FILE'); + +-- CreateEnum +CREATE TYPE "SubmissionStatus" AS ENUM ('IN_PROGRESS', 'SUBMITTED', 'GRADED'); + -- CreateEnum CREATE TYPE "UserSex" AS ENUM ('MALE', 'FEMALE'); @@ -184,6 +190,13 @@ CREATE TABLE "Exam" ( "subjectId" INTEGER, "lessonId" INTEGER NOT NULL, "academicYearId" INTEGER NOT NULL, + "enableTimer" BOOLEAN NOT NULL DEFAULT true, + "duration" INTEGER, + "enableNavigation" BOOLEAN NOT NULL DEFAULT true, + "enableAutoSave" BOOLEAN NOT NULL DEFAULT true, + "autoSaveInterval" INTEGER NOT NULL DEFAULT 30, + "enableAutoSubmit" BOOLEAN NOT NULL DEFAULT true, + "questionsPerPage" INTEGER NOT NULL DEFAULT 1, CONSTRAINT "Exam_pkey" PRIMARY KEY ("id") ); @@ -193,8 +206,11 @@ CREATE TABLE "Assignment" ( "id" SERIAL NOT NULL, "schoolId" INTEGER NOT NULL, "title" TEXT NOT NULL, + "description" TEXT, "startDate" TIMESTAMP(3) NOT NULL, "endDate" TIMESTAMP(3) NOT NULL, + "fileUrl" TEXT, + "fileName" TEXT, "classId" INTEGER, "subjectId" INTEGER, "lessonId" INTEGER NOT NULL, @@ -203,6 +219,40 @@ CREATE TABLE "Assignment" ( CONSTRAINT "Assignment_pkey" PRIMARY KEY ("id") ); +-- CreateTable +CREATE TABLE "AssignmentSubmission" ( + "id" SERIAL NOT NULL, + "fileUrl" TEXT NOT NULL, + "fileName" TEXT NOT NULL, + "fileType" TEXT NOT NULL, + "note" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "assignmentId" INTEGER NOT NULL, + "studentId" TEXT NOT NULL, + "schoolId" INTEGER NOT NULL, + "academicYearId" INTEGER NOT NULL, + + CONSTRAINT "AssignmentSubmission_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "StudyMaterial" ( + "id" SERIAL NOT NULL, + "schoolId" INTEGER NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "fileUrl" TEXT NOT NULL, + "fileName" TEXT NOT NULL, + "fileType" TEXT NOT NULL, + "subjectId" INTEGER NOT NULL, + "teacherId" TEXT NOT NULL, + "academicYearId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "StudyMaterial_pkey" PRIMARY KEY ("id") +); + -- CreateTable CREATE TABLE "Result" ( "id" SERIAL NOT NULL, @@ -266,6 +316,63 @@ CREATE TABLE "Message" ( CONSTRAINT "Message_pkey" PRIMARY KEY ("id") ); +-- CreateTable +CREATE TABLE "Question" ( + "id" SERIAL NOT NULL, + "schoolId" INTEGER NOT NULL, + "examId" INTEGER NOT NULL, + "text" TEXT NOT NULL, + "type" "QuestionType" NOT NULL, + "points" INTEGER NOT NULL DEFAULT 1, + "order" INTEGER NOT NULL, + "options" JSONB, + "correctAnswer" TEXT[], + "allowMultiple" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "Question_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Submission" ( + "id" SERIAL NOT NULL, + "schoolId" INTEGER NOT NULL, + "examId" INTEGER NOT NULL, + "studentId" TEXT NOT NULL, + "status" "SubmissionStatus" NOT NULL DEFAULT 'IN_PROGRESS', + "currentPage" INTEGER NOT NULL DEFAULT 1, + "startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "submittedAt" TIMESTAMP(3), + "autoSubmitted" BOOLEAN NOT NULL DEFAULT false, + "lastSyncedAt" TIMESTAMP(3), + "totalScore" DOUBLE PRECISION, + "extraTime" INTEGER, + "extendedBy" TEXT, + "extendedAt" TIMESTAMP(3), + "disconnectedAt" TIMESTAMP(3), + "reconnectedAt" TIMESTAMP(3), + "totalOfflineTime" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "academicYearId" INTEGER, + + CONSTRAINT "Submission_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Answer" ( + "id" SERIAL NOT NULL, + "schoolId" INTEGER NOT NULL, + "submissionId" INTEGER NOT NULL, + "questionId" INTEGER NOT NULL, + "textAnswer" TEXT, + "fileUrl" TEXT, + "isDraft" BOOLEAN NOT NULL DEFAULT true, + "savedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "score" DOUBLE PRECISION, + + CONSTRAINT "Answer_pkey" PRIMARY KEY ("id") +); + -- CreateTable CREATE TABLE "_TeacherClasses" ( "A" INTEGER NOT NULL, @@ -426,6 +533,27 @@ CREATE INDEX "Exam_schoolId_idx" ON "Exam"("schoolId"); -- CreateIndex CREATE INDEX "Assignment_schoolId_idx" ON "Assignment"("schoolId"); +-- CreateIndex +CREATE INDEX "AssignmentSubmission_schoolId_idx" ON "AssignmentSubmission"("schoolId"); + +-- CreateIndex +CREATE INDEX "AssignmentSubmission_assignmentId_idx" ON "AssignmentSubmission"("assignmentId"); + +-- CreateIndex +CREATE INDEX "AssignmentSubmission_studentId_idx" ON "AssignmentSubmission"("studentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "AssignmentSubmission_assignmentId_studentId_key" ON "AssignmentSubmission"("assignmentId", "studentId"); + +-- CreateIndex +CREATE INDEX "StudyMaterial_schoolId_idx" ON "StudyMaterial"("schoolId"); + +-- CreateIndex +CREATE INDEX "StudyMaterial_subjectId_idx" ON "StudyMaterial"("subjectId"); + +-- CreateIndex +CREATE INDEX "StudyMaterial_teacherId_idx" ON "StudyMaterial"("teacherId"); + -- CreateIndex CREATE INDEX "Result_schoolId_idx" ON "Result"("schoolId"); @@ -459,6 +587,33 @@ CREATE INDEX "Announcement_schoolId_idx" ON "Announcement"("schoolId"); -- CreateIndex CREATE INDEX "Message_schoolId_idx" ON "Message"("schoolId"); +-- CreateIndex +CREATE INDEX "Question_schoolId_idx" ON "Question"("schoolId"); + +-- CreateIndex +CREATE INDEX "Question_examId_idx" ON "Question"("examId"); + +-- CreateIndex +CREATE INDEX "Submission_schoolId_idx" ON "Submission"("schoolId"); + +-- CreateIndex +CREATE INDEX "Submission_examId_idx" ON "Submission"("examId"); + +-- CreateIndex +CREATE INDEX "Submission_studentId_idx" ON "Submission"("studentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Submission_examId_studentId_key" ON "Submission"("examId", "studentId"); + +-- CreateIndex +CREATE INDEX "Answer_schoolId_idx" ON "Answer"("schoolId"); + +-- CreateIndex +CREATE INDEX "Answer_submissionId_idx" ON "Answer"("submissionId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Answer_submissionId_questionId_key" ON "Answer"("submissionId", "questionId"); + -- CreateIndex CREATE INDEX "_TeacherClasses_B_index" ON "_TeacherClasses"("B"); @@ -588,6 +743,30 @@ ALTER TABLE "Assignment" ADD CONSTRAINT "Assignment_lessonId_fkey" FOREIGN KEY ( -- AddForeignKey ALTER TABLE "Assignment" ADD CONSTRAINT "Assignment_academicYearId_fkey" FOREIGN KEY ("academicYearId") REFERENCES "AcademicYear"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +-- AddForeignKey +ALTER TABLE "AssignmentSubmission" ADD CONSTRAINT "AssignmentSubmission_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "Assignment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AssignmentSubmission" ADD CONSTRAINT "AssignmentSubmission_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "Student"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AssignmentSubmission" ADD CONSTRAINT "AssignmentSubmission_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AssignmentSubmission" ADD CONSTRAINT "AssignmentSubmission_academicYearId_fkey" FOREIGN KEY ("academicYearId") REFERENCES "AcademicYear"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "StudyMaterial" ADD CONSTRAINT "StudyMaterial_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "StudyMaterial" ADD CONSTRAINT "StudyMaterial_subjectId_fkey" FOREIGN KEY ("subjectId") REFERENCES "Subject"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "StudyMaterial" ADD CONSTRAINT "StudyMaterial_teacherId_fkey" FOREIGN KEY ("teacherId") REFERENCES "Teacher"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "StudyMaterial" ADD CONSTRAINT "StudyMaterial_academicYearId_fkey" FOREIGN KEY ("academicYearId") REFERENCES "AcademicYear"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + -- AddForeignKey ALTER TABLE "Result" ADD CONSTRAINT "Result_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School"("id") ON DELETE RESTRICT ON UPDATE CASCADE; @@ -633,6 +812,33 @@ ALTER TABLE "Message" ADD CONSTRAINT "Message_schoolId_fkey" FOREIGN KEY ("schoo -- AddForeignKey ALTER TABLE "Message" ADD CONSTRAINT "Message_academicYearId_fkey" FOREIGN KEY ("academicYearId") REFERENCES "AcademicYear"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +-- AddForeignKey +ALTER TABLE "Question" ADD CONSTRAINT "Question_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Question" ADD CONSTRAINT "Question_examId_fkey" FOREIGN KEY ("examId") REFERENCES "Exam"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Submission" ADD CONSTRAINT "Submission_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Submission" ADD CONSTRAINT "Submission_examId_fkey" FOREIGN KEY ("examId") REFERENCES "Exam"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Submission" ADD CONSTRAINT "Submission_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "Student"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Submission" ADD CONSTRAINT "Submission_academicYearId_fkey" FOREIGN KEY ("academicYearId") REFERENCES "AcademicYear"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Answer" ADD CONSTRAINT "Answer_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Answer" ADD CONSTRAINT "Answer_submissionId_fkey" FOREIGN KEY ("submissionId") REFERENCES "Submission"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Answer" ADD CONSTRAINT "Answer_questionId_fkey" FOREIGN KEY ("questionId") REFERENCES "Question"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + -- AddForeignKey ALTER TABLE "_TeacherClasses" ADD CONSTRAINT "_TeacherClasses_A_fkey" FOREIGN KEY ("A") REFERENCES "Class"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260522203148_add_allow_late_submission/migration.sql b/prisma/migrations/20260522203148_add_allow_late_submission/migration.sql new file mode 100644 index 0000000..97d87a5 --- /dev/null +++ b/prisma/migrations/20260522203148_add_allow_late_submission/migration.sql @@ -0,0 +1,37 @@ +-- CreateEnum +CREATE TYPE "SchoolDayExceptionType" AS ENUM ('HOLIDAY', 'OFF_DAY', 'WORKING_OVERRIDE'); + +-- AlterEnum +ALTER TYPE "Day" ADD VALUE 'FRIDAY'; + +-- AlterTable +ALTER TABLE "Assignment" ADD COLUMN "allowLateSubmission" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "AssignmentSubmission" ADD COLUMN "teacherFeedback" TEXT; + +-- CreateTable +CREATE TABLE "SchoolDayException" ( + "id" SERIAL NOT NULL, + "schoolId" INTEGER NOT NULL, + "date" DATE NOT NULL, + "type" "SchoolDayExceptionType" NOT NULL, + "name" TEXT, + "notes" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SchoolDayException_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "SchoolDayException_schoolId_idx" ON "SchoolDayException"("schoolId"); + +-- CreateIndex +CREATE INDEX "SchoolDayException_date_idx" ON "SchoolDayException"("date"); + +-- CreateIndex +CREATE UNIQUE INDEX "SchoolDayException_schoolId_date_key" ON "SchoolDayException"("schoolId", "date"); + +-- AddForeignKey +ALTER TABLE "SchoolDayException" ADD CONSTRAINT "SchoolDayException_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260523104852_add_subject_page_settings/migration.sql b/prisma/migrations/20260523104852_add_subject_page_settings/migration.sql new file mode 100644 index 0000000..a1e7925 --- /dev/null +++ b/prisma/migrations/20260523104852_add_subject_page_settings/migration.sql @@ -0,0 +1,36 @@ +-- CreateTable +CREATE TABLE "SubjectPageSettings" ( + "id" SERIAL NOT NULL, + "schoolId" INTEGER NOT NULL, + "subjectId" INTEGER NOT NULL, + "teacherId" TEXT NOT NULL, + "announcement" TEXT, + "description" TEXT, + "bannerImage" TEXT, + "sectionsOrder" JSONB NOT NULL DEFAULT '["assignments","lessons","materials"]', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SubjectPageSettings_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "SubjectPageSettings_schoolId_idx" ON "SubjectPageSettings"("schoolId"); + +-- CreateIndex +CREATE INDEX "SubjectPageSettings_subjectId_idx" ON "SubjectPageSettings"("subjectId"); + +-- CreateIndex +CREATE INDEX "SubjectPageSettings_teacherId_idx" ON "SubjectPageSettings"("teacherId"); + +-- CreateIndex +CREATE UNIQUE INDEX "SubjectPageSettings_subjectId_teacherId_key" ON "SubjectPageSettings"("subjectId", "teacherId"); + +-- AddForeignKey +ALTER TABLE "SubjectPageSettings" ADD CONSTRAINT "SubjectPageSettings_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SubjectPageSettings" ADD CONSTRAINT "SubjectPageSettings_subjectId_fkey" FOREIGN KEY ("subjectId") REFERENCES "Subject"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SubjectPageSettings" ADD CONSTRAINT "SubjectPageSettings_teacherId_fkey" FOREIGN KEY ("teacherId") REFERENCES "Teacher"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20260523124020_add_banner_height/migration.sql b/prisma/migrations/20260523124020_add_banner_height/migration.sql new file mode 100644 index 0000000..d6f7e0c --- /dev/null +++ b/prisma/migrations/20260523124020_add_banner_height/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "SubjectPageSettings" ADD COLUMN "bannerHeight" TEXT NOT NULL DEFAULT 'md'; diff --git a/prisma/migrations/20260524120000_add_answer_is_overridden/migration.sql b/prisma/migrations/20260524120000_add_answer_is_overridden/migration.sql new file mode 100644 index 0000000..08cec7a --- /dev/null +++ b/prisma/migrations/20260524120000_add_answer_is_overridden/migration.sql @@ -0,0 +1,2 @@ +-- Keep the existing database column in Prisma's migration history. +ALTER TABLE "Answer" ADD COLUMN IF NOT EXISTS "isOverridden" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/migrations/20260525120000_add_submission_grade_published/migration.sql b/prisma/migrations/20260525120000_add_submission_grade_published/migration.sql new file mode 100644 index 0000000..c8639c6 --- /dev/null +++ b/prisma/migrations/20260525120000_add_submission_grade_published/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "Submission" ADD COLUMN IF NOT EXISTS "gradePublished" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/migrations/20260525120734_add_notifications/migration.sql b/prisma/migrations/20260525120734_add_notifications/migration.sql new file mode 100644 index 0000000..669fe9e --- /dev/null +++ b/prisma/migrations/20260525120734_add_notifications/migration.sql @@ -0,0 +1,44 @@ +-- CreateEnum +CREATE TYPE "SchoolStatus" AS ENUM ('PENDING', 'ACTIVE', 'PAUSED'); + +-- CreateEnum +CREATE TYPE "NotificationType" AS ENUM ('NEW_ASSIGNMENT', 'ASSIGNMENT_SUBMITTED', 'ASSIGNMENT_FEEDBACK', 'NEW_EXAM', 'NEW_ANNOUNCEMENT', 'NEW_MESSAGE'); + +-- AlterTable +ALTER TABLE "School" ADD COLUMN "address" TEXT, +ADD COLUMN "city" TEXT, +ADD COLUMN "contactEmail" TEXT, +ADD COLUMN "contactPhone" TEXT, +ADD COLUMN "country" TEXT, +ADD COLUMN "pauseReason" TEXT, +ADD COLUMN "registrationNumber" TEXT, +ADD COLUMN "status" "SchoolStatus" NOT NULL DEFAULT 'PENDING', +ADD COLUMN "website" TEXT; + +-- AlterTable +ALTER TABLE "SchoolSettings" ADD COLUMN "workingDays" TEXT[] DEFAULT ARRAY[]::TEXT[]; + +-- CreateTable +CREATE TABLE "Notification" ( + "id" SERIAL NOT NULL, + "schoolId" INTEGER NOT NULL, + "userId" TEXT NOT NULL, + "userRole" TEXT NOT NULL, + "type" "NotificationType" NOT NULL, + "title" TEXT NOT NULL, + "body" TEXT NOT NULL, + "link" TEXT, + "isRead" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Notification_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Notification_userId_isRead_idx" ON "Notification"("userId", "isRead"); + +-- CreateIndex +CREATE INDEX "Notification_schoolId_idx" ON "Notification"("schoolId"); + +-- AddForeignKey +ALTER TABLE "Notification" ADD CONSTRAINT "Notification_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260526224521_update_notification_types/migration.sql b/prisma/migrations/20260526224521_update_notification_types/migration.sql new file mode 100644 index 0000000..c6bc173 --- /dev/null +++ b/prisma/migrations/20260526224521_update_notification_types/migration.sql @@ -0,0 +1,15 @@ +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "NotificationType" ADD VALUE 'ASSIGNMENT_UPDATED'; +ALTER TYPE "NotificationType" ADD VALUE 'GRADE_POSTED'; +ALTER TYPE "NotificationType" ADD VALUE 'GRADE_UPDATED'; +ALTER TYPE "NotificationType" ADD VALUE 'NEW_EVENT'; +ALTER TYPE "NotificationType" ADD VALUE 'SCHEDULE_UPDATED'; +ALTER TYPE "NotificationType" ADD VALUE 'ATTENDANCE_SAVED'; +ALTER TYPE "NotificationType" ADD VALUE 'SUPERVISOR_ASSIGNED'; diff --git a/prisma/migrations/20260530120000_add_file_upload_fields/migration.sql b/prisma/migrations/20260530120000_add_file_upload_fields/migration.sql new file mode 100644 index 0000000..c9a6186 --- /dev/null +++ b/prisma/migrations/20260530120000_add_file_upload_fields/migration.sql @@ -0,0 +1,4 @@ +ALTER TABLE "Answer" ADD COLUMN IF NOT EXISTS "filePublicId" TEXT; +ALTER TABLE "Answer" ADD COLUMN IF NOT EXISTS "fileOriginalName" TEXT; +ALTER TABLE "Answer" ADD COLUMN IF NOT EXISTS "fileMimeType" TEXT; +ALTER TABLE "Answer" ADD COLUMN IF NOT EXISTS "fileSizeBytes" INTEGER; diff --git a/prisma/migrations/20260531093303_add_school_status/migration.sql b/prisma/migrations/20260531093303_add_school_status/migration.sql new file mode 100644 index 0000000..b82cc25 --- /dev/null +++ b/prisma/migrations/20260531093303_add_school_status/migration.sql @@ -0,0 +1,3 @@ +-- This migration is intentionally empty. +-- The school status fields were already introduced by +-- 20260525120734_add_notifications in the merged migration history. diff --git a/prisma/migrations/20260602110624_add_feedback_system/migration.sql b/prisma/migrations/20260602110624_add_feedback_system/migration.sql new file mode 100644 index 0000000..c618a0b --- /dev/null +++ b/prisma/migrations/20260602110624_add_feedback_system/migration.sql @@ -0,0 +1,12 @@ +-- CreateTable +CREATE TABLE "Feedback" ( + "id" TEXT NOT NULL, + "type" TEXT NOT NULL, + "message" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending', + "schoolId" INTEGER NOT NULL, + "userId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Feedback_pkey" PRIMARY KEY ("id") +); diff --git a/prisma/migrations/20260603120000_make_exam_lesson_optional_add_teacher/migration.sql b/prisma/migrations/20260603120000_make_exam_lesson_optional_add_teacher/migration.sql new file mode 100644 index 0000000..4d9b753 --- /dev/null +++ b/prisma/migrations/20260603120000_make_exam_lesson_optional_add_teacher/migration.sql @@ -0,0 +1,24 @@ +-- Make lesson linkage optional for exams and store the responsible teacher directly. +ALTER TABLE "Exam" ADD COLUMN "teacherId" TEXT; + +ALTER TABLE "Exam" DROP CONSTRAINT IF EXISTS "Exam_lessonId_fkey"; + +ALTER TABLE "Exam" ALTER COLUMN "lessonId" DROP NOT NULL; + +ALTER TABLE "Exam" +ADD CONSTRAINT "Exam_lessonId_fkey" +FOREIGN KEY ("lessonId") REFERENCES "Lesson"("id") +ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "Exam" +ADD CONSTRAINT "Exam_teacherId_fkey" +FOREIGN KEY ("teacherId") REFERENCES "Teacher"("id") +ON DELETE SET NULL ON UPDATE CASCADE; + +CREATE INDEX "Exam_teacherId_idx" ON "Exam"("teacherId"); + +UPDATE "Exam" AS exam +SET "teacherId" = lesson."teacherId" +FROM "Lesson" AS lesson +WHERE exam."lessonId" = lesson."id" + AND exam."teacherId" IS NULL; diff --git a/prisma/migrations/20260603121000_make_assignment_lesson_optional_add_teacher/migration.sql b/prisma/migrations/20260603121000_make_assignment_lesson_optional_add_teacher/migration.sql new file mode 100644 index 0000000..10cb098 --- /dev/null +++ b/prisma/migrations/20260603121000_make_assignment_lesson_optional_add_teacher/migration.sql @@ -0,0 +1,24 @@ +-- Make lesson linkage optional for assignments and store the responsible teacher directly. +ALTER TABLE "Assignment" ADD COLUMN "teacherId" TEXT; + +ALTER TABLE "Assignment" DROP CONSTRAINT IF EXISTS "Assignment_lessonId_fkey"; + +ALTER TABLE "Assignment" ALTER COLUMN "lessonId" DROP NOT NULL; + +ALTER TABLE "Assignment" +ADD CONSTRAINT "Assignment_lessonId_fkey" +FOREIGN KEY ("lessonId") REFERENCES "Lesson"("id") +ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "Assignment" +ADD CONSTRAINT "Assignment_teacherId_fkey" +FOREIGN KEY ("teacherId") REFERENCES "Teacher"("id") +ON DELETE SET NULL ON UPDATE CASCADE; + +CREATE INDEX "Assignment_teacherId_idx" ON "Assignment"("teacherId"); + +UPDATE "Assignment" AS assignment +SET "teacherId" = lesson."teacherId" +FROM "Lesson" AS lesson +WHERE assignment."lessonId" = lesson."id" + AND assignment."teacherId" IS NULL; diff --git a/prisma/migrations/20260603122000_add_assignment_submission_grading/migration.sql b/prisma/migrations/20260603122000_add_assignment_submission_grading/migration.sql new file mode 100644 index 0000000..b741022 --- /dev/null +++ b/prisma/migrations/20260603122000_add_assignment_submission_grading/migration.sql @@ -0,0 +1,5 @@ +ALTER TABLE "AssignmentSubmission" +ADD COLUMN "score" DOUBLE PRECISION, +ADD COLUMN "gradePublished" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "gradedAt" TIMESTAMP(3), +ADD COLUMN "gradedBy" TEXT; diff --git a/prisma/migrations/20260603123000_add_assignment_max_score/migration.sql b/prisma/migrations/20260603123000_add_assignment_max_score/migration.sql new file mode 100644 index 0000000..55176c4 --- /dev/null +++ b/prisma/migrations/20260603123000_add_assignment_max_score/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "Assignment" +ADD COLUMN "maxScore" DOUBLE PRECISION NOT NULL DEFAULT 10; diff --git a/prisma/migrations/20260604120000_add_ai_evaluations/migration.sql b/prisma/migrations/20260604120000_add_ai_evaluations/migration.sql new file mode 100644 index 0000000..064c225 --- /dev/null +++ b/prisma/migrations/20260604120000_add_ai_evaluations/migration.sql @@ -0,0 +1,44 @@ +CREATE TYPE "AiEvaluationStatus" AS ENUM ('SUGGESTED', 'APPROVED', 'FAILED'); + +CREATE TABLE "AiEvaluation" ( + "id" SERIAL NOT NULL, + "schoolId" INTEGER NOT NULL, + "answerId" INTEGER, + "assignmentSubmissionId" INTEGER, + "provider" TEXT NOT NULL DEFAULT 'gemini', + "model" TEXT NOT NULL, + "status" "AiEvaluationStatus" NOT NULL DEFAULT 'SUGGESTED', + "score" DOUBLE PRECISION, + "maxScore" DOUBLE PRECISION NOT NULL, + "feedback" TEXT, + "strengths" JSONB NOT NULL DEFAULT '[]', + "weaknesses" JSONB NOT NULL DEFAULT '[]', + "needsReview" BOOLEAN NOT NULL DEFAULT true, + "error" TEXT, + "approvedScore" DOUBLE PRECISION, + "approvedFeedback" TEXT, + "approvedBy" TEXT, + "approvedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AiEvaluation_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "AiEvaluation_answerId_key" ON "AiEvaluation"("answerId"); +CREATE UNIQUE INDEX "AiEvaluation_assignmentSubmissionId_key" ON "AiEvaluation"("assignmentSubmissionId"); +CREATE INDEX "AiEvaluation_schoolId_idx" ON "AiEvaluation"("schoolId"); +CREATE INDEX "AiEvaluation_answerId_idx" ON "AiEvaluation"("answerId"); +CREATE INDEX "AiEvaluation_assignmentSubmissionId_idx" ON "AiEvaluation"("assignmentSubmissionId"); + +ALTER TABLE "AiEvaluation" +ADD CONSTRAINT "AiEvaluation_schoolId_fkey" +FOREIGN KEY ("schoolId") REFERENCES "School"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE "AiEvaluation" +ADD CONSTRAINT "AiEvaluation_answerId_fkey" +FOREIGN KEY ("answerId") REFERENCES "Answer"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "AiEvaluation" +ADD CONSTRAINT "AiEvaluation_assignmentSubmissionId_fkey" +FOREIGN KEY ("assignmentSubmissionId") REFERENCES "AssignmentSubmission"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260604123000_add_assignment_rubric/migration.sql b/prisma/migrations/20260604123000_add_assignment_rubric/migration.sql new file mode 100644 index 0000000..ea15033 --- /dev/null +++ b/prisma/migrations/20260604123000_add_assignment_rubric/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Assignment" ADD COLUMN "rubric" TEXT; diff --git a/prisma/migrations/20260604124500_add_exam_instructions/migration.sql b/prisma/migrations/20260604124500_add_exam_instructions/migration.sql new file mode 100644 index 0000000..8bbd7ab --- /dev/null +++ b/prisma/migrations/20260604124500_add_exam_instructions/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "Exam" ADD COLUMN "instructions" TEXT; diff --git a/prisma/migrations/20260606190000_add_student_final_results/migration.sql b/prisma/migrations/20260606190000_add_student_final_results/migration.sql new file mode 100644 index 0000000..149ad53 --- /dev/null +++ b/prisma/migrations/20260606190000_add_student_final_results/migration.sql @@ -0,0 +1,22 @@ +CREATE TABLE "StudentFinalResult" ( + "id" SERIAL NOT NULL, + "schoolId" INTEGER NOT NULL, + "studentId" TEXT NOT NULL, + "academicYearId" INTEGER NOT NULL, + "averageScore" DOUBLE PRECISION NOT NULL, + "assessmentCount" INTEGER NOT NULL DEFAULT 0, + "status" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "StudentFinalResult_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "StudentFinalResult_schoolId_studentId_academicYearId_key" ON "StudentFinalResult"("schoolId", "studentId", "academicYearId"); +CREATE INDEX "StudentFinalResult_schoolId_idx" ON "StudentFinalResult"("schoolId"); +CREATE INDEX "StudentFinalResult_studentId_idx" ON "StudentFinalResult"("studentId"); +CREATE INDEX "StudentFinalResult_academicYearId_idx" ON "StudentFinalResult"("academicYearId"); + +ALTER TABLE "StudentFinalResult" ADD CONSTRAINT "StudentFinalResult_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "StudentFinalResult" ADD CONSTRAINT "StudentFinalResult_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "Student"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "StudentFinalResult" ADD CONSTRAINT "StudentFinalResult_academicYearId_fkey" FOREIGN KEY ("academicYearId") REFERENCES "AcademicYear"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6b6647d..a352977 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -8,201 +8,246 @@ datasource db { // Docker PostgreSQL — no directUrl needed } - model School { - id Int @id @default(autoincrement()) - name String @unique - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - academicYears AcademicYear[] - admins Admin[] - students Student[] - studentAcademicYears StudentAcademicYear[] - teachers Teacher[] - parents Parent[] - grades Grade[] - classes Class[] - subjects Subject[] - lessons Lesson[] - events Event[] - announcements Announcement[] - messages Message[] - attendances Attendance[] - exams Exam[] - assignments Assignment[] - schoolSettings SchoolSettings[] - results Result[] + id Int @id @default(autoincrement()) + name String @unique + contactEmail String? + contactPhone String? + website String? + country String? + city String? + address String? + registrationNumber String? + status SchoolStatus @default(PENDING) + pauseReason String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + academicYears AcademicYear[] + admins Admin[] + students Student[] + studentAcademicYears StudentAcademicYear[] + teachers Teacher[] + parents Parent[] + grades Grade[] + classes Class[] + subjects Subject[] + lessons Lesson[] + events Event[] + announcements Announcement[] + messages Message[] + attendances Attendance[] + exams Exam[] + assignments Assignment[] + assignmentSubmissions AssignmentSubmission[] + studyMaterials StudyMaterial[] + schoolSettings SchoolSettings[] + schoolDayExceptions SchoolDayException[] + results Result[] + studentFinalResults StudentFinalResult[] + aiEvaluations AiEvaluation[] // Exam workflow relations questions Question[] submissions Submission[] answers Answer[] -} + subjectPageSettings SubjectPageSettings[] + notifications Notification[] +} -model Admin{ - id String @id +model Admin { + id String @id username String schoolId Int - school School @relation(fields: [schoolId], references: [id]) - @@unique([schoolId, username]) + school School @relation(fields: [schoolId], references: [id]) + @@unique([schoolId, username]) @@index([schoolId]) } - model SchoolSettings { - id Int @id @default(autoincrement()) - schoolId Int @unique - school School @relation(fields: [schoolId], references: [id]) + id Int @id @default(autoincrement()) + schoolId Int @unique + school School @relation(fields: [schoolId], references: [id]) workDayStart DateTime @db.Time - workDayEnd DateTime @db.Time + workDayEnd DateTime @db.Time lessonDuration Int - lessonsPerDay Int + lessonsPerDay Int + workingDays String[] @default([]) updatedAt DateTime @updatedAt @@index([schoolId]) } +model SchoolDayException { + id Int @id @default(autoincrement()) + schoolId Int + school School @relation(fields: [schoolId], references: [id], onDelete: Cascade) + date DateTime @db.Date + type SchoolDayExceptionType + name String? + notes String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([schoolId, date]) + @@index([schoolId]) + @@index([date]) +} + model AcademicYear { - id Int @id @default(autoincrement()) - schoolId Int - school School @relation(fields: [schoolId], references: [id]) - name String + id Int @id @default(autoincrement()) + schoolId Int + school School @relation(fields: [schoolId], references: [id]) + name String startDate DateTime @db.Date - endDate DateTime @db.Date - isCurrent Boolean @default(false) + endDate DateTime @db.Date + isCurrent Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - lessons Lesson[] - exams Exam[] - assignments Assignment[] - results Result[] - attendances Attendance[] - events Event[] - announcements Announcement[] - messages Message[] - studentAcademicYears StudentAcademicYear[] + lessons Lesson[] + exams Exam[] + assignments Assignment[] + studyMaterials StudyMaterial[] + results Result[] + studentFinalResults StudentFinalResult[] + attendances Attendance[] + events Event[] + announcements Announcement[] + messages Message[] + studentAcademicYears StudentAcademicYear[] + submissions Submission[] // exam submissions + assignmentSubmissions AssignmentSubmission[] // assignment submissions - @@unique([schoolId, name]) + @@unique([schoolId, name]) } -model Student{ - id String @id - schoolId Int - school School @relation(fields: [schoolId], references: [id]) - username String +model Student { + id String @id + schoolId Int + school School @relation(fields: [schoolId], references: [id]) + username String + name String + email String? + phone String? + address String + img String? + bloodType String + sex UserSex + status StudentStatus @default(ACTIVE) + repeatCount Int @default(0) + createdAt DateTime @default(now()) + parentId String? + parent Parent? @relation(fields: [parentId], references: [id]) + classId Int + class Class @relation(fields: [classId], references: [id]) + gradeId Int + grade Grade @relation(fields: [gradeId], references: [id]) + attendances Attendance[] + results Result[] + finalResults StudentFinalResult[] + messages Message[] + academicYears StudentAcademicYear[] + birthday DateTime + submissions Submission[] // exam submissions + assignmentSubmissions AssignmentSubmission[] // assignment submissions + @@unique([schoolId, username]) - name String - email String? @@unique([schoolId, email]) - phone String? @@unique([schoolId, phone]) - address String - img String? - bloodType String - sex UserSex - status StudentStatus @default(ACTIVE) - repeatCount Int @default(0) - createdAt DateTime @default(now()) - parentId String? - parent Parent? @relation(fields:[parentId], references: [id]) - classId Int - class Class @relation(fields:[classId], references: [id]) - gradeId Int - grade Grade @relation(fields:[gradeId], references: [id]) - attendances Attendance[] - results Result[] - messages Message[] - academicYears StudentAcademicYear[] - birthday DateTime - submissions Submission[] - @@index([schoolId]) } -model Teacher{ - id String @id - schoolId Int - school School @relation(fields: [schoolId], references: [id]) - username String + +model Teacher { + id String @id + schoolId Int + school School @relation(fields: [schoolId], references: [id]) + username String + name String + email String? + phone String? + address String + img String? + bloodType String + sex UserSex + createdAt DateTime @default(now()) + subjects Subject[] + lessons Lesson[] + classes Class[] @relation("TeacherClasses") + supervisedClasses Class[] @relation("ClassSupervisor") + attendances Attendance[] + messages Message[] + studyMaterials StudyMaterial[] + birthday DateTime + exams Exam[] + assignments Assignment[] + subjectPageSettings SubjectPageSettings[] + @@unique([schoolId, username]) - name String - email String? @@unique([schoolId, email]) - phone String? @@unique([schoolId, phone]) - address String - img String? - bloodType String - sex UserSex - createdAt DateTime @default(now()) - subjects Subject[] - lessons Lesson[] - classes Class[] @relation("TeacherClasses") - supervisedClasses Class[] @relation("ClassSupervisor") - attendances Attendance[] - messages Message[] - birthday DateTime - @@index([schoolId]) } -model Parent{ - id String @id - schoolId Int - school School @relation(fields: [schoolId], references: [id]) - username String + +model Parent { + id String @id + schoolId Int + school School @relation(fields: [schoolId], references: [id]) + username String + name String + email String? + phone String + address String + createdAt DateTime @default(now()) + students Student[] + messages Message[] + @@unique([schoolId, username]) - name String - email String? @@unique([schoolId, email]) - phone String @@unique([schoolId, phone]) - address String - createdAt DateTime @default(now()) - students Student[] - messages Message[] - @@index([schoolId]) } -model Grade{ - id Int @id @default(autoincrement()) - schoolId Int - school School @relation(fields: [schoolId], references: [id]) - level Int - @@unique([schoolId, level]) - students Student[] - classes Class[] - subjects Subject[] + +model Grade { + id Int @id @default(autoincrement()) + schoolId Int + school School @relation(fields: [schoolId], references: [id]) + level Int + students Student[] + classes Class[] + subjects Subject[] studentAcademicYears StudentAcademicYear[] + @@unique([schoolId, level]) @@index([schoolId]) } -model Class{ - id Int @id @default(autoincrement()) - schoolId Int - school School @relation(fields: [schoolId], references: [id]) - name String - @@unique([schoolId, name]) - capacity Int - supervisorId String? - supervisor Teacher? @relation("ClassSupervisor", fields: [supervisorId], references: [id]) - teachers Teacher[] @relation("TeacherClasses") - lessons Lesson[] - students Student[] - gradeId Int - grade Grade @relation(fields: [gradeId], references: [id]) - events Event[] - announcements Announcement[] - messages Message[] - exams Exam[] - assignments Assignment[] + +model Class { + id Int @id @default(autoincrement()) + schoolId Int + school School @relation(fields: [schoolId], references: [id]) + name String + capacity Int + supervisorId String? + supervisor Teacher? @relation("ClassSupervisor", fields: [supervisorId], references: [id]) + teachers Teacher[] @relation("TeacherClasses") + lessons Lesson[] + students Student[] + gradeId Int + grade Grade @relation(fields: [gradeId], references: [id]) + events Event[] + announcements Announcement[] + messages Message[] + exams Exam[] + assignments Assignment[] studentAcademicYears StudentAcademicYear[] + @@unique([schoolId, name]) @@index([schoolId]) } @@ -210,19 +255,19 @@ model StudentAcademicYear { id Int @id @default(autoincrement()) studentId String - student Student @relation(fields: [studentId], references: [id]) + student Student @relation(fields: [studentId], references: [id]) schoolId Int - school School @relation(fields: [schoolId], references: [id]) + school School @relation(fields: [schoolId], references: [id]) academicYearId Int - academicYear AcademicYear @relation(fields: [academicYearId], references: [id]) + academicYear AcademicYear @relation(fields: [academicYearId], references: [id]) gradeId Int - grade Grade @relation(fields: [gradeId], references: [id]) + grade Grade @relation(fields: [gradeId], references: [id]) classId Int? - class Class? @relation(fields: [classId], references: [id]) + class Class? @relation(fields: [classId], references: [id]) performanceStatus PassFailStatus? @@ -235,135 +280,245 @@ model StudentAcademicYear { @@index([classId]) @@index([schoolId]) } -model Subject{ - id Int @id @default(autoincrement()) - schoolId Int - school School @relation(fields: [schoolId], references: [id]) - name String - @@unique([schoolId, name]) - gradeId Int - grade Grade @relation(fields: [gradeId], references: [id]) - teachers Teacher[] - lessons Lesson[] - exams Exam[] - assignments Assignment[] +model Subject { + id Int @id @default(autoincrement()) + schoolId Int + school School @relation(fields: [schoolId], references: [id]) + name String + gradeId Int + grade Grade @relation(fields: [gradeId], references: [id]) + teachers Teacher[] + lessons Lesson[] + exams Exam[] + assignments Assignment[] + studyMaterials StudyMaterial[] + pageSettings SubjectPageSettings[] + + @@unique([schoolId, name]) @@index([schoolId]) } -model Lesson{ - id Int @id @default(autoincrement()) - schoolId Int - school School @relation(fields: [schoolId], references: [id]) - name String - day Day - startTime DateTime - endTime DateTime - subjectId Int - subject Subject @relation(fields: [subjectId], references: [id]) - classId Int - class Class @relation(fields: [classId], references: [id]) - teacherId String - teacher Teacher @relation(fields: [teacherId], references: [id]) + +model Lesson { + id Int @id @default(autoincrement()) + schoolId Int + school School @relation(fields: [schoolId], references: [id]) + name String + day Day + startTime DateTime + endTime DateTime + subjectId Int + subject Subject @relation(fields: [subjectId], references: [id]) + classId Int + class Class @relation(fields: [classId], references: [id]) + teacherId String + teacher Teacher @relation(fields: [teacherId], references: [id]) academicYearId Int - academicYear AcademicYear @relation(fields: [academicYearId], references: [id]) - exams Exam[] - assignments Assignment[] + academicYear AcademicYear @relation(fields: [academicYearId], references: [id]) + exams Exam[] + assignments Assignment[] @@index([schoolId]) } -model Exam{ - id Int @id @default(autoincrement()) - schoolId Int - school School @relation(fields: [schoolId], references: [id]) - title String - startTime DateTime - endTime DateTime - classId Int? - class Class? @relation(fields: [classId], references: [id]) +model Exam { + id Int @id @default(autoincrement()) + schoolId Int + school School @relation(fields: [schoolId], references: [id]) + title String + instructions String? + startTime DateTime + endTime DateTime + + classId Int? + class Class? @relation(fields: [classId], references: [id]) subjectId Int? - subject Subject? @relation(fields: [subjectId], references: [id]) + subject Subject? @relation(fields: [subjectId], references: [id]) + + teacherId String? + teacher Teacher? @relation(fields: [teacherId], references: [id]) - lessonId Int - lesson Lesson @relation(fields: [lessonId], references: [id]) + lessonId Int? + lesson Lesson? @relation(fields: [lessonId], references: [id]) academicYearId Int - academicYear AcademicYear @relation(fields: [academicYearId], references: [id]) + academicYear AcademicYear @relation(fields: [academicYearId], references: [id]) results Result[] - // Feature toggles — all per-exam configurable + // Feature toggles enableTimer Boolean @default(true) - duration Int? // minutes — required if enableTimer = true - enableNavigation Boolean @default(true) // allow prev page navigation - enableAutoSave Boolean @default(true) // auto-save answers - autoSaveInterval Int @default(30) // seconds between auto-saves - enableAutoSubmit Boolean @default(true) // cron auto-submit backstop - questionsPerPage Int @default(1) // server-side pagination size + duration Int? + enableNavigation Boolean @default(true) + enableAutoSave Boolean @default(true) + autoSaveInterval Int @default(30) + enableAutoSubmit Boolean @default(true) + questionsPerPage Int @default(1) // Exam workflow relations questions Question[] submissions Submission[] @@index([schoolId]) + @@index([teacherId]) } -model Assignment{ - id Int @id @default(autoincrement()) - schoolId Int - school School @relation(fields: [schoolId], references: [id]) - title String - startDate DateTime - endDate DateTime - classId Int? - class Class? @relation(fields: [classId], references: [id]) +model Assignment { + id Int @id @default(autoincrement()) + schoolId Int + school School @relation(fields: [schoolId], references: [id]) + title String + description String? + rubric String? + startDate DateTime + endDate DateTime + fileUrl String? + fileName String? + maxScore Float @default(10) + allowLateSubmission Boolean @default(false) // ✅ أضف هذا السطر + + classId Int? + class Class? @relation(fields: [classId], references: [id]) subjectId Int? - subject Subject? @relation(fields: [subjectId], references: [id]) + subject Subject? @relation(fields: [subjectId], references: [id]) - lessonId Int - lesson Lesson @relation(fields: [lessonId], references: [id]) + teacherId String? + teacher Teacher? @relation(fields: [teacherId], references: [id]) + + lessonId Int? + lesson Lesson? @relation(fields: [lessonId], references: [id]) academicYearId Int - academicYear AcademicYear @relation(fields: [academicYearId], references: [id]) + academicYear AcademicYear @relation(fields: [academicYearId], references: [id]) - results Result[] + results Result[] + assignmentSubmissions AssignmentSubmission[] @@index([schoolId]) + @@index([teacherId]) } -model Result{ - id Int @id @default(autoincrement()) + +// Assignment file submission (from stash) +model AssignmentSubmission { + id Int @id @default(autoincrement()) + fileUrl String + fileName String + fileType String + note String? + teacherFeedback String? + score Float? + gradePublished Boolean @default(false) + gradedAt DateTime? + gradedBy String? + aiEvaluation AiEvaluation? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + assignmentId Int + assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade) + + studentId String + student Student @relation(fields: [studentId], references: [id], onDelete: Cascade) + schoolId Int - school School @relation(fields: [schoolId], references: [id]) - score Int + school School @relation(fields: [schoolId], references: [id]) - examId Int? - exam Exam? @relation(fields: [examId], references: [id]) + academicYearId Int + academicYear AcademicYear @relation(fields: [academicYearId], references: [id]) + + @@unique([assignmentId, studentId]) + @@index([schoolId]) + @@index([assignmentId]) + @@index([studentId]) +} + +model StudyMaterial { + id Int @id @default(autoincrement()) + schoolId Int + school School @relation(fields: [schoolId], references: [id]) + + title String + description String? + fileUrl String + fileName String + fileType String + + subjectId Int + subject Subject @relation(fields: [subjectId], references: [id]) + + teacherId String + teacher Teacher @relation(fields: [teacherId], references: [id]) + + academicYearId Int + academicYear AcademicYear @relation(fields: [academicYearId], references: [id]) + + createdAt DateTime @default(now()) + + @@index([schoolId]) + @@index([subjectId]) + @@index([teacherId]) +} + +model Result { + id Int @id @default(autoincrement()) + schoolId Int + school School @relation(fields: [schoolId], references: [id]) + score Int + + examId Int? + exam Exam? @relation(fields: [examId], references: [id]) assignmentId Int? - assignment Assignment? @relation(fields: [assignmentId], references: [id]) + assignment Assignment? @relation(fields: [assignmentId], references: [id]) studentId String - student Student @relation(fields: [studentId], references: [id]) + student Student @relation(fields: [studentId], references: [id]) academicYearId Int - academicYear AcademicYear @relation(fields: [academicYearId], references: [id]) + academicYear AcademicYear @relation(fields: [academicYearId], references: [id]) @@index([schoolId]) } -model Attendance{ - id Int @id @default(autoincrement()) + +model StudentFinalResult { + id Int @id @default(autoincrement()) + schoolId Int + school School @relation(fields: [schoolId], references: [id]) + + studentId String + student Student @relation(fields: [studentId], references: [id]) + + academicYearId Int + academicYear AcademicYear @relation(fields: [academicYearId], references: [id]) + + averageScore Float + assessmentCount Int @default(0) + status String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([schoolId, studentId, academicYearId]) + @@index([schoolId]) + @@index([studentId]) + @@index([academicYearId]) +} + +model Attendance { + id Int @id @default(autoincrement()) schoolId Int - school School @relation(fields: [schoolId], references: [id]) - date DateTime - present Boolean + school School @relation(fields: [schoolId], references: [id]) + date DateTime + present Boolean academicYearId Int - academicYear AcademicYear @relation(fields: [academicYearId], references: [id]) + academicYear AcademicYear @relation(fields: [academicYearId], references: [id]) studentId String? - student Student? @relation(fields: [studentId], references: [id]) + student Student? @relation(fields: [studentId], references: [id]) teacherId String? - teacher Teacher? @relation(fields: [teacherId], references: [id]) + teacher Teacher? @relation(fields: [teacherId], references: [id]) @@unique([studentId, academicYearId, date]) @@unique([teacherId, academicYearId, date]) @@ -373,51 +528,89 @@ model Attendance{ @@index([teacherId]) @@index([schoolId]) } -model Event{ - id Int @id @default(autoincrement()) - schoolId Int - school School @relation(fields: [schoolId], references: [id]) - title String - description String - startDate DateTime - endDate DateTime + +model Event { + id Int @id @default(autoincrement()) + schoolId Int + school School @relation(fields: [schoolId], references: [id]) + title String + description String + startDate DateTime + endDate DateTime academicYearId Int - academicYear AcademicYear @relation(fields: [academicYearId], references: [id]) - classes Class[] + academicYear AcademicYear @relation(fields: [academicYearId], references: [id]) + classes Class[] @@index([schoolId]) } -model Announcement{ - id Int @id @default(autoincrement()) - schoolId Int - school School @relation(fields: [schoolId], references: [id]) - title String - description String - date DateTime + +model Announcement { + id Int @id @default(autoincrement()) + schoolId Int + school School @relation(fields: [schoolId], references: [id]) + title String + description String + date DateTime academicYearId Int - academicYear AcademicYear @relation(fields: [academicYearId], references: [id]) - classes Class[] + academicYear AcademicYear @relation(fields: [academicYearId], references: [id]) + classes Class[] @@index([schoolId]) } model Message { - id Int @id @default(autoincrement()) - schoolId Int - school School @relation(fields: [schoolId], references: [id]) - title String - description String - date DateTime @default(now()) + id Int @id @default(autoincrement()) + schoolId Int + school School @relation(fields: [schoolId], references: [id]) + title String + description String + date DateTime @default(now()) academicYearId Int - academicYear AcademicYear @relation(fields: [academicYearId], references: [id]) - students Student[] - parents Parent[] - teachers Teacher[] - classes Class[] + academicYear AcademicYear @relation(fields: [academicYearId], references: [id]) + students Student[] + parents Parent[] + teachers Teacher[] + classes Class[] @@index([schoolId]) } +// ===== SUBJECT PAGE SETTINGS ===== + +model SubjectPageSettings { + id Int @id @default(autoincrement()) + schoolId Int + school School @relation(fields: [schoolId], references: [id]) + + subjectId Int + subject Subject @relation(fields: [subjectId], references: [id], onDelete: Cascade) + + teacherId String + teacher Teacher @relation(fields: [teacherId], references: [id]) + + // Welcome announcement shown at top of page for students + announcement String? + + // Short course description + description String? + + // Banner image (Cloudinary URL) + bannerImage String? + + // Banner height: sm | md | lg + bannerHeight String @default("md") + + // Order of sections: JSON array e.g. ["assignments","lessons","materials"] + sectionsOrder Json @default("[\"assignments\",\"lessons\",\"materials\"]") + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([subjectId, teacherId]) + @@index([schoolId]) + @@index([subjectId]) + @@index([teacherId]) +} // ===== EXAM WORKFLOW MODELS ===== @@ -431,9 +624,10 @@ model Question { type QuestionType points Int @default(1) order Int - options Json? // MCQ only: string[] - correctAnswer String[] // T/F and MCQ — array supports multiple correct answers + options Json? + correctAnswer String[] allowMultiple Boolean @default(false) + textAnswer String? // Optional model answer for TEXT questions answers Answer[] @@index([schoolId]) @@ -453,44 +647,103 @@ model Submission { startedAt DateTime @default(now()) submittedAt DateTime? autoSubmitted Boolean @default(false) - lastSyncedAt DateTime? // Anti-cheat: updated on every saveAnswer + lastSyncedAt DateTime? totalScore Float? - extraTime Int? // Additional minutes granted by teacher - extendedBy String? // Teacher Clerk ID who granted extension + gradePublished Boolean @default(false) + extraTime Int? + extendedBy String? extendedAt DateTime? - disconnectedAt DateTime? // Logged on reconnection for audit + disconnectedAt DateTime? reconnectedAt DateTime? - totalOfflineTime Int? // Seconds offline — computed server-side + totalOfflineTime Int? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt answers Answer[] + academicYear AcademicYear? @relation(fields: [academicYearId], references: [id]) + academicYearId Int? - @@unique([examId, studentId]) // One submission per student per exam + @@unique([examId, studentId]) @@index([schoolId]) @@index([examId]) @@index([studentId]) } model Answer { - id Int @id @default(autoincrement()) - schoolId Int - school School @relation(fields: [schoolId], references: [id]) - submissionId Int - submission Submission @relation(fields: [submissionId], references: [id], onDelete: Cascade) - questionId Int - question Question @relation(fields: [questionId], references: [id]) - textAnswer String? // T/F, MCQ (comma-separated for multi-select), TEXT - fileUrl String? // FILE — relative path or URL - isDraft Boolean @default(true) // false after submitExam finalizes it - savedAt DateTime @default(now()) // ALWAYS set by server — never accepted from client - score Float? // null until graded + id Int @id @default(autoincrement()) + schoolId Int + school School @relation(fields: [schoolId], references: [id]) + submissionId Int + submission Submission @relation(fields: [submissionId], references: [id], onDelete: Cascade) + questionId Int + question Question @relation(fields: [questionId], references: [id]) + textAnswer String? // T/F, MCQ (comma-separated for multi-select), TEXT + fileUrl String? // FILE — Cloudinary secure_url + isDraft Boolean @default(true) // false after submitExam finalizes it + savedAt DateTime @default(now()) // ALWAYS set by server — never accepted from client + score Float? // null until graded + isOverridden Boolean @default(false) + filePublicId String? // Cloudinary public_id for deletion + fileOriginalName String? // Student's original filename + fileMimeType String? // MIME type from Cloudinary + fileSizeBytes Int? // File size in bytes + aiEvaluation AiEvaluation? @@unique([submissionId, questionId]) @@index([schoolId]) @@index([submissionId]) } -// ===== EXAM WORKFLOW ENUMS ===== +model AiEvaluation { + id Int @id @default(autoincrement()) + + schoolId Int + school School @relation(fields: [schoolId], references: [id]) + + answerId Int? @unique + answer Answer? @relation(fields: [answerId], references: [id], onDelete: Cascade) + + assignmentSubmissionId Int? @unique + assignmentSubmission AssignmentSubmission? @relation(fields: [assignmentSubmissionId], references: [id], onDelete: Cascade) + + provider String @default("gemini") + model String + status AiEvaluationStatus @default(SUGGESTED) + + score Float? + maxScore Float + feedback String? + strengths Json @default("[]") + weaknesses Json @default("[]") + needsReview Boolean @default(true) + error String? + + approvedScore Float? + approvedFeedback String? + approvedBy String? + approvedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([schoolId]) + @@index([answerId]) + @@index([assignmentSubmissionId]) +} + +model Feedback { + id String @id @default(cuid()) + + type String + message String + status String @default("pending") + + schoolId Int + userId String? + + createdAt DateTime @default(now()) +} + +// ===== ENUMS ===== enum QuestionType { TRUE_FALSE @@ -505,18 +758,25 @@ enum SubmissionStatus { GRADED } +enum AiEvaluationStatus { + SUGGESTED + APPROVED + FAILED +} + enum UserSex { MALE FEMALE } -enum Day{ +enum Day { SATURDAY SUNDAY MONDAY TUESDAY WEDNESDAY THURSDAY + FRIDAY } enum StudentStatus { @@ -530,3 +790,51 @@ enum PassFailStatus { PASS FAIL } + +enum SchoolStatus { + PENDING + ACTIVE + PAUSED +} + +enum SchoolDayExceptionType { + HOLIDAY + OFF_DAY + WORKING_OVERRIDE +} + +model Notification { + id Int @id @default(autoincrement()) + schoolId Int + school School @relation(fields: [schoolId], references: [id], onDelete: Cascade) + + userId String + userRole String + + type NotificationType + title String + body String + link String? + + isRead Boolean @default(false) + createdAt DateTime @default(now()) + + @@index([userId, isRead]) + @@index([schoolId]) +} + +enum NotificationType { + NEW_ASSIGNMENT + ASSIGNMENT_UPDATED + ASSIGNMENT_SUBMITTED + ASSIGNMENT_FEEDBACK + NEW_EXAM + GRADE_POSTED + GRADE_UPDATED + NEW_ANNOUNCEMENT + NEW_EVENT + SCHEDULE_UPDATED + ATTENDANCE_SAVED + SUPERVISOR_ASSIGNED + NEW_MESSAGE +} diff --git a/prisma/seed.ts b/prisma/seed.ts index cab1681..9be77c9 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,5 +1,10 @@ import "dotenv/config"; -import { Day, PrismaClient } from "@prisma/client"; +import { + PrismaClient, + SchoolStatus, + StudentStatus, + UserSex, +} from "@prisma/client"; import { PrismaPg } from "@prisma/adapter-pg"; const adapter = new PrismaPg({ @@ -13,26 +18,48 @@ const normalizeDay = (date: Date) => { return new Date(`${iso}T00:00:00.000Z`); }; +const safeDeleteMany = async (run: () => Promise) => { + try { + await run(); + } catch (error) { + if ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code?: string }).code === "P2021" + ) { + return; + } + throw error; + } +}; + const clearDatabase = async () => { - await prisma.result.deleteMany(); - await prisma.attendance.deleteMany(); - await prisma.message.deleteMany(); - await prisma.event.deleteMany(); - await prisma.announcement.deleteMany(); - await prisma.assignment.deleteMany(); - await prisma.exam.deleteMany(); - await prisma.lesson.deleteMany(); - await prisma.studentAcademicYear.deleteMany(); - await prisma.student.deleteMany(); - await prisma.parent.deleteMany(); - await prisma.subject.deleteMany(); - await prisma.class.deleteMany(); - await prisma.teacher.deleteMany(); - await prisma.grade.deleteMany(); - await prisma.admin.deleteMany(); - await prisma.academicYear.deleteMany(); - await prisma.schoolSettings.deleteMany(); - await prisma.school.deleteMany(); + await prisma.answer.deleteMany(); + await prisma.submission.deleteMany(); + await prisma.question.deleteMany(); + await safeDeleteMany(() => prisma.answer.deleteMany()); + await safeDeleteMany(() => prisma.submission.deleteMany()); + await safeDeleteMany(() => prisma.question.deleteMany()); + await safeDeleteMany(() => prisma.result.deleteMany()); + await safeDeleteMany(() => prisma.attendance.deleteMany()); + await safeDeleteMany(() => prisma.message.deleteMany()); + await safeDeleteMany(() => prisma.event.deleteMany()); + await safeDeleteMany(() => prisma.announcement.deleteMany()); + await safeDeleteMany(() => prisma.assignment.deleteMany()); + await safeDeleteMany(() => prisma.exam.deleteMany()); + await safeDeleteMany(() => prisma.lesson.deleteMany()); + await safeDeleteMany(() => prisma.studentAcademicYear.deleteMany()); + await safeDeleteMany(() => prisma.student.deleteMany()); + await safeDeleteMany(() => prisma.parent.deleteMany()); + await safeDeleteMany(() => prisma.subject.deleteMany()); + await safeDeleteMany(() => prisma.class.deleteMany()); + await safeDeleteMany(() => prisma.teacher.deleteMany()); + await safeDeleteMany(() => prisma.grade.deleteMany()); + await safeDeleteMany(() => prisma.admin.deleteMany()); + await safeDeleteMany(() => prisma.academicYear.deleteMany()); + await safeDeleteMany(() => prisma.schoolSettings.deleteMany()); + await safeDeleteMany(() => prisma.school.deleteMany()); }; const seedSchool = async ({ @@ -50,6 +77,8 @@ const seedSchool = async ({ studentName, className, subjectName, + schoolStatus, + pauseReason, }: { schoolName: string; adminId: string; @@ -65,9 +94,18 @@ const seedSchool = async ({ studentName: string; className: string; subjectName: string; + schoolStatus: SchoolStatus; + pauseReason?: string | null; }) => { const school = await prisma.school.create({ - data: { name: schoolName }, + data: { + name: schoolName, + status: schoolStatus, + pauseReason: + schoolStatus === SchoolStatus.PAUSED + ? (pauseReason ?? "Paused by super admin for review.") + : null, + }, }); await prisma.schoolSettings.create({ @@ -77,6 +115,14 @@ const seedSchool = async ({ workDayEnd: new Date("1970-01-01T14:00:00.000Z"), lessonDuration: 45, lessonsPerDay: 6, + workingDays: [ + "SATURDAY", + "SUNDAY", + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + ], }, }); @@ -149,19 +195,19 @@ const seedSchool = async ({ }, }); - const lesson = await prisma.lesson.create({ - data: { - name: "Lesson 1", - day: Day.MONDAY, - startTime: new Date("1970-01-01T08:00:00.000Z"), - endTime: new Date("1970-01-01T08:45:00.000Z"), - schoolId: school.id, - subjectId: subject.id, - classId: classA.id, - teacherId: teacher.id, - academicYearId: currentYear.id, - }, - }); + // const lesson = await prisma.lesson.create({ + // data: { + // name: "Lesson 1", + // day: Day.MONDAY, + // startTime: new Date("1970-01-01T08:00:00.000Z"), + // endTime: new Date("1970-01-01T08:45:00.000Z"), + // schoolId: school.id, + // subjectId: subject.id, + // classId: classA.id, + // teacherId: teacher.id, + // academicYearId: currentYear.id, + // }, + // }); const parent = await prisma.parent.create({ data: { @@ -184,13 +230,16 @@ const seedSchool = async ({ phone: `${school.id}33333333`, address: `${schoolName} City`, bloodType: "A+", - sex: "MALE", + + sex: UserSex.MALE, birthday: new Date("2010-01-01"), + schoolId: school.id, parentId: parent.id, classId: classA.id, gradeId: grade1.id, - status: "ACTIVE", + + status: StudentStatus.ACTIVE, }, }); @@ -216,51 +265,51 @@ const seedSchool = async ({ }, }); - const exam = await prisma.exam.create({ - data: { - title: `${subjectName} Midterm`, - startTime: new Date("2026-01-15T08:00:00.000Z"), - endTime: new Date("2026-01-15T09:00:00.000Z"), - schoolId: school.id, - classId: classA.id, - subjectId: subject.id, - lessonId: lesson.id, - academicYearId: currentYear.id, - }, - }); - - const assignment = await prisma.assignment.create({ - data: { - title: `${subjectName} Homework 1`, - startDate: new Date("2026-01-10T08:00:00.000Z"), - endDate: new Date("2026-01-20T23:59:00.000Z"), - schoolId: school.id, - classId: classA.id, - subjectId: subject.id, - lessonId: lesson.id, - academicYearId: currentYear.id, - }, - }); - - await prisma.result.create({ - data: { - score: 88, - schoolId: school.id, - examId: exam.id, - studentId: student.id, - academicYearId: currentYear.id, - }, - }); - - await prisma.result.create({ - data: { - score: 92, - schoolId: school.id, - assignmentId: assignment.id, - studentId: student.id, - academicYearId: currentYear.id, - }, - }); + // const exam = await prisma.exam.create({ + // data: { + // title: `${subjectName} Midterm`, + // startTime: new Date("2026-01-15T08:00:00.000Z"), + // endTime: new Date("2026-01-15T09:00:00.000Z"), + // schoolId: school.id, + // classId: classA.id, + // subjectId: subject.id, + // lessonId: lesson.id, + // academicYearId: currentYear.id, + // }, + // }); + + // const assignment = await prisma.assignment.create({ + // data: { + // title: `${subjectName} Homework 1`, + // startDate: new Date("2026-01-10T08:00:00.000Z"), + // endDate: new Date("2026-01-20T23:59:00.000Z"), + // schoolId: school.id, + // classId: classA.id, + // subjectId: subject.id, + // lessonId: lesson.id, + // academicYearId: currentYear.id, + // }, + // }); + + // await prisma.result.create({ + // data: { + // score: 88, + // schoolId: school.id, + // examId: exam.id, + // studentId: student.id, + // academicYearId: currentYear.id, + // }, + // }); + + // await prisma.result.create({ + // data: { + // score: 92, + // schoolId: school.id, + // assignmentId: assignment.id, + // studentId: student.id, + // academicYearId: currentYear.id, + // }, + // }); const today = normalizeDay(new Date()); @@ -340,6 +389,7 @@ async function main() { studentName: "Student Alpha", className: "1A", subjectName: "Mathematics", + schoolStatus: SchoolStatus.ACTIVE, }); await seedSchool({ @@ -357,9 +407,31 @@ async function main() { studentName: "Student Beta", className: "1B", subjectName: "Science", + schoolStatus: SchoolStatus.PENDING, + }); + + await seedSchool({ + schoolName: "Gamma School", + adminId: "admin_gamma", + adminUsername: "admin_gamma", + teacherId: "teacher_gamma", + teacherUsername: "teacher_gamma", + teacherName: "Teacher Gamma", + parentId: "parent_gamma", + parentUsername: "parent_gamma", + parentName: "Parent Gamma", + studentId: "student_gamma", + studentUsername: "student_gamma", + studentName: "Student Gamma", + className: "1C", + subjectName: "English", + schoolStatus: SchoolStatus.PAUSED, + pauseReason: "Pending compliance document update.", }); - console.log("Seed completed successfully for 2 schools."); + console.log( + "Seed completed successfully for 3 schools (ACTIVE, PENDING, PAUSED).", + ); } main() diff --git a/public/blood.png b/public/blood.png deleted file mode 100644 index e1f3382..0000000 Binary files a/public/blood.png and /dev/null differ diff --git a/public/calendar.png b/public/calendar.png deleted file mode 100644 index c808804..0000000 Binary files a/public/calendar.png and /dev/null differ diff --git a/public/date.png b/public/date.png deleted file mode 100644 index 4a8c6a1..0000000 Binary files a/public/date.png and /dev/null differ diff --git a/public/finance.png b/public/finance.png deleted file mode 100644 index 042c6ea..0000000 Binary files a/public/finance.png and /dev/null differ diff --git a/public/googlee7f4264b8dc368a1.html b/public/googlee7f4264b8dc368a1.html new file mode 100644 index 0000000..5aace7c --- /dev/null +++ b/public/googlee7f4264b8dc368a1.html @@ -0,0 +1 @@ +google-site-verification: googlee7f4264b8dc368a1.html \ No newline at end of file diff --git a/public/logo-purple.png b/public/logo-purple.png new file mode 100644 index 0000000..6493dd5 Binary files /dev/null and b/public/logo-purple.png differ diff --git a/public/logo-white.png b/public/logo-white.png new file mode 100644 index 0000000..4f24bba Binary files /dev/null and b/public/logo-white.png differ diff --git a/public/mail.png b/public/mail.png deleted file mode 100644 index 3b8f857..0000000 Binary files a/public/mail.png and /dev/null differ diff --git a/public/maleFemale.png b/public/maleFemale.png deleted file mode 100644 index fbf1b5b..0000000 Binary files a/public/maleFemale.png and /dev/null differ diff --git a/public/phone.png b/public/phone.png deleted file mode 100644 index f2d4135..0000000 Binary files a/public/phone.png and /dev/null differ diff --git a/public/singleAttendance.png b/public/singleAttendance.png deleted file mode 100644 index cf7db9b..0000000 Binary files a/public/singleAttendance.png and /dev/null differ diff --git a/public/singleBranch.png b/public/singleBranch.png deleted file mode 100644 index b9d8431..0000000 Binary files a/public/singleBranch.png and /dev/null differ diff --git a/public/singleClass.png b/public/singleClass.png deleted file mode 100644 index e9aea85..0000000 Binary files a/public/singleClass.png and /dev/null differ diff --git a/public/singleLesson.png b/public/singleLesson.png deleted file mode 100644 index 0d3de8a..0000000 Binary files a/public/singleLesson.png and /dev/null differ diff --git a/src/app/(dashboard)/admin/feedbacks/page.tsx b/src/app/(dashboard)/admin/feedbacks/page.tsx new file mode 100644 index 0000000..5bbb207 --- /dev/null +++ b/src/app/(dashboard)/admin/feedbacks/page.tsx @@ -0,0 +1,233 @@ +import Link from "next/link"; +import Pagination from "@/components/Pagination"; +import prisma from "@/lib/prisma"; +import { getAuthUser } from "@/lib/auth"; +import { updateFeedbackStatus } from "@/lib/actions/feedback"; +import { getQueryParam, type PageSearchParams } from "@/lib/pageParams"; +import { ITEM_PER_PAGE } from "@/lib/settings"; +import { getTranslations } from "next-intl/server"; + +const statusOrder: Record = { + pending: 1, + reviewed: 2, + resolved: 3, +}; + +const getStatusClass = (status: string) => { + switch (status) { + case "reviewed": + return "bg-blue-100 text-blue-700"; + case "resolved": + return "bg-green-100 text-green-700"; + default: + return "bg-yellow-100 text-yellow-700"; + } +}; + +const filters = [ + { labelKey: "all", href: "/admin/feedbacks" }, + { labelKey: "pending", href: "/admin/feedbacks?status=pending" }, + { labelKey: "reviewed", href: "/admin/feedbacks?status=reviewed" }, + { labelKey: "resolved", href: "/admin/feedbacks?status=resolved" }, + { labelKey: "suggestions", href: "/admin/feedbacks?type=suggestion" }, + { labelKey: "complaints", href: "/admin/feedbacks?type=complaint" }, +]; + +const FeedbacksPage = async ({ + searchParams, +}: { + searchParams: PageSearchParams; +}) => { + const t = await getTranslations("pages"); + const filtersT = await getTranslations("filters"); + const feedbackT = await getTranslations("feedbackPage"); + const user = await getAuthUser(); + + if (!user) { + return
{feedbackT("unauthorized")}
; + } + + const params = await searchParams; + + const status = getQueryParam(params.status); + const type = getQueryParam(params.type); + const currentPage = getQueryParam(params.page); + const p = currentPage ? parseInt(currentPage) : 1; + + const where = { + schoolId: user.schoolId, + ...(status ? { status } : {}), + ...(type ? { type } : {}), + }; + + const [feedbacks, count, pendingCount] = await prisma.$transaction([ + prisma.feedback.findMany({ + where, + orderBy: { + createdAt: "desc", + }, + take: ITEM_PER_PAGE, + skip: (p - 1) * ITEM_PER_PAGE, + }), + prisma.feedback.count({ where }), + prisma.feedback.count({ + where: { + schoolId: user.schoolId, + status: "pending", + }, + }), + ]); + + const sortedFeedbacks = [...feedbacks].sort((a, b) => { + const statusDiff = + (statusOrder[a.status] ?? 99) - (statusOrder[b.status] ?? 99); + + if (statusDiff !== 0) return statusDiff; + + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + }); + + return ( +
+
+
+

{t("feedbacks")}

+
+ +
+ + {feedbackT("pendingCount", { count: pendingCount })} + + + + {feedbackT("shownCount", { count })} + +
+
+ +
+ {filters.map((filter) => { + const isActive = + (!status && !type && filter.labelKey === "all") || + filter.href.includes(`status=${status}`) || + filter.href.includes(`type=${type}`); + + return ( + + {filtersT(filter.labelKey)} + + ); + })} +
+ +
+ {sortedFeedbacks.length === 0 ? ( +
+

{feedbackT("noFeedbacks")}

+
+ ) : ( + sortedFeedbacks.map((feedback) => { + const isResolved = feedback.status === "resolved"; + + return ( +
+
+
+
+ + {feedbackT(`types.${feedback.type}`)} + + + + {filtersT(feedback.status)} + +
+ +

+ {feedback.message} +

+
+ + + {new Date(feedback.createdAt).toLocaleDateString()} + +
+ + {isResolved ? ( +

+ {feedbackT("resolvedLocked")} +

+ ) : ( +
+ {feedback.status !== "reviewed" && ( +
+ + + + +
+ )} + +
+ + + + +
+ + {feedback.status !== "pending" && ( +
+ + + + +
+ )} +
+ )} +
+ ); + }) + )} +
+ + +
+ ); +}; + +export default FeedbacksPage; diff --git a/src/app/(dashboard)/admin/layout.tsx b/src/app/(dashboard)/admin/layout.tsx index 2bfff7b..187e9a9 100644 --- a/src/app/(dashboard)/admin/layout.tsx +++ b/src/app/(dashboard)/admin/layout.tsx @@ -1,5 +1,5 @@ import { redirect } from "next/navigation"; -import { getAuthUser, requireRole } from "@/lib/auth"; +import { enforceAdminSchoolAccess, getAuthUser, requireRole } from "@/lib/auth"; export default async function AdminLayout({ children, @@ -13,6 +13,7 @@ export default async function AdminLayout({ } requireRole(user, ["admin"]); + await enforceAdminSchoolAccess(user); return <>{children}; } diff --git a/src/app/(dashboard)/admin/loading.tsx b/src/app/(dashboard)/admin/loading.tsx new file mode 100644 index 0000000..6cc68f2 --- /dev/null +++ b/src/app/(dashboard)/admin/loading.tsx @@ -0,0 +1,5 @@ +import LoadingIndicator from "@/components/loaders/LoadingIndicator"; + +export default function Loading() { + return ; +} diff --git a/src/app/(dashboard)/admin/page.tsx b/src/app/(dashboard)/admin/page.tsx index 9ed5a2d..2cbf39c 100644 --- a/src/app/(dashboard)/admin/page.tsx +++ b/src/app/(dashboard)/admin/page.tsx @@ -1,42 +1,63 @@ import Announcements from "@/components/Announcements"; import AttendanceChartContainer from "@/components/AttendanceChartContainer"; -import CountChartContainer from "@/components/CountChartContainer"; import EventCalendarContainer from "@/components/EventCalendarContainer"; -import FinanceChartLoader from "@/components/FinanceChartLoader"; import UserCard from "@/components/UserCard"; +import { getCurrentAcademicYearOrNull } from "@/lib/academicYears"; +import { requireAuth } from "@/lib/auth"; +import prisma from "@/lib/prisma"; +import { UserRole } from "@/lib/utils"; +type SchoolDashboardRole = Exclude; -const AdminPage = ({ +const AdminPage = async ({ searchParams, }: { searchParams: Promise<{ [key: string]: string }>; }) => { + const user = await requireAuth(); + const roleTypes: SchoolDashboardRole[] = ["admin", "teacher", "student", "parent"]; + const schoolId = user.schoolId; + const [currentYear, counts] = await Promise.all([ + getCurrentAcademicYearOrNull(schoolId), + Promise.all([ + prisma.admin.count({ where: { schoolId } }), + prisma.teacher.count({ where: { schoolId } }), + prisma.student.count({ where: { schoolId } }), + prisma.parent.count({ where: { schoolId } }), + ]), + ]); + const countByRole: Record = { + admin: counts[0], + teacher: counts[1], + student: counts[2], + parent: counts[3], + }; + const currentYearName = currentYear?.name ?? null; + return ( -
+
{/* LEFT */}
{/* USER CARDS */} -
- - - - +
+ {roleTypes.map((type) => ( + + ))}
{/* MIDDLE CHARTS */}
- {/* COUNT CHART */} -
- {/* */} -
{/* ATTENDANCE CHART */} -
+
- {/* BOTTOM CHART */} -
{/* */}
{/* RIGHT */} -
+
diff --git a/src/app/(dashboard)/archive/page.tsx b/src/app/(dashboard)/archive/page.tsx new file mode 100644 index 0000000..5899767 --- /dev/null +++ b/src/app/(dashboard)/archive/page.tsx @@ -0,0 +1,355 @@ +import ArchiveFilters from "@/components/ArchiveFilters"; +import ExportButton from "@/components/ExportButton"; +import { requireAuth } from "@/lib/auth"; +import prisma from "@/lib/prisma"; +import { getTranslations } from "next-intl/server"; + +type ArchiveRow = { + id: string | number; + name: string; + username?: string | null; + email?: string | null; + phone?: string | null; + status?: string | null; + finalGrade?: string | null; +}; + +type StoredFinalResult = { + studentId: string; + averageScore: number; +}; + +const ArchivePage = async ({ + searchParams, +}: { + searchParams: Promise<{ + type?: string; + academicYearId?: string; + }>; +}) => { + const t = await getTranslations("pages"); + const archiveT = await getTranslations("archivePage"); + const th = await getTranslations("tableHeaders"); + const params = await searchParams; + const user = await requireAuth(); + + const admin = await prisma.admin.findUnique({ + where: { id: user.userId }, + }); + + if (!admin) return null; + + const academicYears = await prisma.academicYear.findMany({ + where: { + schoolId: admin.schoolId, + }, + orderBy: { startDate: "desc" }, + }); + + const selectedType = params.type || "students"; + const selectedYear = + params.academicYearId || academicYears[0]?.id.toString() || ""; + + let rows: ArchiveRow[] = []; + + if (selectedType === "students") { + const students = await prisma.student.findMany({ + where: { + schoolId: admin.schoolId, + academicYears: { + some: { + academicYearId: Number(selectedYear), + }, + }, + }, + select: { + id: true, + name: true, + username: true, + email: true, + phone: true, + status: true, + }, + }); + + const studentIds = students.map((student) => student.id); + const finalResults: StoredFinalResult[] = studentIds.length + ? await ( + prisma as unknown as { + studentFinalResult: { + findMany: (args: { + where: { + schoolId: number; + academicYearId: number; + studentId: { in: string[] }; + }; + select: { + studentId: boolean; + averageScore: boolean; + }; + }) => Promise; + }; + } + ).studentFinalResult.findMany({ + where: { + schoolId: admin.schoolId, + academicYearId: Number(selectedYear), + studentId: { in: studentIds }, + }, + select: { + studentId: true, + averageScore: true, + }, + }) + : []; + const finalGradeByStudent = new Map( + finalResults.map((result) => [ + result.studentId, + `${result.averageScore.toFixed(2)}%`, + ]), + ); + + rows = students.map((student) => ({ + ...student, + finalGrade: finalGradeByStudent.get(student.id) ?? "-", + })); + } + + if (selectedType === "teachers") { + rows = await prisma.teacher.findMany({ + where: { + schoolId: admin.schoolId, + }, + select: { + id: true, + name: true, + username: true, + email: true, + phone: true, + }, + }); + } + + if (selectedType === "parents") { + rows = await prisma.parent.findMany({ + where: { + schoolId: admin.schoolId, + }, + select: { + id: true, + name: true, + username: true, + email: true, + phone: true, + }, + }); + } + + if (selectedType === "subjects") { + rows = await prisma.subject.findMany({ + where: { + schoolId: admin.schoolId, + }, + select: { + id: true, + name: true, + }, + }); + } + + if (selectedType === "classes") { + rows = await prisma.class.findMany({ + where: { + schoolId: admin.schoolId, + }, + select: { + id: true, + name: true, + }, + }); + } + + if (selectedType === "exams") { + rows = await prisma.exam + .findMany({ + where: { + lesson: { + subject: { + schoolId: admin.schoolId, + }, + }, + }, + select: { + id: true, + title: true, + }, + }) + .then((items) => + items.map((item) => ({ + id: item.id, + name: item.title, + })), + ); + } + + if (selectedType === "assignments") { + rows = await prisma.assignment + .findMany({ + where: { + lesson: { + subject: { + schoolId: admin.schoolId, + }, + }, + }, + select: { + id: true, + title: true, + }, + }) + .then((items) => + items.map((item) => ({ + id: item.id, + name: item.title, + })), + ); + } + + if (selectedType === "results") { + rows = await prisma.result + .findMany({ + where: { + student: { + schoolId: admin.schoolId, + }, + }, + select: { + id: true, + score: true, + student: { + select: { + name: true, + }, + }, + }, + }) + .then((items) => + items.map((item) => ({ + id: item.id, + name: item.student.name, + status: String(item.score), + })), + ); + } + + if (selectedType === "attendance") { + rows = await prisma.attendance + .findMany({ + where: { + student: { + schoolId: admin.schoolId, + }, + }, + select: { + id: true, + present: true, + student: { + select: { + name: true, + }, + }, + }, + }) + .then((items) => + items.map((item) => ({ + id: item.id, + name: item.student?.name || archiveT("unknownStudent"), + status: item.present ? archiveT("present") : archiveT("absent"), + })), + ); + } + + return ( +
+
+
+
+

{t("archive")}

+
+
+
+ + ({ + id: year.id.toString(), + name: year.name, + }))} + selectedType={selectedType} + selectedYear={selectedYear} + /> + +
+
+

+ {archiveT("previewData")} +

+ +
+ +
+ + + + + + + + {selectedType === "students" && ( + + )} + + + + + + {rows.slice(0, 5).map((row) => ( + + + + + + {selectedType === "students" && ( + + )} + + + ))} + + {rows.length === 0 && ( + + + + )} + +
{th("name")}{th("username")}{th("email")}{th("phone")}{th("finalGrade")}{th("statusValue")}
{row.name}{row.username || "-"}{row.email || "-"}{row.phone || "-"}{row.finalGrade || "-"}{row.status || "-"}
+ {archiveT("noData")} +
+ {rows.length > 5 && ( +
+ +
+ )} +
+
+
+ ); +}; + +export default ArchivePage; diff --git a/src/app/(dashboard)/error.tsx b/src/app/(dashboard)/error.tsx new file mode 100644 index 0000000..fdded98 --- /dev/null +++ b/src/app/(dashboard)/error.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { useEffect, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import ErrorState from "@/components/states/ErrorState"; + +export default function DashboardError({ + error, + reset, +}: { + error: Error; + reset: () => void; +}) { + const t = useTranslations("states"); + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + + useEffect(() => { + console.error(error); + }, [error]); + + const handleRetry = () => { + startTransition(() => { + router.refresh(); + reset(); + }); + }; + + return ( +
+ +
+ ); +} diff --git a/src/app/(dashboard)/feedback/page.tsx b/src/app/(dashboard)/feedback/page.tsx new file mode 100644 index 0000000..800c742 --- /dev/null +++ b/src/app/(dashboard)/feedback/page.tsx @@ -0,0 +1,130 @@ +import FeedbackModal from "@/components/FeedbackModal"; +import Pagination from "@/components/Pagination"; +import { getAuthUser } from "@/lib/auth"; +import { getQueryParam, type PageSearchParams } from "@/lib/pageParams"; +import prisma from "@/lib/prisma"; +import { ITEM_PER_PAGE } from "@/lib/settings"; +import { getTranslations } from "next-intl/server"; + +const getStatusClass = (status: string) => { + switch (status) { + case "reviewed": + return "bg-blue-100 text-blue-700"; + case "resolved": + return "bg-green-100 text-green-700"; + default: + return "bg-yellow-100 text-yellow-700"; + } +}; + +const getTypeLabel = (type: string, t: (key: string) => string) => { + switch (type) { + case "complaint": + return t("types.complaint"); + default: + return t("types.suggestion"); + } +}; + +export default async function FeedbackPage({ + searchParams, +}: { + searchParams: PageSearchParams; +}) { + const feedbackT = await getTranslations("feedbackPage"); + const user = await getAuthUser(); + + if (!user) { + return
{feedbackT("unexpectedError")}
; + } + + const resolvedSearchParams = await searchParams; + const currentPage = getQueryParam(resolvedSearchParams.page); + const p = currentPage ? parseInt(currentPage) : 1; + + const where = { + schoolId: user.schoolId, + userId: user.userId, + }; + + const [feedbacks, count] = await prisma.$transaction([ + prisma.feedback.findMany({ + where, + orderBy: { + createdAt: "desc", + }, + take: ITEM_PER_PAGE, + skip: (p - 1) * ITEM_PER_PAGE, + }), + prisma.feedback.count({ where }), + ]); + + return ( +
+
+
+

+ {feedbackT("historyTitle")} +

+ +

+ {feedbackT("historyDescription")} +

+
+ +
+ + {feedbackT("submittedCount", { count })} + + + +
+
+ +
+ {feedbacks.length === 0 ? ( +
+

+ {feedbackT("emptyHistory")} +

+
+ ) : ( + feedbacks.map((feedback) => ( +
+
+
+
+ + {getTypeLabel(feedback.type, feedbackT)} + + + + {feedbackT(`statuses.${feedback.status}`)} + +
+ +

+ {feedback.message} +

+
+ + + {new Date(feedback.createdAt).toLocaleDateString()} + +
+
+ )) + )} +
+ + +
+ ); +} diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index bca3191..442f8b1 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -1,7 +1,4 @@ -import Menu from "@/components/Menu"; -import Navbar from "@/components/Navbar"; -import Image from "next/image"; -import Link from "next/link"; +import DashboardShell from "@/components/DashboardShell"; import { getAuthUser } from "@/lib/auth"; import { getSchoolName } from "@/lib/school"; @@ -17,30 +14,8 @@ export default async function DashboardLayout({ : null; return ( -
- {/* LEFT */} -
- - logo - ACADEMIX - - -
- - {/* RIGHT */} -
- -
{children}
-
-
+ + {children} + ); } diff --git a/src/app/(dashboard)/list/announcements/loading.tsx b/src/app/(dashboard)/list/announcements/loading.tsx new file mode 100644 index 0000000..6cc68f2 --- /dev/null +++ b/src/app/(dashboard)/list/announcements/loading.tsx @@ -0,0 +1,5 @@ +import LoadingIndicator from "@/components/loaders/LoadingIndicator"; + +export default function Loading() { + return ; +} diff --git a/src/app/(dashboard)/list/announcements/page.tsx b/src/app/(dashboard)/list/announcements/page.tsx index 4a44bb5..86a2cdc 100644 --- a/src/app/(dashboard)/list/announcements/page.tsx +++ b/src/app/(dashboard)/list/announcements/page.tsx @@ -1,9 +1,12 @@ import FilterSortActions from "@/components/FilterSortActions"; import FormContainer from "@/components/FormContainer"; +import ClassFilter from "@/components/ClassFilter"; +import GradeFilter from "@/components/GradeFilter"; import NoCurrentAcademicYearMessage from "@/components/NoCurrentAcademicYearMessage"; import Pagination from "@/components/Pagination"; import Table from "@/components/Table"; import TableSearch from "@/components/TableSearch"; +import { getTranslations } from "next-intl/server"; import { enforceRouteAccess } from "@/lib/enforce-route-access"; import { getQueryParam, type PageSearchParams } from "@/lib/pageParams"; import prisma from "@/lib/prisma"; @@ -16,22 +19,22 @@ type AnnouncementList = Announcement & { classes: Pick[]; }; -const getColumns = (role: UserRole | null) => [ +const getColumns = (role: UserRole | null, th: (key: string) => string) => [ { - header: "Title", + header: th("title"), accessor: "title", }, { - header: "Class", + header: th("class"), accessor: "class", }, { - header: "Date", + header: th("date"), accessor: "date", className: "hidden md:table-cell", }, { - header: role === "admin" ? "Actions" : "", + header: role === "admin" ? th("actions") : "", accessor: "action", }, ]; @@ -40,6 +43,7 @@ const renderRow = ( item: AnnouncementList, role: UserRole | null, totalClassesCount: number, + allClassesLabel: string, ) => ( {item.title} {item.classes.length === totalClassesCount && totalClassesCount > 0 - ? "All Classes" + ? allClassesLabel : item.classes.map((cls) => cls.name).join(", ") || "-"} @@ -72,6 +76,10 @@ const AnnouncementListPage = async ({ }: { searchParams: PageSearchParams; }) => { + const t = await getTranslations("pages"); + const th = await getTranslations("tableHeaders"); + const filtersT = await getTranslations("filters"); + const emptyT = await getTranslations("emptyStates"); const { role, userId, schoolId } = await enforceRouteAccess( "/list/announcements", ); @@ -98,6 +106,24 @@ const AnnouncementListPage = async ({ }); break; } + case "classId": { + const classId = Number.parseInt(value, 10); + if (!Number.isNaN(classId)) { + conditions.push({ + classes: { some: { id: classId } }, + }); + } + break; + } + case "gradeId": { + const gradeId = Number.parseInt(value, 10); + if (!Number.isNaN(gradeId)) { + conditions.push({ + classes: { some: { gradeId } }, + }); + } + break; + } default: break; } @@ -127,31 +153,49 @@ const AnnouncementListPage = async ({ query.AND = conditions; } - const [data, count, totalClassesCount] = await prisma.$transaction([ + const sortParam = getQueryParam(queryParams.sort); + const orderBy: Prisma.AnnouncementOrderByWithRelationInput = + sortParam === "asc" ? { date: "asc" } : { date: "desc" }; + + const [data, count, totalClassesCount, classes, grades] = await prisma.$transaction([ prisma.announcement.findMany({ where: query, include: { classes: { select: { id: true, name: true } }, }, - orderBy: { date: "desc" }, + orderBy, take: ITEM_PER_PAGE, skip: (p - 1) * ITEM_PER_PAGE, }), prisma.announcement.count({ where: query }), prisma.class.count({ where: { schoolId } }), + prisma.class.findMany({ + where: { schoolId }, + select: { id: true, name: true, gradeId: true }, + orderBy: { name: "asc" }, + }), + prisma.grade.findMany({ + where: { schoolId }, + select: { id: true, level: true }, + orderBy: { level: "asc" }, + }), ]); return (
{/* TOP */} -
-

- All Announcements -

-
+
+

{t("allAnnouncements")}

+
-
- + {role === "admin" && ( + <> + + + + )} +
+ {role === "admin" && ( )} @@ -160,9 +204,13 @@ const AnnouncementListPage = async ({
{/* LIST */} renderRow(item, role, totalClassesCount)} + columns={getColumns(role, th)} + renderRow={(item) => + renderRow(item, role, totalClassesCount, filtersT("allClasses")) + } data={data} + emptyTitle={emptyT("announcements")} + emptyDescription={emptyT("filterDescription")} /> {/* PAGINATION */} diff --git a/src/app/(dashboard)/list/assignments/[assignmentId]/submissions/AssignmentSubmissionAiEvaluation.tsx b/src/app/(dashboard)/list/assignments/[assignmentId]/submissions/AssignmentSubmissionAiEvaluation.tsx new file mode 100644 index 0000000..bf55e75 --- /dev/null +++ b/src/app/(dashboard)/list/assignments/[assignmentId]/submissions/AssignmentSubmissionAiEvaluation.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { evaluateAssignmentSubmissionWithAi } from "@/lib/actions/aiEvaluation.actions"; +import { Loader2, Sparkles } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useTransition } from "react"; +import { toast } from "react-toastify"; + +type Props = { + submissionId: number; + disabled?: boolean; +}; + +export default function AssignmentSubmissionAiEvaluation({ + submissionId, + disabled, +}: Props) { + const t = useTranslations("aiEvaluation"); + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + + const handleEvaluate = () => { + startTransition(async () => { + const result = await evaluateAssignmentSubmissionWithAi(submissionId); + + if (result.success) { + toast.success(result.message); + router.refresh(); + } else { + toast.error(result.message); + } + }); + }; + + return ( + + ); +} diff --git a/src/app/(dashboard)/list/assignments/[assignmentId]/submissions/AssignmentSubmissionBulkActions.tsx b/src/app/(dashboard)/list/assignments/[assignmentId]/submissions/AssignmentSubmissionBulkActions.tsx new file mode 100644 index 0000000..a40e4bd --- /dev/null +++ b/src/app/(dashboard)/list/assignments/[assignmentId]/submissions/AssignmentSubmissionBulkActions.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { adjustAssignmentSubmissionScores } from "@/lib/actions/submission.actions"; +import { Loader2, Minus, Plus } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import { toast } from "react-toastify"; + +type BulkAction = "increase" | "decrease"; + +type Props = { + assignmentId: number; + maxScore: number; + classId?: number | null; + search?: string | null; + disabled?: boolean; +}; + +export default function AssignmentSubmissionBulkActions({ + assignmentId, + maxScore, + classId, + search, + disabled, +}: Props) { + const t = useTranslations("assignmentSubmissions"); + const router = useRouter(); + const [amount, setAmount] = useState("1"); + const [pendingAction, setPendingAction] = useState(null); + const [isPending, startTransition] = useTransition(); + + const filters = { + classId: classId ?? null, + search: search ?? null, + }; + + const runAction = (action: BulkAction) => { + const parsedAmount = Number(amount); + + if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) { + toast.error(t("validation.positiveAmount")); + return; + } + + if (parsedAmount > maxScore) { + toast.error(t("validation.maxAmount", { maxScore })); + return; + } + + setPendingAction(action); + startTransition(async () => { + try { + const result = await adjustAssignmentSubmissionScores( + assignmentId, + action === "increase" ? parsedAmount : -parsedAmount, + filters, + ); + + if (result.success) { + toast.success(result.message); + router.refresh(); + } else { + toast.error(result.message); + } + } finally { + setPendingAction(null); + } + }); + }; + + return ( +
+
+ setAmount(event.target.value)} + className="bg-transparent outline-none w-20 h-8 text-sm" + aria-label={t("fields.scoreAdjustmentAmount")} + /> + {t("fields.marks")} +
+ +
+ + + +
+
+ ); +} diff --git a/src/app/(dashboard)/list/assignments/[assignmentId]/submissions/AssignmentSubmissionScoreForm.tsx b/src/app/(dashboard)/list/assignments/[assignmentId]/submissions/AssignmentSubmissionScoreForm.tsx new file mode 100644 index 0000000..383c8d1 --- /dev/null +++ b/src/app/(dashboard)/list/assignments/[assignmentId]/submissions/AssignmentSubmissionScoreForm.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { gradeAssignmentSubmission } from "@/lib/actions/submission.actions"; +import { Loader2, Save } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useEffect, useState, useTransition } from "react"; +import { toast } from "react-toastify"; + +type Props = { + submissionId: number; + maxScore: number; + currentScore: number | null; +}; + +export default function AssignmentSubmissionScoreForm({ + submissionId, + maxScore, + currentScore, +}: Props) { + const router = useRouter(); + const [score, setScore] = useState(currentScore?.toString() ?? "0"); + const [isPending, startTransition] = useTransition(); + + useEffect(() => { + setScore(currentScore?.toString() ?? "0"); + }, [currentScore]); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + const nextScore = Number(score); + + startTransition(async () => { + const result = await gradeAssignmentSubmission(submissionId, nextScore); + + if (result.success) { + toast.success(result.message); + router.refresh(); + } else { + toast.error(result.message); + } + }); + }; + + return ( +
+ setScore(event.target.value)} + placeholder={`0-${maxScore}`} + className="h-9 w-20 rounded-md border border-gray-200 px-2 text-sm outline-none focus:border-academixPurpleDark focus:bg-academixPurpleLight" + /> + + + ); +} diff --git a/src/app/(dashboard)/list/assignments/[assignmentId]/submissions/PublishAssignmentGradesButton.tsx b/src/app/(dashboard)/list/assignments/[assignmentId]/submissions/PublishAssignmentGradesButton.tsx new file mode 100644 index 0000000..17187db --- /dev/null +++ b/src/app/(dashboard)/list/assignments/[assignmentId]/submissions/PublishAssignmentGradesButton.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { publishAssignmentGrades } from "@/lib/actions/submission.actions"; +import { CheckCircle, Loader2 } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import { toast } from "react-toastify"; + +type Props = { + assignmentId: number; + disabled?: boolean; + allPublished?: boolean; +}; + +export default function PublishAssignmentGradesButton({ + assignmentId, + disabled, + allPublished, +}: Props) { + const t = useTranslations("assignmentSubmissions.actions"); + const router = useRouter(); + const [synced, setSynced] = useState(false); + const [isPending, startTransition] = useTransition(); + + const handlePublish = () => { + startTransition(async () => { + const result = await publishAssignmentGrades(assignmentId); + + if (result.success) { + setSynced(true); + toast.success(result.message); + router.refresh(); + } else { + toast.error(result.message); + } + }); + }; + + return ( + + ); +} diff --git a/src/app/(dashboard)/list/assignments/[assignmentId]/submissions/page.tsx b/src/app/(dashboard)/list/assignments/[assignmentId]/submissions/page.tsx new file mode 100644 index 0000000..4c16d30 --- /dev/null +++ b/src/app/(dashboard)/list/assignments/[assignmentId]/submissions/page.tsx @@ -0,0 +1,394 @@ +import Table from "@/components/Table"; +import { enforceRouteAccess } from "@/lib/enforce-route-access"; +import { getQueryParam, PageSearchParams } from "@/lib/pageParams"; +import prisma from "@/lib/prisma"; +import type { AiEvaluation } from "@prisma/client"; +import { Download, Eye } from "lucide-react"; +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { getTranslations } from "next-intl/server"; +import AssignmentSubmissionAiEvaluation from "./AssignmentSubmissionAiEvaluation"; +import AssignmentSubmissionBulkActions from "./AssignmentSubmissionBulkActions"; +import AssignmentSubmissionScoreForm from "./AssignmentSubmissionScoreForm"; +import PublishAssignmentGradesButton from "./PublishAssignmentGradesButton"; + +type Props = { + params: Promise<{ assignmentId: string }>; + searchParams: PageSearchParams; +}; + +type SubmissionRow = { + id: number; + fileUrl: string; + fileName: string; + note: string | null; + teacherFeedback: string | null; + score: number | null; + gradePublished: boolean; + updatedAt: Date; + createdAt: Date; + aiEvaluation: AiEvaluation | null; + student: { + name: string; + username: string; + img: string | null; + }; + assignment: { + class: { name: string } | null; + }; +}; + +const getColumns = (th: (key: string) => string) => [ + { header: th("student"), accessor: "student", className: "min-w-[190px] p-4" }, + { header: th("class"), accessor: "class", className: "min-w-[120px] p-4" }, + { header: th("file"), accessor: "file", className: "hidden md:table-cell min-w-[180px] p-4" }, + { header: th("status"), accessor: "status", className: "min-w-[120px] p-4" }, + { header: th("score"), accessor: "score", className: "min-w-[140px] p-4" }, + { header: th("submitted"), accessor: "submitted", className: "hidden md:table-cell p-4" }, + { header: th("actions"), accessor: "actions", className: "min-w-[130px] p-4" }, +]; + +const formatDateTime = (date: Date) => + new Intl.DateTimeFormat("en-US", { + dateStyle: "short", + timeStyle: "short", + }).format(date); + +const browserPreviewExtensions = new Set([ + "pdf", + "jpg", + "jpeg", + "png", + "gif", + "webp", + "txt", +]); + +const getFileExtension = (fileName: string) => + fileName.split(".").pop()?.toLowerCase() ?? ""; + +const getPreviewHref = (submission: SubmissionRow) => { + const extension = getFileExtension(submission.fileName); + + return browserPreviewExtensions.has(extension) + ? `/api/preview/${submission.id}?type=submission` + : null; +}; + +const getStatusBadge = (submission: SubmissionRow, t: (key: string) => string) => { + if (submission.gradePublished) { + return ( + + {t("status.published")} + + ); + } + + if (submission.score !== null) { + return ( + + {t("status.draftGrade")} + + ); + } + + return ( + + {t("status.submitted")} + + ); +}; + +const renderRow = ( + item: SubmissionRow, + maxScore: number, + t: (key: string) => string, +) => { + const isPdfSubmission = getFileExtension(item.fileName) === "pdf"; + + return ( +
+ + + + + + + + + ); +}; + +const AssignmentSubmissionsPage = async ({ params, searchParams }: Props) => { + const th = await getTranslations("tableHeaders"); + const filtersT = await getTranslations("filters"); + const actionsT = await getTranslations("actions"); + const assignmentSubmissionsT = await getTranslations("assignmentSubmissions"); + const { role, userId, schoolId } = await enforceRouteAccess("/list/assignments"); + const { assignmentId: assignmentIdRaw } = await params; + const resolvedSearchParams = await searchParams; + + if (role !== "admin" && role !== "teacher") redirect("/list/assignments"); + + const assignmentId = Number.parseInt(assignmentIdRaw, 10); + if (Number.isNaN(assignmentId)) redirect("/list/assignments"); + + const classIdRaw = getQueryParam(resolvedSearchParams.classId); + const search = getQueryParam(resolvedSearchParams.search)?.trim() ?? ""; + const parsedClassId = classIdRaw ? Number.parseInt(classIdRaw, 10) : null; + const selectedClassId = + parsedClassId && !Number.isNaN(parsedClassId) ? parsedClassId : null; + + const assignment = await prisma.assignment.findFirst({ + where: { + id: assignmentId, + schoolId, + ...(role === "teacher" + ? { OR: [{ teacherId: userId }, { lesson: { teacherId: userId } }] } + : {}), + }, + include: { + subject: { select: { name: true } }, + class: { select: { id: true, name: true } }, + }, + }); + + if (!assignment) redirect("/list/assignments"); + + const groupAssignments = await prisma.assignment.findMany({ + where: { + title: assignment.title, + startDate: assignment.startDate, + endDate: assignment.endDate, + subjectId: assignment.subjectId, + schoolId, + academicYearId: assignment.academicYearId, + ...(role === "teacher" + ? { OR: [{ teacherId: userId }, { lesson: { teacherId: userId } }] } + : {}), + }, + include: { + class: { select: { id: true, name: true } }, + }, + orderBy: { class: { name: "asc" } }, + }); + + const assignmentIds = groupAssignments.map((item) => item.id); + const classOptions = groupAssignments + .map((item) => item.class) + .filter((item): item is { id: number; name: string } => Boolean(item)); + + const submissions = await prisma.assignmentSubmission.findMany({ + where: { + assignmentId: { in: assignmentIds }, + schoolId, + ...(selectedClassId ? { assignment: { classId: selectedClassId } } : {}), + ...(search + ? { + student: { + OR: [ + { name: { contains: search, mode: "insensitive" } }, + { username: { contains: search, mode: "insensitive" } }, + ], + }, + } + : {}), + }, + include: { + student: { select: { name: true, username: true, img: true } }, + assignment: { select: { class: { select: { name: true } } } }, + aiEvaluation: true, + }, + orderBy: { updatedAt: "desc" }, + }); + + const submitted = submissions.length; + const graded = submissions.filter((submission) => submission.score !== null).length; + const published = submissions.filter((submission) => submission.gradePublished).length; + const draftGrades = submissions.filter( + (submission) => submission.score !== null && !submission.gradePublished, + ).length; + const allPublished = graded > 0 && published === graded; + + return ( +
+
+
+
+ + {assignmentSubmissionsT("breadcrumb")} + + / + {assignment.title} +
+

+ {assignmentSubmissionsT("title")} +

+

+ {assignment.subject?.name ?? assignmentSubmissionsT("subjectFallback")} -{" "} + {assignmentSubmissionsT("marks", { count: assignment.maxScore })} +

+
+ +
+ +
+
+ + {assignmentSubmissionsT("stats.visibleSubmissions")} + +

{submitted}

+
+
+ + {assignmentSubmissionsT("stats.graded")} + +

{graded}

+
+
+ + {assignmentSubmissionsT("stats.draftGrades")} + +

{draftGrades}

+
+
+ + {assignmentSubmissionsT("stats.published")} + +

{published}

+
+
+ +
+
+ + + + {(selectedClassId || search) && ( + + {actionsT("clear")} + + )} + + + +
+ +
+
+
+
+ {item.student.img ? ( + {item.student.name} + ) : ( +
+ {item.student.name[0] ?? "S"} +
+ )} +
+ {item.student.name} + {item.student.username} +
+
+
{item.assignment.class?.name ?? "-"} + {item.fileName} + {getStatusBadge(item, t)} + + + {formatDateTime(item.updatedAt)} + +
+ {getPreviewHref(item) ? ( + + + {t("actions.view")} + + ) : ( + + {t("empty.previewUnavailable")} + + )} + + + {t("actions.download")} + + +
+
+ renderRow( + item as SubmissionRow, + assignment.maxScore, + assignmentSubmissionsT, + ) + } + data={submissions} + emptyTitle={assignmentSubmissionsT("empty.title")} + emptyDescription={assignmentSubmissionsT("empty.description")} + /> + + + + ); +}; + +export default AssignmentSubmissionsPage; diff --git a/src/app/(dashboard)/list/assignments/loading.tsx b/src/app/(dashboard)/list/assignments/loading.tsx new file mode 100644 index 0000000..6cc68f2 --- /dev/null +++ b/src/app/(dashboard)/list/assignments/loading.tsx @@ -0,0 +1,5 @@ +import LoadingIndicator from "@/components/loaders/LoadingIndicator"; + +export default function Loading() { + return ; +} diff --git a/src/app/(dashboard)/list/assignments/page.tsx b/src/app/(dashboard)/list/assignments/page.tsx index 0585104..4e63f1e 100644 --- a/src/app/(dashboard)/list/assignments/page.tsx +++ b/src/app/(dashboard)/list/assignments/page.tsx @@ -1,208 +1,230 @@ +// src/app/(dashboard)/list/assignments/page.tsx +import ExportButton from "@/components/ExportButton"; import FilterSortActions from "@/components/FilterSortActions"; import FormContainer from "@/components/FormContainer"; +import ClassFilter from "@/components/ClassFilter"; +import GradeFilter from "@/components/GradeFilter"; import NoCurrentAcademicYearMessage from "@/components/NoCurrentAcademicYearMessage"; import Pagination from "@/components/Pagination"; import Table from "@/components/Table"; import TableSearch from "@/components/TableSearch"; +import { getTranslations } from "next-intl/server"; import { enforceRouteAccess } from "@/lib/enforce-route-access"; -import { getQueryParam, type PageSearchParams } from "@/lib/pageParams"; +import { buildAssignmentQuery } from "@/lib/query-builders/assignment-query"; import prisma from "@/lib/prisma"; import { ITEM_PER_PAGE } from "@/lib/settings"; -import { getCurrentAcademicYearIdOrNull } from "@/lib/academicYears"; -import { Assignment, Class, Prisma, Subject, Teacher } from "@prisma/client"; import { UserRole } from "@/lib/utils"; +import { Assignment, Class, Subject, Teacher } from "@prisma/client"; +import type { PageSearchParams } from "@/lib/pageParams"; +import { Download, Users } from "lucide-react"; +import Link from "next/link"; +import AssignmentSubmit from "@/components/AssignmentSubmit"; type AssignmentList = Assignment & { subject: Pick | null; class: Pick | null; - lesson: { - teacher: Pick; - }; + teacher: Pick | null; + lesson: { teacher: Pick } | null; + assignmentSubmissions?: { // تم التغيير من submissions إلى assignmentSubmissions + id: number; + fileUrl: string; + fileName: string; + createdAt: Date; + note: string | null; + teacherFeedback: string | null; + score: number | null; + gradePublished: boolean; + }[]; + _count?: { assignmentSubmissions: number }; }; -const getColumns = (role: UserRole | null) => { +const formatDateTime = (date: Date) => + new Intl.DateTimeFormat("en-US", { + dateStyle: "medium", + timeStyle: "short", + }).format(date); + +const getColumns = (role: UserRole | null, th: (key: string) => string) => { const columns: { header: string; accessor: string; className?: string }[] = [ - { - header: "Title", - accessor: "title", - }, - { - header: "Subject", - accessor: "name", - }, + { header: th("title"), accessor: "title" }, + { header: th("subject"), accessor: "subject" }, ]; if (role !== "student") { - columns.push({ - header: "Class", - accessor: "class", - }); + columns.push({ header: th("class"), accessor: "class" }); } if (role !== "teacher") { - columns.push({ - header: "Teacher", - accessor: "teacher", - className: "hidden md:table-cell", - }); + columns.push({ header: th("teacher"), accessor: "teacher", className: "hidden md:table-cell" }); } - columns.push({ - header: "Start Date", - accessor: "startDate", - className: "hidden md:table-cell", - }); + if (role === "student") { + columns.push({ header: th("submission"), accessor: "submission" }); + columns.push({ header: th("score"), accessor: "score" }); + } columns.push({ - header: "End Date", + header: th("endDate"), accessor: "endDate", - className: "hidden md:table-cell", + className: "hidden md:table-cell min-w-[180px] w-[180px]", }); columns.push({ - header: role === "admin" || role === "teacher" ? "Actions" : "", + header: role === "admin" || role === "teacher" ? th("actions") : "", accessor: "action", }); return columns; }; -const renderRow = (item: AssignmentList, role: UserRole | null) => ( - - - - {role !== "student" && } - {role !== "teacher" && ( - - )} - - - - -); +const renderRow = ( + item: AssignmentList, + role: UserRole | null, + assignmentsT: (key: string) => string, +) => { + // ✅ استخدام assignmentSubmissions بدلاً من submissions + const mySubmission = item.assignmentSubmissions?.[0] ?? null; + const canManage = role === "teacher" || role === "admin"; + + return ( + + + + {role !== "student" && } + {role !== "teacher" && ( + + )} + {role === "student" && ( + + )} + {role === "student" && ( + + )} + + + + ); +}; + const AssignmentListPage = async ({ searchParams, }: { searchParams: PageSearchParams; }) => { - const { role, userId, schoolId } = - await enforceRouteAccess("/list/assignments"); + const t = await getTranslations("pages"); + const th = await getTranslations("tableHeaders"); + const assignmentsT = await getTranslations("assignmentsPage"); + const { role, userId, schoolId } = await enforceRouteAccess("/list/assignments"); + const resolvedSearchParams = await searchParams; - const { page, ...queryParams } = resolvedSearchParams; - const currentPage = getQueryParam(page); - const p = currentPage ? parseInt(currentPage) : 1; - const academicYearId = await getCurrentAcademicYearIdOrNull(schoolId); + const { + academicYearId, + query, + orderBy, + page: p, + } = await buildAssignmentQuery({ + searchParams, + schoolId, + role, + userId, + }); - if (!academicYearId) { + if (!academicYearId || !query) { return ; } - const query: Prisma.AssignmentWhereInput = { - schoolId, - academicYearId, - }; - query.lesson = query.lesson || {}; - - const conditions: Prisma.AssignmentWhereInput[] = []; - - if (queryParams) { - for (const [key, rawValue] of Object.entries(queryParams)) { - const value = getQueryParam(rawValue); - - if (value !== undefined) { - switch (key) { - case "classId": - conditions.push({ - lesson: { - classId: parseInt(value), - }, - }); - break; - - case "teacherId": - conditions.push({ - lesson: { - teacherId: value, - }, - }); - break; - - case "search": - conditions.push({ - lesson: { - subject: { - name: { contains: value, mode: "insensitive" }, - }, - }, - }); - break; - } + const exportQuery = new URLSearchParams( + Object.entries(resolvedSearchParams).flatMap(([key, value]) => { + if (Array.isArray(value)) { + return value.map((item) => [key, item]); } - } - } - - switch (role) { - case "admin": - break; - - case "teacher": - conditions.push({ - lesson: { - teacherId: userId, - }, - }); - break; - - case "student": - conditions.push({ - lesson: { - class: { - students: { - some: { id: userId }, - }, - }, - }, - }); - break; + return value ? [[key, value]] : []; + }), + ); - case "parent": - conditions.push({ - lesson: { - class: { - students: { - some: { parentId: userId }, - }, - }, - }, - }); - break; - } - if (conditions.length > 0) { - query.AND = conditions; - } + const includeSubmissionsCount = role === "teacher" || role === "admin"; const [data, count] = await prisma.$transaction([ prisma.assignment.findMany({ @@ -210,44 +232,71 @@ const AssignmentListPage = async ({ include: { subject: { select: { name: true } }, class: { select: { name: true } }, - lesson: { - select: { - teacher: { select: { name: true } }, - }, - }, + lesson: { select: { teacher: { select: { name: true } } } }, + teacher: { select: { name: true } }, + assignmentSubmissions: role === "student" && userId + ? { where: { studentId: userId }, select: { id: true, fileUrl: true, fileName: true, createdAt: true, note: true, teacherFeedback: true, score: true, gradePublished: true } } + : false, + _count: includeSubmissionsCount ? { select: { assignmentSubmissions: true } } : false, }, - orderBy: { startDate: "desc" }, + orderBy, take: ITEM_PER_PAGE, skip: (p - 1) * ITEM_PER_PAGE, }), - prisma.assignment.count({ - where: query, - }), + prisma.assignment.count({ where: query }), ]); + const [classes, grades] = + role === "admin" + ? await prisma.$transaction([ + prisma.class.findMany({ + where: { schoolId }, + select: { id: true, name: true, gradeId: true }, + orderBy: { name: "asc" }, + }), + prisma.grade.findMany({ + where: { schoolId }, + select: { id: true, level: true }, + orderBy: { level: "asc" }, + }), + ]) + : [[], []]; + return (
- {/* TOP */} -
-

- All Assignments -

-
+
+

{t("allAssignments")}

+
-
- + {role === "admin" && ( + <> + + + + )} +
+ {(role === "admin" || role === "teacher") && ( - + <> + {role === "admin" && ( + + )} + + )}
- {/* LIST */} +
{item.title}{item.subject?.name}{item.class?.name}{item.lesson.teacher.name} - {new Intl.DateTimeFormat("en-US", { - dateStyle: "short", - timeStyle: "short", - }).format(item.startDate)} - - {new Intl.DateTimeFormat("en-US", { - dateStyle: "short", - timeStyle: "short", - }).format(item.endDate)} - -
- {(role === "admin" || role === "teacher") && ( - <> - - - - )} -
-
{item.title}{item.subject?.name ?? "-"}{item.class?.name ?? "-"} + {item.teacher?.name ?? item.lesson?.teacher.name ?? "-"} + + {mySubmission ? ( + + {mySubmission.gradePublished + ? assignmentsT("status.gradePublished") + : mySubmission.teacherFeedback + ? assignmentsT("status.feedbackReady") + : assignmentsT("status.submitted")} + + ) : ( + + {assignmentsT("status.notSubmitted")} + + )} + + {mySubmission?.gradePublished && mySubmission.score !== null + ? `${mySubmission.score}/${item.maxScore}` + : "-"} + + {formatDateTime(item.endDate)} + +
+ {/* زر Download للطالب فقط (إن وجد ملف) */} + {role === "student" && item.fileUrl && ( + + + {assignmentsT("actions.download")} + + )} + {role === "student" && ( + + )} + {canManage && ( + + + {assignmentsT("actions.submissions")} + + )} + {(role === "admin" || role === "teacher") && ( + <> + + + + )} +
+
renderRow(item, role)} + columns={getColumns(role, th)} + renderRow={(item) => renderRow(item, role, assignmentsT)} data={data} + emptyTitle={assignmentsT("empty.title")} + emptyDescription={assignmentsT("empty.description")} /> - {/* PAGINATION */} + ); diff --git a/src/app/(dashboard)/list/attendance/loading.tsx b/src/app/(dashboard)/list/attendance/loading.tsx new file mode 100644 index 0000000..6cc68f2 --- /dev/null +++ b/src/app/(dashboard)/list/attendance/loading.tsx @@ -0,0 +1,5 @@ +import LoadingIndicator from "@/components/loaders/LoadingIndicator"; + +export default function Loading() { + return ; +} diff --git a/src/app/(dashboard)/list/attendance/page.tsx b/src/app/(dashboard)/list/attendance/page.tsx index 5f4052a..40bd4dc 100644 --- a/src/app/(dashboard)/list/attendance/page.tsx +++ b/src/app/(dashboard)/list/attendance/page.tsx @@ -4,21 +4,42 @@ import { getAttendanceData } from "@/lib/attendance"; import AttendanceClient from "@/components/AttendanceClient"; import AttendanceClassSelect from "@/components/AttendanceClassSelect"; +import GradeFilter from "@/components/GradeFilter"; import Pagination from "@/components/Pagination"; import NoCurrentAcademicYearMessage from "@/components/NoCurrentAcademicYearMessage"; +import EmptyState from "@/components/states/EmptyState"; import { ITEM_PER_PAGE } from "@/lib/settings"; import { getAttendanceParams } from "@/lib/attendanceParams"; import { enforceRouteAccess } from "@/lib/enforce-route-access"; +import { getTranslations } from "next-intl/server"; +import { getQueryParam } from "@/lib/pageParams"; +import { getCurrentAcademicYearOrNull } from "@/lib/academicYears"; + +const isDateWithinRange = (date: string, startDate: string, endDate: string) => + date >= startDate && date <= endDate; const AttendancePage = async ({ searchParams, }: { searchParams: PageSearchParams; }) => { + const t = await getTranslations("pages"); + const attendanceT = await getTranslations("attendance"); + const th = await getTranslations("tableHeaders"); + const filtersT = await getTranslations("filters"); const { role, userId, schoolId } = await enforceRouteAccess("/list/attendance"); + const currentAcademicYear = await getCurrentAcademicYearOrNull(schoolId); + + if (!currentAcademicYear) { + return ; + } const resolved = await searchParams; + const gradeIdParam = getQueryParam(resolved.gradeId); + const gradeId = gradeIdParam ? Number.parseInt(gradeIdParam, 10) : undefined; + const selectedGradeId = + gradeId && !Number.isNaN(gradeId) && gradeId > 0 ? gradeId : undefined; // PARAMS const { @@ -32,12 +53,22 @@ const AttendancePage = async ({ const scope: "students" | "teachers" = rawScope === "teachers" ? "teachers" : "students"; + const isSelectedDateInAcademicYear = isDateWithinRange( + selectedDate, + currentAcademicYear.startDate, + currentAcademicYear.endDate, + ); // CLASSES const classes = role === "admin" ? await prisma.class.findMany({ - where: { schoolId: schoolId }, + where: { + schoolId: schoolId, + ...(scope === "students" && selectedGradeId + ? { gradeId: selectedGradeId } + : {}), + }, select: { id: true, name: true }, orderBy: { name: "asc" }, }) @@ -48,15 +79,25 @@ const AttendancePage = async ({ }, select: { id: true, name: true }, }); + const grades = + role === "admin" + ? await prisma.grade.findMany({ + where: { schoolId }, + select: { id: true, level: true }, + orderBy: { level: "asc" }, + }) + : []; // Teacher with no classes if (role === "teacher" && classes.length === 0) { return (
-

Attendance

-
- You are not assigned to supervise any class yet. -
+

{t("attendance")}

+
); } @@ -67,14 +108,17 @@ const AttendancePage = async ({ classId && validClassIds.has(classId) ? classId : classes[0]?.id; // DATA - const { data, hasAttendance, noCurrentYear } = await getAttendanceData({ - role, - userId, - schoolId: schoolId, - scope, - classId: effectiveClassId, - day, - }); + const { data, hasAttendance, noCurrentYear } = isSelectedDateInAcademicYear + ? await getAttendanceData({ + role, + userId, + schoolId: schoolId, + scope, + classId: effectiveClassId, + gradeId: selectedGradeId, + day, + }) + : { data: [], hasAttendance: false, noCurrentYear: false }; if (noCurrentYear) { return ; @@ -87,14 +131,16 @@ const AttendancePage = async ({ return (
{/* TOP */} -
-

Attendance

+
+

{t("attendance")}

-
+ @@ -102,46 +148,57 @@ const AttendancePage = async ({ {effectiveClassId && ( )} + {selectedGradeId && ( + + )} -
- {/* ADMIN TABS */} - {role === "admin" && ( - - )} +
+ {/* ADMIN TABS */} + {role === "admin" && ( +
+ + {filtersT("students")} + - {/* CLASS SELECT (ADMIN) */} - {role === "admin" && scope === "students" && ( -
- -
- )} + + {filtersT("teachers")} + +
+ )} + + {/* CLASS SELECT (ADMIN) */} + {role === "admin" && scope === "students" && ( +
+ + {th("class")}: + +
+ )} +
{/* TEACHER CLASS TABS */} {role === "teacher" && ( @@ -152,7 +209,7 @@ const AttendancePage = async ({ href={`/list/attendance?classId=${cls.id}`} className={`px-3 py-1 rounded ${ effectiveClassId === cls.id - ? "bg-blue-500 text-white" + ? "bg-academixPurpleDark text-white" : "bg-gray-100" }`} > @@ -162,6 +219,15 @@ const AttendancePage = async ({
)} + {!isSelectedDateInAcademicYear && ( +
+ {attendanceT("dateOutsideAcademicYear", { + start: currentAcademicYear.startDate, + end: currentAcademicYear.endDate, + })} +
+ )} + {/* TABLE */} ; +} diff --git a/src/app/(dashboard)/list/classes/page.tsx b/src/app/(dashboard)/list/classes/page.tsx index f5b6166..02a8995 100644 --- a/src/app/(dashboard)/list/classes/page.tsx +++ b/src/app/(dashboard)/list/classes/page.tsx @@ -1,44 +1,45 @@ +import ExportButton from "@/components/ExportButton"; import FilterSortActions from "@/components/FilterSortActions"; import FormContainer from "@/components/FormContainer"; +import GradeFilter from "@/components/GradeFilter"; import Pagination from "@/components/Pagination"; import Table from "@/components/Table"; import TableSearch from "@/components/TableSearch"; +import { getTranslations } from "next-intl/server"; import { enforceRouteAccess } from "@/lib/enforce-route-access"; -import { getQueryParam, type PageSearchParams } from "@/lib/pageParams"; +import { buildClassQuery } from "@/lib/query-builders/class-query"; import prisma from "@/lib/prisma"; import { ITEM_PER_PAGE } from "@/lib/settings"; import { UserRole } from "@/lib/utils"; -import { Class, Prisma, Teacher } from "@prisma/client"; +import { Class, Teacher } from "@prisma/client"; +import type { PageSearchParams } from "@/lib/pageParams"; type ClassList = Class & { supervisor: Teacher | null; grade: { level: number; - }; + } | null; }; -const getColumns = (role: UserRole | null) => [ +const getColumns = (role: UserRole | null, th: (key: string) => string) => [ + { header: th("className"), accessor: "name" }, { - header: "Class Name", - accessor: "name", - }, - { - header: "Capacity", + header: th("capacity"), accessor: "capacity", className: "hidden md:table-cell", }, { - header: "Grade", + header: th("grade"), accessor: "grade", className: "hidden md:table-cell", }, { - header: "Supervisor", + header: th("supervisor"), accessor: "supervisor", className: "hidden md:table-cell", }, { - header: role === "admin" ? "Actions" : "", + header: role === "admin" ? th("actions") : "", accessor: "action", }, ]; @@ -49,11 +50,15 @@ const renderRow = (item: ClassList, role: UserRole | null) => ( className="hover:bg-academixPurpleLight even:bg-slate-50 border-gray-200 border-b text-sm" >
+ - + + + + ); + const ClassListPage = async ({ searchParams, }: { searchParams: PageSearchParams; }) => { - const { role, schoolId } = await enforceRouteAccess("/list/classes"); + const t = await getTranslations("pages"); + const th = await getTranslations("tableHeaders"); + const emptyT = await getTranslations("emptyStates"); + const { role, userId, schoolId } = await enforceRouteAccess("/list/classes"); const resolvedSearchParams = await searchParams; - const { page, ...queryParams } = resolvedSearchParams; - const currentPage = getQueryParam(page); - const p = currentPage ? parseInt(currentPage) : 1; - const query: Prisma.ClassWhereInput = { + const { + query, + orderBy, + page: p, + } = await buildClassQuery({ + searchParams, schoolId, - }; - const conditions: Prisma.ClassWhereInput[] = []; - - if (queryParams) { - for (const [key, rawValue] of Object.entries(queryParams)) { - const value = getQueryParam(rawValue); - - if (value !== undefined) { - switch (key) { - case "supervisorId": - conditions.push({ - supervisorId: value, - }); - break; - - case "search": - conditions.push({ - name: { contains: value, mode: "insensitive" }, - }); - break; - } + role, + userId, + }); + + const exportQuery = new URLSearchParams( + Object.entries(resolvedSearchParams).flatMap(([key, value]) => { + if (Array.isArray(value)) { + return value.map((item) => [key, item]); } - } - } - if (conditions.length > 0) { - query.AND = conditions; - } + return value ? [[key, value]] : []; + }), + ); const [data, count] = await prisma.$transaction([ prisma.class.findMany({ @@ -120,7 +116,7 @@ const ClassListPage = async ({ }, }, }, - orderBy: { name: "asc" }, + orderBy, take: ITEM_PER_PAGE, skip: (p - 1) * ITEM_PER_PAGE, }), @@ -128,26 +124,48 @@ const ClassListPage = async ({ where: query, }), ]); + const grades = + role === "admin" + ? await prisma.grade.findMany({ + where: { schoolId }, + select: { id: true, level: true }, + orderBy: { level: "asc" }, + }) + : []; + return (
- {/* TOP */} -
-

All Classes

-
+
+

{t("allClasses")}

+ +
-
- - {role === "admin" && } + {role === "admin" && } + +
+ + + {role === "admin" && ( + <> + + + + + )}
- {/* LIST */} +
{item.name}{item.capacity}{item.grade?.level}{item.grade?.level ?? "-"} {item.supervisor?.name ?? "No supervisor"}
{role === "admin" && ( @@ -66,48 +71,39 @@ const renderRow = (item: ClassList, role: UserRole | null) => (
renderRow(item, role)} data={data} + emptyTitle={emptyT("classes")} + emptyDescription={emptyT("filterDescription")} /> - {/* PAGINATION */} + ); diff --git a/src/app/(dashboard)/list/events/loading.tsx b/src/app/(dashboard)/list/events/loading.tsx new file mode 100644 index 0000000..6cc68f2 --- /dev/null +++ b/src/app/(dashboard)/list/events/loading.tsx @@ -0,0 +1,5 @@ +import LoadingIndicator from "@/components/loaders/LoadingIndicator"; + +export default function Loading() { + return ; +} diff --git a/src/app/(dashboard)/list/events/page.tsx b/src/app/(dashboard)/list/events/page.tsx index 16936ad..77097e0 100644 --- a/src/app/(dashboard)/list/events/page.tsx +++ b/src/app/(dashboard)/list/events/page.tsx @@ -1,9 +1,12 @@ import FilterSortActions from "@/components/FilterSortActions"; import FormContainer from "@/components/FormContainer"; +import ClassFilter from "@/components/ClassFilter"; +import GradeFilter from "@/components/GradeFilter"; import NoCurrentAcademicYearMessage from "@/components/NoCurrentAcademicYearMessage"; import Pagination from "@/components/Pagination"; import Table from "@/components/Table"; import TableSearch from "@/components/TableSearch"; +import { getTranslations } from "next-intl/server"; import { enforceRouteAccess } from "@/lib/enforce-route-access"; import { getQueryParam, type PageSearchParams } from "@/lib/pageParams"; import prisma from "@/lib/prisma"; @@ -14,32 +17,32 @@ import { UserRole } from "@/lib/utils"; type EventList = Event & { classes: Pick[] }; -const getColumns = (role: UserRole | null) => [ +const getColumns = (role: UserRole | null, th: (key: string) => string) => [ { - header: "Title", + header: th("title"), accessor: "title", }, { - header: "Class", + header: th("class"), accessor: "class", }, { - header: "Date", + header: th("date"), accessor: "date", className: "hidden md:table-cell", }, { - header: "Start Time", + header: th("startTime"), accessor: "startTime", className: "hidden md:table-cell", }, { - header: "End Time", + header: th("endTime"), accessor: "endTime", className: "hidden md:table-cell", }, { - header: role === "admin" ? "Actions" : "", + header: role === "admin" ? th("actions") : "", accessor: "action", }, ]; @@ -48,6 +51,7 @@ const renderRow = ( item: EventList, role: UserRole | null, totalClassesCount: number, + allClassesLabel: string, ) => ( {item.title}
{item.classes.length === totalClassesCount && totalClassesCount > 0 - ? "All Classes" + ? allClassesLabel : item.classes.map((cls) => cls.name).join(", ") || "-"} @@ -94,6 +98,10 @@ const EventListPage = async ({ }: { searchParams: PageSearchParams; }) => { + const t = await getTranslations("pages"); + const th = await getTranslations("tableHeaders"); + const filtersT = await getTranslations("filters"); + const emptyT = await getTranslations("emptyStates"); const { role, userId, schoolId } = await enforceRouteAccess("/list/events"); const resolvedSearchParams = await searchParams; const { page, ...queryParams } = resolvedSearchParams; @@ -118,6 +126,24 @@ const EventListPage = async ({ }); break; } + case "classId": { + const classId = Number.parseInt(value, 10); + if (!Number.isNaN(classId)) { + conditions.push({ + classes: { some: { id: classId } }, + }); + } + break; + } + case "gradeId": { + const gradeId = Number.parseInt(value, 10); + if (!Number.isNaN(gradeId)) { + conditions.push({ + classes: { some: { gradeId } }, + }); + } + break; + } default: break; } @@ -150,13 +176,17 @@ const EventListPage = async ({ query.AND = conditions; } - const [data, count, totalClassesCount] = await prisma.$transaction([ + const sortParam = getQueryParam(queryParams.sort); + const orderBy: Prisma.EventOrderByWithRelationInput = + sortParam === "asc" ? { startDate: "asc" } : { startDate: "desc" }; + + const [data, count, totalClassesCount, classes, grades] = await prisma.$transaction([ prisma.event.findMany({ where: query, include: { classes: { select: { id: true, name: true } }, }, - orderBy: { startDate: "desc" }, + orderBy, take: ITEM_PER_PAGE, skip: (p - 1) * ITEM_PER_PAGE, }), @@ -164,26 +194,46 @@ const EventListPage = async ({ where: query, }), prisma.class.count({ where: { schoolId } }), + prisma.class.findMany({ + where: { schoolId }, + select: { id: true, name: true, gradeId: true }, + orderBy: { name: "asc" }, + }), + prisma.grade.findMany({ + where: { schoolId }, + select: { id: true, level: true }, + orderBy: { level: "asc" }, + }), ]); return (
{/* TOP */} -
-

All Events

-
+
+

{t("allEvents")}

+
-
- + {role === "admin" && ( + <> + + + + )} +
+ {role === "admin" && }
{/* LIST */} renderRow(item, role, totalClassesCount)} + columns={getColumns(role, th)} + renderRow={(item) => + renderRow(item, role, totalClassesCount, filtersT("allClasses")) + } data={data} + emptyTitle={emptyT("events")} + emptyDescription={emptyT("filterDescription")} /> {/* PAGINATION */} diff --git a/src/app/(dashboard)/list/exams/[examId]/submissions/ExtendTimeButton.tsx b/src/app/(dashboard)/list/exams/[examId]/submissions/ExtendTimeButton.tsx new file mode 100644 index 0000000..8fd0372 --- /dev/null +++ b/src/app/(dashboard)/list/exams/[examId]/submissions/ExtendTimeButton.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { extendTime } from "@/lib/actions/examWorkflow.actions"; +import { toast } from "react-toastify"; +import { useRouter } from "next/navigation"; +import { Clock, X, Loader2 } from "lucide-react"; + +const QUICK_OPTIONS = [5, 10, 15, 30]; + +export default function ExtendTimeButton({ + submissionId, + studentName, +}: { + submissionId: number; + studentName: string; +}) { + const [open, setOpen] = useState(false); + const [minutes, setMinutes] = useState(5); + const [isPending, startTransition] = useTransition(); + const router = useRouter(); + + const handleExtend = () => { + if (minutes < 1) { + toast.error("Please enter at least 1 minute."); + return; + } + + startTransition(async () => { + const res = await extendTime( + { success: true, error: false }, + { submissionId, extraMinutes: minutes } + ); + + if (res.error) { + toast.error( + ("message" in res ? res.message : null) ?? "Failed to extend time." + ); + } else { + toast.success(`Added ${minutes} minute(s) for ${studentName}.`); + setOpen(false); + router.refresh(); + } + }); + }; + + return ( + <> + + + {open && ( +
+
+ {/* Header */} +
+
+
+ +
+
+

+ Extend Time +

+

+ {studentName} +

+
+
+ +
+ + {/* Body */} +
+
+ {QUICK_OPTIONS.map((opt) => ( + + ))} +
+ +
+ + setMinutes(parseInt(e.target.value) || 1)} + className="h-9 w-20 rounded-md border border-gray-200 px-2 text-center text-sm outline-none focus:border-academixPurpleDark" + /> + minutes +
+
+ + {/* Footer */} +
+ + +
+
+
+ )} + + ); +} diff --git a/src/app/(dashboard)/list/exams/[examId]/submissions/PublishGradesButton.tsx b/src/app/(dashboard)/list/exams/[examId]/submissions/PublishGradesButton.tsx new file mode 100644 index 0000000..023d474 --- /dev/null +++ b/src/app/(dashboard)/list/exams/[examId]/submissions/PublishGradesButton.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { publishExamGrades } from "@/lib/actions/examWorkflow.actions"; +import { CheckCircle, Loader2 } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import { toast } from "react-toastify"; + +type PublishGradesButtonProps = { + examId: number; + disabled?: boolean; + allPublished?: boolean; + allGraded?: boolean; + alreadyPublished?: boolean; +}; + +const PublishGradesButton = ({ + examId, + disabled, + allPublished, + allGraded, + alreadyPublished, +}: PublishGradesButtonProps) => { + const t = useTranslations("examSubmissions"); + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + const [synced, setSynced] = useState(false); + const published = allPublished ?? alreadyPublished ?? false; + const hasPublishableGrades = allGraded ?? !disabled; + const isDisabled = disabled ?? (!hasPublishableGrades || published); + + const handlePublish = () => { + startTransition(async () => { + const result = await publishExamGrades(examId); + + if (result.success) { + setSynced(true); + toast.success( + published + ? t("toast.publishedSynced") + : t("toast.gradesPublished") + ); + router.refresh(); + } else { + toast.error(result.message ?? t("toast.publishFailed")); + } + }); + }; + + return ( + + ); +}; + +export default PublishGradesButton; diff --git a/src/app/(dashboard)/list/exams/[examId]/submissions/[submissionId]/GradeAnswerAiEvaluation.tsx b/src/app/(dashboard)/list/exams/[examId]/submissions/[submissionId]/GradeAnswerAiEvaluation.tsx new file mode 100644 index 0000000..fe621d8 --- /dev/null +++ b/src/app/(dashboard)/list/exams/[examId]/submissions/[submissionId]/GradeAnswerAiEvaluation.tsx @@ -0,0 +1,29 @@ +"use client"; + +import AiEvaluationCard, { + AiEvaluationCardData, +} from "@/components/AiEvaluationCard"; +import { evaluateExamAnswerWithAi } from "@/lib/actions/aiEvaluation.actions"; + +type Props = { + answerId: number; + maxScore: number; + evaluation: AiEvaluationCardData | null; + disabledMessage?: string; +}; + +export default function GradeAnswerAiEvaluation({ + answerId, + maxScore, + evaluation, + disabledMessage, +}: Props) { + return ( + evaluateExamAnswerWithAi(answerId)} + /> + ); +} diff --git a/src/app/(dashboard)/list/exams/[examId]/submissions/[submissionId]/GradeClient.tsx b/src/app/(dashboard)/list/exams/[examId]/submissions/[submissionId]/GradeClient.tsx index 9339054..a716ee4 100644 --- a/src/app/(dashboard)/list/exams/[examId]/submissions/[submissionId]/GradeClient.tsx +++ b/src/app/(dashboard)/list/exams/[examId]/submissions/[submissionId]/GradeClient.tsx @@ -1,14 +1,19 @@ "use client"; -import { gradeAnswer } from "@/lib/actions"; +import { gradeAnswer, approveAndFinalizeGrading } from "@/lib/actions"; import { useRouter } from "next/navigation"; -import { useState, useTransition } from "react"; +import { useEffect, useState, useTransition } from "react"; import { toast } from "react-toastify"; -import { CheckCircle, ExternalLink } from "lucide-react"; -import type { Answer, Question, Submission, Student } from "@prisma/client"; +import { CheckCircle, Eye, Loader2, AlertCircle, Download, FileText } from "lucide-react"; +import type { AiEvaluation, Answer, Question, Submission, Student } from "@prisma/client"; import { parseAnswerList } from "@/lib/examAnswerUtils"; +import { useTranslations } from "next-intl"; +import GradeAnswerAiEvaluation from "./GradeAnswerAiEvaluation"; -type AnswerWithQuestion = Answer & { question: Question }; +type AnswerWithQuestion = Answer & { + question: Question; + aiEvaluation: AiEvaluation | null; +}; type GradeClientProps = { submission: Submission & { student: Pick }; @@ -34,9 +39,11 @@ const QuestionTypeBadge = ({ type }: { type: string }) => { const AnswerDisplay = ({ value, multi = false, + noAnswerLabel, }: { value: string | string[] | null | undefined; multi?: boolean; + noAnswerLabel: string; }) => { if (!multi) { if (Array.isArray(value)) { @@ -45,11 +52,11 @@ const AnswerDisplay = ({ .filter((item) => item.length > 0) .join(", "); - return text ? {text} : No answer provided; + return text ? {text} : {noAnswerLabel}; } if (!value || !value.trim()) { - return No answer provided; + return {noAnswerLabel}; } return {value}; @@ -60,7 +67,7 @@ const AnswerDisplay = ({ : parseAnswerList(value); if (items.length === 0) { - return No answer provided; + return {noAnswerLabel}; } if (items.length === 1) { @@ -81,12 +88,26 @@ const AnswerDisplay = ({ ); }; +const previewableFileExtensions = new Set([ + "pdf", + "jpg", + "jpeg", + "png", + "gif", + "webp", + "txt", +]); + +const getFileExtension = (fileName?: string | null) => + fileName?.split(".").pop()?.toLowerCase() ?? ""; + const GradeClient = ({ submission, answers, examTitle, examId, }: GradeClientProps) => { + const t = useTranslations("examSubmissions"); const router = useRouter(); const [isPending, startTransition] = useTransition(); const [scores, setScores] = useState>(() => { @@ -99,16 +120,83 @@ const GradeClient = ({ return map; }); + const [savingStatus, setSavingStatus] = useState< + Record + >({}); + const [isFinalizing, setIsFinalizing] = useState(false); + + useEffect(() => { + setScores((prev) => { + const next = { ...prev }; + for (const answer of answers) { + if (answer.score !== null && answer.score !== undefined) { + next[answer.id] = String(answer.score); + } + } + return next; + }); + }, [answers]); + + const handleAutoSave = async (answerId: number, maxPoints: number) => { + const scoreStr = scores[answerId]; + if (scoreStr === undefined || scoreStr === "") { + return; + } + + const score = parseFloat(scoreStr); + if (isNaN(score) || score < 0) { + toast.error(t("toast.scorePositive")); + setSavingStatus((prev) => ({ ...prev, [answerId]: "error" })); + return; + } + + if (score > maxPoints) { + toast.error(t("toast.scoreMax", { maxPoints })); + setSavingStatus((prev) => ({ ...prev, [answerId]: "error" })); + return; + } + + const originalAnswer = answers.find((a) => a.id === answerId); + if (originalAnswer && originalAnswer.score === score) { + return; // No change, skip calling server action + } + + setSavingStatus((prev) => ({ ...prev, [answerId]: "saving" })); + + try { + const result = await gradeAnswer( + { success: false, error: false }, + { answerId, score } + ); + if (result.success) { + setSavingStatus((prev) => ({ ...prev, [answerId]: "saved" })); + router.refresh(); + } else { + toast.error(result.message ?? t("toast.somethingWrong")); + setSavingStatus((prev) => ({ ...prev, [answerId]: "error" })); + } + } catch (err) { + toast.error(t("toast.autoSaveFailed")); + setSavingStatus((prev) => ({ ...prev, [answerId]: "error" })); + } + }; + const handleGrade = (answerId: number) => { const scoreStr = scores[answerId]; if (scoreStr === undefined || scoreStr === "") { - toast.error("Please enter a score."); + toast.error(t("toast.scoreRequired")); return; } const score = parseFloat(scoreStr); if (isNaN(score) || score < 0) { - toast.error("Score must be a positive number."); + toast.error(t("toast.scorePositive")); + return; + } + + const answer = answers.find((a) => a.id === answerId); + if (answer && score > answer.question.points) { + toast.error(t("toast.scoreMax", { maxPoints: answer.question.points })); return; } @@ -118,14 +206,33 @@ const GradeClient = ({ { answerId, score } ); if (result.success) { - toast.success("Score saved!"); + toast.success(t("toast.scoreSaved")); router.refresh(); } else { - toast.error(result.message ?? "Something went wrong."); + toast.error(result.message ?? t("toast.somethingWrong")); } }); }; + const handleFinalize = async () => { + setIsFinalizing(true); + try { + const result = await approveAndFinalizeGrading(submission.id); + if (result.success) { + toast.success(t("toast.finalized")); + router.push(`/list/exams/${examId}/submissions`); + } else if ("warning" in result && result.warning) { + toast.warning(result.warning, { autoClose: 6000 }); + } else { + toast.error("message" in result ? result.message : t("toast.somethingWrong")); + } + } catch (err) { + toast.error(t("toast.finalizeError")); + } finally { + setIsFinalizing(false); + } + }; + const totalMax = answers.reduce((sum, a) => sum + a.question.points, 0); const gradedAnswers = answers.filter( (a) => a.score !== null && a.score !== undefined @@ -149,7 +256,7 @@ const GradeClient = ({ {examTitle} / - Grade Submission + {t("gradeTitle")}
@@ -164,12 +271,15 @@ const GradeClient = ({ {currentTotal} / {totalMax}
- {gradedAnswers.length} of {answers.length} graded + {t("gradedCount", { + graded: gradedAnswers.length, + total: answers.length, + })}
{allGraded && (
- All graded + {t("status.allGraded")}
)} @@ -188,6 +298,15 @@ const GradeClient = ({ answer.question.type === "MCQ" && answer.question.allowMultiple; const isGraded = answer.score !== null && answer.score !== undefined; + const isPdfFileAnswer = + answer.question.type === "FILE" && + getFileExtension((answer as any).fileOriginalName) === "pdf"; + const canAiEvaluate = + answer.question.type === "TEXT" || isPdfFileAnswer; + const aiDisabledMessage = + answer.question.type === "FILE" && !isPdfFileAnswer + ? t("ai.pdfOnly") + : undefined; return (
{isAutoGraded && ( - Auto-graded + {t("status.autoGraded")} )}
@@ -220,24 +339,73 @@ const GradeClient = ({ {/* Student Answer */}
-

Student Answer:

+

+ {t("answers.studentAnswer")} +

{/* FILE */} - {answer.question.type === "FILE" && answer.fileUrl ? ( - - View uploaded file - - + {answer.question.type === "FILE" ? ( + answer.fileUrl && (answer as any).filePublicId ? ( +
+
+ +
+

+ {(answer as any).fileOriginalName ?? t("answers.uploadedFile")} +

+

+ {(answer as any).fileSizeBytes + ? `${((answer as any).fileSizeBytes / (1024 * 1024)).toFixed(2)} MB` + : ""} + {(answer as any).fileSizeBytes && (answer as any).fileMimeType ? " · " : ""} + {(answer as any).fileMimeType?.split("/")?.[1]?.toUpperCase() ?? t("answers.file")} + {(answer as any).fileSizeBytes || (answer as any).fileMimeType ? " · " : ""} + {t("answers.uploaded")} {answer.savedAt + ? new Intl.DateTimeFormat("en-US", { dateStyle: "short", timeStyle: "short" }).format(answer.savedAt) + : ""} +

+
+
+
+ {previewableFileExtensions.has( + getFileExtension((answer as any).fileOriginalName), + ) ? ( + + + {t("actions.view")} + + ) : ( + + {t("answers.previewUnavailable")} + + )} + + + {t("actions.downloadFile")} + +
+
+ ) : ( + + {t("answers.noFile")} + + ) ) : (
)} @@ -246,20 +414,46 @@ const GradeClient = ({ {/* Correct Answer - for T/F and MCQ */} {isAutoGraded && (
-

Correct Answer:

+

+ {t("answers.correctAnswer")} +

)} + {/* Correct Answer - for TEXT */} + {answer.question.type === "TEXT" && answer.question.textAnswer && ( +
+

+ {t("answers.correctAnswer")} +

+
+ {answer.question.textAnswer} +
+
+ )} + + {canAiEvaluate || aiDisabledMessage ? ( + + ) : null} + {/* Score Input */}
- + handleAutoSave(answer.id, answer.question.points)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.currentTarget.blur(); + } + }} + disabled={isPending || savingStatus[answer.id] === "saving"} + className="w-20 p-1.5 rounded-md ring-[1.5px] ring-gray-300 text-sm text-center + focus:outline-none focus:ring-academixPurpleDark" /> / {answer.question.points}
- {/* Save button - for TEXT and FILE only */} - {!isAutoGraded && ( - + {/* Localized Status Indicator */} + {savingStatus[answer.id] === "saving" && ( + + + {t("status.saving")} + + )} + {savingStatus[answer.id] === "saved" && ( + + + {t("status.saved")} + )} + {savingStatus[answer.id] === "error" && ( + + + {t("status.error")} + + )} + + {/* Graded indicator */} - {isGraded && ( + {isGraded && !savingStatus[answer.id] && ( )}
@@ -304,16 +513,43 @@ const GradeClient = ({ })}
- {/* Back button */} -
+ {/* Action buttons */} +
+ +
diff --git a/src/app/(dashboard)/list/exams/[examId]/submissions/[submissionId]/loading.tsx b/src/app/(dashboard)/list/exams/[examId]/submissions/[submissionId]/loading.tsx new file mode 100644 index 0000000..6cc68f2 --- /dev/null +++ b/src/app/(dashboard)/list/exams/[examId]/submissions/[submissionId]/loading.tsx @@ -0,0 +1,5 @@ +import LoadingIndicator from "@/components/loaders/LoadingIndicator"; + +export default function Loading() { + return ; +} diff --git a/src/app/(dashboard)/list/exams/[examId]/submissions/[submissionId]/page.tsx b/src/app/(dashboard)/list/exams/[examId]/submissions/[submissionId]/page.tsx index 60fa4d5..ead90d3 100644 --- a/src/app/(dashboard)/list/exams/[examId]/submissions/[submissionId]/page.tsx +++ b/src/app/(dashboard)/list/exams/[examId]/submissions/[submissionId]/page.tsx @@ -20,29 +20,58 @@ const GradeSubmissionPage = async ({ if (isNaN(examId) || isNaN(submissionId)) redirect("/list/exams"); // Check that the teacher owns the exam - const exam = await prisma.exam.findFirst({ + const urlExam = await prisma.exam.findFirst({ where: { id: examId, schoolId, ...(role === "teacher" - ? { lesson: { teacherId: userId } } + ? { OR: [{ teacherId: userId }, { lesson: { teacherId: userId } }] } : {}), }, - select: { id: true, title: true }, + select: { + id: true, + title: true, + startTime: true, + endTime: true, + subjectId: true, + academicYearId: true, + }, }); - if (!exam) redirect("/list/exams"); + if (!urlExam) redirect("/list/exams"); + + // Find all exams in the same group + const groupExams = await prisma.exam.findMany({ + where: { + title: urlExam.title, + startTime: urlExam.startTime, + endTime: urlExam.endTime, + subjectId: urlExam.subjectId, + schoolId, + academicYearId: urlExam.academicYearId, + ...(role === "teacher" + ? { OR: [{ teacherId: userId }, { lesson: { teacherId: userId } }] } + : {}), + }, + select: { id: true }, + }); + const allowedExamIds = groupExams.map((e) => e.id); await syncAutoGrades(submissionId); // Load the submission with its answers const submission = await prisma.submission.findFirst({ - where: { id: submissionId, examId, schoolId }, + where: { + id: submissionId, + examId: { in: allowedExamIds }, + schoolId, + }, include: { student: { select: { name: true, username: true } }, answers: { include: { question: true, + aiEvaluation: true, }, }, }, @@ -58,7 +87,7 @@ const GradeSubmissionPage = async ({ ); diff --git a/src/app/(dashboard)/list/exams/[examId]/submissions/loading.tsx b/src/app/(dashboard)/list/exams/[examId]/submissions/loading.tsx new file mode 100644 index 0000000..6cc68f2 --- /dev/null +++ b/src/app/(dashboard)/list/exams/[examId]/submissions/loading.tsx @@ -0,0 +1,5 @@ +import LoadingIndicator from "@/components/loaders/LoadingIndicator"; + +export default function Loading() { + return ; +} diff --git a/src/app/(dashboard)/list/exams/[examId]/submissions/page.tsx b/src/app/(dashboard)/list/exams/[examId]/submissions/page.tsx index 15597a5..be24233 100644 --- a/src/app/(dashboard)/list/exams/[examId]/submissions/page.tsx +++ b/src/app/(dashboard)/list/exams/[examId]/submissions/page.tsx @@ -1,129 +1,190 @@ import Table from "@/components/Table"; +import { autoSubmitExpiredSubmissions } from "@/lib/actions/examWorkflow.actions"; import { enforceRouteAccess } from "@/lib/enforce-route-access"; +import { getQueryParam, PageSearchParams } from "@/lib/pageParams"; import prisma from "@/lib/prisma"; -import { Submission, Student, SubmissionStatus } from "@prisma/client"; +import { Class, Submission, Student, SubmissionStatus } from "@prisma/client"; import Link from "next/link"; import { redirect } from "next/navigation"; +import { getTranslations } from "next-intl/server"; +import PublishGradesButton from "./PublishGradesButton"; + +import ExtendTimeButton from "./ExtendTimeButton"; type SubmissionRow = Submission & { student: Pick; + exam: { class: Pick | null }; }; -const getStatusBadge = (status: SubmissionStatus) => { +const getStatusBadge = (status: SubmissionStatus, t: (key: string) => string) => { switch (status) { case "IN_PROGRESS": return ( - - In Progress + + {t("status.inProgress")} ); case "SUBMITTED": return ( - - Submitted + + {t("status.submitted")} ); case "GRADED": return ( - - Graded + + {t("status.graded")} ); } }; -const columns = [ - { header: "Student", accessor: "student" }, - { header: "Status", accessor: "status" }, - { header: "Score", accessor: "score", className: "hidden md:table-cell" }, - { header: "Submitted At", accessor: "submittedAt", className: "hidden md:table-cell" }, - { header: "Actions", accessor: "action" }, +const getColumns = (th: (key: string) => string) => [ + { header: th("student"), accessor: "student", className: "min-w-[190px] p-4" }, + { header: th("class"), accessor: "class", className: "min-w-[120px] p-4" }, + { header: th("status"), accessor: "status", className: "min-w-[120px] p-4" }, + { header: th("score"), accessor: "score", className: "min-w-[140px] p-4" }, + { + header: th("submittedAt"), + accessor: "submittedAt", + className: "hidden md:table-cell p-4", + }, + { header: th("actions"), accessor: "action", className: "min-w-[110px] p-4" }, ]; -const renderRow = (item: SubmissionRow, examId: number) => ( +const formatDateTime = (date: Date) => + new Intl.DateTimeFormat("en-US", { + dateStyle: "short", + timeStyle: "short", + }).format(date); + +const renderRow = ( + item: SubmissionRow, + examId: number, + maxScore: number, + t: (key: string) => string, +) => (
- {/* Student */} - - - {/* Status */} - - - {/* Score */} - + + - - {/* Submitted At */} - - - {/* Actions */} - ); const ExamSubmissionsPage = async ({ params, + searchParams, }: { params: Promise<{ examId: string }>; + searchParams: PageSearchParams; }) => { - const { role, userId, schoolId } = await enforceRouteAccess( - "/list/exams" - ); + const th = await getTranslations("tableHeaders"); + const filtersT = await getTranslations("filters"); + const actionsT = await getTranslations("actions"); + const examSubmissionsT = await getTranslations("examSubmissions"); + const { role, userId, schoolId } = await enforceRouteAccess("/list/exams"); const { examId: examIdStr } = await params; + const resolvedSearchParams = await searchParams; if (role !== "admin" && role !== "teacher") redirect("/list/exams"); const examId = parseInt(examIdStr); if (isNaN(examId)) redirect("/list/exams"); - // Check that the teacher owns the exam + const classIdRaw = getQueryParam(resolvedSearchParams.classId); + const search = getQueryParam(resolvedSearchParams.search)?.trim() ?? ""; + const parsedClassId = classIdRaw ? Number.parseInt(classIdRaw, 10) : null; + const selectedClassId = + parsedClassId && !Number.isNaN(parsedClassId) ? parsedClassId : null; + const exam = await prisma.exam.findFirst({ where: { id: examId, schoolId, ...(role === "teacher" - ? { lesson: { teacherId: userId } } + ? { OR: [{ teacherId: userId }, { lesson: { teacherId: userId } }] } : {}), }, include: { subject: { select: { name: true } }, - class: { select: { name: true } }, + questions: { select: { points: true } }, }, }); if (!exam) redirect("/list/exams"); + const maxScore = exam.questions.reduce((sum, q) => sum + q.points, 0); + + await autoSubmitExpiredSubmissions(examId).catch(() => {}); + + const groupExams = await prisma.exam.findMany({ + where: { + title: exam.title, + startTime: exam.startTime, + endTime: exam.endTime, + subjectId: exam.subjectId, + schoolId, + academicYearId: exam.academicYearId, + ...(role === "teacher" + ? { OR: [{ teacherId: userId }, { lesson: { teacherId: userId } }] } + : {}), + }, + include: { class: { select: { id: true, name: true } } }, + orderBy: { class: { name: "asc" } }, + }); + + const examIds = groupExams.map((e) => e.id); + const classOptions = groupExams + .map((item) => item.class) + .filter((item): item is { id: number; name: string } => Boolean(item)); const submissions = await prisma.submission.findMany({ - where: { examId, schoolId }, + where: { + examId: { in: examIds }, + schoolId, + ...(selectedClassId ? { exam: { classId: selectedClassId } } : {}), + ...(search + ? { + student: { + OR: [ + { name: { contains: search, mode: "insensitive" } }, + { username: { contains: search, mode: "insensitive" } }, + ], + }, + } + : {}), + }, include: { student: { select: { name: true, username: true } }, + exam: { select: { class: { select: { name: true } } } }, }, orderBy: { submittedAt: "desc" }, }); @@ -131,57 +192,124 @@ const ExamSubmissionsPage = async ({ const submitted = submissions.filter((s) => s.status !== "IN_PROGRESS").length; const graded = submissions.filter((s) => s.status === "GRADED").length; const inProgress = submissions.filter((s) => s.status === "IN_PROGRESS").length; + const published = submissions.filter((s) => s.gradePublished).length; + const draftGrades = submissions.filter( + (s) => s.status === "GRADED" && !s.gradePublished, + ).length; + const hasGradedSubmissions = graded > 0; + const allGradedPublished = + hasGradedSubmissions && + submissions + .filter((s) => s.status === "GRADED") + .every((s) => s.gradePublished); return ( -
- - {/* Header */} -
+
+
-
+
- Exams + {examSubmissionsT("breadcrumb")} / {exam.title}
-

Submissions

+

{examSubmissionsT("title")}

+

+ {exam.subject?.name ?? examSubmissionsT("subjectFallback")} -{" "} + {examSubmissionsT("marks", { count: maxScore })} +

+
- {/* Stats */} -
-
- Total - {submissions.length} +
+
+ + {examSubmissionsT("stats.visible")} + + {submissions.length}
-
- Submitted - {submitted} +
+ + {examSubmissionsT("stats.submitted")} + + {submitted}
-
- Graded - {graded} +
+ + {examSubmissionsT("stats.graded")} + + {graded}
-
- In Progress - {inProgress} +
+ + {examSubmissionsT("stats.inProgress")} + + {inProgress}
+
+ + {examSubmissionsT("stats.published")} + + {published} +
+
+ +
+
+ + + + {(selectedClassId || search) && ( + + {actionsT("clear")} + + )} +
- {/* Table */} - {submissions.length === 0 ? ( -
-

No submissions yet

-

Students haven't submitted this exam yet.

+
+
+
+
{item.student.name} - {item.student.username} + {item.student.username}
{getStatusBadge(item.status)} + {item.exam.class?.name ?? "-"}{getStatusBadge(item.status, t)} {item.totalScore !== null && item.totalScore !== undefined - ? item.totalScore - : "—"} + ? `${item.totalScore}/${maxScore}` + : "-"} - {item.submittedAt - ? new Intl.DateTimeFormat("en-US", { - dateStyle: "short", - timeStyle: "short", - }).format(item.submittedAt) - : "—"} + + {item.submittedAt ? formatDateTime(item.submittedAt) : "-"} + {(item.status === "SUBMITTED" || item.status === "GRADED") && ( - {item.status === "GRADED" ? "View" : "Grade"} + {item.status === "GRADED" ? t("actions.view") : t("actions.grade")} )} + {item.status === "IN_PROGRESS" && ( + + )}
+ renderRow(item as SubmissionRow, examId, maxScore, examSubmissionsT) + } + data={submissions} + emptyTitle={examSubmissionsT("empty.title")} + emptyDescription={examSubmissionsT("empty.description")} + /> - ) : ( -
renderRow(item as SubmissionRow, examId)} - data={submissions} - /> - )} + ); }; diff --git a/src/app/(dashboard)/list/exams/[examId]/take/loading.tsx b/src/app/(dashboard)/list/exams/[examId]/take/loading.tsx new file mode 100644 index 0000000..6cc68f2 --- /dev/null +++ b/src/app/(dashboard)/list/exams/[examId]/take/loading.tsx @@ -0,0 +1,5 @@ +import LoadingIndicator from "@/components/loaders/LoadingIndicator"; + +export default function Loading() { + return ; +} diff --git a/src/app/(dashboard)/list/exams/create-workflow/page.tsx b/src/app/(dashboard)/list/exams/create-workflow/page.tsx index cbd2a7c..a379f2a 100644 --- a/src/app/(dashboard)/list/exams/create-workflow/page.tsx +++ b/src/app/(dashboard)/list/exams/create-workflow/page.tsx @@ -4,16 +4,21 @@ import ExamWorkflowForm from "@/components/forms/ExamWorkflowForm"; import { getCurrentAcademicYearIdOrNull } from "@/lib/academicYears"; import NoCurrentAcademicYearMessage from "@/components/NoCurrentAcademicYearMessage"; import { getQueryParam, type PageSearchParams } from "@/lib/pageParams"; +import { isFileConfig } from "@/lib/formValidationSchemas"; export default async function CreateExamWorkflowPage({ searchParams, }: { searchParams: PageSearchParams; }) { - const { role, userId, schoolId } = await enforceRouteAccess("/list/exams", ["admin", "teacher"]); + const { role, userId, schoolId } = await enforceRouteAccess("/list/exams"); const resolvedSearchParams = await searchParams; const examIdParam = getQueryParam(resolvedSearchParams.examId); + const subjectIdParam = getQueryParam(resolvedSearchParams.subjectId); const examId = examIdParam ? Number.parseInt(examIdParam, 10) : NaN; + const preselectedSubjectId = subjectIdParam + ? Number.parseInt(subjectIdParam, 10) + : NaN; const academicYearId = await getCurrentAcademicYearIdOrNull(schoolId); if (!academicYearId) { @@ -23,9 +28,16 @@ export default async function CreateExamWorkflowPage({ const subjects = await prisma.subject.findMany({ where: { schoolId, - ...(role === "teacher" ? { teachers: { some: { id: userId } } } : {}), + ...(role === "teacher" + ? { + OR: [ + { teachers: { some: { id: userId } } }, + { lessons: { some: { teacherId: userId, academicYearId } } }, + ], + } + : {}), }, - select: { id: true, name: true }, + select: { id: true, name: true, gradeId: true }, }); const exam = Number.isNaN(examId) @@ -35,7 +47,9 @@ export default async function CreateExamWorkflowPage({ id: examId, schoolId, academicYearId, - ...(role === "teacher" ? { lesson: { teacherId: userId } } : {}), + ...(role === "teacher" + ? { OR: [{ teacherId: userId }, { lesson: { teacherId: userId } }] } + : {}), }, include: { questions: { @@ -53,7 +67,9 @@ export default async function CreateExamWorkflowPage({ subjectId: exam.subjectId, academicYearId, schoolId, - ...(role === "teacher" ? { lesson: { teacherId: userId } } : {}), + ...(role === "teacher" + ? { OR: [{ teacherId: userId }, { lesson: { teacherId: userId } }] } + : {}), }, select: { classId: true }, }) @@ -70,6 +86,8 @@ export default async function CreateExamWorkflowPage({ const initialData = exam ? { title: exam.title, + instructions: exam.instructions ?? "", + teacherId: exam.teacherId ?? "", startTime: exam.startTime, endTime: exam.endTime, subjectId: exam.subjectId ?? undefined, @@ -87,28 +105,95 @@ export default async function CreateExamWorkflowPage({ points: question.points, order: question.order, allowMultiple: question.allowMultiple, - options: Array.isArray(question.options) ? (question.options as string[]) : [], + options: question.type === "FILE" + ? [] + : Array.isArray(question.options) + ? (question.options as string[]) + : [], + fileConfig: + question.type === "FILE" && isFileConfig(question.options) + ? question.options + : undefined, correctAnswer: question.correctAnswer ?? [], + textAnswer: question.textAnswer ?? "", })), } - : undefined; + : Number.isNaN(preselectedSubjectId) || + !subjects.some((subject) => subject.id === preselectedSubjectId) + ? undefined + : { subjectId: preselectedSubjectId }; + + const subjectGradeIds = Array.from( + new Set(subjects.map((subject) => subject.gradeId)), + ); const classes = await prisma.class.findMany({ where: { schoolId, - ...(role === "teacher" ? { teachers: { some: { id: userId } } } : {}), + ...(role === "teacher" + ? { + OR: [ + { gradeId: { in: subjectGradeIds } }, + { teachers: { some: { id: userId } } }, + { lessons: { some: { teacherId: userId, academicYearId } } }, + ], + } + : {}), + }, + select: { id: true, name: true, gradeId: true }, + }); + + const teachers = + role === "admin" + ? await prisma.teacher.findMany({ + where: { schoolId }, + select: { id: true, name: true }, + orderBy: { name: "asc" }, + }) + : []; + + const lessonPairs = await prisma.lesson.findMany({ + where: { + schoolId, + academicYearId, + ...(role === "teacher" ? { teacherId: userId } : {}), }, - select: { id: true, name: true }, + select: { subjectId: true, classId: true }, }); + const subjectClassPairsMap = new Map< + string, + { subjectId: number; classId: number } + >(); + + for (const subject of subjects) { + for (const classItem of classes) { + if (classItem.gradeId === subject.gradeId) { + subjectClassPairsMap.set(`${subject.id}:${classItem.id}`, { + subjectId: subject.id, + classId: classItem.id, + }); + } + } + } + + for (const pair of lessonPairs) { + subjectClassPairsMap.set(`${pair.subjectId}:${pair.classId}`, pair); + } + + const subjectClassPairs = Array.from(subjectClassPairsMap.values()); + return (
-

- {exam ? "Update Exam Workflow" : "Create New Exam Workflow"} +

+ {exam ? "Update Exam" : "Create New Exam"}

; +} diff --git a/src/app/(dashboard)/list/exams/page.tsx b/src/app/(dashboard)/list/exams/page.tsx index 4ace280..66a0f6b 100644 --- a/src/app/(dashboard)/list/exams/page.tsx +++ b/src/app/(dashboard)/list/exams/page.tsx @@ -1,16 +1,21 @@ +import ExportButton from "@/components/ExportButton"; import FilterSortActions from "@/components/FilterSortActions"; import FormContainer from "@/components/FormContainer"; +import ClassFilter from "@/components/ClassFilter"; +import GradeFilter from "@/components/GradeFilter"; import NoCurrentAcademicYearMessage from "@/components/NoCurrentAcademicYearMessage"; import Pagination from "@/components/Pagination"; import Table from "@/components/Table"; import TableSearch from "@/components/TableSearch"; +import { getTranslations } from "next-intl/server"; +import TakeExamConfirmation from "@/components/exam/TakeExamConfirmation"; import { enforceRouteAccess } from "@/lib/enforce-route-access"; -import { getQueryParam, type PageSearchParams } from "@/lib/pageParams"; +import { buildExamQuery } from "@/lib/query-builders/exam-query"; import prisma from "@/lib/prisma"; import { ITEM_PER_PAGE } from "@/lib/settings"; -import { getCurrentAcademicYearIdOrNull } from "@/lib/academicYears"; -import { Class, Exam, Prisma, Subject, Teacher } from "@prisma/client"; +import { Class, Exam, Subject, Teacher } from "@prisma/client"; import { UserRole } from "@/lib/utils"; +import type { PageSearchParams } from "@/lib/pageParams"; import Link from "next/link"; import { Pencil } from "lucide-react"; @@ -18,223 +23,313 @@ type ExamList = Exam & { displayClasses?: string; subject: Pick | null; class: Pick | null; + teacher: Pick | null; lesson: { teacher: Pick; - }; + } | null; + studentSubmission?: { + status: string; + gradePublished: boolean; + totalScore: number | null; + startedAt: Date; + extraTime: number | null; + } | null; + questions?: { points: number }[]; }; -const getColumns = (role: UserRole | null) => { - const columns: { header: string; accessor: string; className?: string }[] = [ +const formatDateTime = (date: Date) => + new Intl.DateTimeFormat("en-US", { + dateStyle: "medium", + timeStyle: "short", + }).format(date); + +const getColumns = (role: UserRole | null, th: (key: string) => string) => { + const columns: { + header: string; + accessor: string; + className?: string; + }[] = [ { - header: "Title", + header: th("title"), accessor: "title", }, { - header: "Subject", - accessor: "name", + header: th("subject"), + accessor: "subject", }, ]; if (role !== "student") { columns.push({ - header: "Class", + header: th("class"), accessor: "class", }); } if (role !== "teacher") { columns.push({ - header: "Teacher", + header: th("teacher"), accessor: "teacher", className: "hidden md:table-cell", }); } - columns.push({ - header: "Start Time", - accessor: "startTime", - className: "hidden md:table-cell", - }); + columns.push( + { + header: th("startTime"), + accessor: "startTime", + className: "hidden md:table-cell min-w-[180px] w-[180px]", + }, + { + header: th("endTime"), + accessor: "endTime", + className: "hidden md:table-cell min-w-[180px] w-[180px]", + }, + ); - columns.push({ - header: "End Time", - accessor: "endTime", - className: "hidden md:table-cell", - }); + if (role === "student") { + columns.push({ + header: th("status"), + accessor: "myStatus", + }); + columns.push({ + header: th("score"), + accessor: "myScore", + }); + } columns.push({ - header: role === "admin" || role === "teacher" ? "Actions" : "", + header: role === "admin" || role === "teacher" ? th("actions") : "", accessor: "action", }); return columns; }; -const renderRow = (item: ExamList, role: UserRole | null) => ( -
- - - {role !== "student" && } - {role !== "teacher" && ( - - )} - - - + + + + + {role !== "student" && ( + + )} + + {role !== "teacher" && ( + + )} + + + + + + {role === "student" && ( + + )} + + {role === "student" && ( + + )} + + - -); + )} + {role === "student" && !item.studentSubmission && !hasExamEnded && ( + + )} + {role === "student" && + item.studentSubmission?.status === "IN_PROGRESS" && + !isTimerExpired && + !hasExamEnded && ( + + + + )} + + + + ); +}; + const ExamListPage = async ({ searchParams, }: { searchParams: PageSearchParams; }) => { + const t = await getTranslations("pages"); + const th = await getTranslations("tableHeaders"); + const examT = await getTranslations("examList"); const { role, userId, schoolId } = await enforceRouteAccess("/list/exams"); + const resolvedSearchParams = await searchParams; - const { page, ...queryParams } = resolvedSearchParams; - const currentPage = getQueryParam(page); - const p = currentPage ? parseInt(currentPage) : 1; - const academicYearId = await getCurrentAcademicYearIdOrNull(schoolId); - if (!academicYearId) { + const { + academicYearId, + query, + orderBy, + page: p, + } = await buildExamQuery({ + searchParams, + schoolId, + role, + userId, + }); + + if (!academicYearId || !query) { return ; } - const query: Prisma.ExamWhereInput = { schoolId, academicYearId }; - const conditions: Prisma.ExamWhereInput[] = []; - - if (queryParams) { - for (const [key, rawValue] of Object.entries(queryParams)) { - const value = getQueryParam(rawValue); - - if (value !== undefined) { - switch (key) { - case "classId": - conditions.push({ - lesson: { - classId: parseInt(value), - }, - }); - break; - - case "teacherId": - conditions.push({ - lesson: { - teacherId: value, - }, - }); - break; - - case "search": - conditions.push({ - lesson: { - subject: { - name: { contains: value, mode: "insensitive" }, - }, - }, - }); - break; - } + const exportQuery = new URLSearchParams( + Object.entries(resolvedSearchParams).flatMap(([key, value]) => { + if (Array.isArray(value)) { + return value.map((item) => [key, item]); } - } - } - // ROLE CONDITIONS - switch (role) { - case "admin": - break; - case "teacher": - conditions.push({ - lesson: { - teacherId: userId, - }, - }); - break; + return value ? [[key, value]] : []; + }), + ); - case "student": - conditions.push({ - lesson: { - class: { - students: { - some: { id: userId }, - }, + const [data, count] = await prisma.$transaction([ + prisma.exam.findMany({ + where: query, + include: { + subject: { + select: { + name: true, + }, + }, + class: { + select: { + name: true, }, }, - }); - break; - - case "parent": - conditions.push({ lesson: { - class: { - students: { - some: { parentId: userId }, + select: { + teacher: { + select: { + name: true, + }, }, }, }, - }); - break; - } - - if (conditions.length > 0) { - query.AND = conditions; - } - - const [data, count] = await prisma.$transaction([ - prisma.exam.findMany({ - where: query, - include: { - subject: { select: { name: true } }, - class: { select: { name: true } }, - lesson: { + teacher: { + select: { + name: true, + }, + }, + questions: { select: { - teacher: { select: { name: true } }, + points: true, }, }, }, - orderBy: { startTime: "desc" }, + orderBy, take: ITEM_PER_PAGE, skip: (p - 1) * ITEM_PER_PAGE, }), @@ -242,76 +337,149 @@ const ExamListPage = async ({ where: query, }), ]); + const [classes, grades] = + role === "admin" + ? await prisma.$transaction([ + prisma.class.findMany({ + where: { schoolId }, + select: { id: true, name: true, gradeId: true }, + orderBy: { name: "asc" }, + }), + prisma.grade.findMany({ + where: { schoolId }, + select: { id: true, level: true }, + orderBy: { level: "asc" }, + }), + ]) + : [[], []]; + + const classGroups = new Map>(); + const uniqueExamsMap = new Map(); - const dataWithClassDisplay: ExamList[] = - role === "admin" || role === "teacher" - ? (() => { - const classGroups = new Map>(); - - for (const exam of data) { - const groupKey = [ - exam.title, - exam.startTime.toISOString(), - exam.endTime.toISOString(), - exam.subject?.name, - ].join("|"); - - if (!classGroups.has(groupKey)) { - classGroups.set(groupKey, new Set()); - } - - if (exam.class?.name) { - classGroups.get(groupKey)!.add(exam.class.name); - } - } - - return data.map((exam) => { - const groupKey = [ - exam.title, - exam.startTime.toISOString(), - exam.endTime.toISOString(), - exam.subject?.name, - ].join("|"); - - const groupedClasses = classGroups.get(groupKey); - return { - ...exam, - displayClasses: - groupedClasses && groupedClasses.size > 1 - ? Array.from(groupedClasses).sort().join(", ") - : exam.class?.name, - }; - }); - })() - : data; + for (const exam of data) { + const groupKey = [ + exam.title, + exam.startTime.toISOString(), + exam.endTime.toISOString(), + exam.subject?.name, + ].join("|"); + + if (!classGroups.has(groupKey)) { + classGroups.set(groupKey, new Set()); + } + + if (exam.class?.name) { + classGroups.get(groupKey)!.add(exam.class.name); + } + + if (!uniqueExamsMap.has(groupKey)) { + uniqueExamsMap.set(groupKey, exam); + } + } + + // For student role: fetch their submission status for each displayed exam + let studentSubmissionsMap = new Map< + number, + { + status: string; + gradePublished: boolean; + totalScore: number | null; + startedAt: Date; + extraTime: number | null; + } + >(); + if (role === "student") { + const examIds = data.map((e) => e.id); + const studentSubmissions = await prisma.submission.findMany({ + where: { studentId: userId, examId: { in: examIds }, schoolId }, + select: { + examId: true, + status: true, + gradePublished: true, + totalScore: true, + startedAt: true, + extraTime: true, + }, + }); + for (const sub of studentSubmissions) { + studentSubmissionsMap.set(sub.examId, { + status: sub.status, + gradePublished: sub.gradePublished, + totalScore: sub.totalScore ?? null, + startedAt: sub.startedAt, + extraTime: sub.extraTime, + }); + } + } + + const dataWithClassDisplay: ExamList[] = Array.from( + uniqueExamsMap.values(), + ).map((exam) => { + const groupKey = [ + exam.title, + exam.startTime.toISOString(), + exam.endTime.toISOString(), + exam.subject?.name, + ].join("|"); + + const groupedClasses = classGroups.get(groupKey); + return { + ...exam, + displayClasses: + groupedClasses && groupedClasses.size > 1 + ? Array.from(groupedClasses).sort().join(", ") + : exam.class?.name, + studentSubmission: + role === "student" + ? (studentSubmissionsMap.get(exam.id) ?? null) + : undefined, + }; + }); return (
- {/* TOP */} -
-

All Exams

-
+
+

{t("allExams")}

+ +
-
- + {role === "admin" && ( + <> + + + + )} + +
+ + {(role === "admin" || role === "teacher") && ( - - Add Exam - + <> + {role === "admin" && ( + + )} + + {examT("addExam")} + + )}
- {/* LIST */} +
{item.title}{item.subject?.name}{item.displayClasses ?? item.class?.name}{item.lesson.teacher.name} - {new Intl.DateTimeFormat("en-US", { - dateStyle: "short", - timeStyle: "short", - }).format(item.startTime)} - - {new Intl.DateTimeFormat("en-US", { - dateStyle: "short", - timeStyle: "short", - }).format(item.endTime)} - -
- {(role === "admin" || role === "teacher") && ( -
+const renderRow = (item: ExamList, role: UserRole | null) => { + const sub = item.studentSubmission; + const isTimerExpired = + sub && + sub.status === "IN_PROGRESS" && + Date.now() > + new Date(sub.startedAt).getTime() + + ((item.duration ?? 0) + (sub.extraTime ?? 0)) * 60000; + const hasExamEnded = Date.now() > new Date(item.endTime).getTime(); + const maxScore = item.questions?.reduce((sum, q) => sum + q.points, 0) ?? 0; + + return ( +
{item.title} + {item.subject?.name ?? "-"} + {item.displayClasses ?? item.class?.name ?? "-"} + {item.teacher?.name ?? item.lesson?.teacher.name ?? "-"} + + {formatDateTime(item.startTime)} + + {formatDateTime(item.endTime)} + + {(() => { + if (!sub) { + if (hasExamEnded) { + return ( + + Ended + + ); + } + return ( + + Not Started + + ); + } + if ( + sub.status === "IN_PROGRESS" && + (isTimerExpired || hasExamEnded) + ) { + return ( + + Ended + + ); + } + if (sub.status === "IN_PROGRESS") + return ( + + In Progress + + ); + if (sub.status === "SUBMITTED") + return ( + + Submitted + + ); + if (sub.status === "GRADED" && !sub.gradePublished) + return ( + + Graded + + ); + if (sub.status === "GRADED" && sub.gradePublished) + return ( + + Published + + ); + })()} + + {item.studentSubmission?.gradePublished && + item.studentSubmission.totalScore !== null ? ( + `${item.studentSubmission.totalScore}/${maxScore}` + ) : ( + — + )} + +
+ {(role === "admin" || role === "teacher") && ( +
+ + + + +
+ )} + {(role === "admin" || role === "teacher") && ( - + Submissions - -
- )} - {(role === "admin" || role === "teacher") && ( - - Submissions - - )} - {role === "student" && ( - - - - )} - -
renderRow(item, role)} data={dataWithClassDisplay} + emptyTitle={examT("noExams")} + emptyDescription={examT("emptyDescription")} /> - {/* PAGINATION */} + ); diff --git a/src/app/(dashboard)/list/final-results/page.tsx b/src/app/(dashboard)/list/final-results/page.tsx new file mode 100644 index 0000000..326ad63 --- /dev/null +++ b/src/app/(dashboard)/list/final-results/page.tsx @@ -0,0 +1,407 @@ +import AcademicYearFilter from "@/components/AcademicYearFilter"; +import ClassFilter from "@/components/ClassFilter"; +import FinalGradeEditButton from "@/components/FinalGradeEditButton"; +import GradeFilter from "@/components/GradeFilter"; +import NoCurrentAcademicYearMessage from "@/components/NoCurrentAcademicYearMessage"; +import Pagination from "@/components/Pagination"; +import EmptyState from "@/components/states/EmptyState"; +import Table from "@/components/Table"; +import TableSearch from "@/components/TableSearch"; +import { + getAcademicYears, + getCurrentAcademicYearIdOrNull, +} from "@/lib/academicYears"; +import { enforceRouteAccess } from "@/lib/enforce-route-access"; +import { + calculateFinalResultSummary, + type AssessmentScore, + type FinalResultStatus, +} from "@/lib/finalResults"; +import { getQueryParam, type PageSearchParams } from "@/lib/pageParams"; +import prisma from "@/lib/prisma"; +import { ITEM_PER_PAGE } from "@/lib/settings"; +import { Prisma } from "@prisma/client"; +import { getTranslations } from "next-intl/server"; + +type FinalResultRow = { + id: string; + name: string; + className: string; + averageScore: number | null; + assessmentCount: number; + status: FinalResultStatus; + storedAverageScore: number | null; +}; + +type StoredFinalResult = { + studentId: string; + averageScore: number; + assessmentCount: number; + status: string | null; +}; + +const formatValue = (value: number | null, suffix = "") => + value === null ? "-" : `${value.toFixed(2)}${suffix}`; + +const columns = (th: (key: string) => string, canEdit: boolean) => { + const baseColumns = [ + { header: th("student"), accessor: "student" }, + { + header: th("class"), + accessor: "class", + className: "hidden md:table-cell", + }, + { header: th("assessmentCount"), accessor: "assessmentCount" }, + { header: th("averageScore"), accessor: "averageScore" }, + { header: th("status"), accessor: "status" }, + ]; + + if (canEdit) { + baseColumns.push({ header: th("actions"), accessor: "actions" }); + } + + return baseColumns; +}; + +const statusClassName: Record = { + PASS: "bg-emerald-50 text-emerald-700", + FAIL: "bg-red-50 text-red-700", + NO_RESULTS: "bg-gray-100 text-gray-500", + NOT_UPDATED: "bg-amber-50 text-amber-700", +}; + +const AverageBadge = ({ value }: { value: number | null }) => ( + + {formatValue(value, "%")} + +); + +const StatusBadge = ({ + status, + statusT, +}: { + status: FinalResultStatus; + statusT: (key: FinalResultStatus) => string; +}) => ( + + {statusT(status)} + +); + +const renderRow = ( + item: FinalResultRow, + statusT: (key: FinalResultStatus) => string, + academicYearId: number, + canEdit: boolean, +) => ( + + + + + + + {canEdit && ( + + )} + +); + +export default async function FinalResultsPage({ + searchParams, +}: { + searchParams: PageSearchParams; +}) { + const t = await getTranslations("pages"); + const th = await getTranslations("tableHeaders"); + const emptyT = await getTranslations("emptyStates"); + const statusT = await getTranslations("finalResultStatus"); + const { role, userId, schoolId } = await enforceRouteAccess( + "/list/final-results", + ); + const resolvedSearchParams = await searchParams; + const pageParam = getQueryParam(resolvedSearchParams.page); + const search = getQueryParam(resolvedSearchParams.search); + const classIdParam = getQueryParam(resolvedSearchParams.classId); + const gradeIdParam = getQueryParam(resolvedSearchParams.gradeId); + const academicYearParam = getQueryParam(resolvedSearchParams.academicYearId); + const page = pageParam ? Number.parseInt(pageParam, 10) : 1; + const currentPage = Number.isNaN(page) || page < 1 ? 1 : page; + const classId = classIdParam ? Number.parseInt(classIdParam, 10) : null; + const gradeId = gradeIdParam ? Number.parseInt(gradeIdParam, 10) : null; + const currentAcademicYearId = await getCurrentAcademicYearIdOrNull(schoolId); + + if (!currentAcademicYearId) return ; + + const academicYears = + role === "student" || role === "parent" + ? await getAcademicYears(schoolId) + : []; + const selectedAcademicYearId = academicYearParam + ? Number.parseInt(academicYearParam, 10) + : currentAcademicYearId; + const academicYearId = + Number.isNaN(selectedAcademicYearId) || selectedAcademicYearId < 1 + ? currentAcademicYearId + : selectedAcademicYearId; + + const conditions: Prisma.StudentWhereInput[] = []; + if (search) { + conditions.push({ + OR: [ + { name: { contains: search, mode: "insensitive" } }, + { username: { contains: search, mode: "insensitive" } }, + { email: { contains: search, mode: "insensitive" } }, + ], + }); + } + if (classId && !Number.isNaN(classId)) conditions.push({ classId }); + if (gradeId && !Number.isNaN(gradeId)) { + conditions.push({ class: { gradeId } }); + } + + if (role === "teacher") { + conditions.push({ + class: { + supervisorId: userId, + }, + }); + } else if (role === "student") { + conditions.push({ id: userId }); + } else if (role === "parent") { + conditions.push({ parentId: userId }); + } else if (role !== "admin") { + conditions.push({ id: "__forbidden__" }); + } + + const studentWhere: Prisma.StudentWhereInput = { + schoolId, + ...(conditions.length ? { AND: conditions } : {}), + }; + const classWhere: Prisma.ClassWhereInput = { schoolId }; + if (role === "teacher") { + classWhere.supervisorId = userId; + } + + const [students, count, classes, grades] = await prisma.$transaction([ + prisma.student.findMany({ + where: studentWhere, + select: { id: true, name: true, class: { select: { name: true } } }, + orderBy: { name: "asc" }, + take: ITEM_PER_PAGE, + skip: (currentPage - 1) * ITEM_PER_PAGE, + }), + prisma.student.count({ where: studentWhere }), + prisma.class.findMany({ + where: classWhere, + select: { id: true, name: true, gradeId: true }, + orderBy: { name: "asc" }, + }), + prisma.grade.findMany({ + where: { schoolId }, + select: { id: true, level: true }, + orderBy: { level: "asc" }, + }), + ]); + + const studentIds = students.map((student) => student.id); + const results = studentIds.length + ? await prisma.result.findMany({ + where: { schoolId, academicYearId, studentId: { in: studentIds } }, + select: { + studentId: true, + score: true, + assignment: { select: { maxScore: true } }, + exam: { select: { questions: { select: { points: true } } } }, + }, + }) + : []; + const storedFinalResults: StoredFinalResult[] = studentIds.length + ? await ( + prisma as unknown as { + studentFinalResult: { + findMany: (args: { + where: { + schoolId: number; + academicYearId: number; + studentId: { in: string[] }; + }; + select: { + studentId: boolean; + averageScore: boolean; + assessmentCount: boolean; + status: boolean; + }; + }) => Promise; + }; + } + ).studentFinalResult.findMany({ + where: { schoolId, academicYearId, studentId: { in: studentIds } }, + select: { + studentId: true, + averageScore: true, + assessmentCount: true, + status: true, + }, + }) + : []; + const scoresByStudent = new Map(); + const storedStatusByStudent = new Map( + storedFinalResults.map((result) => [ + result.studentId, + result.status === "PASS" || result.status === "FAIL" + ? result.status + : "NOT_UPDATED", + ]), + ); + const storedFinalResultByStudent = new Map( + storedFinalResults.map((result) => [result.studentId, result]), + ); + + for (const result of results) { + const maxScore = + result.assignment?.maxScore ?? + result.exam?.questions.reduce( + (sum, question) => sum + question.points, + 0, + ) ?? + null; + const scores = scoresByStudent.get(result.studentId) ?? []; + scores.push({ score: result.score, maxScore }); + scoresByStudent.set(result.studentId, scores); + } + + const data: FinalResultRow[] = students.map((student) => { + const summary = calculateFinalResultSummary( + scoresByStudent.get(student.id) ?? [], + ); + const storedFinalResult = storedFinalResultByStudent.get(student.id); + const hasStoredFinalResult = !!storedFinalResult; + const averageScore = + storedFinalResult?.averageScore ?? summary.averageScore; + const assessmentCount = + storedFinalResult?.assessmentCount ?? summary.assessmentCount; + + return { + id: student.id, + name: student.name, + className: student.class.name, + averageScore, + assessmentCount, + storedAverageScore: storedFinalResult?.averageScore ?? null, + status: + !hasStoredFinalResult && summary.assessmentCount === 0 + ? "NO_RESULTS" + : (storedStatusByStudent.get(student.id) ?? "NOT_UPDATED"), + }; + }); + const canEdit = role === "admin" || role === "teacher"; + + return ( +
+
+

{t("finalAverages")}

+
+ + {(role === "student" || role === "parent") && ( + + )} + {role === "admin" && ( + <> + + + + )} +
+
+ +
+ {data.length === 0 ? ( +
+ +
+ ) : ( + data.map((item) => ( +
+
+
+

+ {item.name} +

+

+ {th("class")}: {item.className} +

+
+ +
+ +
+
+

{th("assessmentCount")}

+

+ {item.assessmentCount} +

+
+
+

{th("averageScore")}

+
+ +
+
+
+ + {canEdit && ( +
+ +
+ )} +
+ )) + )} +
+ +
+
{item.name}{item.className}{item.assessmentCount} + + + + + +
+ renderRow(item, statusT, academicYearId, canEdit) + } + data={data} + emptyTitle={emptyT("finalAverages")} + emptyDescription={emptyT("filterDescription")} + /> + + + + ); +} diff --git a/src/app/(dashboard)/list/grades/loading.tsx b/src/app/(dashboard)/list/grades/loading.tsx new file mode 100644 index 0000000..6cc68f2 --- /dev/null +++ b/src/app/(dashboard)/list/grades/loading.tsx @@ -0,0 +1,5 @@ +import LoadingIndicator from "@/components/loaders/LoadingIndicator"; + +export default function Loading() { + return ; +} diff --git a/src/app/(dashboard)/list/grades/page.tsx b/src/app/(dashboard)/list/grades/page.tsx new file mode 100644 index 0000000..788f709 --- /dev/null +++ b/src/app/(dashboard)/list/grades/page.tsx @@ -0,0 +1,139 @@ +import FormContainer from "@/components/FormContainer"; +import Pagination from "@/components/Pagination"; +import Table from "@/components/Table"; +import TableSearch from "@/components/TableSearch"; +import { getTranslations } from "next-intl/server"; +import { enforceRouteAccess } from "@/lib/enforce-route-access"; +import { ITEM_PER_PAGE } from "@/lib/settings"; +import { UserRole } from "@/lib/utils"; +import prisma from "@/lib/prisma"; +import { Grade } from "@prisma/client"; +import type { PageSearchParams } from "@/lib/pageParams"; +import { Prisma } from "@prisma/client"; + +type GradeList = Grade & { + classes: Array<{ id: number; name: string }>; +}; + +const getColumns = (role: UserRole | null, th: (key: string) => string) => [ + { + header: th("gradeLevel"), + accessor: "level", + }, + { + header: th("classes"), + accessor: "classes", + className: "hidden md:table-cell", + }, + { + header: role === "admin" ? th("actions") : "", + accessor: "action", + }, +]; + +const renderRow = (item: GradeList, role: UserRole | null) => ( + + + + + + + +); + +const GradeListPage = async ({ + searchParams, +}: { + searchParams: PageSearchParams; +}) => { + const t = await getTranslations("pages"); + const th = await getTranslations("tableHeaders"); + const emptyT = await getTranslations("emptyStates"); + const { role, schoolId } = await enforceRouteAccess("/list/grades"); + + const resolvedSearchParams = await searchParams; + const p = Number(resolvedSearchParams?.page) || 1; + const q = + typeof resolvedSearchParams?.search === "string" + ? resolvedSearchParams.search.trim() + : ""; + + const numericSearch = Number(q); + const query: Prisma.GradeWhereInput = { + schoolId, + ...(q + ? { + OR: [ + ...(Number.isNaN(numericSearch) ? [] : [{ level: numericSearch }]), + { + classes: { + some: { + name: { + contains: q, + mode: "insensitive", + }, + }, + }, + }, + ], + } + : {}), + }; + + const [data, count] = await prisma.$transaction([ + prisma.grade.findMany({ + where: query, + include: { + classes: { + select: { id: true, name: true }, + orderBy: { name: "asc" }, + }, + }, + orderBy: { level: "asc" }, + take: ITEM_PER_PAGE, + skip: (p - 1) * ITEM_PER_PAGE, + }), + prisma.grade.count({ where: query }), + ]); + + return ( +
+
+

{t("allGrades")}

+ +
+ + +
+ {role === "admin" && } +
+
+
+ +
Grade {item.level} + {item.classes.length > 0 + ? item.classes.map((classItem) => classItem.name).join(", ") + : "-"} + +
+ {role === "admin" && ( + + )} +
+
renderRow(item, role)} + data={data} + emptyTitle={emptyT("grades")} + emptyDescription={emptyT("searchDescription")} + /> + + + + ); +}; + +export default GradeListPage; diff --git a/src/app/(dashboard)/list/layout.tsx b/src/app/(dashboard)/list/layout.tsx index fb2fe73..a35b763 100644 --- a/src/app/(dashboard)/list/layout.tsx +++ b/src/app/(dashboard)/list/layout.tsx @@ -1,5 +1,5 @@ import { redirect } from "next/navigation"; -import { getAuthUser } from "@/lib/auth"; +import { enforceAdminSchoolAccess, getAuthUser } from "@/lib/auth"; export default async function ListLayout({ children, @@ -12,5 +12,7 @@ export default async function ListLayout({ redirect("/sign-in"); } - return <>{children}; + await enforceAdminSchoolAccess(user); + + return
{children}
; } diff --git a/src/app/(dashboard)/list/lessons/loading.tsx b/src/app/(dashboard)/list/lessons/loading.tsx new file mode 100644 index 0000000..6cc68f2 --- /dev/null +++ b/src/app/(dashboard)/list/lessons/loading.tsx @@ -0,0 +1,5 @@ +import LoadingIndicator from "@/components/loaders/LoadingIndicator"; + +export default function Loading() { + return ; +} diff --git a/src/app/(dashboard)/list/lessons/page.tsx b/src/app/(dashboard)/list/lessons/page.tsx index 4360356..ca981ca 100644 --- a/src/app/(dashboard)/list/lessons/page.tsx +++ b/src/app/(dashboard)/list/lessons/page.tsx @@ -1,10 +1,13 @@ import BigCalendarContainer from "@/components/BigCalendarContainer"; +import EmptyState from "@/components/states/EmptyState"; +import ExportButton from "@/components/ExportButton"; import FormContainer from "@/components/FormContainer"; import { enforceRouteAccess } from "@/lib/enforce-route-access"; import { computeClassSelection } from "@/lib/lessons/classSelection"; import { getAccessibleClasses } from "@/lib/lessons/getClasses"; import { parseLessonParams } from "@/lib/lessons/lessonParams"; import { type PageSearchParams } from "@/lib/pageParams"; +import { getTranslations } from "next-intl/server"; import Link from "next/link"; const LessonListPage = async ({ @@ -13,6 +16,8 @@ const LessonListPage = async ({ searchParams: PageSearchParams; }) => { const user = await enforceRouteAccess("/list/lessons"); + const t = await getTranslations("lessonsPage"); + const filtersT = await getTranslations("filters"); const resolvedSearchParams = await searchParams; const role = user.role; @@ -33,7 +38,6 @@ const LessonListPage = async ({ selectedGrade: grade, }); - // preserve params const baseParams: Record = {}; for (const [key, value] of Object.entries(resolvedSearchParams)) { @@ -58,31 +62,44 @@ const LessonListPage = async ({ params.set("classId", classId.toString()); return `/list/lessons?${params.toString()}`; }; + return (
-
-

Lessons Calendar

- {role === "admin" && selectedClass && ( -
- +

{t("title")}

+ +
+ {role === "admin" && selectedClass && ( + -
- )} + )} + + {role === "admin" && selectedClass && ( +
+ +
+ )} +
{classes.length === 0 ? ( -
- No classes with lessons are available for this view. -
+ ) : ( <>
- - Grade: + + {t("grade")} + {availableGrades.map((gradeLevel) => ( - {`Grade ${gradeLevel}`} + {filtersT("gradeLevel", { level: gradeLevel })} ))}
- - Classes: + + {t("classes")} + {filteredClasses.map((item) => { const isActive = selectedClass?.id === item.id; + return ( -

- {`Schedule for Grade ${selectedClass.grade.level} - ${selectedClass.name}`} -

+
+

+ {t("scheduleFor", { + grade: selectedClass.grade.level, + className: selectedClass.name, + })} +

+
+
) : ( -
- No classes found for the selected grade. -
+ )} )} diff --git a/src/app/(dashboard)/list/messages/loading.tsx b/src/app/(dashboard)/list/messages/loading.tsx new file mode 100644 index 0000000..6cc68f2 --- /dev/null +++ b/src/app/(dashboard)/list/messages/loading.tsx @@ -0,0 +1,5 @@ +import LoadingIndicator from "@/components/loaders/LoadingIndicator"; + +export default function Loading() { + return ; +} diff --git a/src/app/(dashboard)/list/messages/page.tsx b/src/app/(dashboard)/list/messages/page.tsx index 497ab80..edbfdf7 100644 --- a/src/app/(dashboard)/list/messages/page.tsx +++ b/src/app/(dashboard)/list/messages/page.tsx @@ -17,31 +17,32 @@ import { MessageList, } from "@/lib/message"; import { UserRole } from "@/lib/utils"; +import { getTranslations } from "next-intl/server"; const isLimitedMessageRole = (role: UserRole | null) => role === "student" || role === "parent" || role === "teacher"; -const getColumns = (role: UserRole | null) => { +const getColumns = (role: UserRole | null, th: (key: string) => string) => { if (isLimitedMessageRole(role)) { return [ { - header: "Title", + header: th("title"), accessor: "title", }, { - header: "Content", + header: th("content"), accessor: "description", }, { - header: "Date", + header: th("date"), accessor: "date", }, { - header: "Time", + header: th("time"), accessor: "time", }, { - header: "Actions", + header: th("actions"), accessor: "action", }, ]; @@ -49,33 +50,33 @@ const getColumns = (role: UserRole | null) => { return [ { - header: "Title", + header: th("title"), accessor: "title", }, { - header: "Content", + header: th("content"), accessor: "description", }, { - header: "Sent To", + header: th("sentTo"), accessor: "recipients", }, { - header: "Class", + header: th("class"), accessor: "class", }, { - header: "Date", + header: th("date"), accessor: "date", className: "hidden md:table-cell", }, { - header: "Time", + header: th("time"), accessor: "time", className: "hidden md:table-cell", }, { - header: role === "admin" ? "Actions" : "", + header: role === "admin" ? th("actions") : "", accessor: "action", }, ]; @@ -85,6 +86,7 @@ const renderRow = ( item: MessageList, role: UserRole | null, totalClassesCount: number, + allClassesLabel: string, ) => { if (isLimitedMessageRole(role)) { return ( @@ -121,7 +123,7 @@ const renderRow = (
- - - - - -); const SubjectListPage = async ({ searchParams, }: { searchParams: PageSearchParams; }) => { + const t = await getTranslations("pages"); + const emptyT = await getTranslations("emptyStates"); const { role, userId, schoolId } = await enforceRouteAccess("/list/subjects"); - const resolvedSearchParams = await searchParams; - const { page, ...queryParams } = resolvedSearchParams; - const currentPage = getQueryParam(page); - const p = currentPage ? parseInt(currentPage) : 1; - - const query: Prisma.SubjectWhereInput = { schoolId }; - const conditions: Prisma.SubjectWhereInput[] = []; - if (queryParams) { - for (const [key, rawValue] of Object.entries(queryParams)) { - const value = getQueryParam(rawValue); - - if (value !== undefined) { - switch (key) { - case "search": - conditions.push({ - name: { contains: value, mode: "insensitive" }, - }); - break; - } - } - } - } - - // ROLE CONDITIONS - switch (role) { - case "admin": - break; - - case "teacher": - if (!userId) throw new Error("Unauthorized"); - - conditions.push({ - teachers: { - some: { id: userId }, - }, - }); - break; - - case "student": - if (!userId) throw new Error("Unauthorized"); - - const student = await prisma.student.findUnique({ - where: { id: userId }, - select: { gradeId: true }, - }); - - if (student?.gradeId) { - conditions.push({ - gradeId: student.gradeId, - }); - } - break; - - case "parent": - if (!userId) throw new Error("Unauthorized"); - const children = await prisma.student.findMany({ - where: { parentId: userId }, - select: { gradeId: true }, - }); + const { + query, + orderBy, + page: p, + } = await buildSubjectQuery({ + searchParams, + schoolId, + role, + userId, + }); - const gradeIds = children.map((c) => c.gradeId); + const resolvedSearchParams = await searchParams; - if (gradeIds.length > 0) { - conditions.push({ - gradeId: { in: gradeIds }, - }); - } else { - conditions.push({ id: -1 }); + const exportQuery = new URLSearchParams( + Object.entries(resolvedSearchParams).flatMap(([key, value]) => { + if (Array.isArray(value)) { + return value.map((item) => [key, item]); } - - break; - - default: - break; - } - - if (conditions.length > 0) { - query.AND = conditions; - } + return value ? [[key, value]] : []; + }), + ); const [data, count] = await prisma.$transaction([ prisma.subject.findMany({ @@ -152,36 +58,115 @@ const SubjectListPage = async ({ teachers: true, grade: true, }, - orderBy: { name: "asc" }, + orderBy, take: ITEM_PER_PAGE, skip: (p - 1) * ITEM_PER_PAGE, }), - prisma.subject.count({ - where: query, - }), + prisma.subject.count({ where: query }), ]); + const grades = + role === "admin" + ? await prisma.grade.findMany({ + where: { schoolId }, + select: { id: true, level: true }, + orderBy: { level: "asc" }, + }) + : []; + return (
- {/* TOP */} -
-

All Subjects

-
+
+

{t("allSubjects")}

+ +
-
- + {role === "admin" && } + +
+ + {role === "admin" && ( - + <> + + + )}
- {/* LIST */} -
{getRecipientsPreview(item)} {item.classes.length === totalClassesCount && totalClassesCount > 0 - ? "All Classes" + ? allClassesLabel : item.classes.map((cls) => cls.name).join(", ") || "-"} @@ -154,6 +156,10 @@ const MessageListPage = async ({ }: { searchParams: PageSearchParams; }) => { + const t = await getTranslations("pages"); + const th = await getTranslations("tableHeaders"); + const filtersT = await getTranslations("filters"); + const emptyT = await getTranslations("emptyStates"); const { role, userId, schoolId } = await enforceRouteAccess("/list/messages"); const resolvedSearchParams = await searchParams; const { page, ...queryParams } = resolvedSearchParams; @@ -238,6 +244,9 @@ const MessageListPage = async ({ } const where = query; + const sortParam = getQueryParam(queryParams.sort); + const orderBy: Prisma.MessageOrderByWithRelationInput = + sortParam === "asc" ? { date: "asc" } : { date: "desc" }; const [data, count, totalClassesCount] = await prisma.$transaction([ prisma.message.findMany({ @@ -248,7 +257,7 @@ const MessageListPage = async ({ parents: { select: { id: true, name: true } }, teachers: { select: { id: true, name: true } }, }, - orderBy: { date: "desc" }, + orderBy, take: ITEM_PER_PAGE, skip: (p - 1) * ITEM_PER_PAGE, }), @@ -259,12 +268,12 @@ const MessageListPage = async ({ return (
{/* TOP */} -
-

All Messages

-
+
+

{t("allMessages")}

+
-
- +
+ {role === "admin" && ( )} @@ -273,9 +282,13 @@ const MessageListPage = async ({
{/* LIST */} renderRow(item, role, totalClassesCount)} + columns={getColumns(role, th)} + renderRow={(item) => + renderRow(item, role, totalClassesCount, filtersT("allClasses")) + } data={data} + emptyTitle={emptyT("messages")} + emptyDescription={emptyT("filterDescription")} /> {/* PAGINATION */} diff --git a/src/app/(dashboard)/list/notifications/NotificationsClient.tsx b/src/app/(dashboard)/list/notifications/NotificationsClient.tsx new file mode 100644 index 0000000..aaa7c50 --- /dev/null +++ b/src/app/(dashboard)/list/notifications/NotificationsClient.tsx @@ -0,0 +1,321 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter, usePathname, useSearchParams } from "next/navigation"; +import { + Bell, ClipboardList, MessageSquare, Megaphone, + BookOpen, Mail, CheckCheck, Search, Filter, + Clock, CheckCircle, ArrowUpDown, Trash2, + BarChart3, Calendar, Users, +} from "lucide-react"; +import Pagination from "@/components/Pagination"; +import { useTranslations } from "next-intl"; + +type NotificationType = + | "NEW_ASSIGNMENT" + | "ASSIGNMENT_UPDATED" + | "ASSIGNMENT_SUBMITTED" + | "ASSIGNMENT_FEEDBACK" + | "NEW_EXAM" + | "GRADE_POSTED" + | "GRADE_UPDATED" + | "NEW_ANNOUNCEMENT" + | "NEW_EVENT" + | "SCHEDULE_UPDATED" + | "ATTENDANCE_SAVED" + | "SUPERVISOR_ASSIGNED" + | "NEW_MESSAGE"; + +type Notification = { + id: number; + type: NotificationType; + title: string; + body: string; + link: string | null; + isRead: boolean; + createdAt: string; +}; + +type TypeOption = { value: string; labelKey: string }; + +type Props = { + notifications: Notification[]; + count: number; + page: number; + unreadCount: number; + typeOptions: TypeOption[]; + currentSearch: string; + currentType: string; + currentSort: string; +}; + +const TYPE_ICON: Record = { + NEW_ASSIGNMENT: { icon: ClipboardList, color: "text-orange-600", bg: "bg-orange-100" }, + ASSIGNMENT_UPDATED: { icon: ClipboardList, color: "text-amber-600", bg: "bg-amber-100" }, + ASSIGNMENT_SUBMITTED: { icon: ClipboardList, color: "text-green-600", bg: "bg-green-100" }, + ASSIGNMENT_FEEDBACK: { icon: MessageSquare, color: "text-indigo-600", bg: "bg-indigo-100" }, + NEW_EXAM: { icon: BookOpen, color: "text-red-600", bg: "bg-red-100" }, + GRADE_POSTED: { icon: BarChart3, color: "text-green-600", bg: "bg-green-100" }, + GRADE_UPDATED: { icon: BarChart3, color: "text-amber-600", bg: "bg-amber-100" }, + NEW_ANNOUNCEMENT: { icon: Megaphone, color: "text-purple-600", bg: "bg-purple-100" }, + NEW_EVENT: { icon: Calendar, color: "text-blue-600", bg: "bg-blue-100" }, + SCHEDULE_UPDATED: { icon: Calendar, color: "text-teal-600", bg: "bg-teal-100" }, + ATTENDANCE_SAVED: { icon: CheckCheck, color: "text-gray-600", bg: "bg-gray-100" }, + SUPERVISOR_ASSIGNED: { icon: Users, color: "text-violet-600", bg: "bg-violet-100" }, + NEW_MESSAGE: { icon: Mail, color: "text-blue-600", bg: "bg-blue-100" }, +}; + +function timeAgo(dateStr: string): string { + const diff = Date.now() - new Date(dateStr).getTime(); + const mins = Math.floor(diff / 60000); + const hours = Math.floor(diff / 3600000); + const days = Math.floor(diff / 86400000); + if (days >= 1) return `${days}d ago`; + if (hours >= 1) return `${hours}h ago`; + if (mins >= 1) return `${mins}m ago`; + return "Just now"; +} + +export default function NotificationsClient({ + notifications: initialNotifs, + count, + page, + unreadCount: initialUnread, + typeOptions, + currentSearch, + currentType, + currentSort, +}: Props) { + const t = useTranslations("pages"); + const commonT = useTranslations("common"); + const filtersT = useTranslations("filters"); + const actionsT = useTranslations("actions"); + const emptyT = useTranslations("emptyStates"); + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const [isPending, startTransition] = useTransition(); + + const [notifications, setNotifs] = useState(initialNotifs); + const [unreadCount, setUnreadCount] = useState(initialUnread); + const [search, setSearch] = useState(currentSearch); + + function navigate(updates: Record) { + const params = new URLSearchParams(searchParams.toString()); + Object.entries(updates).forEach(([k, v]) => { + if (v) params.set(k, v); else params.delete(k); + }); + params.delete("page"); + startTransition(() => router.push(`${pathname}?${params}`)); + } + + async function markRead(id: number) { + setNotifs((prev) => prev.map((n) => n.id === id ? { ...n, isRead: true } : n)); + setUnreadCount((c) => Math.max(0, c - 1)); + await fetch("/api/notifications", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id }), + }); + } + + async function markAllRead() { + setNotifs((prev) => prev.map((n) => ({ ...n, isRead: true }))); + setUnreadCount(0); + await fetch("/api/notifications", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ markAllRead: true }), + }); + } + + async function deleteOne(id: number) { + setNotifs((prev) => prev.filter((n) => n.id !== id)); + setUnreadCount((c) => { + const wasUnread = notifications.find((n) => n.id === id && !n.isRead); + return wasUnread ? Math.max(0, c - 1) : c; + }); + await fetch("/api/notifications", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id }), + }); + } + + async function deleteAll() { + setNotifs([]); + setUnreadCount(0); + await fetch("/api/notifications", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ deleteAll: true }), + }); + } + + return ( +
+ {/* ── Header — نفس هيكلية باقي الصفحات ── */} +
+

{t("allNotifications")}

+
+ {/* Search */} +
+ + setSearch(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && navigate({ search })} + className="text-sm outline-none w-full text-gray-700 placeholder-gray-400 bg-transparent" + /> +
+ +
+ {/* Type filter */} +
+ + +
+ + {/* Sort */} + + + {/* Mark all read */} + {unreadCount > 0 && ( + + )} + + {/* Delete all */} + {notifications.length > 0 && ( + + )} +
+
+
+ + {/* ── List ── */} + {isPending ? ( +
+
+
+ ) : notifications.length === 0 ? ( +
+ +

+ {emptyT("notifications")} +

+

+ {emptyT("filterDescription")} +

+
+ ) : ( +
    + {notifications.map((n) => { + const { icon: Icon, color, bg } = TYPE_ICON[n.type] ?? TYPE_ICON.NEW_MESSAGE; + const row = ( +
  • + {/* Icon */} +
    + +
    + + {/* Content */} +
    +
    +

    + {n.title} +

    +
    + + + {timeAgo(n.createdAt)} + + {!n.isRead && ( + + )} + +
    +
    +

    {n.body}

    + + + {timeAgo(n.createdAt)} + +
    + + {/* Unread dot */} + {!n.isRead && ( +
    + )} +
  • + ); + + return n.link ? ( + markRead(n.id)} className="block"> + {row} + + ) :
    {row}
    ; + })} +
+ )} + + {/* Pagination */} + {count > 0 && } +
+ ); +} diff --git a/src/app/(dashboard)/list/notifications/page.tsx b/src/app/(dashboard)/list/notifications/page.tsx new file mode 100644 index 0000000..16858d4 --- /dev/null +++ b/src/app/(dashboard)/list/notifications/page.tsx @@ -0,0 +1,75 @@ +// src/app/(dashboard)/list/notifications/page.tsx +import { enforceRouteAccess } from "@/lib/enforce-route-access"; +import { getQueryParam, type PageSearchParams } from "@/lib/pageParams"; +import prisma from "@/lib/prisma"; +import { ITEM_PER_PAGE } from "@/lib/settings"; +import { Prisma } from "@prisma/client"; +import NotificationsClient from "./NotificationsClient"; + +const TYPE_OPTIONS = [ + { value: "", labelKey: "allTypes" }, + { value: "NEW_ASSIGNMENT", labelKey: "assignments" }, + { value: "ASSIGNMENT_UPDATED", labelKey: "assignmentUpdates" }, + { value: "ASSIGNMENT_FEEDBACK", labelKey: "feedback" }, + { value: "NEW_EXAM", labelKey: "exams" }, + { value: "GRADE_POSTED", labelKey: "grades" }, + { value: "GRADE_UPDATED", labelKey: "gradeUpdates" }, + { value: "NEW_ANNOUNCEMENT", labelKey: "announcements" }, + { value: "NEW_EVENT", labelKey: "events" }, + { value: "SCHEDULE_UPDATED", labelKey: "schedule" }, + { value: "ATTENDANCE_SAVED", labelKey: "attendance" }, + { value: "SUPERVISOR_ASSIGNED", labelKey: "supervisor" }, + { value: "NEW_MESSAGE", labelKey: "messages" }, +]; + +export default async function NotificationsPage({ + searchParams, +}: { + searchParams: PageSearchParams; +}) { + const { userId } = await enforceRouteAccess("/list/notifications"); + const resolved = await searchParams; + + const p = parseInt(getQueryParam(resolved.page) ?? "1"); + const search = getQueryParam(resolved.search) ?? ""; + const type = getQueryParam(resolved.type) ?? ""; + const sort = getQueryParam(resolved.sort) ?? "desc"; + + const where: Prisma.NotificationWhereInput = { + userId, + ...(type ? { type: type as any } : {}), + ...(search ? { OR: [ + { title: { contains: search, mode: "insensitive" } }, + { body: { contains: search, mode: "insensitive" } }, + ]} : {}), + }; + + const [notifications, count, unreadCount] = await prisma.$transaction([ + prisma.notification.findMany({ + where, + orderBy: { createdAt: sort === "asc" ? "asc" : "desc" }, + take: ITEM_PER_PAGE, + skip: (p - 1) * ITEM_PER_PAGE, + }), + prisma.notification.count({ where }), + prisma.notification.count({ where: { userId, isRead: false } }), + ]); + + return ( +
+ ({ + ...n, + createdAt: n.createdAt.toISOString(), + }))} + count={count} + page={p} + unreadCount={unreadCount} + typeOptions={TYPE_OPTIONS} + currentSearch={search} + currentType={type} + currentSort={sort} + /> +
+ ); +} diff --git a/src/app/(dashboard)/list/parents/loading.tsx b/src/app/(dashboard)/list/parents/loading.tsx new file mode 100644 index 0000000..6cc68f2 --- /dev/null +++ b/src/app/(dashboard)/list/parents/loading.tsx @@ -0,0 +1,5 @@ +import LoadingIndicator from "@/components/loaders/LoadingIndicator"; + +export default function Loading() { + return ; +} diff --git a/src/app/(dashboard)/list/parents/page.tsx b/src/app/(dashboard)/list/parents/page.tsx index 6c4131d..4b3aaf6 100644 --- a/src/app/(dashboard)/list/parents/page.tsx +++ b/src/app/(dashboard)/list/parents/page.tsx @@ -1,39 +1,41 @@ +import ExportButton from "@/components/ExportButton"; import FilterSortActions from "@/components/FilterSortActions"; import FormContainer from "@/components/FormContainer"; +import ClassFilter from "@/components/ClassFilter"; +import GradeFilter from "@/components/GradeFilter"; import Pagination from "@/components/Pagination"; import Table from "@/components/Table"; import TableSearch from "@/components/TableSearch"; +import { getTranslations } from "next-intl/server"; import { enforceRouteAccess } from "@/lib/enforce-route-access"; -import { getQueryParam, type PageSearchParams } from "@/lib/pageParams"; +import { buildParentQuery } from "@/lib/query-builders/parent-query"; import prisma from "@/lib/prisma"; import { ITEM_PER_PAGE } from "@/lib/settings"; import { UserRole } from "@/lib/utils"; -import { Parent, Prisma, Student } from "@prisma/client"; +import { Parent, Student } from "@prisma/client"; +import type { PageSearchParams } from "@/lib/pageParams"; type ParentList = Parent & { students: Student[] }; -const getColumns = (role: UserRole | null) => [ +const getColumns = (role: UserRole | null, th: (key: string) => string) => [ + { header: th("info"), accessor: "info" }, { - header: "Info", - accessor: "info", - }, - { - header: "Student Names", + header: th("studentNames"), accessor: "students", className: "hidden md:table-cell", }, { - header: "Phone", + header: th("phone"), accessor: "phone", className: "hidden lg:table-cell", }, { - header: "Address", + header: th("address"), accessor: "address", className: "hidden lg:table-cell", }, { - header: role === "admin" ? "Actions" : "", + header: role === "admin" ? th("actions") : "", accessor: "action", }, ]; @@ -46,14 +48,17 @@ const renderRow = (item: ParentList, role: UserRole | null) => (
+ + + ); + const ParentListPage = async ({ searchParams, }: { searchParams: PageSearchParams; }) => { + const t = await getTranslations("pages"); + const th = await getTranslations("tableHeaders"); + const emptyT = await getTranslations("emptyStates"); const { role, userId, schoolId } = await enforceRouteAccess("/list/parents"); + + const { + query, + orderBy, + page: p, + } = await buildParentQuery({ + searchParams, + schoolId, + role, + userId, + }); + const resolvedSearchParams = await searchParams; - const { page, ...queryParams } = resolvedSearchParams; - const currentPage = getQueryParam(page); - const p = currentPage ? parseInt(currentPage) : 1; - - const query: Prisma.ParentWhereInput = { schoolId }; - const conditions: Prisma.ParentWhereInput[] = []; - - if (queryParams) { - for (const [key, rawValue] of Object.entries(queryParams)) { - const value = getQueryParam(rawValue); - - if (value !== undefined) { - switch (key) { - case "search": - conditions.push({ - name: { contains: value, mode: "insensitive" }, - }); - break; - } + + const exportQuery = new URLSearchParams( + Object.entries(resolvedSearchParams).flatMap(([key, value]) => { + if (Array.isArray(value)) { + return value.map((item) => [key, item]); } - } - } - - if (role === "teacher") { - conditions.push({ - students: { - some: { - class: { - lessons: { - some: { - teacherId: userId, - }, - }, - }, - }, - }, - }); - } - if (conditions.length > 0) { - query.AND = conditions; - } + return value ? [[key, value]] : []; + }), + ); const [data, count] = await prisma.$transaction([ prisma.parent.findMany({ @@ -122,7 +111,7 @@ const ParentListPage = async ({ include: { students: true, }, - orderBy: { name: "asc" }, + orderBy, take: ITEM_PER_PAGE, skip: (p - 1) * ITEM_PER_PAGE, }), @@ -130,26 +119,60 @@ const ParentListPage = async ({ where: query, }), ]); + const [classes, grades] = + role === "admin" + ? await prisma.$transaction([ + prisma.class.findMany({ + where: { schoolId }, + select: { id: true, name: true, gradeId: true }, + orderBy: { name: "asc" }, + }), + prisma.grade.findMany({ + where: { schoolId }, + select: { id: true, level: true }, + orderBy: { level: "asc" }, + }), + ]) + : [[], []]; + return (
- {/* TOP */} -
-

All Parents

-
+
+

{t("allParents")}

+ +
-
- - {role === "admin" && } + {role === "admin" && ( + <> + + + + )} + +
+ + + {role === "admin" && ( + <> + + + + + )}
- {/* LIST */} +

{item.name}

-

{item?.email}

+

{item.email}

- {item.students.map((s) => s.name).join(",")} + {item.students.map((s) => s.name).join(", ")} {item.phone} {item.address}
{role === "admin" && ( @@ -66,55 +71,39 @@ const renderRow = (item: ParentList, role: UserRole | null) => (
renderRow(item, role)} data={data} + emptyTitle={emptyT("parents")} + emptyDescription={emptyT("filterDescription")} /> - {/* PAGINATION */} + ); diff --git a/src/app/(dashboard)/list/results/loading.tsx b/src/app/(dashboard)/list/results/loading.tsx new file mode 100644 index 0000000..6cc68f2 --- /dev/null +++ b/src/app/(dashboard)/list/results/loading.tsx @@ -0,0 +1,5 @@ +import LoadingIndicator from "@/components/loaders/LoadingIndicator"; + +export default function Loading() { + return ; +} diff --git a/src/app/(dashboard)/list/results/page.tsx b/src/app/(dashboard)/list/results/page.tsx index e50f307..b175b37 100644 --- a/src/app/(dashboard)/list/results/page.tsx +++ b/src/app/(dashboard)/list/results/page.tsx @@ -1,80 +1,55 @@ +import ExportButton from "@/components/ExportButton"; import FilterSortActions from "@/components/FilterSortActions"; import FormContainer from "@/components/FormContainer"; +import ClassFilter from "@/components/ClassFilter"; +import GradeFilter from "@/components/GradeFilter"; import NoCurrentAcademicYearMessage from "@/components/NoCurrentAcademicYearMessage"; import Pagination from "@/components/Pagination"; import Table from "@/components/Table"; import TableSearch from "@/components/TableSearch"; +import { getTranslations } from "next-intl/server"; import { enforceRouteAccess } from "@/lib/enforce-route-access"; -import { getQueryParam, type PageSearchParams } from "@/lib/pageParams"; +import { buildResultQuery } from "@/lib/query-builders/result-query"; import prisma from "@/lib/prisma"; import { ITEM_PER_PAGE } from "@/lib/settings"; -import { getCurrentAcademicYearIdOrNull } from "@/lib/academicYears"; -import { Prisma } from "@prisma/client"; import { UserRole } from "@/lib/utils"; - -type ResultList = { - id: number; - title: string; - subjectName: string; - studentId: string; - studentName: string; - teacherName: string; - score: number; - className: string; - startTime: Date; - examId: number | null; - assignmentId: number | null; +import { Assignment, Exam, Result, Student } from "@prisma/client"; +import Image from "next/image"; +import type { PageSearchParams } from "@/lib/pageParams"; + +type ResultList = Result & { + student: Pick; + exam: Pick | null; + assignment: Pick | null; }; -const getColumns = (role: UserRole | null) => { +const getColumns = (role: UserRole | null, th: (key: string) => string) => { const columns = [ { - header: "Title", - accessor: "name", - }, - ...(role !== "student" - ? [ - { - header: "Student", - accessor: "student", - }, - ] - : []), - { - header: "Subject", - accessor: "subject", - className: "hidden md:table-cell", + header: th("assessment"), + accessor: "assessment", }, { - header: "Score", + header: th("score"), accessor: "score", className: "hidden md:table-cell", }, - ...(role !== "teacher" - ? [ - { - header: "Teacher", - accessor: "teacher", - className: "hidden md:table-cell", - }, - ] - : []), - { - header: "Class", - accessor: "class", - className: "hidden md:table-cell", - }, - { - header: "Date", - accessor: "date", - className: "hidden md:table-cell", - }, - { - header: role === "admin" || role === "teacher" ? "Actions" : "", - accessor: "action", - }, ]; + if (role !== "student") { + columns.unshift({ + header: th("student"), + accessor: "student", + }); + } + + if (role === "admin" || role === "teacher") { + columns.push({ + header: th("actions"), + accessor: "action", + }); + } + return columns; }; @@ -83,140 +58,95 @@ const renderRow = (item: ResultList, role: UserRole | null) => ( key={item.id} className="hover:bg-academixPurpleLight even:bg-slate-50 border-gray-200 border-b text-sm" > - - {role !== "student" && } - - - {role !== "teacher" && ( - + {role !== "student" && ( + )} - - - + + + + {(role === "admin" || role === "teacher") && ( + + )} ); + const ResultListPage = async ({ searchParams, }: { searchParams: PageSearchParams; }) => { + const t = await getTranslations("pages"); + const th = await getTranslations("tableHeaders"); + const emptyT = await getTranslations("emptyStates"); const { role, userId, schoolId } = await enforceRouteAccess("/list/results"); + const resolvedSearchParams = await searchParams; - const { page, ...queryParams } = resolvedSearchParams; - const currentPage = getQueryParam(page); - const p = currentPage ? parseInt(currentPage) : 1; - const academicYearId = await getCurrentAcademicYearIdOrNull(schoolId); - if (!academicYearId) { + const { + academicYearId, + query, + orderBy, + page: p, + } = await buildResultQuery({ + searchParams, + schoolId, + role, + userId, + }); + + if (!academicYearId || !query) { return ; } - const query: Prisma.ResultWhereInput = { schoolId, academicYearId }; - const conditions: Prisma.ResultWhereInput[] = []; - if (queryParams) { - for (const [key, rawValue] of Object.entries(queryParams)) { - const value = getQueryParam(rawValue); - - if (value !== undefined) { - switch (key) { - case "studentId": - conditions.push({ - studentId: value, - }); - break; - - case "search": - conditions.push({ - OR: [ - { - student: { - name: { contains: value, mode: "insensitive" }, - }, - }, - { - exam: { - title: { contains: value, mode: "insensitive" }, - }, - }, - ], - }); - break; - } + const exportQuery = new URLSearchParams( + Object.entries(resolvedSearchParams).flatMap(([key, value]) => { + if (Array.isArray(value)) { + return value.map((item) => [key, item]); } - } - } - - switch (role) { - case "admin": - break; - case "teacher": - conditions.push({ - OR: [ - { exam: { lesson: { teacherId: userId } } }, - { assignment: { lesson: { teacherId: userId } } }, - ], - }); - break; - - case "student": - conditions.push({ - studentId: userId, - }); - break; - - case "parent": - conditions.push({ - student: { - parentId: userId, - }, - }); - break; - } + return value ? [[key, value]] : []; + }), + ); - if (conditions.length > 0) { - query.AND = conditions; - } - const [dataRes, count] = await prisma.$transaction([ + const [data, count] = await prisma.$transaction([ prisma.result.findMany({ where: query, include: { - student: { select: { name: true } }, + student: { + select: { + name: true, + }, + }, exam: { - include: { - lesson: { - select: { - subject: { select: { name: true } }, - teacher: { select: { name: true } }, - class: { select: { name: true } }, - }, - }, + select: { + title: true, }, }, assignment: { - include: { - lesson: { - select: { - subject: { select: { name: true } }, - teacher: { select: { name: true } }, - class: { select: { name: true } }, - }, - }, + select: { + title: true, }, }, }, - orderBy: { id: "desc" }, + orderBy, take: ITEM_PER_PAGE, skip: (p - 1) * ITEM_PER_PAGE, }), @@ -224,52 +154,62 @@ const ResultListPage = async ({ where: query, }), ]); - - const data = dataRes.flatMap((item) => { - const assesment = item.exam || item.assignment; - - if (!assesment) return []; - - const isExam = "startTime" in assesment; - return [ - { - id: item.id, - title: assesment.title, - subjectName: assesment.lesson.subject.name, - studentId: item.studentId, - studentName: item.student.name, - teacherName: assesment.lesson.teacher.name, - score: item.score, - className: assesment.lesson.class.name, - startTime: isExam ? assesment.startTime : assesment.startDate, - examId: item.examId, - assignmentId: item.assignmentId, - }, - ]; - }); + const [classes, grades] = + role === "admin" + ? await prisma.$transaction([ + prisma.class.findMany({ + where: { schoolId }, + select: { id: true, name: true, gradeId: true }, + orderBy: { name: "asc" }, + }), + prisma.grade.findMany({ + where: { schoolId }, + select: { id: true, level: true }, + orderBy: { level: "asc" }, + }), + ]) + : [[], []]; return (
- {/* TOP */} -
-

All Results

-
+
+

{t("allResults")}

+ +
-
- + {role === "admin" && ( + <> + + + + )} + +
+ + {(role === "admin" || role === "teacher") && ( - + <> + {role === "admin" && ( + + )} + + + )}
- {/* LIST */} +
{item.title}{item.studentName}{item.subjectName}{item.score}{item.teacherName} + + {item.student.name} + {item.className} - {new Intl.DateTimeFormat("en-US").format(item.startTime)} - -
- {(role === "admin" || role === "teacher") && ( - <> - - - - )} -
+ +
+ {item.exam?.title || item.assignment?.title || "-"} {item.score} +
+ + +
+
renderRow(item, role)} data={data} + emptyTitle={emptyT("results")} + emptyDescription={emptyT("filterDescription")} /> - {/* PAGINATION */} + ); diff --git a/src/app/(dashboard)/list/students/[id]/page.tsx b/src/app/(dashboard)/list/students/[id]/page.tsx index 6eddc9a..3c6bb94 100644 --- a/src/app/(dashboard)/list/students/[id]/page.tsx +++ b/src/app/(dashboard)/list/students/[id]/page.tsx @@ -1,15 +1,25 @@ -import Announcements from "@/components/Announcements"; +import Announcements from "@/components/Announcements"; import BigCalendarContainer from "@/components/BigCalendarContainer"; import FormContainer from "@/components/FormContainer"; import UserActionButtons from "@/components/UserActionButtons"; -import Performance from "@/components/Performance"; -import StudentAttendanceCard from "@/components/StudentAttendanceCard"; +import AttendanceCard from "@/components/AttendanceCard"; import { enforceRouteAccess } from "@/lib/enforce-route-access"; import prisma from "@/lib/prisma"; import { Prisma } from "@prisma/client"; +import { + BookOpen, + Calendar, + CalendarCheck2, + Droplet, + GraduationCap, + Mail, + Phone, + School, +} from "lucide-react"; import Image from "next/image"; import Link from "next/link"; import { notFound } from "next/navigation"; +import { getTranslations } from "next-intl/server"; import { Suspense } from "react"; const SingleStudentPage = async ({ @@ -17,6 +27,8 @@ const SingleStudentPage = async ({ }: { params: Promise<{ id: string }>; }) => { + const t = await getTranslations("profilePages"); + const statesT = await getTranslations("states"); const { id } = await params; const { role, userId, schoolId } = await enforceRouteAccess(`/list/students`); @@ -78,7 +90,7 @@ const SingleStudentPage = async ({
{/* USER INFO CARD */}
-
+
{role === "admin" && ( )} @@ -101,28 +113,25 @@ const SingleStudentPage = async ({

{student.name}

-

- Lorem ipsum, dolor sit amet consectetur adipisicing elit. -

- + {student.bloodType}
- + {new Intl.DateTimeFormat("en-US").format(student.birthday)}
- + {student.email || "-"}
- + {student.phone || "-"}
@@ -134,112 +143,90 @@ const SingleStudentPage = async ({
- - - + + +
- Attendance + + {t("cards.attendance")} +
{/* CARD */}
- {" "} +

{student.class?.name.charAt(0)}

- Grade + {t("cards.grade")}
{/* CARD */}
- +

{student.class._count.lessons}

- Lessons + + {t("cards.lessons")} +
{/* CARD */}
- {" "} - +

{student.class?.name}

- Class + {t("cards.class")}
{/* BOTTOM */}
-

Student's Schedule

+

{t("student.schedule")}

{/* RIGHT */}
-

Shortcuts

-
+

{t("shortcuts.title")}

+
- Student's Lessons + {t("student.lessons")} - Student's Teachers + {t("student.teachers")} - Student's Exams + {t("student.exams")} - Student's Assignments + {t("student.assignments")} - Student's Results + {t("student.results")}
-
diff --git a/src/app/(dashboard)/list/students/loading.tsx b/src/app/(dashboard)/list/students/loading.tsx new file mode 100644 index 0000000..6cc68f2 --- /dev/null +++ b/src/app/(dashboard)/list/students/loading.tsx @@ -0,0 +1,5 @@ +import LoadingIndicator from "@/components/loaders/LoadingIndicator"; + +export default function Loading() { + return ; +} diff --git a/src/app/(dashboard)/list/students/page.tsx b/src/app/(dashboard)/list/students/page.tsx index ae8f256..e97b355 100644 --- a/src/app/(dashboard)/list/students/page.tsx +++ b/src/app/(dashboard)/list/students/page.tsx @@ -1,53 +1,52 @@ +import ExportButton from "@/components/ExportButton"; +import FilterSortActions from "@/components/FilterSortActions"; import FormContainer from "@/components/FormContainer"; import PromoteStudentsButton from "@/components/PromoteStudentsButton"; import Pagination from "@/components/Pagination"; import Table from "@/components/Table"; import TableSearch from "@/components/TableSearch"; -import StudentsFilters from "@/components/StudentsFilters"; -import { - getAcademicYears, - getCurrentAcademicYearOrNull, -} from "@/lib/academicYears"; +import { getTranslations } from "next-intl/server"; +import { getCurrentAcademicYearOrNull } from "@/lib/academicYears"; import { enforceRouteAccess } from "@/lib/enforce-route-access"; -import { getQueryParam, type PageSearchParams } from "@/lib/pageParams"; +import { buildStudentQuery } from "@/lib/query-builders/student-query"; import prisma from "@/lib/prisma"; import { ITEM_PER_PAGE } from "@/lib/settings"; -import { Class, Prisma, Student, StudentStatus } from "@prisma/client"; +import { Class, Student } from "@prisma/client"; import { Eye } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; import NoCurrentAcademicYearMessage from "@/components/NoCurrentAcademicYearMessage"; import { UserRole } from "@/lib/utils"; +import type { PageSearchParams } from "@/lib/pageParams"; +import GradeFilter from "@/components/GradeFilter"; +import ClassFilter from "@/components/ClassFilter"; type StudentList = Student & { class: Class }; -const getColumns = (role: UserRole | null) => [ +const getColumns = (role: UserRole | null, th: (key: string) => string) => [ + { header: th("info"), accessor: "info" }, { - header: "Info", - accessor: "info", - }, - { - header: "Student ID", + header: th("studentId"), accessor: "studentId", className: "hidden md:table-cell", }, { - header: "Grade", + header: th("grade"), accessor: "grade", className: "hidden md:table-cell", }, { - header: "Phone", + header: th("phone"), accessor: "phone", className: "hidden lg:table-cell", }, { - header: "Address", + header: th("address"), accessor: "address", className: "hidden lg:table-cell", }, { - header: role === "admin" ? "Actions" : "", + header: role === "admin" ? th("actions") : "", accessor: "action", }, ]; @@ -70,17 +69,21 @@ const renderRow = (item: StudentList, role: UserRole | null) => (

{item.class.name}

+
+ ); + const StudentListPage = async ({ searchParams, }: { searchParams: PageSearchParams; }) => { + const t = await getTranslations("pages"); + const th = await getTranslations("tableHeaders"); + const emptyT = await getTranslations("emptyStates"); const { role, userId, schoolId } = await enforceRouteAccess("/list/students"); + const currentAcademicYear = await getCurrentAcademicYearOrNull(schoolId); const academicYearId = currentAcademicYear?.id ?? null; - const academicYears = - role === "admin" ? await getAcademicYears(schoolId) : []; if (!academicYearId || !currentAcademicYear) { return ; } - const resolvedSearchParams = await searchParams; - const { page, ...queryParams } = resolvedSearchParams; - const currentPage = getQueryParam(page); - const p = currentPage ? parseInt(currentPage) : 1; - - const academicYearParam = getQueryParam(queryParams.academicYearId); - const statusParam = getQueryParam(queryParams.status); - const repeatCountParam = getQueryParam(queryParams.repeatCount); - const selectedAcademicYearId = academicYearParam - ? Number.parseInt(academicYearParam, 10) - : academicYearId; - - const validStatuses: StudentStatus[] = [ - "ACTIVE", - "REPEATED", - "GRADUATED", - "LEFT", - ]; - const selectedStatus = validStatuses.includes(statusParam as StudentStatus) - ? (statusParam as StudentStatus) - : "ACTIVE"; - - const selectedRepeatCount = - repeatCountParam && selectedStatus === "REPEATED" - ? Number.parseInt(repeatCountParam, 10) - : 1; - - const query: Prisma.StudentWhereInput = { + const { + query, + orderBy, + page: p, + } = await buildStudentQuery({ + searchParams, schoolId, - academicYears: { - some: { - academicYearId: selectedAcademicYearId, - }, - }, - status: selectedStatus, - }; + currentAcademicYearId: academicYearId, + role, + userId, + }); + + const resolvedSearchParams = await searchParams; - const conditions: Prisma.StudentWhereInput[] = []; + const exportQuery = new URLSearchParams( + Object.entries(resolvedSearchParams).flatMap(([key, value]) => { + if (Array.isArray(value)) { + return value.map((item) => [key, item]); + } - if (selectedStatus === "REPEATED") { - query.repeatCount = Number.isNaN(selectedRepeatCount) - ? 1 - : selectedRepeatCount; - } + return value ? [[key, value]] : []; + }), + ); + + exportQuery.set("academicYearId", String(academicYearId)); - if (queryParams) { - for (const [key, rawValue] of Object.entries(queryParams)) { - const value = getQueryParam(rawValue); - - if (value !== undefined) { - switch (key) { - case "teacherId": - conditions.push({ - class: { - lessons: { - some: { teacherId: value }, - }, - }, - }); - break; - - case "search": - conditions.push({ - name: { contains: value, mode: "insensitive" }, - }); - break; + const classWhere = + role === "teacher" + ? { + schoolId, + OR: [ + { teachers: { some: { id: userId } } }, + { lessons: { some: { teacherId: userId } } }, + ], } - } - } - } + : { schoolId }; - switch (role) { - case "admin": - break; - - case "teacher": - conditions.push({ - class: { - lessons: { - some: { teacherId: userId }, - }, - }, - }); - break; - } + const classes = await prisma.class.findMany({ + where: classWhere, + select: { + id: true, + name: true, + gradeId: true, + }, + orderBy: { + name: "asc", + }, + }); - if (conditions.length > 0) { - query.AND = conditions; - } + const teacherGradeIds = + role === "teacher" + ? Array.from(new Set(classes.map((classItem) => classItem.gradeId))) + : []; + + const grades = await prisma.grade.findMany({ + where: { + schoolId, + ...(role === "teacher" ? { id: { in: teacherGradeIds } } : {}), + }, + select: { + id: true, + level: true, + }, + orderBy: { + level: "asc", + }, + }); const [data, count] = await prisma.$transaction([ prisma.student.findMany({ @@ -199,7 +183,7 @@ const StudentListPage = async ({ include: { class: true, }, - orderBy: { name: "asc" }, + orderBy, take: ITEM_PER_PAGE, skip: (p - 1) * ITEM_PER_PAGE, }), @@ -209,41 +193,64 @@ const StudentListPage = async ({ ]); return ( -
- {/* TOP */} -
-

All Students

-
+
+ + {/* HEADER */} +
+ + {/* TOP ROW */} +
+

{t("allStudents")}

+ +
+ + + {role === "admin" && ( - + <> + + + + )} -
- {role === "admin" && ( - <> - - - - )} -
- {/* LIST */} -
{item.username} {item.class.name[0]} {item.phone} {item.address}
- + {role === "admin" && ( )} @@ -88,110 +91,91 @@ const renderRow = (item: StudentList, role: UserRole | null) => (
renderRow(item, role)} - data={data} - /> - {/* PAGINATION */} + + {/* FILTERS */} +
+ + + + + +
+ + + {/* TABLE */} +
renderRow(item, role)} + data={data} + emptyTitle={emptyT("students")} + emptyDescription={emptyT("filterDescription")} + /> + + {/* PAGINATION */} +
+ {role === "admin" && ( + + )} +
- ); + +); }; export default StudentListPage; diff --git a/src/app/(dashboard)/list/subjects/[id]/loading.tsx b/src/app/(dashboard)/list/subjects/[id]/loading.tsx new file mode 100644 index 0000000..6cc68f2 --- /dev/null +++ b/src/app/(dashboard)/list/subjects/[id]/loading.tsx @@ -0,0 +1,5 @@ +import LoadingIndicator from "@/components/loaders/LoadingIndicator"; + +export default function Loading() { + return ; +} diff --git a/src/app/(dashboard)/list/subjects/[id]/page.tsx b/src/app/(dashboard)/list/subjects/[id]/page.tsx new file mode 100644 index 0000000..b61ef77 --- /dev/null +++ b/src/app/(dashboard)/list/subjects/[id]/page.tsx @@ -0,0 +1,667 @@ +import { notFound } from "next/navigation"; +import Link from "next/link"; +import Image from "next/image"; +import type { ElementType, ReactNode } from "react"; +import { + BookOpen, + Users, + ClipboardList, + FileText, + ChevronRight, + GraduationCap, +} from "lucide-react"; +import prisma from "@/lib/prisma"; +import { requireAuth } from "@/lib/auth"; +import StudyMaterialList, { + StudyMaterialItem, +} from "@/components/StudyMaterialList"; +import SubjectPageEditor from "@/components/SubjectPageEditor"; +import SubjectDetailsTabs from "@/components/SubjectDetailsTabs"; +import FormContainer from "@/components/FormContainer"; +import { getTranslations } from "next-intl/server"; + +// ─── Stat card ──────────────────────────────────────────────────────────────── +function StatCard({ + icon: Icon, + label, + value, + color, +}: { + icon: ElementType; + label: string; + value: number | string; + color: string; +}) { + return ( +
+
+ +
+
+

{value}

+

{label}

+
+
+ ); +} + +// ─── Day label map ──────────────────────────────────────────────────────────── +// ─── Section renderers ──────────────────────────────────────────────────────── +function AssignmentsSection({ + assignments, + createAction, + emptyLabel, + labels, +}: { + assignments: { + id: number; + title: string; + description: string | null; + endDate: Date; + fileUrl: string | null; + class: { name: string } | null; + lesson: { class: { name: string } } | null; + }[]; + createAction?: ReactNode; + emptyLabel: string; + labels: { + assignments: string; + manageAssignments: string; + class: string; + due: string; + overdue: string; + download: string; + downloadAssignmentFile: string; + }; +}) { + return ( +
+
+
+ +

+ {labels.assignments} +

+ + ({assignments.length}) + +
+
+ + {labels.manageAssignments} + + {createAction} +
+
+ {assignments.length === 0 ? ( +
+

{emptyLabel}

+
+ ) : ( +
    + {assignments.map((a) => { + const isOverdue = new Date(a.endDate) < new Date() && !a.fileUrl; + return ( +
  • +
    +

    + {a.title} +

    + {a.description && ( +

    + {a.description} +

    + )} +
    + + {labels.class}: {a.class?.name ?? a.lesson?.class.name ?? "-"} + + · + + {labels.due}:{" "} + {new Date(a.endDate).toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + })} + + {isOverdue && ( + + {labels.overdue} + + )} +
    +
    + {a.fileUrl && ( + + + {labels.download} + + )} +
  • + ); + })} +
+ )} +
+ ); +} + +function ExamsSection({ + exams, + createAction, + tableHeaders, + labels, +}: { + exams: { + id: number; + title: string; + startTime: Date; + endTime: Date; + class: { name: string } | null; + }[]; + createAction?: ReactNode; + tableHeaders: { + title: string; + class: string; + startTime: string; + endTime: string; + }; + labels: { + exams: string; + manageExams: string; + noExams: string; + }; +}) { + return ( +
+
+
+ +

+ {labels.exams} +

+ ({exams.length}) +
+
+ + {labels.manageExams} + + {createAction} +
+
+ {exams.length === 0 ? ( +
+

{labels.noExams}

+
+ ) : ( +
+
+ + + + + + + + + + {exams.map((exam) => ( + + + + + + + ))} + +
{tableHeaders.title}{tableHeaders.class}{tableHeaders.startTime}{tableHeaders.endTime}
+ {exam.title} + + {exam.class?.name ?? "-"} + + {new Date(exam.startTime).toLocaleString("en-GB", { + day: "numeric", + month: "short", + hour: "2-digit", + minute: "2-digit", + })} + + {new Date(exam.endTime).toLocaleString("en-GB", { + day: "numeric", + month: "short", + hour: "2-digit", + minute: "2-digit", + })} +
+
+ )} +
+ ); +} +// ─── Page ───────────────────────────────────────────────────────────────────── +export default async function SubjectDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const th = await getTranslations("tableHeaders"); + const emptyT = await getTranslations("emptyStates"); + const subjectT = await getTranslations("subjectDetails"); + const { id } = await params; + const subjectId = Number(id); + if (isNaN(subjectId)) notFound(); + + const { userId, role, schoolId } = await requireAuth(); + const roleStr = role as string; + + // جلب المادة مع كل البيانات + إعدادات الصفحة + const subject = await prisma.subject.findFirst({ + where: { + id: subjectId, + schoolId, + ...(roleStr === "student" + ? { + grade: { + students: { + some: { + id: userId, + }, + }, + }, + } + : {}), + }, + include: { + grade: { select: { level: true } }, + teachers: { + select: { id: true, name: true, img: true }, + }, + studyMaterials: { + include: { + teacher: { select: { id: true, name: true } }, + }, + orderBy: { createdAt: "desc" }, + }, + assignments: { + select: { + id: true, + title: true, + description: true, + startDate: true, + endDate: true, + fileUrl: true, + fileName: true, + class: { select: { name: true } }, + lesson: { + select: { class: { select: { name: true } } }, + }, + }, + orderBy: { id: "desc" }, + }, + exams: { + select: { + id: true, + title: true, + startTime: true, + endTime: true, + class: { select: { name: true } }, + }, + orderBy: { startTime: "asc" }, + }, + // جلب إعدادات الصفحة — المعلم يجلب إعداداته، الأدمن والطالب يجلبون أول إعداد موجود + pageSettings: + roleStr === "teacher" && userId + ? { where: { teacherId: userId }, take: 1 } + : { take: 1 }, + }, + }); + + if (!subject) notFound(); + + const classCount = new Set([ + ...subject.assignments + .map((assignment) => assignment.class?.name ?? assignment.lesson?.class.name) + .filter((className): className is string => !!className), + ...subject.exams + .map((exam) => exam.class?.name) + .filter((className): className is string => !!className), + ]).size; + + const materials: StudyMaterialItem[] = subject.studyMaterials.map((m) => ({ + ...m, + createdAt: new Date(m.createdAt), + })); + + // إعدادات الصفحة + const pageSettings = subject.pageSettings[0] ?? null; + const sectionsOrder: string[] = ( + (pageSettings?.sectionsOrder as string[]) ?? [ + "materials", + "assignments", + "exams", + ] + ).map((section) => (section === "lessons" ? "exams" : section)); + + const isAuthorized = roleStr === "teacher" || roleStr === "admin"; // الأدمن له نفس صلاحيات المعلم + + // خريطة الأقسام + const sectionMap: Record = { + assignments: ( + + ) : undefined + } + /> + ), + exams: ( + + {subjectT("createExam")} + + ) : undefined + } + /> + ), + materials: ( + + ), + }; + + return ( +
+ {/* Breadcrumb */} + + + {/* Banner Image */} + {pageSettings?.bannerImage && ( +
+ {`${subject.name} +
+ )} + + {/* Header */} +
+
+
+ +
+
+

+ {subject.name} +

+

+ {subjectT("gradeLevel", { level: subject.grade.level })} ·{" "} + {subjectT("teacherCount", { count: subject.teachers.length })} +

+ {/* Course description */} + {pageSettings?.description && ( +

+ {pageSettings.description} +

+ )} +
+
+ + {/* Edit Page button — teachers only */} + {isAuthorized && ( + + )} +
+ + {/* Announcement banner */} + {pageSettings?.announcement && ( +
+ 📢 {pageSettings.announcement} +
+ )} + + {/* Stats */} +
+ + + + +
+ + {/* Main grid */} +
+
+ +
+ + {/* Right column (1/3) */} +
+ {/* Subject info card */} +
+

+ {subjectT("subjectInfo")} +

+
+
+
{subjectT("grade")}
+
+ {subjectT("gradeLevel", { level: subject.grade.level })} +
+
+
+
{subjectT("classes")}
+
{classCount}
+
+
+
{subjectT("assignments")}
+
+ {subject.assignments.length} +
+
+
+
{subjectT("materials")}
+
+ {subject.studyMaterials.length} +
+
+
+
{subjectT("exams")}
+
+ {subject.exams.length} +
+
+
+
+ + {roleStr === "admin" && ( +
+

+ + {subjectT("teachers")} +

+ {subject.teachers.length === 0 ? ( +

+ {emptyT("teachersAssigned")} +

+ ) : ( +
    + {subject.teachers.map((t) => ( +
  • + + {t.img ? ( + {t.name} + ) : ( +
    + {t.name[0]} +
    + )} + + {t.name} + + +
  • + ))} +
+ )} +
+ )} + {roleStr === "student" && subject.teachers.length > 0 && ( +
+

+ + {subjectT("yourTeachers", { count: subject.teachers.length })} +

+
    + {subject.teachers.map((t) => ( +
  • + {t.img ? ( + {t.name} + ) : ( +
    + {t.name[0]} +
    + )} +
    +

    + {t.name} +

    +

    + {subjectT("subjectTeacher")} +

    +
    +
  • + ))} +
+
+ )} +
+
+
+ ); +} diff --git a/src/app/(dashboard)/list/subjects/loading.tsx b/src/app/(dashboard)/list/subjects/loading.tsx new file mode 100644 index 0000000..6cc68f2 --- /dev/null +++ b/src/app/(dashboard)/list/subjects/loading.tsx @@ -0,0 +1,5 @@ +import LoadingIndicator from "@/components/loaders/LoadingIndicator"; + +export default function Loading() { + return ; +} diff --git a/src/app/(dashboard)/list/subjects/page.tsx b/src/app/(dashboard)/list/subjects/page.tsx index 773519e..d0e5422 100644 --- a/src/app/(dashboard)/list/subjects/page.tsx +++ b/src/app/(dashboard)/list/subjects/page.tsx @@ -1,149 +1,55 @@ +import ExportButton from "@/components/ExportButton"; import FilterSortActions from "@/components/FilterSortActions"; import FormContainer from "@/components/FormContainer"; +import GradeFilter from "@/components/GradeFilter"; import Pagination from "@/components/Pagination"; -import Table from "@/components/Table"; import TableSearch from "@/components/TableSearch"; +import { getTranslations } from "next-intl/server"; import { enforceRouteAccess } from "@/lib/enforce-route-access"; -import { getQueryParam, type PageSearchParams } from "@/lib/pageParams"; +import { buildSubjectQuery } from "@/lib/query-builders/subject-query"; import prisma from "@/lib/prisma"; import { ITEM_PER_PAGE } from "@/lib/settings"; import { UserRole } from "@/lib/utils"; -import { Prisma, Subject, Teacher } from "@prisma/client"; +import { Subject, Teacher } from "@prisma/client"; +import type { PageSearchParams } from "@/lib/pageParams"; +import Link from "next/link"; +import { BookText } from "lucide-react"; type SubjectList = Subject & { teachers: Teacher[]; grade: { id: number; level: number } | null; }; -const getColumns = (role: UserRole | null) => [ - { - header: "Subject Name", - accessor: "name", - }, - { - header: "Grade", - accessor: "grade", - className: "hidden md:table-cell", - }, - { - header: "Teachers", - accessor: "teachers", - className: "hidden md:table-cell", - }, - { - header: role === "admin" ? "Actions" : "", - accessor: "action", - }, -]; - -const renderRow = (item: SubjectList, role: UserRole | null) => ( -
{item.name}{item.grade?.level ?? "-"} - {item.teachers.map((t) => t.name).join(", ")} - -
- {role === "admin" && ( - <> - - - - )} -
-
renderRow(item, role)} - data={data} - /> - {/* PAGINATION */} + + {data.length === 0 ? ( +
+

+ {emptyT("subjects")} +

+

+ {emptyT("filterDescription")} +

+
+ ) : ( +
+ {data.map((item: SubjectList) => ( +
+ + +
+
+ {" "} +
+ + {item.name} +
+ {role === "admin" && ( +
+ + +
+ )} +
+ +
+

+ Grade:{" "} + + {item.grade?.level ?? "-"} + +

+ +

+ Teachers:{" "} + + {item.teachers.length > 0 + ? item.teachers + .map((teacher) => teacher.name) + .join(", ") + : "-"} + +

+
+
+
+ ))} +
+ )} + ); diff --git a/src/app/(dashboard)/list/teachers/[id]/page.tsx b/src/app/(dashboard)/list/teachers/[id]/page.tsx index dd63b75..b60843a 100644 --- a/src/app/(dashboard)/list/teachers/[id]/page.tsx +++ b/src/app/(dashboard)/list/teachers/[id]/page.tsx @@ -2,19 +2,33 @@ import Announcements from "@/components/Announcements"; import BigCalendarContainer from "@/components/BigCalendarContainer"; import FormContainer from "@/components/FormContainer"; import UserActionButtons from "@/components/UserActionButtons"; -import Performance from "@/components/Performance"; +import AttendanceCard from "@/components/AttendanceCard"; import { enforceRouteAccess } from "@/lib/enforce-route-access"; import prisma from "@/lib/prisma"; import { Prisma } from "@prisma/client"; +import { + BookOpen, + Calendar, + CalendarCheck2, + Droplet, + GraduationCap, + Mail, + Phone, + School, +} from "lucide-react"; import Image from "next/image"; import Link from "next/link"; import { notFound } from "next/navigation"; +import { getTranslations } from "next-intl/server"; +import { Suspense } from "react"; const SingleTeacherPage = async ({ params, }: { params: Promise<{ id: string }>; }) => { + const t = await getTranslations("profilePages"); + const statesT = await getTranslations("states"); const { id } = await params; const { role, schoolId } = await enforceRouteAccess(`/list/teachers`); @@ -50,7 +64,7 @@ const SingleTeacherPage = async ({
{/* USER INFO CARD */}
-
+
{role === "admin" && ( )} @@ -73,28 +87,25 @@ const SingleTeacherPage = async ({

{teacher.name}

-

- Lorem ipsum, dolor sit amet consectetur adipisicing elit. -

- + {teacher.bloodType}
- + {new Intl.DateTimeFormat("en-US").format(teacher.birthday)}
- + {teacher.email || "-"}
- + {teacher.phone || "-"}
@@ -105,108 +116,90 @@ const SingleTeacherPage = async ({ {/* CARD */}
- -

90%

+ + + +
- Attendance + + {t("cards.attendance")} +
{/* CARD */}
- {" "} - +

{teacher._count.subjects}

- Branches + + {t("cards.subjects")} +
{/* CARD */}
- {" "} - +

{teacher._count.lessons}

- Lessons + + {t("cards.lessons")} +
{/* CARD */}
- {" "} - {" "} +

{teacher._count.classes}

- Classes + + {t("cards.classes")} +
{/* BOTTOM */}
-

Teacher's Schedule

+

{t("teacher.schedule")}

{/* RIGHT */}
-

Shortcuts

-
+

{t("shortcuts.title")}

+
- Teacher's Classes + {t("teacher.classes")} - Teacher's Students + {t("teacher.students")} - Teacher's Exams + {t("teacher.exams")} - Teacher's Assignments + {t("teacher.assignments")}
-
diff --git a/src/app/(dashboard)/list/teachers/loading.tsx b/src/app/(dashboard)/list/teachers/loading.tsx new file mode 100644 index 0000000..6cc68f2 --- /dev/null +++ b/src/app/(dashboard)/list/teachers/loading.tsx @@ -0,0 +1,5 @@ +import LoadingIndicator from "@/components/loaders/LoadingIndicator"; + +export default function Loading() { + return ; +} diff --git a/src/app/(dashboard)/list/teachers/page.tsx b/src/app/(dashboard)/list/teachers/page.tsx index 2dd78c9..b57072a 100644 --- a/src/app/(dashboard)/list/teachers/page.tsx +++ b/src/app/(dashboard)/list/teachers/page.tsx @@ -1,49 +1,50 @@ +import ExportButton from "@/components/ExportButton"; import FilterSortActions from "@/components/FilterSortActions"; import FormContainer from "@/components/FormContainer"; import Pagination from "@/components/Pagination"; import Table from "@/components/Table"; import TableSearch from "@/components/TableSearch"; +import { getTranslations } from "next-intl/server"; import { enforceRouteAccess } from "@/lib/enforce-route-access"; -import { getQueryParam, type PageSearchParams } from "@/lib/pageParams"; +import { buildTeacherQuery } from "@/lib/query-builders/teacher-query"; import prisma from "@/lib/prisma"; import { ITEM_PER_PAGE } from "@/lib/settings"; import { UserRole } from "@/lib/utils"; -import { Prisma, Subject, Teacher } from "@prisma/client"; +import { Subject, Teacher } from "@prisma/client"; import { Eye } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; - +import type { PageSearchParams } from "@/lib/pageParams"; +import SubjectFilter from "@/components/SubjectFilter"; +import ClassFilter from "@/components/ClassFilter"; type TeacherList = Teacher & { subjects: Subject[]; }; -const getColumns = (role: UserRole | null) => [ - { - header: "Info", - accessor: "info", - }, +const getColumns = (role: UserRole | null, th: (key: string) => string) => [ + { header: th("info"), accessor: "info" }, { - header: "Teacher ID", + header: th("teacherId"), accessor: "teacherId", className: "hidden md:table-cell", }, { - header: "Subjects", + header: th("subjects"), accessor: "subjects", className: "hidden md:table-cell", }, { - header: "Phone", + header: th("phone"), accessor: "phone", className: "hidden lg:table-cell", }, { - header: "Address", + header: th("address"), accessor: "address", className: "hidden lg:table-cell", }, { - header: role === "admin" ? "Actions" : "", + header: role === "admin" ? th("actions") : "", accessor: "action", }, ]; @@ -63,22 +64,28 @@ const renderRow = (item: TeacherList, role: UserRole | null) => ( />

{item.name}

-

{item?.email}

+

{item.email}

+
+ + + ); + const TeacherListPage = async ({ searchParams, }: { searchParams: PageSearchParams; }) => { + const t = await getTranslations("pages"); + const th = await getTranslations("tableHeaders"); + const emptyT = await getTranslations("emptyStates"); const { role, schoolId } = await enforceRouteAccess("/list/teachers"); + const { + query, + orderBy, + page: p, + } = await buildTeacherQuery({ + searchParams, + schoolId, + }); + const resolvedSearchParams = await searchParams; - const { page, ...queryParams } = resolvedSearchParams; - const currentPage = getQueryParam(page); - const p = currentPage ? parseInt(currentPage) : 1; - - const query: Prisma.TeacherWhereInput = { schoolId }; - const conditions: Prisma.TeacherWhereInput[] = []; - if (queryParams) { - for (const [key, rawValue] of Object.entries(queryParams)) { - const value = getQueryParam(rawValue); - - if (value !== undefined) { - switch (key) { - case "classId": - conditions.push({ - lessons: { - some: { - classId: parseInt(value), - }, - }, - }); - break; - - case "search": - conditions.push({ - name: { contains: value, mode: "insensitive" }, - }); - break; - } + + const exportQuery = new URLSearchParams( + Object.entries(resolvedSearchParams).flatMap(([key, value]) => { + if (Array.isArray(value)) { + return value.map((item) => [key, item]); } - } - } - if (conditions.length > 0) { - query.AND = conditions; - } + return value ? [[key, value]] : []; + }), + ); + const subjects = await prisma.subject.findMany({ + where: { + schoolId, + }, + select: { + id: true, + name: true, + gradeId: true, + }, + orderBy: { + name: "asc", + }, +}); + +const classes = await prisma.class.findMany({ + where: { + schoolId, + }, + select: { + id: true, + name: true, + gradeId: true, + }, + orderBy: { + name: "asc", + }, +}); const [data, count] = await prisma.$transaction([ prisma.teacher.findMany({ @@ -136,7 +158,7 @@ const TeacherListPage = async ({ include: { subjects: true, }, - orderBy: { name: "asc" }, + orderBy, take: ITEM_PER_PAGE, skip: (p - 1) * ITEM_PER_PAGE, }), @@ -147,26 +169,38 @@ const TeacherListPage = async ({ return (
- {/* TOP */} -
-

All Teachers

-
+
+

{t("allTeachers")}

+ +
+ + -
- + +
+ + {role === "admin" && ( - + <> + + + + )}
- {/* LIST */} +
{item.username} {item.subjects.map((subject) => subject.name).join(", ")} {item.phone} {item.address}
- + {role === "admin" && ( )} @@ -86,49 +93,64 @@ const renderRow = (item: TeacherList, role: UserRole | null) => (
renderRow(item, role)} data={data} + emptyTitle={emptyT("teachers")} + emptyDescription={emptyT("filterDescription")} /> - {/* PAGINATION */} + ); diff --git a/src/app/(dashboard)/parent/layout.tsx b/src/app/(dashboard)/parent/layout.tsx index 36e383e..faf2381 100644 --- a/src/app/(dashboard)/parent/layout.tsx +++ b/src/app/(dashboard)/parent/layout.tsx @@ -1,5 +1,5 @@ import { redirect } from "next/navigation"; -import { getAuthUser, requireRole } from "@/lib/auth"; +import { enforceAdminSchoolAccess, getAuthUser, requireRole } from "@/lib/auth"; export default async function ParentLayout({ children, @@ -13,6 +13,7 @@ export default async function ParentLayout({ } requireRole(user, ["parent"]); + await enforceAdminSchoolAccess(user); return <>{children}; } diff --git a/src/app/(dashboard)/parent/loading.tsx b/src/app/(dashboard)/parent/loading.tsx new file mode 100644 index 0000000..6cc68f2 --- /dev/null +++ b/src/app/(dashboard)/parent/loading.tsx @@ -0,0 +1,5 @@ +import LoadingIndicator from "@/components/loaders/LoadingIndicator"; + +export default function Loading() { + return ; +} diff --git a/src/app/(dashboard)/parent/page.tsx b/src/app/(dashboard)/parent/page.tsx index 401c2a6..37537a2 100644 --- a/src/app/(dashboard)/parent/page.tsx +++ b/src/app/(dashboard)/parent/page.tsx @@ -1,57 +1,106 @@ import Announcements from "@/components/Announcements"; import BigCalendarContainer from "@/components/BigCalendarContainer"; +import EmptyState from "@/components/states/EmptyState"; import EventCalendarContainer from "@/components/EventCalendarContainer"; import StudentSelector from "@/components/StudentSelector"; -import { getAuthUser, requireAuth } from "@/lib/auth"; +import { requireAuth } from "@/lib/auth"; +import { getQueryParam, type PageSearchParams } from "@/lib/pageParams"; import prisma from "@/lib/prisma"; +import { getTranslations } from "next-intl/server"; const ParentPage = async ({ searchParams, }: { - searchParams: Promise<{ [key: string]: string }>; + searchParams: PageSearchParams; }) => { + const t = await getTranslations("sidebar.items"); + const th = await getTranslations("tableHeaders"); + const statesT = await getTranslations("states"); const params = await searchParams; - const user = requireAuth(); + const user = await requireAuth(); const students = await prisma.student.findMany({ where: { - parentId: (await user).userId, - schoolId: (await user).schoolId, + parentId: user.userId, + schoolId: user.schoolId, }, select: { id: true, name: true, classId: true, + class: { + select: { + name: true, + }, + }, }, + orderBy: { name: "asc" }, }); if (!students.length) { - return
No students found
; + return ( +
+ +
+ ); } - const selectedStudentId = params?.studentId || students[0].id; - - const selectedStudent = students.find((s) => s.id === selectedStudentId); + const classOptions = Array.from( + new Map( + students + .filter((student) => student.classId) + .map((student) => [ + student.classId, + { + id: student.classId!, + name: student.class?.name ?? String(student.classId), + }, + ]), + ).values(), + ); + const requestedClassId = Number.parseInt( + getQueryParam(params.classId) ?? "", + 10, + ); + const selectedClass = + classOptions.find((classOption) => classOption.id === requestedClassId) ?? + classOptions[0]; - const classId = selectedStudent?.classId; + const classId = selectedClass?.id; return (
{/* LEFT */}
-

Schedule

- {students.length > 1 && } +
+

{t("schedules")}

+ {classOptions.length > 1 && ( + + )} +
+ {classId ? ( ) : ( -
No class found
+ )}
{/* RIGHT */} -
+
diff --git a/src/app/(dashboard)/profile/page.tsx b/src/app/(dashboard)/profile/page.tsx new file mode 100644 index 0000000..cf1bf1a --- /dev/null +++ b/src/app/(dashboard)/profile/page.tsx @@ -0,0 +1,232 @@ +import Image from "next/image"; +import { requireAuth } from "@/lib/auth"; +import prisma from "@/lib/prisma"; +import { getTranslations } from "next-intl/server"; + +const ProfilePage = async () => { + const t = await getTranslations("profile"); + const user = await requireAuth(); + + if (user.role === "admin") { + const profile = await prisma.admin.findUnique({ + where: { id: user.userId }, + include: { school: true }, + }); + + if (!profile) return
{t("notFound")}
; + + return ( + + + + + + + + ); + } + + if (user.role === "teacher") { + const profile = await prisma.teacher.findUnique({ + where: { id: user.userId }, + include: { school: true, subjects: true, classes: true }, + }); + + if (!profile) return
{t("notFound")}
; + + return ( + + + + + + + + + + s.name).join(", ")} + fallback={t("notProvided")} + /> + c.name).join(", ")} + fallback={t("notProvided")} + /> + + + ); + } + + if (user.role === "student") { + const profile = await prisma.student.findUnique({ + where: { id: user.userId }, + include: { school: true, grade: true, class: true, parent: true }, + }); + + if (!profile) return
{t("notFound")}
; + + return ( + + + + + + + + + + + + + + + + ); + } + + const profile = await prisma.parent.findUnique({ + where: { id: user.userId }, + include: { school: true, students: true }, + }); + + if (!profile) return
{t("notFound")}
; + + return ( + + + + + + + s.name).join(", ")} + fallback={t("notProvided")} + /> + + + ); +}; + +const ProfileLayout = ({ + image, + name, + avatarAlt, + children, +}: { + image: string | null; + name: string; + avatarAlt: string; + children: React.ReactNode; +}) => { + return ( +
+
+ + {/* Banner */} +
+ +
+
+ + + +

+ {name} +

+
+ +
+ + +
+ {children} +
+
+
+
+
+ ); +}; + +const Info = ({ + title, + value, + fallback, +}: { + title: string; + value?: React.ReactNode; + fallback: string; +}) => { + return ( +
+

+ {title} +

+ +

+ {value || fallback} +

+
+ ); +}; + +export default ProfilePage; diff --git a/src/app/(dashboard)/settings/loading.tsx b/src/app/(dashboard)/settings/loading.tsx new file mode 100644 index 0000000..6cc68f2 --- /dev/null +++ b/src/app/(dashboard)/settings/loading.tsx @@ -0,0 +1,5 @@ +import LoadingIndicator from "@/components/loaders/LoadingIndicator"; + +export default function Loading() { + return ; +} diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index 99f005f..db1c7cb 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -1,40 +1,37 @@ -import AcademicYearForm from "@/components/AcademicYearForm"; -import SchoolSettingsForm from "@/components/SchoolSettingsForm"; +import SettingsSectionsPanel from "@/components/SettingsSectionsPanel"; import { getAcademicYears } from "@/lib/academicYears"; -import { requireAuth, requireRole } from "@/lib/auth"; -import { getSchoolScheduleSettings } from "@/lib/schoolSettings"; +import { enforceAdminSchoolAccess, requireAuth, requireRole } from "@/lib/auth"; +import { + getSchoolDayExceptions, + getSchoolScheduleSettings, +} from "@/lib/schoolSettings"; +import { getTranslations } from "next-intl/server"; const SettingsPage = async () => { + const t = await getTranslations("pages"); + const settingsT = await getTranslations("settings"); const user = await requireAuth(); requireRole(user, ["admin"]); + await enforceAdminSchoolAccess(user); - const [settings, academicYears] = await Promise.all([ + const [settings, academicYears, dayExceptions] = await Promise.all([ getSchoolScheduleSettings(user.schoolId), getAcademicYears(user.schoolId), + getSchoolDayExceptions(user.schoolId), ]); return (
-

School Settings

+

{t("settings")}

- Configure weekly schedule defaults used by lessons and calendar - rendering. + {settingsT("description")}

-
- -
- -
-

Academic Years

-

- Create and maintain academic year ranges. -

- -
- -
-
+
); }; diff --git a/src/app/(dashboard)/student/layout.tsx b/src/app/(dashboard)/student/layout.tsx index 981a32c..73ffab0 100644 --- a/src/app/(dashboard)/student/layout.tsx +++ b/src/app/(dashboard)/student/layout.tsx @@ -1,5 +1,5 @@ import { redirect } from "next/navigation"; -import { getAuthUser, requireRole } from "@/lib/auth"; +import { enforceAdminSchoolAccess, getAuthUser, requireRole } from "@/lib/auth"; export default async function StudentLayout({ children, @@ -13,6 +13,7 @@ export default async function StudentLayout({ } requireRole(user, ["student"]); + await enforceAdminSchoolAccess(user); return <>{children}; } diff --git a/src/app/(dashboard)/student/loading.tsx b/src/app/(dashboard)/student/loading.tsx new file mode 100644 index 0000000..6cc68f2 --- /dev/null +++ b/src/app/(dashboard)/student/loading.tsx @@ -0,0 +1,5 @@ +import LoadingIndicator from "@/components/loaders/LoadingIndicator"; + +export default function Loading() { + return ; +} diff --git a/src/app/(dashboard)/student/page.tsx b/src/app/(dashboard)/student/page.tsx index 1b625cc..e81a082 100644 --- a/src/app/(dashboard)/student/page.tsx +++ b/src/app/(dashboard)/student/page.tsx @@ -1,8 +1,10 @@ import Announcements from "@/components/Announcements"; import BigCalendarContainer from "@/components/BigCalendarContainer"; +import EmptyState from "@/components/states/EmptyState"; import EventCalendarContainer from "@/components/EventCalendarContainer"; import { getAuthUser, requireAuth } from "@/lib/auth"; import prisma from "@/lib/prisma"; +import { getTranslations } from "next-intl/server"; const StudentPage = async ({ searchParams, @@ -10,6 +12,8 @@ const StudentPage = async ({ searchParams: Promise<{ [key: string]: string }>; }) => { const user = requireAuth(); + const t = await getTranslations("sidebar.items"); + const statesT = await getTranslations("states"); const studentClass = await prisma.class.findFirst({ where: { @@ -30,18 +34,22 @@ const StudentPage = async ({ {/* LEFT */}
-

Schedule

+

{t("schedules")}

{classId ? ( ) : ( -
No class found
+ )}
{/* RIGHT */} -
+
diff --git a/src/app/(dashboard)/super-admin/layout.tsx b/src/app/(dashboard)/super-admin/layout.tsx new file mode 100644 index 0000000..43985b9 --- /dev/null +++ b/src/app/(dashboard)/super-admin/layout.tsx @@ -0,0 +1,16 @@ +import { redirect } from "next/navigation"; +import { getAuthUser, requireRole } from "@/lib/auth"; + +export default async function SuperAdminLayout({ + children, +}: { + children: React.ReactNode; +}) { + const user = await getAuthUser(); + if (!user) { + redirect("/sign-in"); + } + + requireRole(user, ["superAdmin"]); + return <>{children}; +} diff --git a/src/app/(dashboard)/super-admin/page.tsx b/src/app/(dashboard)/super-admin/page.tsx new file mode 100644 index 0000000..077b863 --- /dev/null +++ b/src/app/(dashboard)/super-admin/page.tsx @@ -0,0 +1,172 @@ +import Pagination from "@/components/Pagination"; +import SchoolStatusActions from "@/components/SchoolStatusActions"; +import SchoolStatusFilter from "@/components/SchoolStatusFilter"; +import Table from "@/components/Table"; +import TableSearch from "@/components/TableSearch"; +import { enforceRouteAccess } from "@/lib/enforce-route-access"; +import type { PageSearchParams } from "@/lib/pageParams"; +import { getQueryParam } from "@/lib/pageParams"; +import prisma from "@/lib/prisma"; +import { ITEM_PER_PAGE } from "@/lib/settings"; +import { School, SchoolStatus } from "@prisma/client"; +import { getTranslations } from "next-intl/server"; + +const statusPillClass: Record = { + PENDING: "bg-yellow-100 text-yellow-800", + ACTIVE: "bg-green-100 text-green-800", + PAUSED: "bg-red-100 text-red-800", +}; + +const StatusPill = ({ status }: { status: SchoolStatus }) => ( + + {status} + +); + +const SuperAdminPage = async ({ + searchParams, +}: { + searchParams: PageSearchParams; +}) => { + const pagesT = await getTranslations("sidebar.items"); + const th = await getTranslations("tableHeaders"); + const emptyT = await getTranslations("emptyStates"); + await enforceRouteAccess("/super-admin"); + const resolved = await searchParams; + const page = Number.parseInt(getQueryParam(resolved.page) ?? "1", 10) || 1; + const search = getQueryParam(resolved.search)?.trim() ?? ""; + const status = getQueryParam(resolved.status) as SchoolStatus | undefined; + + const where = { + ...(search + ? { + OR: [ + { name: { contains: search, mode: "insensitive" as const } }, + { + admins: { + some: { + username: { contains: search, mode: "insensitive" as const }, + }, + }, + }, + ], + } + : {}), + ...(status && ["PENDING", "ACTIVE", "PAUSED"].includes(status) + ? { status } + : {}), + }; + + const [schools, count] = await Promise.all([ + prisma.school.findMany({ + where, + include: { admins: true }, + orderBy: { createdAt: "desc" }, + take: ITEM_PER_PAGE, + skip: (page - 1) * ITEM_PER_PAGE, + }), + prisma.school.count({ where }), + ]); + + return ( +
+
+

{pagesT("schools")}

+
+ + +
+
+ +
+ {schools.length === 0 ? ( +
+
+

+ {emptyT("defaultTitle")} +

+

{emptyT("defaultDescription")}

+
+
+ ) : ( + schools.map((school) => ( +
+
+
+

{school.name}

+

+ {th("adminUsername")}: {school.admins[0]?.username ?? "-"} +

+
+ +
+ + {school.pauseReason && ( +

+ {th("pauseReason")}:{" "} + {school.pauseReason} +

+ )} + +
+ +
+
+ )) + )} +
+ +
+
( + + + + + + + + )} + /> + + + + + ); +}; + +export default SuperAdminPage; diff --git a/src/app/(dashboard)/teacher/layout.tsx b/src/app/(dashboard)/teacher/layout.tsx index 474f18f..83c08c8 100644 --- a/src/app/(dashboard)/teacher/layout.tsx +++ b/src/app/(dashboard)/teacher/layout.tsx @@ -1,5 +1,5 @@ import { redirect } from "next/navigation"; -import { getAuthUser, requireRole } from "@/lib/auth"; +import { enforceAdminSchoolAccess, getAuthUser, requireRole } from "@/lib/auth"; export default async function TeacherLayout({ children, @@ -13,6 +13,7 @@ export default async function TeacherLayout({ } requireRole(user, ["teacher"]); + await enforceAdminSchoolAccess(user); return <>{children}; } diff --git a/src/app/(dashboard)/teacher/loading.tsx b/src/app/(dashboard)/teacher/loading.tsx new file mode 100644 index 0000000..6cc68f2 --- /dev/null +++ b/src/app/(dashboard)/teacher/loading.tsx @@ -0,0 +1,5 @@ +import LoadingIndicator from "@/components/loaders/LoadingIndicator"; + +export default function Loading() { + return ; +} diff --git a/src/app/(dashboard)/teacher/page.tsx b/src/app/(dashboard)/teacher/page.tsx index a4f7d6d..7290f1e 100644 --- a/src/app/(dashboard)/teacher/page.tsx +++ b/src/app/(dashboard)/teacher/page.tsx @@ -23,7 +23,7 @@ const TeacherPage = async ({ {/* RIGHT */} -
+
diff --git a/src/app/api/admin/assignments/export/route.ts b/src/app/api/admin/assignments/export/route.ts new file mode 100644 index 0000000..9a995e5 --- /dev/null +++ b/src/app/api/admin/assignments/export/route.ts @@ -0,0 +1,102 @@ +import { buildAssignmentQuery } from "@/lib/query-builders/assignment-query"; +import { createCsvResponse, generateCsv } from "@/lib/csv"; +import { searchParamsToRecord } from "@/lib/pageParams"; +import { enforceRouteAccess } from "@/lib/enforce-route-access"; +import prisma from "@/lib/prisma"; +import { NextRequest, NextResponse } from "next/server"; + +const formatDateTime = (date: Date) => + new Intl.DateTimeFormat("en-US", { + dateStyle: "medium", + timeStyle: "short", + }).format(date); + +export async function GET(request: NextRequest) { + const { role, userId, schoolId } = + await enforceRouteAccess("/list/assignments"); + + if (role !== "admin") { + return new NextResponse("Forbidden", { status: 403 }); + } + + const searchParams = Promise.resolve( + searchParamsToRecord(request.nextUrl.searchParams), + ); + +const { academicYearId, query, orderBy } = + await buildAssignmentQuery({ + searchParams, + schoolId, + role, + userId, + }); + + if (!academicYearId || !query) { + return new NextResponse("No active academic year found.", { + status: 400, + }); + } + + const assignments = await prisma.assignment.findMany({ + where: query, + include: { + subject: { + select: { + name: true, + }, + }, + class: { + select: { + name: true, + }, + }, + lesson: { + select: { + teacher: { + select: { + name: true, + }, + }, + }, + }, + teacher: { + select: { + name: true, + }, + }, + }, + orderBy, + }); + + const csv = generateCsv( + [ + { + header: "Title", + value: (row) => row.title, + }, + { + header: "Subject", + value: (row) => row.subject?.name ?? "-", + }, + { + header: "Class", + value: (row) => row.class?.name ?? "-", + }, + { + header: "Teacher", + value: (row) => row.teacher?.name ?? row.lesson?.teacher.name ?? "-", + }, + { + header: "Start Date", + value: (row) => formatDateTime(row.startDate), + }, + { + header: "End Date", + value: (row) => formatDateTime(row.endDate), + }, + ], + assignments + ); + + return createCsvResponse("assignments.csv", csv); +} diff --git a/src/app/api/admin/classes/export/route.ts b/src/app/api/admin/classes/export/route.ts new file mode 100644 index 0000000..3a1f100 --- /dev/null +++ b/src/app/api/admin/classes/export/route.ts @@ -0,0 +1,60 @@ +import { NextRequest } from "next/server"; +import { enforceRouteAccess } from "@/lib/enforce-route-access"; +import { buildClassQuery } from "@/lib/query-builders/class-query"; +import { createCsvResponse, generateCsv } from "@/lib/csv"; +import { searchParamsToRecord } from "@/lib/pageParams"; +import prisma from "@/lib/prisma"; + +export async function GET(req: NextRequest) { + const { role, schoolId } = await enforceRouteAccess("/list/classes"); + + if (role !== "admin") { + return new Response("Forbidden", { status: 403 }); + } + + const searchParams = Promise.resolve( + searchParamsToRecord(req.nextUrl.searchParams), + ); + + const { query, orderBy } = await buildClassQuery({ + searchParams, + schoolId, + }); + + const classes = await prisma.class.findMany({ + where: query, + include: { + supervisor: true, + grade: { + select: { + level: true, + }, + }, + }, + orderBy, + }); + + const csv = generateCsv( + [ + { + header: "Class Name", + value: (item) => item.name, + }, + { + header: "Capacity", + value: (item) => item.capacity, + }, + { + header: "Grade", + value: (item) => item.grade?.level ?? "", + }, + { + header: "Supervisor", + value: (item) => item.supervisor?.name ?? "No supervisor", + }, + ], + classes + ); + + return createCsvResponse("classes-export.csv", csv); +} diff --git a/src/app/api/admin/exams/export/route.ts b/src/app/api/admin/exams/export/route.ts new file mode 100644 index 0000000..d3b91fe --- /dev/null +++ b/src/app/api/admin/exams/export/route.ts @@ -0,0 +1,98 @@ +import { NextRequest } from "next/server"; +import { enforceRouteAccess } from "@/lib/enforce-route-access"; +import { createCsvResponse, generateCsv } from "@/lib/csv"; +import { buildExamQuery } from "@/lib/query-builders/exam-query"; +import { searchParamsToRecord } from "@/lib/pageParams"; +import prisma from "@/lib/prisma"; + +export async function GET(req: NextRequest) { + const { role, userId, schoolId } = await enforceRouteAccess("/list/exams"); + + if (role !== "admin") { + return new Response("Forbidden", { status: 403 }); + } + + const { query, orderBy } = await buildExamQuery({ + searchParams: Promise.resolve( + searchParamsToRecord(req.nextUrl.searchParams), + ), + schoolId, + role, + userId, + }); + + if (!query) { + return new Response("No current academic year found", { status: 400 }); + } + + const exams = await prisma.exam.findMany({ + where: query, + include: { + subject: { + select: { + name: true, + }, + }, + class: { + select: { + name: true, + }, + }, + lesson: { + select: { + teacher: { + select: { + name: true, + }, + }, + }, + }, + teacher: { + select: { + name: true, + }, + }, + }, + orderBy, + }); + + const csv = generateCsv( + [ + { + header: "Title", + value: (exam) => exam.title, + }, + { + header: "Subject", + value: (exam) => exam.subject?.name, + }, + { + header: "Class", + value: (exam) => exam.class?.name, + }, + { + header: "Teacher", + value: (exam) => exam.teacher?.name ?? exam.lesson?.teacher.name, + }, + { + header: "Start Time", + value: (exam) => + new Intl.DateTimeFormat("en-US", { + dateStyle: "short", + timeStyle: "short", + }).format(exam.startTime), + }, + { + header: "End Time", + value: (exam) => + new Intl.DateTimeFormat("en-US", { + dateStyle: "short", + timeStyle: "short", + }).format(exam.endTime), + }, + ], + exams + ); + + return createCsvResponse("exams-export.csv", csv); +} diff --git a/src/app/api/admin/lessons/export/route.ts b/src/app/api/admin/lessons/export/route.ts new file mode 100644 index 0000000..181d781 --- /dev/null +++ b/src/app/api/admin/lessons/export/route.ts @@ -0,0 +1,88 @@ +import { NextRequest } from "next/server"; +import { enforceRouteAccess } from "@/lib/enforce-route-access"; +import { createCsvResponse, generateCsv } from "@/lib/csv"; +import prisma from "@/lib/prisma"; + +export async function GET(req: NextRequest) { + const { role, schoolId } = await enforceRouteAccess("/list/lessons"); + + if (role !== "admin") { + return new Response("Forbidden", { status: 403 }); + } + + const classId = req.nextUrl.searchParams.get("classId"); + + if (!classId) { + return new Response("Class ID is required", { status: 400 }); + } + + const lessons = await prisma.lesson.findMany({ + where: { + classId: Number(classId), + class: { + schoolId, + }, + }, + include: { + subject: true, + teacher: true, + class: { + include: { + grade: true, + }, + }, + }, + orderBy: [ + { day: "asc" }, + { startTime: "asc" }, + ], + }); + + const csv = generateCsv( + [ + { + header: "Lesson Name", + value: (lesson) => lesson.name, + }, + { + header: "Day", + value: (lesson) => lesson.day, + }, + { + header: "Start Time", + value: (lesson) => + new Intl.DateTimeFormat("en-US", { + hour: "2-digit", + minute: "2-digit", + }).format(lesson.startTime), + }, + { + header: "End Time", + value: (lesson) => + new Intl.DateTimeFormat("en-US", { + hour: "2-digit", + minute: "2-digit", + }).format(lesson.endTime), + }, + { + header: "Subject", + value: (lesson) => lesson.subject?.name, + }, + { + header: "Teacher", + value: (lesson) => lesson.teacher?.name, + }, + { + header: "Class", + value: (lesson) => lesson.class?.name, + }, + { + header: "Grade", + value: (lesson) => lesson.class?.grade?.level, + }, + ], + lessons + ); + + return createCsvResponse("lessons-export.csv", csv); +} \ No newline at end of file diff --git a/src/app/api/admin/parents/export/route.ts b/src/app/api/admin/parents/export/route.ts new file mode 100644 index 0000000..defba45 --- /dev/null +++ b/src/app/api/admin/parents/export/route.ts @@ -0,0 +1,68 @@ +import { NextRequest } from "next/server"; +import { enforceRouteAccess } from "@/lib/enforce-route-access"; +import { buildParentQuery } from "@/lib/query-builders/parent-query"; +import { createCsvResponse, generateCsv } from "@/lib/csv"; +import { searchParamsToRecord } from "@/lib/pageParams"; +import prisma from "@/lib/prisma"; + +export async function GET(req: NextRequest) { + const { role, userId, schoolId } = await enforceRouteAccess("/list/parents"); + + if (role !== "admin") { + return new Response("Forbidden", { status: 403 }); + } + + const searchParams = Promise.resolve( + searchParamsToRecord(req.nextUrl.searchParams), + ); + + const { query } = await buildParentQuery({ + searchParams, + schoolId, + role, + userId, + }); + + const parents = await prisma.parent.findMany({ + where: query, + include: { + students: true, + }, + orderBy: { + name: "asc", + }, + }); + + const csv = generateCsv( + [ + { + header: "Name", + value: (parent) => parent.name, + }, + { + header: "Username", + value: (parent) => parent.username, + }, + { + header: "Email", + value: (parent) => parent.email, + }, + { + header: "Student Names", + value: (parent) => + parent.students.map((student) => student.name).join(" | "), + }, + { + header: "Phone", + value: (parent) => parent.phone, + }, + { + header: "Address", + value: (parent) => parent.address, + }, + ], + parents + ); + + return createCsvResponse("parents-export.csv", csv); +} diff --git a/src/app/api/admin/results/export/route.ts b/src/app/api/admin/results/export/route.ts new file mode 100644 index 0000000..d16a29e --- /dev/null +++ b/src/app/api/admin/results/export/route.ts @@ -0,0 +1,54 @@ +import { buildResultQuery } from "@/lib/query-builders/result-query"; +import { createCsvResponse, generateCsv } from "@/lib/csv"; +import { enforceRouteAccess } from "@/lib/enforce-route-access"; +import { searchParamsToRecord } from "@/lib/pageParams"; +import prisma from "@/lib/prisma"; +import { NextRequest, NextResponse } from "next/server"; + +export async function GET(request: NextRequest) { + const { role, userId, schoolId } = + await enforceRouteAccess("/list/results"); + + if (role !== "admin") { + return new NextResponse("Forbidden", { status: 403 }); + } + + const searchParams = Promise.resolve( + searchParamsToRecord(request.nextUrl.searchParams), + ); + + const { academicYearId, query, orderBy } = await buildResultQuery({ + searchParams, + schoolId, + role, + userId, + }); + + if (!academicYearId || !query) { + return new NextResponse("No active academic year found.", { status: 400 }); + } + + const results = await prisma.result.findMany({ + where: query, + include: { + student: { select: { name: true } }, + exam: { select: { title: true } }, + assignment: { select: { title: true } }, + }, + orderBy, + }); + + const csv = generateCsv( + [ + { header: "Student", value: (row) => row.student.name }, + { + header: "Assessment", + value: (row) => row.exam?.title ?? row.assignment?.title ?? "-", + }, + { header: "Score", value: (row) => row.score }, + ], + results + ); + + return createCsvResponse("results.csv", csv); +} diff --git a/src/app/api/admin/students/export/route.ts b/src/app/api/admin/students/export/route.ts new file mode 100644 index 0000000..3922124 --- /dev/null +++ b/src/app/api/admin/students/export/route.ts @@ -0,0 +1,71 @@ +import { NextRequest } from "next/server"; +import { enforceRouteAccess } from "@/lib/enforce-route-access"; +import { buildStudentQuery } from "@/lib/query-builders/student-query"; +import { createCsvResponse, generateCsv } from "@/lib/csv"; +import { searchParamsToRecord } from "@/lib/pageParams"; +import { + getCurrentAcademicYearOrNull, +} from "@/lib/academicYears"; +import prisma from "@/lib/prisma"; + +export async function GET(req: NextRequest) { + const { role, userId, schoolId } = await enforceRouteAccess("/list/students"); + + if (role !== "admin") { + return new Response("Forbidden", { status: 403 }); + } + + const currentAcademicYear = await getCurrentAcademicYearOrNull(schoolId); + + if (!currentAcademicYear) { + return new Response("No current academic year found", { status: 400 }); + } + + const searchParams = Promise.resolve( + searchParamsToRecord(req.nextUrl.searchParams), + ); + + const { query, orderBy } = await buildStudentQuery({ + searchParams, + schoolId, + currentAcademicYearId: currentAcademicYear.id, + role, + userId, + }); + + const students = await prisma.student.findMany({ + where: query, + include: { + class: true, + }, + orderBy, + }); + + const csv = generateCsv( + [ + { + header: "Name", + value: (student) => student.name, + }, + { + header: "Student ID", + value: (student) => student.username, + }, + { + header: "Class", + value: (student) => student.class?.name, + }, + { + header: "Phone", + value: (student) => student.phone, + }, + { + header: "Address", + value: (student) => student.address, + }, + ], + students + ); + + return createCsvResponse("students-export.csv", csv); +} diff --git a/src/app/api/admin/subjects/export/route.ts b/src/app/api/admin/subjects/export/route.ts new file mode 100644 index 0000000..1d63751 --- /dev/null +++ b/src/app/api/admin/subjects/export/route.ts @@ -0,0 +1,55 @@ +import { NextRequest } from "next/server"; +import { enforceRouteAccess } from "@/lib/enforce-route-access"; +import { buildSubjectQuery } from "@/lib/query-builders/subject-query"; +import { createCsvResponse, generateCsv } from "@/lib/csv"; +import { searchParamsToRecord } from "@/lib/pageParams"; +import prisma from "@/lib/prisma"; + +export async function GET(req: NextRequest) { + const { role, userId, schoolId } = await enforceRouteAccess("/list/subjects"); + + if (role !== "admin") { + return new Response("Forbidden", { status: 403 }); + } + + const searchParams = Promise.resolve( + searchParamsToRecord(req.nextUrl.searchParams), + ); + + const { query, orderBy } = await buildSubjectQuery({ + searchParams, + schoolId, + role, + userId, + }); + + const subjects = await prisma.subject.findMany({ + where: query, + include: { + teachers: true, + grade: true, + }, + orderBy, + }); + + const csv = generateCsv( + [ + { + header: "Subject Name", + value: (subject) => subject.name, + }, + { + header: "Grade", + value: (subject) => subject.grade?.level ?? "", + }, + { + header: "Teachers", + value: (subject) => + subject.teachers.map((teacher) => teacher.name).join(" | "), + }, + ], + subjects + ); + + return createCsvResponse("subjects-export.csv", csv); +} diff --git a/src/app/api/admin/teachers/export/route.ts b/src/app/api/admin/teachers/export/route.ts new file mode 100644 index 0000000..e1ca209 --- /dev/null +++ b/src/app/api/admin/teachers/export/route.ts @@ -0,0 +1,64 @@ +import { NextRequest } from "next/server"; +import { enforceRouteAccess } from "@/lib/enforce-route-access"; +import { buildTeacherQuery } from "@/lib/query-builders/teacher-query"; +import { createCsvResponse, generateCsv } from "@/lib/csv"; +import { searchParamsToRecord } from "@/lib/pageParams"; +import prisma from "@/lib/prisma"; + +export async function GET(req: NextRequest) { + const { role, schoolId } = await enforceRouteAccess("/list/teachers"); + + if (role !== "admin") { + return new Response("Forbidden", { status: 403 }); + } + + const searchParams = Promise.resolve( + searchParamsToRecord(req.nextUrl.searchParams), + ); + + const { query, orderBy } = await buildTeacherQuery({ + searchParams, + schoolId, + }); + + const teachers = await prisma.teacher.findMany({ + where: query, + include: { + subjects: true, + }, + orderBy, + }); + + const csv = generateCsv( + [ + { + header: "Name", + value: (teacher) => teacher.name, + }, + { + header: "Teacher ID", + value: (teacher) => teacher.username, + }, + { + header: "Email", + value: (teacher) => teacher.email, + }, + { + header: "Subjects", + value: (teacher) => + teacher.subjects.map((subject) => subject.name).join(" | "), + }, + { + header: "Phone", + value: (teacher) => teacher.phone, + }, + { + header: "Address", + value: (teacher) => teacher.address, + }, + ], + teachers + ); + + return createCsvResponse("teachers-export.csv", csv); +} diff --git a/src/app/api/cron/auto-submit/route.ts b/src/app/api/cron/auto-submit/route.ts new file mode 100644 index 0000000..8c1c822 --- /dev/null +++ b/src/app/api/cron/auto-submit/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from "next/server"; +import { autoSubmitExpiredSubmissions } from "@/lib/actions/examWorkflow.actions"; + +const validateCronSecret = (req: NextRequest): boolean => { + const authHeader = req.headers.get("authorization"); + if (authHeader?.startsWith("Bearer ")) { + const token = authHeader.slice(7); + if (token === process.env.CRON_SECRET) return true; + } + + const { searchParams } = new URL(req.url); + const secretParam = searchParams.get("secret"); + if (secretParam === process.env.CRON_SECRET) return true; + + return false; +}; + +export async function GET(req: NextRequest) { + if (!validateCronSecret(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const result = await autoSubmitExpiredSubmissions(); + return NextResponse.json(result); + } catch (err) { + console.error("[cron:auto-submit]", err); + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/cron/cleanup-files/route.ts b/src/app/api/cron/cleanup-files/route.ts new file mode 100644 index 0000000..925b001 --- /dev/null +++ b/src/app/api/cron/cleanup-files/route.ts @@ -0,0 +1,82 @@ +import { NextRequest, NextResponse } from "next/server"; +import cloudinary, { deleteExamFileFromCloudinary } from "@/lib/cloudinary"; +import prisma from "@/lib/prisma"; + +const validateCronSecret = (req: NextRequest): boolean => { + const authHeader = req.headers.get("authorization"); + if (authHeader?.startsWith("Bearer ")) { + const token = authHeader.slice(7); + if (token === process.env.CRON_SECRET) return true; + } + + const { searchParams } = new URL(req.url); + const secretParam = searchParams.get("secret"); + if (secretParam === process.env.CRON_SECRET) return true; + + return false; +}; + +export async function GET(req: NextRequest) { + if (!validateCronSecret(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000; + + const searchResult = await cloudinary.search + .expression("resource_type:raw AND type:private AND folder:exams/*") + .max_results(500) + .execute(); + + const allResources = searchResult.resources as Array<{ + public_id: string; + created_at: string; + }>; + + const oldResources = allResources.filter( + (r) => new Date(r.created_at).getTime() < twoHoursAgo + ); + + if (oldResources.length === 0) { + return NextResponse.json({ deleted: 0, orphaned: 0, total: 0 }); + } + + const dbPublicIds = await prisma.answer.findMany({ + where: { filePublicId: { not: null } }, + select: { filePublicId: true }, + }); + + const knownPublicIds = new Set( + dbPublicIds + .map((a) => a.filePublicId) + .filter((id): id is string => id !== null) + ); + + const orphaned = oldResources.filter( + (r) => !knownPublicIds.has(r.public_id) + ); + + let deletedCount = 0; + for (const resource of orphaned) { + try { + await deleteExamFileFromCloudinary(resource.public_id); + deletedCount++; + } catch { + // Continue deleting remaining orphaned files + } + } + + return NextResponse.json({ + deleted: deletedCount, + orphaned: orphaned.length, + total: oldResources.length, + }); + } catch (err) { + console.error("[cron:cleanup-files]", err); + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/download/[id]/route.ts b/src/app/api/download/[id]/route.ts new file mode 100644 index 0000000..cb5b35f --- /dev/null +++ b/src/app/api/download/[id]/route.ts @@ -0,0 +1,334 @@ +// src/app/api/download/[id]/route.ts +import { NextRequest, NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; +import cloudinary from "@/lib/cloudinary"; + +// ========== استخراج resource_type من URL ========== +function extractResourceType(fileUrl: string): "image" | "video" | "raw" { + if (fileUrl.includes("/image/upload/")) return "image"; + if (fileUrl.includes("/video/upload/")) return "video"; + return "raw"; +} + +// ========== استخراج delivery type من URL ========== +function extractDeliveryType( + fileUrl: string, +): "upload" | "private" | "authenticated" { + try { + const url = new URL(fileUrl); + if (url.pathname.includes("/private/")) return "private"; + if (url.pathname.includes("/authenticated/")) return "authenticated"; + return "upload"; + } catch { + return "upload"; + } +} + +// ========== استخراج public_id (بدون extension) + format ========== +function extractCloudinaryAssetParts(fileUrl: string): { + publicIdNoExt: string; + format: string; +} | null { + try { + const url = new URL(fileUrl); + const match = url.pathname.match( + /\/(?:image|video|raw)\/(?:upload|private|authenticated)\/(?:v\d+\/)?(.+)$/, + ); + const pathAfterType = match?.[1]; + if (!pathAfterType) return null; + + const lastDot = pathAfterType.lastIndexOf("."); + if (lastDot === -1) return null; + + const publicIdNoExt = pathAfterType.slice(0, lastDot); + const format = pathAfterType.slice(lastDot + 1).toLowerCase(); + if (!publicIdNoExt || !format) return null; + + return { publicIdNoExt, format }; + } catch { + return null; + } +} + +// ========== بناء قائمة URLs ========== +function buildUrlsToTry(fileUrl: string): string[] { + try { + const url = new URL(fileUrl); + url.protocol = "https:"; + + const resourceType = extractResourceType(fileUrl); + const deliveryType = extractDeliveryType(fileUrl); + const assetParts = extractCloudinaryAssetParts(fileUrl); + + console.log("[Download] resourceType:", resourceType); + console.log("[Download] deliveryType:", deliveryType); + console.log("[Download] assetParts:", assetParts); + + // URL أصلي كما هو (بالـ version) + url.protocol = "https:"; + const originalUrl = url.toString(); + + if (!assetParts) return [originalUrl]; + + // Cloudinary SDK-generated signed download URLs (more reliable than manual signing). + const signedPrimary = cloudinary.utils.private_download_url( + assetParts.publicIdNoExt, + assetParts.format, + { + resource_type: resourceType, + type: deliveryType, + expires_at: Math.floor(Date.now() / 1000) + 300, + attachment: true, + }, + ); + + const signedRawUpload = cloudinary.utils.private_download_url( + assetParts.publicIdNoExt, + assetParts.format, + { + resource_type: "raw", + type: "upload", + expires_at: Math.floor(Date.now() / 1000) + 300, + attachment: true, + }, + ); + + // Try original first (public assets), then signed fallbacks. + return [originalUrl, signedPrimary, signedRawUpload]; + } catch { + return [fileUrl]; + } +} + +// ========== كشف الامتداد من Magic Bytes ========== +function detectExtensionFromBuffer(buffer: Buffer): string | null { + const signatures: { bytes: number[]; ext: string }[] = [ + { bytes: [0x25, 0x50, 0x44, 0x46], ext: "pdf" }, + { bytes: [0x50, 0x4b, 0x03, 0x04], ext: "zip" }, + { bytes: [0x50, 0x4b, 0x05, 0x06], ext: "zip" }, + { bytes: [0x50, 0x4b, 0x07, 0x08], ext: "zip" }, + { bytes: [0xff, 0xd8, 0xff], ext: "jpg" }, + { bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], ext: "png" }, + { bytes: [0x47, 0x49, 0x46, 0x38], ext: "gif" }, + { bytes: [0x52, 0x61, 0x72, 0x21, 0x1a, 0x07], ext: "rar" }, + { bytes: [0x1f, 0x8b], ext: "gz" }, + { bytes: [0x42, 0x5a, 0x68], ext: "bz2" }, + ]; + + for (const sig of signatures) { + if ( + buffer.length >= sig.bytes.length && + sig.bytes.every((b, i) => buffer[i] === b) + ) { + if (sig.ext === "zip") { + const str = buffer.toString("utf8", 0, 200); + if (str.includes("[Content_Types].xml")) return "docx"; + if (str.includes("xl/")) return "xlsx"; + if (str.includes("ppt/")) return "pptx"; + return "zip"; + } + return sig.ext; + } + } + return null; +} + +function getExtensionFromUrl(url: string): string | null { + try { + const pathname = new URL(url).pathname; + const lastSegment = pathname.split("/").pop() || ""; + const dotIndex = lastSegment.lastIndexOf("."); + if (dotIndex !== -1) { + const ext = lastSegment.slice(dotIndex + 1).toLowerCase(); + if (ext.length > 0 && ext.length <= 5) return ext; + } + } catch {} + return null; +} + +function getExtensionFromFileName( + fileName: string | null | undefined, +): string | null { + if (!fileName) return null; + const trimmed = fileName.trim(); + const dotIndex = trimmed.lastIndexOf("."); + if (dotIndex === -1) return null; + const ext = trimmed.slice(dotIndex + 1).toLowerCase(); + if (ext.length === 0 || ext.length > 8) return null; + return ext; +} + +function getExtensionFromContentType(contentType: string): string { + const map: Record = { + "application/pdf": "pdf", + "application/msword": "doc", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": + "docx", + "application/vnd.ms-excel": "xls", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx", + "application/vnd.ms-powerpoint": "ppt", + "application/vnd.openxmlformats-officedocument.presentationml.presentation": + "pptx", + "image/jpeg": "jpg", + "image/png": "png", + "image/gif": "gif", + "image/webp": "webp", + "text/plain": "txt", + "application/zip": "zip", + "application/x-rar-compressed": "rar", + "application/x-7z-compressed": "7z", + }; + const baseType = contentType.split(";")[0].trim(); + return map[baseType] || "bin"; +} + +type DownloadType = "assignment" | "studyMaterial" | "submission"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { searchParams } = new URL(request.url); + const type = searchParams.get("type") as DownloadType; + const { id: idStr } = await params; + const recordId = Number(idStr); + + if (isNaN(recordId)) { + return new NextResponse("Invalid download id", { status: 400 }); + } + + let fileUrl: string | null = null; + let baseName = "file"; + let originalFileName: string | null = null; + + // ========== 1. جلب البيانات من DB ========== + if (type === "assignment") { + const assignment = await prisma.assignment.findUnique({ + where: { id: recordId }, + select: { fileUrl: true, title: true, fileName: true }, + }); + if (!assignment) + return new NextResponse("Assignment not found", { status: 404 }); + if (!assignment.fileUrl) + return new NextResponse("This assignment has no attached file", { + status: 404, + }); + fileUrl = assignment.fileUrl; + originalFileName = assignment.fileName || null; + baseName = assignment.fileName || assignment.title || "assignment"; + } else if (type === "studyMaterial") { + const material = await prisma.studyMaterial.findUnique({ + where: { id: recordId }, + select: { fileUrl: true, title: true, fileName: true }, + }); + if (!material) + return new NextResponse("Study material not found", { status: 404 }); + if (!material.fileUrl) + return new NextResponse("This material has no attached file", { + status: 404, + }); + fileUrl = material.fileUrl; + originalFileName = material.fileName || null; + baseName = material.fileName || material.title || "study-material"; + } else if (type === "submission") { + const submission = await prisma.assignmentSubmission.findUnique({ + where: { id: recordId }, + include: { + student: { select: { name: true } }, + assignment: { select: { title: true } }, + }, + }); + if (!submission) + return new NextResponse("Submission not found", { status: 404 }); + if (!submission.fileUrl) + return new NextResponse("This submission has no attached file", { + status: 404, + }); + fileUrl = submission.fileUrl; + originalFileName = submission.fileName || null; + const studentName = submission.student?.name || "student"; + const assignmentTitle = submission.assignment?.title || "submission"; + baseName = submission.fileName || `${studentName}_${assignmentTitle}`; + } else { + return new NextResponse("Invalid download type", { status: 400 }); + } + + // ========== 2. بناء قائمة URLs ========== + const isCloudinaryUrl = + fileUrl.includes("cloudinary.com") || fileUrl.includes("res.cloudinary"); + + const urlsToTry = isCloudinaryUrl ? buildUrlsToTry(fileUrl) : [fileUrl]; + + console.log("[Download] fileUrl from DB:", fileUrl); + console.log("[Download] urlsToTry:", urlsToTry); + + // ========== 3. جلب الملف ========== + let fileResponse: Response | null = null; + let lastStatus = 0; + let lastUrl = ""; + + for (const url of urlsToTry) { + try { + const res = await fetch(url, { + redirect: "follow", + headers: { "User-Agent": "Mozilla/5.0 (compatible; SchoolApp/1.0)" }, + }); + console.log("[Download] tried:", url, "->", res.status); + if (res.ok) { + fileResponse = res; + break; + } + lastStatus = res.status; + lastUrl = url; + } catch (err) { + console.error("[Download] fetch error for:", url, err); + } + } + + if (!fileResponse) { + console.error("[Download] All URLs failed. Last:", lastUrl, lastStatus); + return new NextResponse( + `Failed to fetch file (storage returned ${lastStatus})`, + { status: 502 }, + ); + } + + const arrayBuffer = await fileResponse.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + if (buffer.length === 0) { + return new NextResponse("File is empty", { status: 502 }); + } + + // ========== 4. كشف الامتداد ========== + const extension = + getExtensionFromFileName(originalFileName) || + getExtensionFromUrl(fileUrl) || + getExtensionFromContentType( + fileResponse.headers.get("Content-Type") || "application/octet-stream", + ) || + detectExtensionFromBuffer(buffer) || + getExtensionFromFileName(baseName); + + // ========== 5. الرد ========== + const safeBaseName = baseName.replace(/[^a-zA-Z0-9\u0600-\u06FF\-_]/g, "_"); + const finalFileName = `${safeBaseName}.${extension}`; + + const headers = new Headers(); + const contentType = + fileResponse.headers.get("Content-Type") || "application/octet-stream"; + headers.set("Content-Type", contentType.split(";")[0].trim()); + headers.set( + "Content-Disposition", + `attachment; filename*=UTF-8''${encodeURIComponent(finalFileName)}`, + ); + headers.set("Content-Length", buffer.length.toString()); + headers.set("Cache-Control", "no-cache, no-store, must-revalidate"); + + return new NextResponse(buffer, { status: 200, headers }); + } catch (error) { + console.error("[Download] Unexpected error:", error); + return new NextResponse("Internal Server Error", { status: 500 }); + } +} diff --git a/src/app/api/exam-files/[answerId]/preview/route.ts b/src/app/api/exam-files/[answerId]/preview/route.ts new file mode 100644 index 0000000..ffb5428 --- /dev/null +++ b/src/app/api/exam-files/[answerId]/preview/route.ts @@ -0,0 +1,158 @@ +import { NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import prisma from "@/lib/prisma"; +import cloudinary from "@/lib/cloudinary"; + +function getFormat(fileName: string | null, mimeType: string | null) { + if (fileName) { + const dotIndex = fileName.lastIndexOf("."); + if (dotIndex !== -1) return fileName.slice(dotIndex + 1).toLowerCase(); + } + + return mimeType?.split("/")?.[1]?.toLowerCase() || "bin"; +} + +function getContentType(fileName: string | null, fallback: string | null) { + const format = getFormat(fileName, fallback); + const map: Record = { + pdf: "application/pdf", + jpg: "image/jpeg", + jpeg: "image/jpeg", + png: "image/png", + gif: "image/gif", + webp: "image/webp", + txt: "text/plain; charset=utf-8", + }; + + return map[format] || fallback || "application/octet-stream"; +} + +export async function GET( + _req: Request, + { params }: { params: Promise<{ answerId: string }> }, +) { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { answerId: answerIdStr } = await params; + const answerId = parseInt(answerIdStr, 10); + if (isNaN(answerId)) { + return NextResponse.json({ error: "Invalid answer ID" }, { status: 400 }); + } + + const student = await prisma.student.findUnique({ + where: { id: userId }, + select: { id: true, schoolId: true }, + }); + + const teacher = student + ? null + : await prisma.teacher.findUnique({ + where: { id: userId }, + select: { id: true, schoolId: true }, + }); + + const admin = + student || teacher + ? null + : await prisma.admin.findUnique({ + where: { id: userId }, + select: { id: true, schoolId: true }, + }); + + if (!student && !teacher && !admin) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const answer = await prisma.answer.findUnique({ + where: { id: answerId }, + include: { + submission: { + include: { + exam: { include: { lesson: true } }, + }, + }, + }, + }); + + if (!answer) { + return NextResponse.json({ error: "Answer not found" }, { status: 404 }); + } + + if (!answer.filePublicId) { + return NextResponse.json( + { error: "No file uploaded for this answer" }, + { status: 404 }, + ); + } + + if (student) { + if ( + answer.submission.studentId !== userId || + answer.submission.schoolId !== student.schoolId + ) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + } else if (teacher) { + if ( + answer.submission.schoolId !== teacher.schoolId || + (answer.submission.exam.teacherId !== teacher.id && + answer.submission.exam.lesson?.teacherId !== teacher.id) + ) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + } else if (admin && answer.submission.schoolId !== admin.schoolId) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const format = getFormat(answer.fileOriginalName, answer.fileMimeType); + const signedUrl = cloudinary.utils.private_download_url( + answer.filePublicId, + format, + { + resource_type: "raw", + type: "private", + expires_at: Math.floor(Date.now() / 1000) + 300, + attachment: false, + }, + ); + + const response = await fetch(signedUrl, { + redirect: "follow", + headers: { "User-Agent": "Mozilla/5.0 (compatible; SchoolApp/1.0)" }, + }); + + if (!response.ok) { + return NextResponse.json( + { error: "Failed to fetch preview file" }, + { status: 502 }, + ); + } + + const buffer = Buffer.from(await response.arrayBuffer()); + const fileName = answer.fileOriginalName || "answer-file"; + const headers = new Headers(); + + headers.set( + "Content-Type", + getContentType(fileName, response.headers.get("Content-Type")), + ); + headers.set( + "Content-Disposition", + `inline; filename*=UTF-8''${encodeURIComponent(fileName)}`, + ); + headers.set("Content-Length", buffer.length.toString()); + headers.set("Cache-Control", "private, max-age=300"); + + return new NextResponse(buffer, { status: 200, headers }); + } catch (err) { + console.error("[exam-file-preview]", err); + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/exam-files/[answerId]/route.ts b/src/app/api/exam-files/[answerId]/route.ts new file mode 100644 index 0000000..8954db4 --- /dev/null +++ b/src/app/api/exam-files/[answerId]/route.ts @@ -0,0 +1,118 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import prisma from "@/lib/prisma"; +import { generatePrivateDownloadUrl } from "@/lib/cloudinary"; + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ answerId: string }> } +) { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { answerId: answerIdStr } = await params; + const answerId = parseInt(answerIdStr, 10); + if (isNaN(answerId)) { + return NextResponse.json({ error: "Invalid answer ID" }, { status: 400 }); + } + + const user = await prisma.student.findUnique({ + where: { id: userId }, + select: { id: true, schoolId: true }, + }); + + const isStudent = !!user; + + let teacher: { id: string; schoolId: number } | null = null; + let admin: { id: string; schoolId: number } | null = null; + + if (!isStudent) { + teacher = await prisma.teacher.findUnique({ + where: { id: userId }, + select: { id: true, schoolId: true }, + }); + if (!teacher) { + admin = await prisma.admin.findUnique({ + where: { id: userId }, + select: { id: true, schoolId: true }, + }); + } + } + + if (!teacher && !admin && !isStudent) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const answer = await prisma.answer.findUnique({ + where: { id: answerId }, + include: { + submission: { + include: { + exam: { + include: { lesson: true }, + }, + }, + }, + }, + }); + + if (!answer) { + return NextResponse.json({ error: "Answer not found" }, { status: 404 }); + } + + if (!answer.filePublicId) { + return NextResponse.json( + { error: "No file uploaded for this answer" }, + { status: 404 } + ); + } + + if (isStudent) { + if ( + answer.submission.studentId !== userId || + answer.submission.schoolId !== user!.schoolId + ) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + } else if (teacher) { + if ( + answer.submission.schoolId !== teacher.schoolId || + (answer.submission.exam.teacherId !== teacher.id && + answer.submission.exam.lesson?.teacherId !== teacher.id) + ) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + } else if (admin) { + if (answer.submission.schoolId !== admin.schoolId) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + } + + let format = "bin"; + if (answer.fileOriginalName) { + const dotIndex = answer.fileOriginalName.lastIndexOf("."); + if (dotIndex !== -1) { + format = answer.fileOriginalName.slice(dotIndex + 1); + } else if (answer.fileMimeType) { + const parts = answer.fileMimeType.split("/"); + if (parts.length === 2 && parts[1]) format = parts[1]; + } + } else if (answer.fileMimeType) { + const parts = answer.fileMimeType.split("/"); + if (parts.length === 2 && parts[1]) format = parts[1]; + } + + const signedUrl = generatePrivateDownloadUrl(answer.filePublicId, format); + + return NextResponse.redirect(signedUrl, { status: 302 }); + } catch (err) { + console.error("[exam-files]", err); + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/exam-timer/[submissionId]/route.ts b/src/app/api/exam-timer/[submissionId]/route.ts index af82ce4..c053426 100644 --- a/src/app/api/exam-timer/[submissionId]/route.ts +++ b/src/app/api/exam-timer/[submissionId]/route.ts @@ -22,6 +22,7 @@ export async function GET( const stream = new ReadableStream({ async start(controller) { let intervalId: NodeJS.Timeout; + let lastDbUpdate = 0; const sendTime = async () => { try { @@ -40,6 +41,15 @@ export async function GET( return; } + // Update lastSyncedAt every 20 seconds to keep the session alive + if (Date.now() - lastDbUpdate > 20000) { + await prisma.submission.update({ + where: { id: submissionId }, + data: { lastSyncedAt: new Date() }, + }); + lastDbUpdate = Date.now(); + } + const examEndsAt = new Date( submission.startedAt.getTime() + ((submission.exam.duration ?? 0) + (submission.extraTime ?? 0)) * 60000 diff --git a/src/app/api/export/archive/route.ts b/src/app/api/export/archive/route.ts new file mode 100644 index 0000000..4c82a18 --- /dev/null +++ b/src/app/api/export/archive/route.ts @@ -0,0 +1,255 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireAuth } from "@/lib/auth"; +import prisma from "@/lib/prisma"; + +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url); + + const type = searchParams.get("type"); + const academicYearId = searchParams.get("academicYearId"); + + if (!type || !academicYearId) { + return NextResponse.json( + { error: "Missing parameters" }, + { status: 400 } + ); + } + + const user = await requireAuth(); + + const admin = await prisma.admin.findUnique({ + where: { + id: user.userId, + }, + }); + + if (!admin) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ); + } + + let data: Record[] = []; + + switch (type) { + case "students": + const students = await prisma.student.findMany({ + where: { + schoolId: admin.schoolId, + academicYears: { + some: { + academicYearId: Number(academicYearId), + }, + }, + }, + }); + const studentIds = students.map((student) => student.id); + const finalResults: Array<{ + studentId: string; + averageScore: number; + }> = studentIds.length + ? await ( + prisma as unknown as { + studentFinalResult: { + findMany: (args: { + where: { + schoolId: number; + academicYearId: number; + studentId: { in: string[] }; + }; + select: { + studentId: boolean; + averageScore: boolean; + }; + }) => Promise>; + }; + } + ).studentFinalResult.findMany({ + where: { + schoolId: admin.schoolId, + academicYearId: Number(academicYearId), + studentId: { in: studentIds }, + }, + select: { + studentId: true, + averageScore: true, + }, + }) + : []; + const finalGradeByStudent = new Map( + finalResults.map((result) => [ + result.studentId, + `${result.averageScore.toFixed(2)}%`, + ]), + ); + + data = students.map((student) => ({ + ...student, + finalGrade: finalGradeByStudent.get(student.id) ?? "", + })); + break; + + case "teachers": + data = await prisma.teacher.findMany({ + where: { + schoolId: admin.schoolId, + }, + }); + break; + + case "parents": + data = await prisma.parent.findMany({ + where: { + schoolId: admin.schoolId, + }, + }); + break; + + case "subjects": + data = await prisma.subject.findMany({ + where: { + schoolId: admin.schoolId, + }, + }); + break; + + case "classes": + data = await prisma.class.findMany({ + where: { + schoolId: admin.schoolId, + }, + }); + break; + + case "exams": + data = await prisma.exam.findMany({ + where: { + lesson: { + subject: { + schoolId: admin.schoolId, + }, + }, + }, + select: { + id: true, + title: true, + startTime: true, + endTime: true, + lessonId: true, + }, + }); + break; + + case "assignments": + try { + data = await prisma.assignment.findMany({ + select: { + id: true, + title: true, + }, + }); + } catch (error) { + return NextResponse.json({ + error: "Assignments export failed", + details: String(error), + }); + } + break; + + + case "results": + data = await prisma.result + .findMany({ + where: { + student: { + schoolId: admin.schoolId, + }, + }, + select: { + id: true, + score: true, + student: { + select: { + name: true, + username: true, + }, + }, + }, + }) + .then((items) => + items.map((item) => ({ + id: item.id, + studentName: item.student?.name || "", + studentUsername: item.student?.username || "", + score: item.score, + })) + ); + break; + + case "attendance": + data = await prisma.attendance + .findMany({ + where: { + student: { + schoolId: admin.schoolId, + }, + }, + select: { + id: true, + date: true, + present: true, + student: { + select: { + name: true, + username: true, + }, + }, + }, + }) + .then((items) => + items.map((item) => ({ + id: item.id, + studentName: item.student?.name || "", + studentUsername: item.student?.username || "", + date: item.date, + status: item.present ? "Present" : "Absent", + })) + ); + break; + + default: + data = []; + } + + if (data.length === 0) { + return NextResponse.json({ + success: true, + count: 0, + message: "No data found", + }); + } + + const headers = Object.keys(data[0]); + + const csvRows = [ + headers.join(","), + ...data.map((row) => + headers + .map((header) => { + const value = row[header]; + return `"${String(value ?? "").replace(/"/g, '""')}"`; + }) + .join(",") + ), + ]; + + const csv = csvRows.join("\n"); + + return new NextResponse(csv, { + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": `attachment; filename="${type}-${academicYearId}.csv"`, + }, + }); +} diff --git a/src/app/api/messages/count/route.ts b/src/app/api/messages/count/route.ts new file mode 100644 index 0000000..043ab0d --- /dev/null +++ b/src/app/api/messages/count/route.ts @@ -0,0 +1,58 @@ +import { getAuthUser } from "@/lib/auth"; +import prisma from "@/lib/prisma"; +import { NextResponse } from "next/server"; + +export async function GET() { + try { + const authUser = await getAuthUser(); + + if (!authUser) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { userId, role, schoolId } = authUser; + + let messageCount = 0; + + if (role === "admin") { + messageCount = await prisma.message.count({ + where: { schoolId }, + }); + } else if (role === "teacher") { + messageCount = await prisma.message.count({ + where: { + schoolId, + teachers: { + some: { id: userId }, + }, + }, + }); + } else if (role === "student") { + messageCount = await prisma.message.count({ + where: { + schoolId, + students: { + some: { id: userId }, + }, + }, + }); + } else if (role === "parent") { + messageCount = await prisma.message.count({ + where: { + schoolId, + parents: { + some: { id: userId }, + }, + }, + }); + } + + return NextResponse.json({ count: messageCount }); + } catch (error) { + console.error("Error fetching message count:", 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..79757e7 --- /dev/null +++ b/src/app/api/notifications/route.ts @@ -0,0 +1,83 @@ +// src/app/api/notifications/route.ts +import { auth } from "@clerk/nextjs/server"; +import { NextRequest, NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; + +// ─── GET: جلب إشعارات المستخدم ─────────────────────────────────────────────── +export async function GET() { + try { + const { userId } = await auth(); + if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const notifications = await prisma.notification.findMany({ + where: { userId }, + orderBy: { createdAt: "desc" }, + take: 20, + }); + + const unreadCount = await prisma.notification.count({ + where: { userId, isRead: false }, + }); + + return NextResponse.json({ notifications, unreadCount }); + } catch (err) { + console.error("[GET /api/notifications]", err); + return NextResponse.json({ error: "Something went wrong" }, { status: 500 }); + } +} + +// ─── PATCH: تحديد إشعار كـ مقروء ───────────────────────────────────────────── +export async function PATCH(req: NextRequest) { + try { + const { userId } = await auth(); + if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const body = await req.json(); + const { id, markAllRead } = body; + + if (markAllRead) { + await prisma.notification.updateMany({ + where: { userId, isRead: false }, + data: { isRead: true }, + }); + } else if (id) { + await prisma.notification.update({ + where: { id: Number(id) }, + data: { isRead: true }, + }); + } + + return NextResponse.json({ success: true }); + } catch (err) { + console.error("[PATCH /api/notifications]", err); + return NextResponse.json({ error: "Something went wrong" }, { status: 500 }); + } +} + +// ─── DELETE: حذف إشعار أو كل الإشعارات ────────────────────────────────────── +export async function DELETE(req: NextRequest) { + try { + const { userId } = await auth(); + if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const body = await req.json(); + const { id, deleteAll } = body; + + if (deleteAll) { + // حذف كل إشعارات المستخدم + await prisma.notification.deleteMany({ + where: { userId }, + }); + } else if (id) { + // حذف إشعار واحد — التأكد أنه يخص المستخدم + await prisma.notification.deleteMany({ + where: { id: Number(id), userId }, + }); + } + + return NextResponse.json({ success: true }); + } catch (err) { + console.error("[DELETE /api/notifications]", err); + return NextResponse.json({ error: "Something went wrong" }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/preview/[id]/route.ts b/src/app/api/preview/[id]/route.ts new file mode 100644 index 0000000..cfc6d8e --- /dev/null +++ b/src/app/api/preview/[id]/route.ts @@ -0,0 +1,173 @@ +import { NextRequest, NextResponse } from "next/server"; +import cloudinary from "@/lib/cloudinary"; +import prisma from "@/lib/prisma"; + +type PreviewType = "submission"; + +function extractResourceType(fileUrl: string): "image" | "video" | "raw" { + if (fileUrl.includes("/image/upload/")) return "image"; + if (fileUrl.includes("/video/upload/")) return "video"; + return "raw"; +} + +function extractDeliveryType( + fileUrl: string, +): "upload" | "private" | "authenticated" { + try { + const url = new URL(fileUrl); + if (url.pathname.includes("/private/")) return "private"; + if (url.pathname.includes("/authenticated/")) return "authenticated"; + return "upload"; + } catch { + return "upload"; + } +} + +function extractCloudinaryAssetParts(fileUrl: string): { + publicIdNoExt: string; + format: string; +} | null { + try { + const url = new URL(fileUrl); + const match = url.pathname.match( + /\/(?:image|video|raw)\/(?:upload|private|authenticated)\/(?:v\d+\/)?(.+)$/, + ); + const pathAfterType = match?.[1]; + if (!pathAfterType) return null; + + const lastDot = pathAfterType.lastIndexOf("."); + if (lastDot === -1) return null; + + const publicIdNoExt = pathAfterType.slice(0, lastDot); + const format = pathAfterType.slice(lastDot + 1).toLowerCase(); + if (!publicIdNoExt || !format) return null; + + return { publicIdNoExt, format }; + } catch { + return null; + } +} + +function buildPreviewUrlsToTry(fileUrl: string): string[] { + try { + const url = new URL(fileUrl); + url.protocol = "https:"; + const originalUrl = url.toString(); + + const resourceType = extractResourceType(fileUrl); + const deliveryType = extractDeliveryType(fileUrl); + const assetParts = extractCloudinaryAssetParts(fileUrl); + + if (!assetParts) return [originalUrl]; + + const signedPrimary = cloudinary.utils.private_download_url( + assetParts.publicIdNoExt, + assetParts.format, + { + resource_type: resourceType, + type: deliveryType, + expires_at: Math.floor(Date.now() / 1000) + 300, + attachment: false, + }, + ); + + const signedRawUpload = cloudinary.utils.private_download_url( + assetParts.publicIdNoExt, + assetParts.format, + { + resource_type: "raw", + type: "upload", + expires_at: Math.floor(Date.now() / 1000) + 300, + attachment: false, + }, + ); + + return [originalUrl, signedPrimary, signedRawUpload]; + } catch { + return [fileUrl]; + } +} + +function getContentType(fileName: string, fallback: string | null) { + const ext = fileName.split(".").pop()?.toLowerCase(); + const map: Record = { + pdf: "application/pdf", + jpg: "image/jpeg", + jpeg: "image/jpeg", + png: "image/png", + gif: "image/gif", + webp: "image/webp", + txt: "text/plain; charset=utf-8", + }; + + return (ext && map[ext]) || fallback || "application/octet-stream"; +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { searchParams } = new URL(request.url); + const type = searchParams.get("type") as PreviewType; + const { id: idRaw } = await params; + const id = Number.parseInt(idRaw, 10); + + if (Number.isNaN(id) || type !== "submission") { + return new NextResponse("Invalid preview request", { status: 400 }); + } + + const submission = await prisma.assignmentSubmission.findUnique({ + where: { id }, + select: { fileUrl: true, fileName: true }, + }); + + if (!submission) { + return new NextResponse("Submission not found", { status: 404 }); + } + + const urlsToTry = + submission.fileUrl.includes("cloudinary.com") || + submission.fileUrl.includes("res.cloudinary") + ? buildPreviewUrlsToTry(submission.fileUrl) + : [submission.fileUrl]; + + let response: Response | null = null; + + for (const url of urlsToTry) { + const candidate = await fetch(url, { + redirect: "follow", + headers: { "User-Agent": "Mozilla/5.0 (compatible; SchoolApp/1.0)" }, + }).catch(() => null); + + if (candidate?.ok) { + response = candidate; + break; + } + } + + if (!response) { + return new NextResponse("Failed to fetch preview file", { status: 502 }); + } + + const buffer = Buffer.from(await response.arrayBuffer()); + const fileName = submission.fileName || "submission"; + const headers = new Headers(); + + headers.set( + "Content-Type", + getContentType(fileName, response.headers.get("Content-Type")), + ); + headers.set( + "Content-Disposition", + `inline; filename*=UTF-8''${encodeURIComponent(fileName)}`, + ); + headers.set("Content-Length", buffer.length.toString()); + headers.set("Cache-Control", "private, max-age=300"); + + return new NextResponse(buffer, { status: 200, headers }); + } catch (error) { + console.error("[Preview] Unexpected error:", error); + return new NextResponse("Internal Server Error", { status: 500 }); + } +} diff --git a/src/app/api/save-answer/route.ts b/src/app/api/save-answer/route.ts index 8d42fc2..56e8494 100644 --- a/src/app/api/save-answer/route.ts +++ b/src/app/api/save-answer/route.ts @@ -1,6 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; import { saveAnswer } from "@/lib/actions/examWorkflow.actions"; +import { saveAnswerSchema } from "@/lib/formValidationSchemas"; import { auth } from "@clerk/nextjs/server"; +import { ZodError } from "zod"; export async function POST(req: NextRequest) { try { @@ -9,23 +11,25 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - let data; + let rawData: unknown; const contentType = req.headers.get("content-type") || ""; if (contentType.includes("application/json")) { - data = await req.json(); + rawData = await req.json(); } else if (contentType.includes("text/plain")) { const text = await req.text(); - data = JSON.parse(text); + rawData = JSON.parse(text); } else if (contentType.includes("application/x-www-form-urlencoded")) { const formData = await req.formData(); - data = Object.fromEntries(formData.entries()); - if (data.submissionId) data.submissionId = Number(data.submissionId); - if (data.questionId) data.questionId = Number(data.questionId); + rawData = Object.fromEntries(formData.entries()); } else { - return NextResponse.json({ error: "Unsupported content type" }, { status: 400 }); + return NextResponse.json( + { error: "Unsupported content type" }, + { status: 400 }, + ); } + const data = saveAnswerSchema.parse(rawData); const result = await saveAnswer({ success: true, error: false }, data); if (result.error) { @@ -34,6 +38,15 @@ export async function POST(req: NextRequest) { return NextResponse.json({ success: true }); } catch (error) { - return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); + if (error instanceof ZodError) { + return NextResponse.json( + { error: "Invalid request body", details: error.flatten() }, + { status: 400 }, + ); + } + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 }, + ); } } diff --git a/src/app/api/submissions/route.ts b/src/app/api/submissions/route.ts new file mode 100644 index 0000000..cd4cf27 --- /dev/null +++ b/src/app/api/submissions/route.ts @@ -0,0 +1,58 @@ +import { auth } from "@clerk/nextjs/server"; +import { NextRequest, NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; + +export async function GET(req: NextRequest) { + try { + const { userId, sessionClaims } = await auth(); + const role = (sessionClaims?.metadata as { role?: string })?.role; + + // ✅ السماح للمعلم والأدمن + if (!userId || (role !== "teacher" && role !== "admin")) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const assignmentId = Number(req.nextUrl.searchParams.get("assignmentId")); + if (!assignmentId) { + return NextResponse.json({ error: "assignmentId is required" }, { status: 400 }); + } + + // ✅ التحقق من صلاحية الوصول إلى الواجب + let assignment; + if (role === "admin") { + // الأدمن يرى كل الواجبات + assignment = await prisma.assignment.findUnique({ + where: { id: assignmentId }, + select: { id: true }, + }); + } else { + // المعلم يرى فقط واجباته + assignment = await prisma.assignment.findFirst({ + where: { + id: assignmentId, + OR: [{ teacherId: userId }, { lesson: { teacherId: userId } }], + }, + select: { id: true }, + }); + } + + if (!assignment) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + const submissions = await prisma.assignmentSubmission.findMany({ + where: { assignmentId }, + include: { + student: { + select: { id: true, name: true, img: true }, + }, + }, + orderBy: { updatedAt: "desc" } // ✅ ترتيب حسب آخر تعديل, + }); + + return NextResponse.json(submissions); + } catch (err) { + console.error("[GET /api/submissions]", err); + return NextResponse.json({ error: "Something went wrong" }, { status: 500 }); + } +} diff --git a/src/app/error.tsx b/src/app/error.tsx new file mode 100644 index 0000000..de49501 --- /dev/null +++ b/src/app/error.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { useEffect, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import ErrorState from "@/components/states/ErrorState"; + +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + const t = useTranslations("states"); + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + + useEffect(() => { + console.error(error); + }, [error]); + + const handleRetry = () => { + startTransition(() => { + router.refresh(); + reset(); + }); + }; + + return ( +
+ +
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css index f620eca..7cbb758 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2,6 +2,38 @@ @tailwind components; @tailwind utilities; +:root { + --academix-ink: #24104f; +} + +html { + scroll-behavior: smooth; +} + +.academix-menu-scroll { + scrollbar-color: #7c3aed transparent; + scrollbar-width: thin; +} + +.academix-menu-scroll::-webkit-scrollbar { + width: 8px; +} + +.academix-menu-scroll::-webkit-scrollbar-track { + background: transparent; +} + +.academix-menu-scroll::-webkit-scrollbar-thumb { + background-color: #7c3aed; + border: 2px solid transparent; + border-radius: 999px; + background-clip: content-box; +} + +.academix-menu-scroll::-webkit-scrollbar-thumb:hover { + background-color: #3b1b76; +} + .react-calendar { width: 100% !important; border: none !important; @@ -122,7 +154,35 @@ margin-bottom: 5px; } -.calendar-week-mode .rbc-time-header-content .rbc-header:last-child, -.calendar-week-mode .rbc-time-content > .rbc-day-slot:last-child { - display: none !important; +.custom-shape-divider-bottom-1779710312 { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + overflow: hidden; + line-height: 0; + transform: rotate(180deg); +} + +.custom-shape-divider-bottom-1779710312 svg { + position: relative; + display: block; + width: calc(126% + 1.3px); + height: 124px; +} + +.custom-shape-divider-bottom-1779710312 .shape-fill { + fill: var(--academix-ink); +} + +@keyframes titleReveal { + from { + opacity: 0; + transform: translateY(2rem); + } + + to { + opacity: 1; + transform: translateY(0); + } } diff --git a/src/app/icon.png b/src/app/icon.png index 306873f..7983c44 100644 Binary files a/src/app/icon.png and b/src/app/icon.png differ diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 5432be3..3e1195c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,6 +3,8 @@ import { Inter } from "next/font/google"; import "./globals.css"; import { ClerkProvider } from "@clerk/nextjs"; import { ToastContainer } from "react-toastify"; +import { getLocale, getMessages } from "next-intl/server"; +import { NextIntlClientProvider } from "next-intl"; const inter = Inter({ subsets: ["latin"] }); @@ -11,17 +13,25 @@ export const metadata: Metadata = { description: "Next.js School Management System", }; -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + const locale = await getLocale(); + const messages = await getMessages(); + return ( - + - - {children} - + + + {children} + + diff --git a/src/app/page.tsx b/src/app/page.tsx index 4a5aad1..fc5aad2 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,16 +1,38 @@ -import Link from "next/link"; +import HeroSection from "@/components/home/HeroSection"; +import AboutSection from "@/components/home/AboutSection"; +import FeaturesSection from "@/components/home/FeaturesSection"; +import RolesSection from "@/components/home/RolesSection"; +import HowItWorksSection from "@/components/home/HowItWorksSection"; +import CtaSection from "@/components/home/CtaSection"; +import Footer from "@/components/home/Footer"; +import HomeNavbar from "@/components/home/HomeNavbar"; const HomePage = () => { return ( -
-

Welcome

-

This is the Academix portal.

- - Log In - +
+
+ + +
+ + + +
+
+ + + + + +
); }; diff --git a/src/app/post-login/page.tsx b/src/app/post-login/page.tsx new file mode 100644 index 0000000..2994cfb --- /dev/null +++ b/src/app/post-login/page.tsx @@ -0,0 +1,66 @@ +import { auth } from "@clerk/nextjs/server"; +import { redirect } from "next/navigation"; +import prisma from "@/lib/prisma"; +import { getRoleHome, type UserRole } from "@/lib/utils"; +import { SchoolStatus } from "@prisma/client"; + +async function resolveRoleAndSchoolStatusFromDb(userId: string): Promise<{ + role: UserRole | null; + schoolStatus: SchoolStatus | null; +}> { + const [admin, teacher, student, parent] = await Promise.all([ + prisma.admin.findUnique({ + where: { id: userId }, + select: { id: true, school: { select: { status: true } } }, + }), + prisma.teacher.findUnique({ + where: { id: userId }, + select: { id: true, school: { select: { status: true } } }, + }), + prisma.student.findUnique({ + where: { id: userId }, + select: { id: true, school: { select: { status: true } } }, + }), + prisma.parent.findUnique({ + where: { id: userId }, + select: { id: true, school: { select: { status: true } } }, + }), + ]); + + if (admin) return { role: "admin", schoolStatus: admin.school.status }; + if (teacher) return { role: "teacher", schoolStatus: teacher.school.status }; + if (student) return { role: "student", schoolStatus: student.school.status }; + if (parent) return { role: "parent", schoolStatus: parent.school.status }; + + return { role: null, schoolStatus: null }; +} + +export default async function PostLoginPage() { + const { userId, sessionClaims } = await auth(); + + if (!userId) { + redirect("/sign-in"); + } + + const roleFromClaims = (sessionClaims?.metadata as { role?: UserRole } | undefined) + ?.role; + const resolved = await resolveRoleAndSchoolStatusFromDb(userId); + const role = roleFromClaims ?? resolved.role; + + if (role === "admin" || role === "teacher" || role === "student" || role === "parent") { + const status = resolved.schoolStatus; + if (status === "PENDING" || status === "PAUSED") { + redirect("/school-access"); + } + + if (status !== "ACTIVE") { + redirect("/unauthorized"); + } + } + + if (role) { + redirect(getRoleHome(role)); + } + + redirect("/unauthorized"); +} diff --git a/src/app/school-access/page.tsx b/src/app/school-access/page.tsx new file mode 100644 index 0000000..b3e54c9 --- /dev/null +++ b/src/app/school-access/page.tsx @@ -0,0 +1,60 @@ +import { requireAuth, requireRole } from "@/lib/auth"; +import SignOutToSignInButton from "@/components/SignOutToSignInButton"; +import Image from "next/image"; +import { getTranslations } from "next-intl/server"; + +export default async function SchoolAccessPage() { + const user = await requireAuth(); + const commonT = await getTranslations("common"); + const t = await getTranslations("auth.schoolAccess"); + requireRole(user, ["admin", "teacher", "student", "parent"]); + + const isPending = user.schoolStatus === "PENDING"; + const isPaused = user.schoolStatus === "PAUSED"; + + return ( +
+
+

+ + {commonT("brand")} +

+ +

{t("title")}

+ {isPending && ( +

+ {t("pending")} +

+ )} + {isPaused && ( +
+

+ {t("paused")} +

+ {user.role === "admin" && ( +

+ {t("reason")}{" "} + + {user.schoolPauseReason || t("noReason")} + +

+ )} +
+ )} + {!isPending && !isPaused && ( +

{t("active")}

+ )} + +
+ +
+
+
+ ); +} diff --git a/src/app/school-signup/page.tsx b/src/app/school-signup/page.tsx new file mode 100644 index 0000000..39971a9 --- /dev/null +++ b/src/app/school-signup/page.tsx @@ -0,0 +1,69 @@ +"use client"; + +import SchoolSignupForm from "@/components/SchoolSignupForm"; +import Image from "next/image"; +import { useRouter } from "next/navigation"; +import { ArrowLeft } from "lucide-react"; +import { useTranslations } from "next-intl"; + +export default function SchoolSignupPage() { + const router = useRouter(); + const commonT = useTranslations("common"); + const t = useTranslations("schoolSignup.page"); + + return ( +
+
+
+
+ +
+ + + + {commonT("brand")} + +
+

+ {t("titleStart")}{" "} + + {t("titleHighlight")} + +

+

+ {t("description")} +

+
+ +
+

+ 1. {t("steps.profile")} +

+

+ 2. {t("steps.verification")} +

+

+ 3. {t("steps.access")} +

+
+
+ +
+ +
+
+
+ ); +} diff --git a/src/app/sign-in/page.tsx b/src/app/sign-in/page.tsx index 1aa9353..a3560ac 100644 --- a/src/app/sign-in/page.tsx +++ b/src/app/sign-in/page.tsx @@ -1,11 +1,12 @@ "use client"; import LoginContent from "@/components/LoginContent"; +import LoadingIndicator from "@/components/loaders/LoadingIndicator"; import { Suspense } from "react"; export default function LoginPage() { return ( - Loading...
}> + }> ); diff --git a/src/app/unauthorized/page.tsx b/src/app/unauthorized/page.tsx new file mode 100644 index 0000000..e51e784 --- /dev/null +++ b/src/app/unauthorized/page.tsx @@ -0,0 +1,36 @@ +import Image from "next/image"; +import SignOutToSignInButton from "@/components/SignOutToSignInButton"; +import { getTranslations } from "next-intl/server"; + +export default async function UnauthorizedPage() { + const t = await getTranslations("states"); + const commonT = await getTranslations("common"); + + return ( +
+
+

+ + {commonT("brand")} +

+ +

+ {t("unauthorizedTitle")} +

+

+ {t("unauthorizedDescription")} +

+ +
+ +
+
+
+ ); +} diff --git a/src/components/AcademicYearFilter.tsx b/src/components/AcademicYearFilter.tsx new file mode 100644 index 0000000..c7ade2d --- /dev/null +++ b/src/components/AcademicYearFilter.tsx @@ -0,0 +1,47 @@ +"use client"; + +import type { AcademicYearItem } from "@/lib/academicYears"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useTranslations } from "next-intl"; + +type AcademicYearFilterProps = { + academicYears: Pick[]; + currentAcademicYearId: number; +}; + +const AcademicYearFilter = ({ + academicYears, + currentAcademicYearId, +}: AcademicYearFilterProps) => { + const t = useTranslations("filters"); + const pathname = usePathname(); + const router = useRouter(); + const searchParams = useSearchParams(); + const selectedAcademicYearId = + searchParams.get("academicYearId") ?? String(currentAcademicYearId); + + const handleChange = (academicYearId: string) => { + const params = new URLSearchParams(searchParams.toString()); + + params.set("academicYearId", academicYearId); + params.set("page", "1"); + router.push(`${pathname}?${params.toString()}`); + }; + + return ( + + ); +}; + +export default AcademicYearFilter; diff --git a/src/components/AcademicYearForm.tsx b/src/components/AcademicYearForm.tsx index ea80baf..0278f07 100644 --- a/src/components/AcademicYearForm.tsx +++ b/src/components/AcademicYearForm.tsx @@ -2,7 +2,9 @@ import { createAcademicYear, updateAcademicYear } from "@/lib/actions"; import type { AcademicYearItem } from "@/lib/academicYears"; +import { X } from "lucide-react"; import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; import { useMemo, useState, useTransition } from "react"; import { toast } from "react-toastify"; @@ -26,9 +28,13 @@ const emptyFormState: FormState = { }; const AcademicYearForm = ({ academicYears }: Props) => { + const th = useTranslations("tableHeaders"); + const t = useTranslations("settings.academicYears"); + const actionsT = useTranslations("actions"); const router = useRouter(); const [isSubmitting, startTransition] = useTransition(); const [form, setForm] = useState(emptyFormState); + const [isModalOpen, setIsModalOpen] = useState(false); const sortedYears = useMemo( () => @@ -44,10 +50,12 @@ const AcademicYearForm = ({ academicYears }: Props) => { endDate: year.endDate, isCurrent: year.isCurrent, }); + setIsModalOpen(true); }; const onReset = () => { setForm(emptyFormState); + setIsModalOpen(false); }; const onSubmit = (e: React.FormEvent) => { @@ -67,110 +75,52 @@ const AcademicYearForm = ({ academicYears }: Props) => { : await createAcademicYear({ success: false, error: false }, payload); if (result.success) { - toast(form.id ? "Academic year updated." : "Academic year created."); + toast(form.id ? t("updated") : t("created")); onReset(); router.refresh(); return; } - toast.error(result.message ?? "Something went wrong!"); + toast.error(result.message ?? actionsT("somethingWentWrong")); }); }; return (
-
-
- - - - - +
+
+

{t("title")}

+

+ {t("description")} +

- - - -
- - - {form.id ? ( - - ) : null} -
- + +
{school.name}{school.admins[0]?.username ?? "-"} + + + {school.pauseReason ?? "-"} + + +
- - - - - + + + + + {sortedYears.length === 0 ? ( ) : ( @@ -180,14 +130,16 @@ const AcademicYearForm = ({ academicYears }: Props) => { - + @@ -196,6 +148,115 @@ const AcademicYearForm = ({ academicYears }: Props) => {
NameRangeCurrentActions
{th("name")}{th("range")}{th("current")}{th("actions")}
- No academic years yet. + {t("empty")}
{year.startDate} - {year.endDate} {year.isCurrent ? "Yes" : "No"} + {year.isCurrent ? actionsT("yes") : actionsT("no")} +
+ + {isModalOpen ? ( +
{ + if (event.target === event.currentTarget) { + onReset(); + } + }} + > +
event.stopPropagation()} + > +
+

+ {form.id ? t("editTitle") : t("add")} +

+ +
+ +
+ + + + + +
+ + + +
+ +
+
+
+ ) : null}
); }; diff --git a/src/components/AiEvaluationCard.tsx b/src/components/AiEvaluationCard.tsx new file mode 100644 index 0000000..35013dd --- /dev/null +++ b/src/components/AiEvaluationCard.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { AlertTriangle, Loader2, Sparkles } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useMemo, useTransition } from "react"; +import { toast } from "react-toastify"; + +type AiActionResult = { + success: boolean; + error: boolean; + message?: string; +}; + +export type AiEvaluationCardData = { + id: number; + status: string; + score: number | null; + maxScore: number; + feedback: string | null; + strengths: unknown; + weaknesses: unknown; + needsReview: boolean; + error: string | null; + approvedScore: number | null; + approvedFeedback: string | null; +}; + +type Props = { + evaluation: AiEvaluationCardData | null; + maxScore: number; + disabledMessage?: string; + onEvaluate: () => Promise; +}; + +const toList = (value: unknown) => { + if (!Array.isArray(value)) return []; + return value.map((item) => String(item).trim()).filter(Boolean); +}; + +export default function AiEvaluationCard({ + evaluation, + maxScore, + disabledMessage, + onEvaluate, +}: Props) { + const t = useTranslations("aiEvaluation"); + const router = useRouter(); + const [isEvaluating, startEvaluation] = useTransition(); + const strengths = useMemo(() => toList(evaluation?.strengths), [evaluation]); + const weaknesses = useMemo(() => toList(evaluation?.weaknesses), [evaluation]); + + const handleEvaluate = () => { + startEvaluation(async () => { + const result = await onEvaluate(); + if (result.success) { + toast.success(result.message ?? t("saved")); + router.refresh(); + } else { + toast.error(result.message ?? t("failed")); + router.refresh(); + } + }); + }; + + return ( +
+
+
+ + {t("title")} +
+ +
+ + {disabledMessage && ( +

{disabledMessage}

+ )} + + {evaluation?.status === "FAILED" && ( +
+ + {evaluation.error ?? t("manualFallback")} +
+ )} + + {evaluation && evaluation.status !== "FAILED" && ( +
+
+ + {t("appliedDraftScore", { + score: evaluation.score ?? "-", + maxScore, + })} + + {evaluation.needsReview && ( + + {t("needsReview")} + + )} +
+ +

+ {evaluation.feedback ?? t("noFeedback")} +

+ + {(strengths.length > 0 || weaknesses.length > 0) && ( +
+
+

+ {t("strengths")} +

+
    + {strengths.map((item) => ( +
  • - {item}
  • + ))} +
+
+
+

+ {t("weaknesses")} +

+
    + {weaknesses.map((item) => ( +
  • - {item}
  • + ))} +
+
+
+ )} +
+ )} +
+ ); +} diff --git a/src/components/Announcements.tsx b/src/components/Announcements.tsx index bc280b1..f8aad31 100644 --- a/src/components/Announcements.tsx +++ b/src/components/Announcements.tsx @@ -3,8 +3,12 @@ import { getCurrentAcademicYearIdOrNull } from "@/lib/academicYears"; import prisma from "@/lib/prisma"; import { Prisma } from "@prisma/client"; import NoCurrentAcademicYearMessage from "./NoCurrentAcademicYearMessage"; +import Link from "next/link"; +import { getTranslations } from "next-intl/server"; const Announcements = async () => { + const t = await getTranslations("pages"); + const widgetsT = await getTranslations("widgets"); const user = await requireAuth(); const { role, userId, schoolId } = user; const academicYearId = await getCurrentAcademicYearIdOrNull(schoolId); @@ -39,8 +43,13 @@ const Announcements = async () => { return (
-

Announcements

- View All +

{t("announcements")}

+ + {widgetsT("viewAll")} +
{data[0] && ( diff --git a/src/components/ArchiveFilters.tsx b/src/components/ArchiveFilters.tsx new file mode 100644 index 0000000..88be475 --- /dev/null +++ b/src/components/ArchiveFilters.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; + +type AcademicYear = { + id: number | string; + name: string; +}; + +const ArchiveFilters = ({ + academicYears, + selectedType, + selectedYear, +}: { + academicYears: AcademicYear[]; + selectedType: string; + selectedYear: string; +}) => { + const t = useTranslations("filters"); + const router = useRouter(); + + const updateFilters = (type: string, year: string) => { + router.push(`/archive?type=${type}&academicYearId=${year}`); + }; + + return ( +
+
+
+ + + +
+ +
+ + + +
+
+
+ ); +}; + +export default ArchiveFilters; diff --git a/src/components/AssignmentFileUpload.tsx b/src/components/AssignmentFileUpload.tsx new file mode 100644 index 0000000..1e60bd1 --- /dev/null +++ b/src/components/AssignmentFileUpload.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { useActionState, useEffect, useRef, useState } from "react"; +import { toast } from "react-toastify"; +import { Upload, Loader2, Paperclip, X } from "lucide-react"; +import { + uploadAssignmentFile, + AssignmentFileState, +} from "@/lib/actions/assignmentFile.actions"; +import { useTranslations } from "next-intl"; + +const initialState: AssignmentFileState = { + success: false, + error: false, + message: "", +}; + +type Props = { + assignmentId: number; + assignmentTitle: string; + currentFileUrl?: string | null; + currentFileName?: string | null; +}; + +export default function AssignmentFileUpload({ + assignmentId, + assignmentTitle, + currentFileUrl, + currentFileName, +}: Props) { + const actionsT = useTranslations("actions"); + const [open, setOpen] = useState(false); + const formRef = useRef(null); + + const [state, formAction, isPending] = useActionState( + uploadAssignmentFile, + initialState + ); + + useEffect(() => { + if (state.success) { + toast.success(state.message); + formRef.current?.reset(); + queueMicrotask(() => setOpen(false)); + } else if (state.error) { + toast.error(state.message); + } + }, [state]); + + return ( +
+ {/* زر فتح الفورم */} + + + {/* فورم الرفع */} + {open && ( +
+
+
+

+ Attach File to Assignment +

+ +
+ +

+ {assignmentTitle} +

+ + {currentFileUrl && ( +
+ Current file: {currentFileName} +
+ Uploading a new file will replace it. +
+ )} + +
+ + +
+ + +
+ +
+ + +
+
+
+
+ )} +
+ ); +} diff --git a/src/components/AssignmentSubmit.tsx b/src/components/AssignmentSubmit.tsx new file mode 100644 index 0000000..44720ff --- /dev/null +++ b/src/components/AssignmentSubmit.tsx @@ -0,0 +1,360 @@ +"use client"; + +import { useRef, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { toast } from "react-toastify"; +import { + Upload, + Loader2, + X, + RefreshCw, + Download, + CheckCircle, + Clock, +} from "lucide-react"; +import { submitAssignment } from "@/lib/actions/submission.actions"; + +type Props = { + assignmentId: number; + assignmentTitle: string; + maxScore: number; + endDate: Date; + allowLateSubmission: boolean; // ✅ prop جديد + existingSubmission?: { + id?: number; + fileUrl: string; + fileName: string; + createdAt: Date; + note?: string | null; + teacherFeedback?: string | null; + score?: number | null; + gradePublished?: boolean; + } | null; +}; + +export default function AssignmentSubmit({ + assignmentId, + assignmentTitle, + maxScore, + endDate, + allowLateSubmission, + existingSubmission, +}: Props) { + const t = useTranslations("assignmentsPage"); + const [open, setOpen] = useState(false); + const [selectedFileName, setSelectedFileName] = useState(""); + const formRef = useRef(null); + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + const isOverdue = new Date() > new Date(endDate); + + // ✅ المنطق الجديد: + // يمكن التسليم إذا: لم ينتهِ الوقت، أو انتهى لكن المعلم سمح بالتسليم المتأخر + const canSubmit = !isOverdue || allowLateSubmission; + const isLate = isOverdue && allowLateSubmission; + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + + startTransition(async () => { + const result = await submitAssignment( + { success: false, error: false, message: "" }, + formData, + ); + if (result.success) { + toast.success(result.message); + formRef.current?.reset(); + setSelectedFileName(""); + setOpen(false); + router.refresh(); + } else { + toast.error(result.message); + } + }); + } + + // ─── زر الحالة ─────────────────────────────────────────────────────────── + const renderButton = () => { + // عنده تسليم + انتهى الوقت + ما في سماح بالتأخير = عرض فقط + if (existingSubmission && isOverdue && !allowLateSubmission) { + return ( + + ); + } + + // عنده تسليم + يمكن التعديل (قبل deadline أو late مسموح) + if (existingSubmission && canSubmit) { + return ( + + ); + } + + // ما عنده تسليم + if (!existingSubmission) { + // انتهى الوقت وما في سماح = زر معطّل + if (isOverdue && !allowLateSubmission) { + return ( + + ); + } + + // يمكن التسليم (قبل deadline أو late مسموح) + return ( + + ); + } + + return null; + }; + + // ─── هل يُفتح المودال؟ ──────────────────────────────────────────────────── + const canOpenModal = + canSubmit || (isOverdue && !allowLateSubmission && !!existingSubmission); // view-only + + const isViewOnly = isOverdue && !allowLateSubmission && !!existingSubmission; + + return ( +
+ {renderButton()} + + {/* ========== المودال ========== */} + {open && canOpenModal && ( +
+
+ {/* Header */} +
+

+ {isViewOnly + ? t("modal.mySubmission") + : existingSubmission + ? t("modal.replaceSubmittedFile") + : t("modal.submitAssignment")} +

+ +
+ +

+ {assignmentTitle} +

+

+ {t("modal.due", { + date: new Date(endDate).toLocaleDateString("en-GB"), + })} +

+ + {/* تنبيه التسليم المتأخر */} + {isLate && ( +
+ +

+ {t("modal.lateWarning")} +

+
+ )} + + {existingSubmission && ( +
+ +
+

+ {t("status.assignmentSubmitted")} +

+

+ {existingSubmission.gradePublished + ? t("status.gradePublishedMessage") + : t("status.waitingReview")} +

+
+
+ )} + + {/* الملف المسلَّم */} + {existingSubmission && ( +
+

+ {t("modal.submittedFile")} +

+
+ + {existingSubmission.fileName} + + + + +
+

+ {t("modal.submittedDate", { + date: new Date(existingSubmission.createdAt).toLocaleDateString( + "en-GB", + ), + })} +

+ {!isViewOnly && ( +

+ {t("modal.replaceWarning")} +

+ )} +
+ )} + + {/* Published Grade */} + {existingSubmission?.gradePublished && existingSubmission.score !== null && existingSubmission.score !== undefined && ( +
+
+

+ {t("modal.publishedGrade")} +

+

+ {existingSubmission.score}/{maxScore} +

+
+
+ )} + + {/* فورم التسليم — يظهر فقط إذا مسموح بالتسليم */} + {!isViewOnly && ( +
+ + +
+ + + setSelectedFileName(event.target.files?.[0]?.name ?? "") + } + className="sr-only" + /> +
+ + + {selectedFileName || t("modal.noFileChosen")} + +
+

+ {t("modal.pdfHint")} +

+
+ +
+ +