From 28e08dc853b1cd64733f2360b9208d0e5506c256 Mon Sep 17 00:00:00 2001 From: Mohammed Alzaq Date: Tue, 12 May 2026 18:43:56 +0300 Subject: [PATCH 001/151] search ,filtter ,export --- prisma/seed.ts | 41 +-- src/app/(dashboard)/list/assignments/page.tsx | 188 ++++-------- src/app/(dashboard)/list/classes/page.tsx | 90 +++--- src/app/(dashboard)/list/exams/page.tsx | 230 +++++++------- src/app/(dashboard)/list/lessons/page.tsx | 24 +- src/app/(dashboard)/list/parents/page.tsx | 100 +++---- src/app/(dashboard)/list/results/page.tsx | 281 ++++++------------ src/app/(dashboard)/list/students/page.tsx | 135 +++------ src/app/(dashboard)/list/subjects/page.tsx | 124 +++----- src/app/(dashboard)/list/teachers/page.tsx | 87 +++--- src/app/api/admin/assignments/export/route.ts | 96 ++++++ src/app/api/admin/classes/export/route.ts | 61 ++++ src/app/api/admin/exams/export/route.ts | 94 ++++++ src/app/api/admin/lessons/export/route.ts | 88 ++++++ src/app/api/admin/parents/export/route.ts | 67 +++++ src/app/api/admin/results/export/route.ts | 53 ++++ src/app/api/admin/students/export/route.ts | 72 +++++ src/app/api/admin/subjects/export/route.ts | 56 ++++ src/app/api/admin/teachers/export/route.ts | 65 ++++ src/components/ExportButton.tsx | 24 ++ src/components/FilterSortActions.tsx | 64 +++- src/lib/actions/student.actions.ts | 39 ++- src/lib/csv.ts | 28 ++ src/lib/query-builders/assignment-query.ts | 148 +++++++++ src/lib/query-builders/class-query.ts | 119 ++++++++ src/lib/query-builders/exam-query.ts | 145 +++++++++ src/lib/query-builders/parent-query.ts | 82 +++++ src/lib/query-builders/result-query.ts | 122 ++++++++ src/lib/query-builders/student-query.ts | 129 ++++++++ src/lib/query-builders/subject-query.ts | 83 ++++++ src/lib/query-builders/teacher-query.ts | 77 +++++ 31 files changed, 2205 insertions(+), 807 deletions(-) create mode 100644 src/app/api/admin/assignments/export/route.ts create mode 100644 src/app/api/admin/classes/export/route.ts create mode 100644 src/app/api/admin/exams/export/route.ts create mode 100644 src/app/api/admin/lessons/export/route.ts create mode 100644 src/app/api/admin/parents/export/route.ts create mode 100644 src/app/api/admin/results/export/route.ts create mode 100644 src/app/api/admin/students/export/route.ts create mode 100644 src/app/api/admin/subjects/export/route.ts create mode 100644 src/app/api/admin/teachers/export/route.ts create mode 100644 src/components/ExportButton.tsx create mode 100644 src/lib/csv.ts create mode 100644 src/lib/query-builders/assignment-query.ts create mode 100644 src/lib/query-builders/class-query.ts create mode 100644 src/lib/query-builders/exam-query.ts create mode 100644 src/lib/query-builders/parent-query.ts create mode 100644 src/lib/query-builders/result-query.ts create mode 100644 src/lib/query-builders/student-query.ts create mode 100644 src/lib/query-builders/subject-query.ts create mode 100644 src/lib/query-builders/teacher-query.ts diff --git a/prisma/seed.ts b/prisma/seed.ts index cab1681..7d686c7 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,5 +1,5 @@ import "dotenv/config"; -import { Day, PrismaClient } from "@prisma/client"; +import { Day, PrismaClient, StudentStatus, UserSex } from "@prisma/client"; import { PrismaPg } from "@prisma/adapter-pg"; const adapter = new PrismaPg({ @@ -175,24 +175,27 @@ const seedSchool = async ({ }, }); - const student = await prisma.student.create({ - data: { - id: studentId, - username: studentUsername, - name: studentName, - email: `${studentUsername}@example.com`, - phone: `${school.id}33333333`, - address: `${schoolName} City`, - bloodType: "A+", - sex: "MALE", - birthday: new Date("2010-01-01"), - schoolId: school.id, - parentId: parent.id, - classId: classA.id, - gradeId: grade1.id, - status: "ACTIVE", - }, - }); +const student = await prisma.student.create({ + data: { + id: studentId, + username: studentUsername, + name: studentName, + email: `${studentUsername}@example.com`, + phone: `${school.id}33333333`, + address: `${schoolName} City`, + bloodType: "A+", + + sex: UserSex.MALE, + birthday: new Date("2010-01-01"), + + schoolId: school.id, + parentId: parent.id, + classId: classA.id, + gradeId: grade1.id, + + status: StudentStatus.ACTIVE, + }, +}); await prisma.studentAcademicYear.create({ data: { diff --git a/src/app/(dashboard)/list/assignments/page.tsx b/src/app/(dashboard)/list/assignments/page.tsx index 0585104..411483d 100644 --- a/src/app/(dashboard)/list/assignments/page.tsx +++ b/src/app/(dashboard)/list/assignments/page.tsx @@ -1,3 +1,4 @@ +import ExportButton from "@/components/ExportButton"; import FilterSortActions from "@/components/FilterSortActions"; import FormContainer from "@/components/FormContainer"; import NoCurrentAcademicYearMessage from "@/components/NoCurrentAcademicYearMessage"; @@ -5,12 +6,12 @@ import Pagination from "@/components/Pagination"; import Table from "@/components/Table"; import TableSearch from "@/components/TableSearch"; 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"; type AssignmentList = Assignment & { subject: Pick | null; @@ -20,23 +21,20 @@ type AssignmentList = Assignment & { }; }; +const formatDateTime = (date: Date) => + new Intl.DateTimeFormat("en-US", { + dateStyle: "medium", + timeStyle: "short", + }).format(date); + const getColumns = (role: UserRole | null) => { const columns: { header: string; accessor: string; className?: string }[] = [ - { - header: "Title", - accessor: "title", - }, - { - header: "Subject", - accessor: "name", - }, + { header: "Title", accessor: "title" }, + { header: "Subject", accessor: "subject" }, ]; if (role !== "student") { - columns.push({ - header: "Class", - accessor: "class", - }); + columns.push({ header: "Class", accessor: "class" }); } if (role !== "teacher") { @@ -47,16 +45,10 @@ const getColumns = (role: UserRole | null) => { }); } - columns.push({ - header: "Start Date", - accessor: "startDate", - className: "hidden md:table-cell", - }); - columns.push({ header: "End Date", accessor: "endDate", - className: "hidden md:table-cell", + className: "hidden md:table-cell min-w-[180px] w-[180px]", }); columns.push({ @@ -73,23 +65,21 @@ const renderRow = (item: AssignmentList, role: UserRole | null) => ( className="hover:bg-academixPurpleLight even:bg-slate-50 border-gray-200 border-b text-sm" > {item.title} - {item.subject?.name} - {role !== "student" && {item.class?.name}} + + + {item.subject?.name ?? "-"} + + + {role !== "student" && {item.class?.name ?? "-"}} + {role !== "teacher" && ( {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)} + + + {formatDateTime(item.endDate)} +
{(role === "admin" || role === "teacher") && ( @@ -102,6 +92,7 @@ const renderRow = (item: AssignmentList, role: UserRole | null) => ( ); + const AssignmentListPage = async ({ searchParams, }: { @@ -109,100 +100,30 @@ const AssignmentListPage = async ({ }) => { 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; - - case "parent": - conditions.push({ - lesson: { - class: { - students: { - some: { parentId: userId }, - }, - }, - }, - }); - break; - } - if (conditions.length > 0) { - query.AND = conditions; - } + return value ? [[key, value]] : []; + }) + ); const [data, count] = await prisma.$transaction([ prisma.assignment.findMany({ @@ -216,7 +137,7 @@ const AssignmentListPage = async ({ }, }, }, - orderBy: { startDate: "desc" }, + orderBy, take: ITEM_PER_PAGE, skip: (p - 1) * ITEM_PER_PAGE, }), @@ -224,33 +145,44 @@ const AssignmentListPage = async ({ where: query, }), ]); + return (
- {/* TOP */}

All Assignments

+
+
- + + {(role === "admin" || role === "teacher") && ( - + <> + {role === "admin" && ( + + )} + + + )}
- {/* LIST */} + renderRow(item, role)} data={data} /> - {/* PAGINATION */} + ); }; -export default AssignmentListPage; +export default AssignmentListPage; \ No newline at end of file diff --git a/src/app/(dashboard)/list/classes/page.tsx b/src/app/(dashboard)/list/classes/page.tsx index f5b6166..0a1bfa7 100644 --- a/src/app/(dashboard)/list/classes/page.tsx +++ b/src/app/(dashboard)/list/classes/page.tsx @@ -1,27 +1,26 @@ +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 { 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) => [ - { - header: "Class Name", - accessor: "name", - }, + { header: "Class Name", accessor: "name" }, { header: "Capacity", accessor: "capacity", @@ -49,11 +48,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, }: { @@ -74,40 +78,21 @@ const ClassListPage = async ({ const { role, 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 = { - 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; - } + + const { query, orderBy, page: p } = await buildClassQuery({ + searchParams, + schoolId, +}); + + 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 +105,7 @@ const ClassListPage = async ({ }, }, }, - orderBy: { name: "asc" }, + orderBy, take: ITEM_PER_PAGE, skip: (p - 1) * ITEM_PER_PAGE, }), @@ -128,29 +113,40 @@ const ClassListPage = async ({ where: query, }), ]); + return (
- {/* TOP */}

All Classes

+
+
- - {role === "admin" && } + + + {role === "admin" && ( + <> + + + + + )}
- {/* LIST */} +
{item.name}{item.capacity}{item.grade?.level}{item.grade?.level ?? "-"} {item.supervisor?.name ?? "No supervisor"}
{role === "admin" && ( @@ -66,6 +69,7 @@ const renderRow = (item: ClassList, role: UserRole | null) => (
renderRow(item, role)} data={data} /> - {/* PAGINATION */} + ); }; -export default ClassListPage; +export default ClassListPage; \ No newline at end of file diff --git a/src/app/(dashboard)/list/exams/page.tsx b/src/app/(dashboard)/list/exams/page.tsx index 52a909a..1081f7a 100644 --- a/src/app/(dashboard)/list/exams/page.tsx +++ b/src/app/(dashboard)/list/exams/page.tsx @@ -1,3 +1,4 @@ +import ExportButton from "@/components/ExportButton"; import FilterSortActions from "@/components/FilterSortActions"; import FormContainer from "@/components/FormContainer"; import NoCurrentAcademicYearMessage from "@/components/NoCurrentAcademicYearMessage"; @@ -5,12 +6,12 @@ import Pagination from "@/components/Pagination"; import Table from "@/components/Table"; import TableSearch from "@/components/TableSearch"; 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"; type ExamList = Exam & { displayClasses?: string; @@ -21,15 +22,25 @@ type ExamList = Exam & { }; }; +const formatDateTime = (date: Date) => + new Intl.DateTimeFormat("en-US", { + dateStyle: "medium", + timeStyle: "short", + }).format(date); + const getColumns = (role: UserRole | null) => { - const columns: { header: string; accessor: string; className?: string }[] = [ + const columns: { + header: string; + accessor: string; + className?: string; + }[] = [ { header: "Title", accessor: "title", }, { header: "Subject", - accessor: "name", + accessor: "subject", }, ]; @@ -48,17 +59,18 @@ const getColumns = (role: UserRole | null) => { }); } - columns.push({ - header: "Start Time", - accessor: "startTime", - className: "hidden md:table-cell", - }); - - columns.push({ - header: "End Time", - accessor: "endTime", - className: "hidden md:table-cell", - }); + columns.push( + { + header: "Start Time", + accessor: "startTime", + className: "hidden md:table-cell min-w-[180px] w-[180px]", + }, + { + header: "End Time", + accessor: "endTime", + className: "hidden md:table-cell min-w-[180px] w-[180px]", + } + ); columns.push({ header: role === "admin" || role === "teacher" ? "Actions" : "", @@ -74,23 +86,29 @@ const renderRow = (item: ExamList, role: UserRole | null) => ( className="hover:bg-academixPurpleLight even:bg-slate-50 border-gray-200 border-b text-sm" > - - {role !== "student" && } + + + + {role !== "student" && ( + + )} + {role !== "teacher" && ( - + )} - - + ); + const ExamListPage = async ({ searchParams, }: { searchParams: PageSearchParams; }) => { - const { role, userId, schoolId } = await enforceRouteAccess("/list/exams"); + 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; - - case "student": - conditions.push({ - lesson: { - class: { - students: { - some: { id: userId }, - }, - }, - }, - }); - break; - - case "parent": - conditions.push({ - lesson: { - class: { - students: { - some: { parentId: userId }, - }, - }, - }, - }); - break; - } - - if (conditions.length > 0) { - query.AND = conditions; - } + return value ? [[key, value]] : []; + }) + ); const [data, count] = await prisma.$transaction([ prisma.exam.findMany({ where: query, include: { - subject: { select: { name: true } }, - class: { select: { name: true } }, + subject: { + select: { + name: true, + }, + }, + class: { + select: { + name: true, + }, + }, lesson: { select: { - teacher: { select: { name: true } }, + teacher: { + select: { + name: true, + }, + }, }, }, }, - orderBy: { startTime: "desc" }, + orderBy, take: ITEM_PER_PAGE, skip: (p - 1) * ITEM_PER_PAGE, }), @@ -251,6 +222,7 @@ const ExamListPage = async ({ ].join("|"); const groupedClasses = classGroups.get(groupKey); + return { ...exam, displayClasses: @@ -264,29 +236,41 @@ const ExamListPage = async ({ return (
- {/* TOP */}
-

All Exams

+

+ All Exams +

+
+
- + + {(role === "admin" || role === "teacher") && ( - + <> + {role === "admin" && ( + + )} + + + )}
- {/* LIST */} +
{item.title}{item.subject?.name}{item.displayClasses ?? item.class?.name} + {item.subject?.name ?? "-"} + {item.displayClasses ?? item.class?.name ?? "-"}{item.lesson.teacher.name} + {item.lesson.teacher.name} + - {new Intl.DateTimeFormat("en-US", { - dateStyle: "short", - timeStyle: "short", - }).format(item.startTime)} + + + {formatDateTime(item.startTime)} - {new Intl.DateTimeFormat("en-US", { - dateStyle: "short", - timeStyle: "short", - }).format(item.endTime)} + + + {formatDateTime(item.endTime)}
{(role === "admin" || role === "teacher") && ( @@ -103,115 +121,68 @@ const renderRow = (item: ExamList, role: UserRole | null) => (
renderRow(item, role)} data={dataWithClassDisplay} /> - {/* PAGINATION */} + ); }; -export default ExamListPage; +export default ExamListPage; \ No newline at end of file diff --git a/src/app/(dashboard)/list/lessons/page.tsx b/src/app/(dashboard)/list/lessons/page.tsx index 4360356..3670199 100644 --- a/src/app/(dashboard)/list/lessons/page.tsx +++ b/src/app/(dashboard)/list/lessons/page.tsx @@ -1,4 +1,5 @@ import BigCalendarContainer from "@/components/BigCalendarContainer"; +import ExportButton from "@/components/ExportButton"; import FormContainer from "@/components/FormContainer"; import { enforceRouteAccess } from "@/lib/enforce-route-access"; import { computeClassSelection } from "@/lib/lessons/classSelection"; @@ -33,7 +34,6 @@ const LessonListPage = async ({ selectedGrade: grade, }); - // preserve params const baseParams: Record = {}; for (const [key, value] of Object.entries(resolvedSearchParams)) { @@ -58,10 +58,12 @@ const LessonListPage = async ({ params.set("classId", classId.toString()); return `/list/lessons?${params.toString()}`; }; + return (

Lessons Calendar

+ {role === "admin" && selectedClass && (
Grade: + {availableGrades.map((gradeLevel) => ( Classes: + {filteredClasses.map((item) => { const isActive = selectedClass?.id === item.id; + return ( -

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

+
+

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

+ + {role === "admin" && ( + + )} +
+
) : ( @@ -138,4 +152,4 @@ const LessonListPage = async ({ ); }; -export default LessonListPage; +export default LessonListPage; \ No newline at end of file diff --git a/src/app/(dashboard)/list/parents/page.tsx b/src/app/(dashboard)/list/parents/page.tsx index 6c4131d..67febc1 100644 --- a/src/app/(dashboard)/list/parents/page.tsx +++ b/src/app/(dashboard)/list/parents/page.tsx @@ -1,22 +1,21 @@ +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 { 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) => [ - { - header: "Info", - accessor: "info", - }, + { header: "Info", accessor: "info" }, { header: "Student Names", accessor: "students", @@ -46,14 +45,17 @@ const renderRow = (item: ParentList, role: UserRole | null) => (
+ + + ); + const ParentListPage = async ({ searchParams, }: { searchParams: PageSearchParams; }) => { 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 +101,7 @@ const ParentListPage = async ({ include: { students: true, }, - orderBy: { name: "asc" }, + orderBy, take: ITEM_PER_PAGE, skip: (p - 1) * ITEM_PER_PAGE, }), @@ -130,29 +109,40 @@ const ParentListPage = async ({ where: query, }), ]); + return (
- {/* TOP */}

All Parents

+
+
- - {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 +68,32 @@ const renderRow = (item: ParentList, role: UserRole | null) => (
renderRow(item, role)} data={data} /> - {/* PAGINATION */} + ); }; -export default ParentListPage; +export default ParentListPage; \ No newline at end of file diff --git a/src/app/(dashboard)/list/results/page.tsx b/src/app/(dashboard)/list/results/page.tsx index e50f307..7ec164b 100644 --- a/src/app/(dashboard)/list/results/page.tsx +++ b/src/app/(dashboard)/list/results/page.tsx @@ -1,3 +1,4 @@ +import ExportButton from "@/components/ExportButton"; import FilterSortActions from "@/components/FilterSortActions"; import FormContainer from "@/components/FormContainer"; import NoCurrentAcademicYearMessage from "@/components/NoCurrentAcademicYearMessage"; @@ -5,76 +6,44 @@ import Pagination from "@/components/Pagination"; import Table from "@/components/Table"; import TableSearch from "@/components/TableSearch"; 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 columns = [ { - header: "Title", - accessor: "name", + header: "Student", + accessor: "student", }, - ...(role !== "student" - ? [ - { - header: "Student", - accessor: "student", - }, - ] - : []), { - header: "Subject", - accessor: "subject", - className: "hidden md:table-cell", + header: "Assessment", + accessor: "assessment", }, { header: "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 === "admin" || role === "teacher") { + columns.push({ + header: "Actions", + accessor: "action", + }); + } + return columns; }; @@ -83,140 +52,89 @@ 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 === "admin" || role === "teacher") && ( + )} - - - ); + const ResultListPage = async ({ searchParams, }: { searchParams: PageSearchParams; }) => { - const { role, userId, schoolId } = await enforceRouteAccess("/list/results"); + 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, }), @@ -225,54 +143,41 @@ const ResultListPage = async ({ }), ]); - 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, - }, - ]; - }); - return (
- {/* TOP */}

All Results

+
+
- + + {(role === "admin" || role === "teacher") && ( - + <> + {role === "admin" && ( + + )} + + + )}
- {/* LIST */} +
{item.title}{item.studentName}{item.subjectName} + + {item.student.name} + {item.exam?.title || item.assignment?.title || "-"}{item.score}{item.teacherName} +
+ + +
+
{item.className} - {new Intl.DateTimeFormat("en-US").format(item.startTime)} - -
- {(role === "admin" || role === "teacher") && ( - <> - - - - )} -
-
renderRow(item, role)} data={data} /> - {/* PAGINATION */} + ); }; -export default ResultListPage; +export default ResultListPage; \ No newline at end of file diff --git a/src/app/(dashboard)/list/students/page.tsx b/src/app/(dashboard)/list/students/page.tsx index ae8f256..f106d2e 100644 --- a/src/app/(dashboard)/list/students/page.tsx +++ b/src/app/(dashboard)/list/students/page.tsx @@ -1,3 +1,5 @@ +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"; @@ -9,23 +11,21 @@ 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"; type StudentList = Student & { class: Class }; const getColumns = (role: UserRole | null) => [ - { - header: "Info", - accessor: "info", - }, + { header: "Info", accessor: "info" }, { header: "Student ID", accessor: "studentId", @@ -70,10 +70,12 @@ const renderRow = (item: StudentList, role: UserRole | null) => (

{item.class.name}

+
+ ); + const StudentListPage = async ({ searchParams, }: { searchParams: PageSearchParams; }) => { 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) : []; @@ -103,94 +109,28 @@ const StudentListPage = async ({ 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 conditions: Prisma.StudentWhereInput[] = []; - - if (selectedStatus === "REPEATED") { - query.repeatCount = Number.isNaN(selectedRepeatCount) - ? 1 - : selectedRepeatCount; - } + const resolvedSearchParams = await searchParams; - 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 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({ - class: { - lessons: { - some: { teacherId: userId }, - }, - }, - }); - break; - } + return value ? [[key, value]] : []; + }) + ); - if (conditions.length > 0) { - query.AND = conditions; + if (!exportQuery.get("academicYearId")) { + exportQuery.set("academicYearId", String(academicYearId)); } const [data, count] = await prisma.$transaction([ @@ -199,7 +139,7 @@ const StudentListPage = async ({ include: { class: true, }, - orderBy: { name: "asc" }, + orderBy, take: ITEM_PER_PAGE, skip: (p - 1) * ITEM_PER_PAGE, }), @@ -210,40 +150,49 @@ const StudentListPage = async ({ return (
- {/* TOP */}

All Students

+
+ {role === "admin" && ( )} +
+ + {role === "admin" && ( <> + + + )}
- {/* LIST */} +
{item.username} {item.class.name[0]} {item.phone} {item.address}
@@ -81,6 +83,7 @@ const renderRow = (item: StudentList, role: UserRole | null) => ( + {role === "admin" && ( )} @@ -88,14 +91,17 @@ const renderRow = (item: StudentList, role: UserRole | null) => (
renderRow(item, role)} data={data} /> - {/* PAGINATION */} + ); }; -export default StudentListPage; +export default StudentListPage; \ No newline at end of file diff --git a/src/app/(dashboard)/list/subjects/page.tsx b/src/app/(dashboard)/list/subjects/page.tsx index 773519e..d91bd01 100644 --- a/src/app/(dashboard)/list/subjects/page.tsx +++ b/src/app/(dashboard)/list/subjects/page.tsx @@ -1,14 +1,16 @@ +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 { 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"; type SubjectList = Subject & { teachers: Teacher[]; @@ -42,10 +44,13 @@ const renderRow = (item: SubjectList, role: UserRole | null) => ( className="hover:bg-academixPurpleLight even:bg-slate-50 border-gray-200 border-b text-sm" > + + + ); + const SubjectListPage = async ({ searchParams, }: { searchParams: PageSearchParams; }) => { 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,7 +97,7 @@ const SubjectListPage = async ({ teachers: true, grade: true, }, - orderBy: { name: "asc" }, + orderBy, take: ITEM_PER_PAGE, skip: (p - 1) * ITEM_PER_PAGE, }), @@ -160,31 +105,40 @@ const SubjectListPage = async ({ where: query, }), ]); + return (
- {/* TOP */}

All Subjects

+
+
- + + {role === "admin" && ( - + <> + + + + )}
- {/* LIST */} +
{item.name}{item.grade?.level ?? "-"} - {item.teachers.map((t) => t.name).join(", ")} + {item.teachers.map((teacher) => teacher.name).join(", ")}
{role === "admin" && ( @@ -58,92 +63,32 @@ const renderRow = (item: SubjectList, role: UserRole | null) => (
renderRow(item, role)} data={data} /> - {/* PAGINATION */} + ); }; -export default SubjectListPage; +export default SubjectListPage; \ No newline at end of file diff --git a/src/app/(dashboard)/list/teachers/page.tsx b/src/app/(dashboard)/list/teachers/page.tsx index 2dd78c9..f52903b 100644 --- a/src/app/(dashboard)/list/teachers/page.tsx +++ b/src/app/(dashboard)/list/teachers/page.tsx @@ -1,27 +1,26 @@ +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 { 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"; type TeacherList = Teacher & { subjects: Subject[]; }; const getColumns = (role: UserRole | null) => [ - { - header: "Info", - accessor: "info", - }, + { header: "Info", accessor: "info" }, { header: "Teacher ID", accessor: "teacherId", @@ -63,15 +62,19 @@ const renderRow = (item: TeacherList, role: UserRole | null) => ( />

{item.name}

-

{item?.email}

+

{item.email}

+
+ + + ); + const TeacherListPage = async ({ searchParams, }: { @@ -93,42 +98,22 @@ const TeacherListPage = async ({ }) => { 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 [data, count] = await prisma.$transaction([ prisma.teacher.findMany({ @@ -136,7 +121,7 @@ const TeacherListPage = async ({ include: { subjects: true, }, - orderBy: { name: "asc" }, + orderBy, take: ITEM_PER_PAGE, skip: (p - 1) * ITEM_PER_PAGE, }), @@ -147,29 +132,37 @@ const TeacherListPage = async ({ return (
- {/* TOP */}

All Teachers

+
+
- + + {role === "admin" && ( - + <> + + + + )}
- {/* LIST */} +
{item.username} {item.subjects.map((subject) => subject.name).join(", ")} {item.phone} {item.address}
@@ -79,6 +82,7 @@ const renderRow = (item: TeacherList, role: UserRole | null) => ( + {role === "admin" && ( )} @@ -86,6 +90,7 @@ const renderRow = (item: TeacherList, role: UserRole | null) => (
renderRow(item, role)} data={data} /> - {/* PAGINATION */} + ); }; -export default TeacherListPage; +export default TeacherListPage; \ No newline at end of file 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..fbfd86f --- /dev/null +++ b/src/app/api/admin/assignments/export/route.ts @@ -0,0 +1,96 @@ +import { buildAssignmentQuery } from "@/lib/query-builders/assignment-query"; +import { createCsvResponse, generateCsv } from "@/lib/csv"; +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( + Object.fromEntries(request.nextUrl.searchParams.entries()) +); + +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, + }, + }, + }, + }, + }, + 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.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); +} \ No newline at end of file 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..74d7bac --- /dev/null +++ b/src/app/api/admin/classes/export/route.ts @@ -0,0 +1,61 @@ +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 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( + Object.fromEntries(req.nextUrl.searchParams.entries()) + ); + + const { query } = await buildClassQuery({ + searchParams, + schoolId, + }); + + const classes = await prisma.class.findMany({ + where: query, + include: { + supervisor: true, + grade: { + select: { + level: true, + }, + }, + }, + orderBy: { + name: "asc", + }, + }); + + 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); +} \ No newline at end of file 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..7ea7cf0 --- /dev/null +++ b/src/app/api/admin/exams/export/route.ts @@ -0,0 +1,94 @@ +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 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 } = await buildExamQuery({ + searchParams: Promise.resolve( + Object.fromEntries(req.nextUrl.searchParams.entries()) + ), + 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, + }, + }, + }, + }, + }, + orderBy: { + startTime: "desc", + }, + }); + + 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.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); +} \ No newline at end of file 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..47839a6 --- /dev/null +++ b/src/app/api/admin/parents/export/route.ts @@ -0,0 +1,67 @@ +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 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( + Object.fromEntries(req.nextUrl.searchParams.entries()) + ); + + 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); +} \ No newline at end of file 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..295b4c7 --- /dev/null +++ b/src/app/api/admin/results/export/route.ts @@ -0,0 +1,53 @@ +import { buildResultQuery } from "@/lib/query-builders/result-query"; +import { createCsvResponse, generateCsv } from "@/lib/csv"; +import { enforceRouteAccess } from "@/lib/enforce-route-access"; +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( + Object.fromEntries(request.nextUrl.searchParams.entries()) +); + + 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); +} \ No newline at end of file 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..eac53a7 --- /dev/null +++ b/src/app/api/admin/students/export/route.ts @@ -0,0 +1,72 @@ +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 { + 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( + Object.fromEntries(req.nextUrl.searchParams.entries()) + ); + + const { query } = await buildStudentQuery({ + searchParams, + schoolId, + currentAcademicYearId: currentAcademicYear.id, + role, + userId, + }); + + const students = await prisma.student.findMany({ + where: query, + include: { + class: true, + }, + orderBy: { + name: "asc", + }, + }); + + 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); +} \ No newline at end of file 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..230e575 --- /dev/null +++ b/src/app/api/admin/subjects/export/route.ts @@ -0,0 +1,56 @@ +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 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( + Object.fromEntries(req.nextUrl.searchParams.entries()) + ); + + const { query } = await buildSubjectQuery({ + searchParams, + schoolId, + role, + userId, + }); + + const subjects = await prisma.subject.findMany({ + where: query, + include: { + teachers: true, + grade: true, + }, + orderBy: { + name: "asc", + }, + }); + + 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); +} \ No newline at end of file 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..e7dcdd5 --- /dev/null +++ b/src/app/api/admin/teachers/export/route.ts @@ -0,0 +1,65 @@ +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 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( + Object.fromEntries(req.nextUrl.searchParams.entries()) + ); + + const { query } = await buildTeacherQuery({ + searchParams, + schoolId, + }); + + const teachers = await prisma.teacher.findMany({ + where: query, + include: { + subjects: true, + }, + orderBy: { + name: "asc", + }, + }); + + 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); +} \ No newline at end of file diff --git a/src/components/ExportButton.tsx b/src/components/ExportButton.tsx new file mode 100644 index 0000000..9533cbe --- /dev/null +++ b/src/components/ExportButton.tsx @@ -0,0 +1,24 @@ +import { Download } from "lucide-react"; +import Link from "next/link"; + +type ExportButtonProps = { + href: string; + title?: string; +}; + +const ExportButton = ({ + href, + title = "Export CSV", +}: ExportButtonProps) => { + return ( + + + + ); +}; + +export default ExportButton; \ No newline at end of file diff --git a/src/components/FilterSortActions.tsx b/src/components/FilterSortActions.tsx index 4e00290..e60be8a 100644 --- a/src/components/FilterSortActions.tsx +++ b/src/components/FilterSortActions.tsx @@ -1,23 +1,71 @@ -import { Filter, ArrowUpDown } from "lucide-react"; +"use client"; + +import { ArrowUpDown, Filter } from "lucide-react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; type Props = { - onFilterClick?: () => void; - onSortClick?: () => void; + filterKey?: string; + filterValue?: string; + sortKey?: string; }; export default function FilterSortActions({ - onFilterClick, - onSortClick, + filterKey, + filterValue, + sortKey = "sort", }: Props) { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const updateParams = (key: string, value: string) => { + const params = new URLSearchParams(searchParams.toString()); + + if (params.get(key) === value) { + params.delete(key); + } else { + params.set(key, value); + } + + params.set("page", "1"); + router.push(`${pathname}?${params.toString()}`); + }; + + const handleFilterClick = () => { + if (!filterKey || !filterValue) return; + updateParams(filterKey, filterValue); + }; + + const handleSortClick = () => { + const currentSort = searchParams.get(sortKey); + const nextSort = currentSort === "asc" ? "desc" : "asc"; + + const params = new URLSearchParams(searchParams.toString()); + params.set(sortKey, nextSort); + params.set("page", "1"); + + router.push(`${pathname}?${params.toString()}`); + }; + return (
- -
); -} +} \ No newline at end of file diff --git a/src/lib/actions/student.actions.ts b/src/lib/actions/student.actions.ts index b310127..a0eba75 100644 --- a/src/lib/actions/student.actions.ts +++ b/src/lib/actions/student.actions.ts @@ -43,6 +43,7 @@ export const createStudent = async ( firstName: data.name, publicMetadata: { role: "student" }, }); + createdUserId = user.id; await prisma.student.create({ @@ -60,9 +61,7 @@ export const createStudent = async ( birthday: data.birthday, gradeId: data.gradeId, classId: data.classId, - ...(data.parentId - ? { parent: { connect: { id: data.parentId } } } - : {}), + parentId: data.parentId || null, status: data.status || "ACTIVE", } as any, }); @@ -71,6 +70,7 @@ export const createStudent = async ( const currentAcademicYearId = await getRequiredAcademicYearId( access.schoolId, ); + if (currentAcademicYearId) { await prisma.studentAcademicYear.create({ data: { @@ -93,6 +93,7 @@ export const createStudent = async ( // Best-effort rollback for partial user creation. } } + return errorResult(err); } }; @@ -105,7 +106,11 @@ export const updateStudent = async ( if ("error" in access) return access; if (!data.id) { - return { success: false, error: true, message: "Student id is required." }; + return { + success: false, + error: true, + message: "Student id is required.", + }; } try { @@ -137,8 +142,13 @@ export const updateStudent = async ( ...(data.status && { status: data.status }), }, }); + if (updated.count === 0) { - return { success: false, error: true, message: "Student not found." }; + return { + success: false, + error: true, + message: "Student not found.", + }; } return successResult(["/list/students"]); @@ -156,8 +166,14 @@ export const deleteStudent = async ( const id = data.get("id") as string; const deleteParent = data.get("deleteParent") === "true"; - if (!id) - return { success: false, error: true, message: "Invalid student id." }; + + if (!id) { + return { + success: false, + error: true, + message: "Invalid student id.", + }; + } try { const student = await prisma.student.findUnique({ @@ -186,13 +202,18 @@ export const deleteStudent = async ( await tx.studentAcademicYear.deleteMany({ where: { studentId: id, schoolId: access.schoolId }, }); + await tx.attendance.deleteMany({ where: { studentId: id, schoolId: access.schoolId }, }); + await tx.result.deleteMany({ where: { studentId: id, schoolId: access.schoolId }, }); - await tx.student.deleteMany({ where: { id, schoolId: access.schoolId } }); + + await tx.student.deleteMany({ + where: { id, schoolId: access.schoolId }, + }); if (deleteParent && parentId) { await tx.parent.deleteMany({ @@ -223,4 +244,4 @@ export const deleteStudent = async ( } catch (err) { return errorResult(err); } -}; +}; \ No newline at end of file diff --git a/src/lib/csv.ts b/src/lib/csv.ts new file mode 100644 index 0000000..5f03946 --- /dev/null +++ b/src/lib/csv.ts @@ -0,0 +1,28 @@ +export type CsvColumn = { + header: string; + value: (row: T) => unknown; +}; + +function csvEscape(value: unknown) { + const text = String(value ?? ""); + return `"${text.replace(/"/g, '""')}"`; +} + +export function generateCsv(columns: CsvColumn[], rows: T[]) { + const headerRow = columns.map((column) => csvEscape(column.header)).join(","); + + const dataRows = rows.map((row) => + columns.map((column) => csvEscape(column.value(row))).join(",") + ); + + return [headerRow, ...dataRows].join("\n"); +} + +export function createCsvResponse(filename: string, csv: string) { + return new Response(csv, { + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": `attachment; filename="${filename}"`, + }, + }); +} \ No newline at end of file diff --git a/src/lib/query-builders/assignment-query.ts b/src/lib/query-builders/assignment-query.ts new file mode 100644 index 0000000..14a1edb --- /dev/null +++ b/src/lib/query-builders/assignment-query.ts @@ -0,0 +1,148 @@ +import { Prisma } from "@prisma/client"; +import { getCurrentAcademicYearIdOrNull } from "@/lib/academicYears"; +import { getQueryParam, type PageSearchParams } from "@/lib/pageParams"; + +type BuildAssignmentQueryInput = { + searchParams: PageSearchParams; +schoolId: number; + role: string | null; + userId: string; +}; + +export async function buildAssignmentQuery({ + searchParams, + schoolId, + role, + userId, +}: BuildAssignmentQueryInput) { + const resolvedSearchParams = await searchParams; + + const { page, sort, ...queryParams } = resolvedSearchParams; + + const currentPage = getQueryParam(page); + const p = currentPage ? Number.parseInt(currentPage, 10) : 1; + + const academicYearId = await getCurrentAcademicYearIdOrNull(schoolId); + + const sortParam = getQueryParam(sort); + + const orderBy: Prisma.AssignmentOrderByWithRelationInput = + sortParam === "desc" ? { endDate: "desc" } : { endDate: "asc" }; + + if (!academicYearId) { + return { + academicYearId: null, + query: null, + orderBy, + page: 1, + }; + } + + const query: Prisma.AssignmentWhereInput = { + schoolId, + academicYearId, + }; + + const conditions: Prisma.AssignmentWhereInput[] = []; + + for (const [key, rawValue] of Object.entries(queryParams)) { + const value = getQueryParam(rawValue); + + if (value === undefined || value === "") continue; + + switch (key) { + case "classId": { + const classId = Number.parseInt(value, 10); + + if (!Number.isNaN(classId)) { + conditions.push({ + lesson: { + classId, + }, + }); + } + + break; + } + + case "teacherId": + conditions.push({ + lesson: { + teacherId: value, + }, + }); + break; + + case "search": + conditions.push({ + OR: [ + { + title: { + contains: value, + mode: "insensitive", + }, + }, + { + lesson: { + subject: { + name: { + contains: value, + mode: "insensitive", + }, + }, + }, + }, + ], + }); + break; + } + } + + switch (role) { + case "admin": + break; + + case "teacher": + conditions.push({ + lesson: { + teacherId: userId, + }, + }); + break; + + case "student": + conditions.push({ + lesson: { + class: { + students: { + some: { id: userId }, + }, + }, + }, + }); + break; + + case "parent": + conditions.push({ + lesson: { + class: { + students: { + some: { parentId: userId }, + }, + }, + }, + }); + break; + } + + if (conditions.length > 0) { + query.AND = conditions; + } + + return { + academicYearId, + query, + orderBy, + page: Number.isNaN(p) || p < 1 ? 1 : p, + }; +} \ No newline at end of file diff --git a/src/lib/query-builders/class-query.ts b/src/lib/query-builders/class-query.ts new file mode 100644 index 0000000..b831bee --- /dev/null +++ b/src/lib/query-builders/class-query.ts @@ -0,0 +1,119 @@ +import { Prisma } from "@prisma/client"; +import { getQueryParam, type PageSearchParams } from "@/lib/pageParams"; + +type BuildClassQueryInput = { + searchParams: PageSearchParams; +schoolId: number; +}; + +export async function buildClassQuery({ + searchParams, + schoolId, +}: BuildClassQueryInput) { + const resolvedSearchParams = await searchParams; + + const { page, sort, ...queryParams } = resolvedSearchParams; + + const currentPage = getQueryParam(page); + const p = currentPage ? Number.parseInt(currentPage, 10) : 1; + + const query: Prisma.ClassWhereInput = { + schoolId, + }; + + const conditions: Prisma.ClassWhereInput[] = []; + + for (const [key, rawValue] of Object.entries(queryParams)) { + const value = getQueryParam(rawValue); + + if (value === undefined || value === "") continue; + + switch (key) { + case "supervisorId": + conditions.push({ + supervisorId: value, + }); + break; + + case "gradeId": { + const gradeId = Number.parseInt(value, 10); + + if (!Number.isNaN(gradeId)) { + conditions.push({ + gradeId, + }); + } + + break; + } + + case "search": + conditions.push({ + OR: [ + { name: { contains: value, mode: "insensitive" } }, + { + supervisor: { + name: { contains: value, mode: "insensitive" }, + }, + }, + ], + }); + break; + } + } + + if (conditions.length > 0) { + query.AND = conditions; + } + + let orderBy: Prisma.ClassOrderByWithRelationInput = { + name: "asc", + }; + + const sortValue = getQueryParam(sort); + + switch (sortValue) { + case "asc": + orderBy = { name: "asc" }; + break; + + case "desc": + case "name_desc": + orderBy = { name: "desc" }; + break; + + case "capacity_asc": + orderBy = { capacity: "asc" }; + break; + + case "capacity_desc": + orderBy = { capacity: "desc" }; + break; + + case "grade_asc": + orderBy = { + grade: { + level: "asc", + }, + }; + break; + + case "grade_desc": + orderBy = { + grade: { + level: "desc", + }, + }; + break; + + default: + orderBy = { name: "asc" }; + break; + } + + return { + query, + orderBy, + page: Number.isNaN(p) || p < 1 ? 1 : p, + }; +} \ No newline at end of file diff --git a/src/lib/query-builders/exam-query.ts b/src/lib/query-builders/exam-query.ts new file mode 100644 index 0000000..948c481 --- /dev/null +++ b/src/lib/query-builders/exam-query.ts @@ -0,0 +1,145 @@ +import { Prisma } from "@prisma/client"; +import { getCurrentAcademicYearIdOrNull } from "@/lib/academicYears"; +import { getQueryParam, type PageSearchParams } from "@/lib/pageParams"; + +type BuildExamQueryInput = { + searchParams: PageSearchParams; + schoolId: number; + role: string | null; + userId: string; +}; + +export async function buildExamQuery({ + searchParams, + schoolId, + role, + userId, +}: BuildExamQueryInput) { + const resolvedSearchParams = await searchParams; + + const { page, sort, ...queryParams } = resolvedSearchParams; + + const currentPage = getQueryParam(page); + const p = currentPage ? Number.parseInt(currentPage, 10) : 1; + + const academicYearId = await getCurrentAcademicYearIdOrNull(schoolId); + + // ترتيب حسب تاريخ الامتحان (startTime) + // asc = الأقدم أولاً + // desc = الأحدث أولاً + const orderBy: Prisma.ExamOrderByWithRelationInput = + getQueryParam(sort) === "desc" + ? { startTime: "desc" } + : { startTime: "asc" }; + + if (!academicYearId) { + return { + academicYearId: null, + query: null, + orderBy, + page: 1, + }; + } + + const query: Prisma.ExamWhereInput = { + schoolId, + academicYearId, + }; + + const conditions: Prisma.ExamWhereInput[] = []; + + for (const [key, rawValue] of Object.entries(queryParams)) { + const value = getQueryParam(rawValue); + + if (value === undefined || value === "") continue; + + switch (key) { + case "classId": + conditions.push({ + lesson: { + classId: Number.parseInt(value, 10), + }, + }); + break; + + case "teacherId": + conditions.push({ + lesson: { + teacherId: value, + }, + }); + break; + + case "search": + conditions.push({ + OR: [ + { + title: { + contains: value, + mode: "insensitive", + }, + }, + { + lesson: { + subject: { + name: { + contains: value, + mode: "insensitive", + }, + }, + }, + }, + ], + }); + break; + } + } + + switch (role) { + case "admin": + break; + + case "teacher": + conditions.push({ + lesson: { + teacherId: userId, + }, + }); + break; + + case "student": + conditions.push({ + lesson: { + class: { + students: { + some: { id: userId }, + }, + }, + }, + }); + break; + + case "parent": + conditions.push({ + lesson: { + class: { + students: { + some: { parentId: userId }, + }, + }, + }, + }); + break; + } + + if (conditions.length > 0) { + query.AND = conditions; + } + + return { + academicYearId, + query, + orderBy, + page: Number.isNaN(p) || p < 1 ? 1 : p, + }; +} \ No newline at end of file diff --git a/src/lib/query-builders/parent-query.ts b/src/lib/query-builders/parent-query.ts new file mode 100644 index 0000000..c135365 --- /dev/null +++ b/src/lib/query-builders/parent-query.ts @@ -0,0 +1,82 @@ +import { Prisma } from "@prisma/client"; +import { getQueryParam, type PageSearchParams } from "@/lib/pageParams"; + +type BuildParentQueryInput = { + searchParams: PageSearchParams; + schoolId: number; + role: string | null; + userId: string; +}; + +export async function buildParentQuery({ + searchParams, + schoolId, + role, + userId, +}: BuildParentQueryInput) { + const resolvedSearchParams = await searchParams; + + const { page, ...queryParams } = resolvedSearchParams; + + const currentPage = getQueryParam(page); + const p = currentPage ? Number.parseInt(currentPage, 10) : 1; + + const query: Prisma.ParentWhereInput = { schoolId }; + const conditions: Prisma.ParentWhereInput[] = []; + + for (const [key, rawValue] of Object.entries(queryParams)) { + const value = getQueryParam(rawValue); + + if (value === undefined || value === "") continue; + + switch (key) { + case "search": + conditions.push({ + OR: [ + { name: { contains: value, mode: "insensitive" } }, + { username: { contains: value, mode: "insensitive" } }, + { email: { contains: value, mode: "insensitive" } }, + { phone: { contains: value, mode: "insensitive" } }, + { address: { contains: value, mode: "insensitive" } }, + { + students: { + some: { + name: { contains: value, mode: "insensitive" }, + }, + }, + }, + ], + }); + break; + } + } + + if (role === "teacher") { + conditions.push({ + students: { + some: { + class: { + lessons: { + some: { teacherId: userId }, + }, + }, + }, + }, + }); + } + + if (conditions.length > 0) { + query.AND = conditions; + } + + const sortParam = getQueryParam(queryParams.sort); + + const orderBy: Prisma.ParentOrderByWithRelationInput = + sortParam === "desc" ? { name: "desc" } : { name: "asc" }; + + return { + query, + orderBy, + page: Number.isNaN(p) || p < 1 ? 1 : p, + }; +} \ No newline at end of file diff --git a/src/lib/query-builders/result-query.ts b/src/lib/query-builders/result-query.ts new file mode 100644 index 0000000..b81a7da --- /dev/null +++ b/src/lib/query-builders/result-query.ts @@ -0,0 +1,122 @@ +import { Prisma } from "@prisma/client"; +import { getCurrentAcademicYearIdOrNull } from "@/lib/academicYears"; +import { getQueryParam, type PageSearchParams } from "@/lib/pageParams"; + +type BuildResultQueryInput = { + searchParams: PageSearchParams; + schoolId: number; + role: string | null; + userId: string; +}; + +export async function buildResultQuery({ + searchParams, + schoolId, + role, + userId, +}: BuildResultQueryInput) { + const resolvedSearchParams = await searchParams; + + const { page, sort, ...queryParams } = resolvedSearchParams; + + const currentPage = getQueryParam(page); + const p = currentPage ? Number.parseInt(currentPage, 10) : 1; + + const academicYearId = await getCurrentAcademicYearIdOrNull(schoolId); + + const sortParam = getQueryParam(sort); + + const orderBy: Prisma.ResultOrderByWithRelationInput = + sortParam === "asc" ? { id: "asc" } : { id: "desc" }; + + if (!academicYearId) { + return { + academicYearId: null, + query: null, + orderBy, + page: 1, + }; + } + + const query: Prisma.ResultWhereInput = { + schoolId, + academicYearId, + }; + + const conditions: Prisma.ResultWhereInput[] = []; + + for (const [key, rawValue] of Object.entries(queryParams)) { + const value = getQueryParam(rawValue); + + if (value === undefined || value === "") continue; + + 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" }, + }, + }, + { + assignment: { + title: { contains: value, mode: "insensitive" }, + }, + }, + ], + }); + break; + } + } + + 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; + } + + if (conditions.length > 0) { + query.AND = conditions; + } + + return { + academicYearId, + query, + orderBy, + page: Number.isNaN(p) || p < 1 ? 1 : p, + }; +} \ No newline at end of file diff --git a/src/lib/query-builders/student-query.ts b/src/lib/query-builders/student-query.ts new file mode 100644 index 0000000..33875de --- /dev/null +++ b/src/lib/query-builders/student-query.ts @@ -0,0 +1,129 @@ +import { Prisma, StudentStatus } from "@prisma/client"; +import { getQueryParam, type PageSearchParams } from "@/lib/pageParams"; + +type BuildStudentQueryInput = { + searchParams: PageSearchParams; + schoolId: number; + currentAcademicYearId: number; + role: string | null; + userId: string; +}; + +const validStatuses: StudentStatus[] = [ + "ACTIVE", + "REPEATED", + "GRADUATED", + "LEFT", +]; + +export async function buildStudentQuery({ + searchParams, + schoolId, + currentAcademicYearId, + role, + userId, +}: BuildStudentQueryInput) { + 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) + : currentAcademicYearId; + + 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 = { + schoolId, + academicYears: { + some: { + academicYearId: selectedAcademicYearId, + }, + }, + status: selectedStatus, + }; + + const conditions: Prisma.StudentWhereInput[] = []; + + if (selectedStatus === "REPEATED") { + query.repeatCount = Number.isNaN(selectedRepeatCount) + ? 1 + : selectedRepeatCount; + } + + for (const [key, rawValue] of Object.entries(queryParams)) { + const value = getQueryParam(rawValue); + + if (value === undefined || value === "") continue; + + switch (key) { + case "teacherId": + conditions.push({ + class: { + lessons: { + some: { teacherId: value }, + }, + }, + }); + break; + + case "search": + conditions.push({ + OR: [ + { name: { contains: value, mode: "insensitive" } }, + { username: { contains: value, mode: "insensitive" } }, + { phone: { contains: value, mode: "insensitive" } }, + { address: { contains: value, mode: "insensitive" } }, + ], + }); + break; + } + } + + switch (role) { + case "admin": + break; + + case "teacher": + conditions.push({ + class: { + lessons: { + some: { teacherId: userId }, + }, + }, + }); + break; + } + + if (conditions.length > 0) { + query.AND = conditions; + } + + const sortParam = getQueryParam(queryParams.sort); + + const orderBy: Prisma.StudentOrderByWithRelationInput = + sortParam === "desc" ? { name: "desc" } : { name: "asc" }; + +return { + query, + orderBy, + page: Number.isNaN(p) || p < 1 ? 1 : p, + selectedAcademicYearId, + selectedStatus, + selectedRepeatCount, +}; +} \ No newline at end of file diff --git a/src/lib/query-builders/subject-query.ts b/src/lib/query-builders/subject-query.ts new file mode 100644 index 0000000..3ea1cc1 --- /dev/null +++ b/src/lib/query-builders/subject-query.ts @@ -0,0 +1,83 @@ +import { Prisma } from "@prisma/client"; +import { getQueryParam, type PageSearchParams } from "@/lib/pageParams"; + +type BuildSubjectQueryInput = { + searchParams: PageSearchParams; + schoolId: number; + role: string | null; + userId: string; +}; + +export async function buildSubjectQuery({ + searchParams, + schoolId, + role, + userId, +}: BuildSubjectQueryInput) { + const resolvedSearchParams = await searchParams; + + const { page, ...queryParams } = resolvedSearchParams; + + const currentPage = getQueryParam(page); + const p = currentPage ? Number.parseInt(currentPage, 10) : 1; + + const query: Prisma.SubjectWhereInput = { + schoolId, + }; + + const conditions: Prisma.SubjectWhereInput[] = []; + + for (const [key, rawValue] of Object.entries(queryParams)) { + const value = getQueryParam(rawValue); + + if (value === undefined || value === "") continue; + + switch (key) { + case "gradeId": + conditions.push({ + gradeId: Number.parseInt(value, 10), + }); + break; + + case "search": + conditions.push({ + OR: [ + { name: { contains: value, mode: "insensitive" } }, + { + teachers: { + some: { + name: { contains: value, mode: "insensitive" }, + }, + }, + }, + ], + }); + break; + } + } + + if (role === "teacher") { + conditions.push({ + teachers: { + some: { + id: userId, + }, + }, + }); + } + + if (conditions.length > 0) { + query.AND = conditions; + } + + const sortParam = getQueryParam(queryParams.sort); + + const orderBy: Prisma.SubjectOrderByWithRelationInput = + sortParam === "desc" ? { name: "desc" } : { name: "asc" }; + + return { + query, + orderBy, + page: Number.isNaN(p) || p < 1 ? 1 : p, + }; +} \ No newline at end of file diff --git a/src/lib/query-builders/teacher-query.ts b/src/lib/query-builders/teacher-query.ts new file mode 100644 index 0000000..6ddc38b --- /dev/null +++ b/src/lib/query-builders/teacher-query.ts @@ -0,0 +1,77 @@ +import { Prisma } from "@prisma/client"; +import { getQueryParam, type PageSearchParams } from "@/lib/pageParams"; + +type BuildTeacherQueryInput = { + searchParams: PageSearchParams; + schoolId: number; +}; + +export async function buildTeacherQuery({ + searchParams, + schoolId, +}: BuildTeacherQueryInput) { + const resolvedSearchParams = await searchParams; + + const { page, ...queryParams } = resolvedSearchParams; + + const currentPage = getQueryParam(page); + const p = currentPage ? parseInt(currentPage, 10) : 1; + + const query: Prisma.TeacherWhereInput = { + schoolId, + }; + + const conditions: Prisma.TeacherWhereInput[] = []; + + for (const [key, rawValue] of Object.entries(queryParams)) { + const value = getQueryParam(rawValue); + + if (value === undefined || value === "") continue; + + switch (key) { + case "classId": + conditions.push({ + lessons: { + some: { + classId: Number.parseInt(value, 10), + }, + }, + }); + break; + + case "search": + conditions.push({ + OR: [ + { name: { contains: value, mode: "insensitive" } }, + { username: { contains: value, mode: "insensitive" } }, + { email: { contains: value, mode: "insensitive" } }, + { phone: { contains: value, mode: "insensitive" } }, + { address: { contains: value, mode: "insensitive" } }, + { + subjects: { + some: { + name: { contains: value, mode: "insensitive" }, + }, + }, + }, + ], + }); + break; + } + } + + if (conditions.length > 0) { + query.AND = conditions; + } + + const sortParam = getQueryParam(queryParams.sort); + + const orderBy: Prisma.TeacherOrderByWithRelationInput = + sortParam === "desc" ? { name: "desc" } : { name: "asc" }; + + return { + query, + orderBy, + page: Number.isNaN(p) || p < 1 ? 1 : p, + }; +} \ No newline at end of file From 671865e466feec9d4fffd2d2e537ed55e2042ee9 Mon Sep 17 00:00:00 2001 From: Abdullah Abusharekh Date: Wed, 13 May 2026 13:35:37 +0300 Subject: [PATCH 002/151] fix(csv): prevent spreadsheet formula injection in exported values --- src/lib/csv.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/lib/csv.ts b/src/lib/csv.ts index 5f03946..3785bfb 100644 --- a/src/lib/csv.ts +++ b/src/lib/csv.ts @@ -4,7 +4,9 @@ export type CsvColumn = { }; function csvEscape(value: unknown) { - const text = String(value ?? ""); + const rawText = String(value ?? ""); + const text = + /^[=+\-@]/.test(rawText) ? `'${rawText}` : rawText; return `"${text.replace(/"/g, '""')}"`; } @@ -25,4 +27,4 @@ export function createCsvResponse(filename: string, csv: string) { "Content-Disposition": `attachment; filename="${filename}"`, }, }); -} \ No newline at end of file +} From 111148100c12c7de9a668724a1b1a9594b086c63 Mon Sep 17 00:00:00 2001 From: Abdullah Abusharekh Date: Wed, 13 May 2026 13:36:04 +0300 Subject: [PATCH 003/151] fix(filters): validate numeric query params and reset pagination on search --- src/components/TableSearch.tsx | 24 ++++++++++++++++++------ src/lib/query-builders/exam-query.ts | 18 +++++++++++------- src/lib/query-builders/subject-query.ts | 14 +++++++++----- src/lib/query-builders/teacher-query.ts | 20 ++++++++++++-------- 4 files changed, 50 insertions(+), 26 deletions(-) diff --git a/src/components/TableSearch.tsx b/src/components/TableSearch.tsx index 9e84be6..98cd307 100644 --- a/src/components/TableSearch.tsx +++ b/src/components/TableSearch.tsx @@ -1,17 +1,27 @@ "use client"; import { Search } from "lucide-react"; -import { useRouter } from "next/navigation"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; const TableSearch = () => { const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); - const handleSubmit = (e: React.FocusEvent) => { + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - const value = (e.currentTarget[0] as HTMLInputElement).value; - const params = new URLSearchParams(window.location.search); - params.set("search", value); - router.push(`${window.location.pathname}?${params}`); + const formData = new FormData(e.currentTarget); + const value = String(formData.get("search") ?? "").trim(); + const params = new URLSearchParams(searchParams.toString()); + + if (value) { + params.set("search", value); + } else { + params.delete("search"); + } + + params.set("page", "1"); + router.push(`${pathname}?${params.toString()}`); }; return (
{ > diff --git a/src/lib/query-builders/exam-query.ts b/src/lib/query-builders/exam-query.ts index 948c481..6eab893 100644 --- a/src/lib/query-builders/exam-query.ts +++ b/src/lib/query-builders/exam-query.ts @@ -54,13 +54,17 @@ export async function buildExamQuery({ if (value === undefined || value === "") continue; switch (key) { - case "classId": - conditions.push({ - lesson: { - classId: Number.parseInt(value, 10), - }, - }); + case "classId": { + const classId = Number.parseInt(value, 10); + if (!Number.isNaN(classId)) { + conditions.push({ + lesson: { + classId, + }, + }); + } break; + } case "teacherId": conditions.push({ @@ -142,4 +146,4 @@ export async function buildExamQuery({ orderBy, page: Number.isNaN(p) || p < 1 ? 1 : p, }; -} \ No newline at end of file +} diff --git a/src/lib/query-builders/subject-query.ts b/src/lib/query-builders/subject-query.ts index 3ea1cc1..9b85b9c 100644 --- a/src/lib/query-builders/subject-query.ts +++ b/src/lib/query-builders/subject-query.ts @@ -33,11 +33,15 @@ export async function buildSubjectQuery({ if (value === undefined || value === "") continue; switch (key) { - case "gradeId": - conditions.push({ - gradeId: Number.parseInt(value, 10), - }); + case "gradeId": { + const gradeId = Number.parseInt(value, 10); + if (!Number.isNaN(gradeId)) { + conditions.push({ + gradeId, + }); + } break; + } case "search": conditions.push({ @@ -80,4 +84,4 @@ export async function buildSubjectQuery({ orderBy, page: Number.isNaN(p) || p < 1 ? 1 : p, }; -} \ No newline at end of file +} diff --git a/src/lib/query-builders/teacher-query.ts b/src/lib/query-builders/teacher-query.ts index 6ddc38b..96da135 100644 --- a/src/lib/query-builders/teacher-query.ts +++ b/src/lib/query-builders/teacher-query.ts @@ -29,15 +29,19 @@ export async function buildTeacherQuery({ if (value === undefined || value === "") continue; switch (key) { - case "classId": - conditions.push({ - lessons: { - some: { - classId: Number.parseInt(value, 10), + case "classId": { + const classId = Number.parseInt(value, 10); + if (!Number.isNaN(classId)) { + conditions.push({ + lessons: { + some: { + classId, + }, }, - }, - }); + }); + } break; + } case "search": conditions.push({ @@ -74,4 +78,4 @@ export async function buildTeacherQuery({ orderBy, page: Number.isNaN(p) || p < 1 ? 1 : p, }; -} \ No newline at end of file +} From 8c4cb5f30c04d741692c99310b79ad519bb706b4 Mon Sep 17 00:00:00 2001 From: Abdullah Abusharekh Date: Wed, 13 May 2026 13:38:17 +0300 Subject: [PATCH 004/151] fix(sort): make list sort effective and align export ordering with active filters --- src/app/(dashboard)/list/announcements/page.tsx | 8 ++++++-- src/app/(dashboard)/list/events/page.tsx | 8 ++++++-- src/app/(dashboard)/list/messages/page.tsx | 7 +++++-- src/app/api/admin/classes/export/route.ts | 8 +++----- src/app/api/admin/students/export/route.ts | 8 +++----- src/app/api/admin/subjects/export/route.ts | 8 +++----- src/app/api/admin/teachers/export/route.ts | 8 +++----- 7 files changed, 29 insertions(+), 26 deletions(-) diff --git a/src/app/(dashboard)/list/announcements/page.tsx b/src/app/(dashboard)/list/announcements/page.tsx index 4a44bb5..4d88531 100644 --- a/src/app/(dashboard)/list/announcements/page.tsx +++ b/src/app/(dashboard)/list/announcements/page.tsx @@ -127,13 +127,17 @@ const AnnouncementListPage = async ({ query.AND = conditions; } + const sortParam = getQueryParam(queryParams.sort); + const orderBy: Prisma.AnnouncementOrderByWithRelationInput = + sortParam === "asc" ? { date: "asc" } : { date: "desc" }; + const [data, count, totalClassesCount] = 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, }), @@ -151,7 +155,7 @@ const AnnouncementListPage = async ({
- + {role === "admin" && ( )} diff --git a/src/app/(dashboard)/list/events/page.tsx b/src/app/(dashboard)/list/events/page.tsx index 16936ad..b1ee760 100644 --- a/src/app/(dashboard)/list/events/page.tsx +++ b/src/app/(dashboard)/list/events/page.tsx @@ -150,13 +150,17 @@ const EventListPage = async ({ query.AND = conditions; } + const sortParam = getQueryParam(queryParams.sort); + const orderBy: Prisma.EventOrderByWithRelationInput = + sortParam === "asc" ? { startDate: "asc" } : { startDate: "desc" }; + const [data, count, totalClassesCount] = 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, }), @@ -174,7 +178,7 @@ const EventListPage = async ({
- + {role === "admin" && }
diff --git a/src/app/(dashboard)/list/messages/page.tsx b/src/app/(dashboard)/list/messages/page.tsx index 497ab80..b3592fc 100644 --- a/src/app/(dashboard)/list/messages/page.tsx +++ b/src/app/(dashboard)/list/messages/page.tsx @@ -238,6 +238,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 +251,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, }), @@ -264,7 +267,7 @@ const MessageListPage = async ({
- + {role === "admin" && ( )} diff --git a/src/app/api/admin/classes/export/route.ts b/src/app/api/admin/classes/export/route.ts index 74d7bac..bc79993 100644 --- a/src/app/api/admin/classes/export/route.ts +++ b/src/app/api/admin/classes/export/route.ts @@ -15,7 +15,7 @@ export async function GET(req: NextRequest) { Object.fromEntries(req.nextUrl.searchParams.entries()) ); - const { query } = await buildClassQuery({ + const { query, orderBy } = await buildClassQuery({ searchParams, schoolId, }); @@ -30,9 +30,7 @@ export async function GET(req: NextRequest) { }, }, }, - orderBy: { - name: "asc", - }, + orderBy, }); const csv = generateCsv( @@ -58,4 +56,4 @@ export async function GET(req: NextRequest) { ); return createCsvResponse("classes-export.csv", csv); -} \ No newline at end of file +} diff --git a/src/app/api/admin/students/export/route.ts b/src/app/api/admin/students/export/route.ts index eac53a7..c62229f 100644 --- a/src/app/api/admin/students/export/route.ts +++ b/src/app/api/admin/students/export/route.ts @@ -24,7 +24,7 @@ export async function GET(req: NextRequest) { Object.fromEntries(req.nextUrl.searchParams.entries()) ); - const { query } = await buildStudentQuery({ + const { query, orderBy } = await buildStudentQuery({ searchParams, schoolId, currentAcademicYearId: currentAcademicYear.id, @@ -37,9 +37,7 @@ export async function GET(req: NextRequest) { include: { class: true, }, - orderBy: { - name: "asc", - }, + orderBy, }); const csv = generateCsv( @@ -69,4 +67,4 @@ export async function GET(req: NextRequest) { ); return createCsvResponse("students-export.csv", csv); -} \ No newline at end of file +} diff --git a/src/app/api/admin/subjects/export/route.ts b/src/app/api/admin/subjects/export/route.ts index 230e575..ccd943b 100644 --- a/src/app/api/admin/subjects/export/route.ts +++ b/src/app/api/admin/subjects/export/route.ts @@ -15,7 +15,7 @@ export async function GET(req: NextRequest) { Object.fromEntries(req.nextUrl.searchParams.entries()) ); - const { query } = await buildSubjectQuery({ + const { query, orderBy } = await buildSubjectQuery({ searchParams, schoolId, role, @@ -28,9 +28,7 @@ export async function GET(req: NextRequest) { teachers: true, grade: true, }, - orderBy: { - name: "asc", - }, + orderBy, }); const csv = generateCsv( @@ -53,4 +51,4 @@ export async function GET(req: NextRequest) { ); return createCsvResponse("subjects-export.csv", csv); -} \ No newline at end of file +} diff --git a/src/app/api/admin/teachers/export/route.ts b/src/app/api/admin/teachers/export/route.ts index e7dcdd5..8e046c1 100644 --- a/src/app/api/admin/teachers/export/route.ts +++ b/src/app/api/admin/teachers/export/route.ts @@ -15,7 +15,7 @@ export async function GET(req: NextRequest) { Object.fromEntries(req.nextUrl.searchParams.entries()) ); - const { query } = await buildTeacherQuery({ + const { query, orderBy } = await buildTeacherQuery({ searchParams, schoolId, }); @@ -25,9 +25,7 @@ export async function GET(req: NextRequest) { include: { subjects: true, }, - orderBy: { - name: "asc", - }, + orderBy, }); const csv = generateCsv( @@ -62,4 +60,4 @@ export async function GET(req: NextRequest) { ); return createCsvResponse("teachers-export.csv", csv); -} \ No newline at end of file +} From b2bd55fca6319f595f2a40ca8b924c5281f8d778 Mon Sep 17 00:00:00 2001 From: Abdullah Abusharekh Date: Wed, 13 May 2026 13:40:44 +0300 Subject: [PATCH 005/151] refactor(export): preserve repeated query params and align exam export sort with list query --- src/app/api/admin/assignments/export/route.ts | 7 ++++--- src/app/api/admin/classes/export/route.ts | 3 ++- src/app/api/admin/exams/export/route.ts | 11 +++++------ src/app/api/admin/parents/export/route.ts | 5 +++-- src/app/api/admin/results/export/route.ts | 9 +++++---- src/app/api/admin/students/export/route.ts | 3 ++- src/app/api/admin/subjects/export/route.ts | 3 ++- src/app/api/admin/teachers/export/route.ts | 3 ++- src/lib/pageParams.ts | 17 +++++++++++++++++ 9 files changed, 42 insertions(+), 19 deletions(-) diff --git a/src/app/api/admin/assignments/export/route.ts b/src/app/api/admin/assignments/export/route.ts index fbfd86f..97ed0c7 100644 --- a/src/app/api/admin/assignments/export/route.ts +++ b/src/app/api/admin/assignments/export/route.ts @@ -1,5 +1,6 @@ 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"; @@ -19,8 +20,8 @@ export async function GET(request: NextRequest) { } const searchParams = Promise.resolve( - Object.fromEntries(request.nextUrl.searchParams.entries()) -); + searchParamsToRecord(request.nextUrl.searchParams), + ); const { academicYearId, query, orderBy } = await buildAssignmentQuery({ @@ -93,4 +94,4 @@ const { academicYearId, query, orderBy } = ); return createCsvResponse("assignments.csv", csv); -} \ No newline at end of file +} diff --git a/src/app/api/admin/classes/export/route.ts b/src/app/api/admin/classes/export/route.ts index bc79993..3a1f100 100644 --- a/src/app/api/admin/classes/export/route.ts +++ b/src/app/api/admin/classes/export/route.ts @@ -2,6 +2,7 @@ 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) { @@ -12,7 +13,7 @@ export async function GET(req: NextRequest) { } const searchParams = Promise.resolve( - Object.fromEntries(req.nextUrl.searchParams.entries()) + searchParamsToRecord(req.nextUrl.searchParams), ); const { query, orderBy } = await buildClassQuery({ diff --git a/src/app/api/admin/exams/export/route.ts b/src/app/api/admin/exams/export/route.ts index 7ea7cf0..61ec85c 100644 --- a/src/app/api/admin/exams/export/route.ts +++ b/src/app/api/admin/exams/export/route.ts @@ -2,6 +2,7 @@ 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) { @@ -11,9 +12,9 @@ export async function GET(req: NextRequest) { return new Response("Forbidden", { status: 403 }); } - const { query } = await buildExamQuery({ + const { query, orderBy } = await buildExamQuery({ searchParams: Promise.resolve( - Object.fromEntries(req.nextUrl.searchParams.entries()) + searchParamsToRecord(req.nextUrl.searchParams), ), schoolId, role, @@ -47,9 +48,7 @@ export async function GET(req: NextRequest) { }, }, }, - orderBy: { - startTime: "desc", - }, + orderBy, }); const csv = generateCsv( @@ -91,4 +90,4 @@ export async function GET(req: NextRequest) { ); return createCsvResponse("exams-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 index 47839a6..defba45 100644 --- a/src/app/api/admin/parents/export/route.ts +++ b/src/app/api/admin/parents/export/route.ts @@ -2,6 +2,7 @@ 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) { @@ -12,7 +13,7 @@ export async function GET(req: NextRequest) { } const searchParams = Promise.resolve( - Object.fromEntries(req.nextUrl.searchParams.entries()) + searchParamsToRecord(req.nextUrl.searchParams), ); const { query } = await buildParentQuery({ @@ -64,4 +65,4 @@ export async function GET(req: NextRequest) { ); return createCsvResponse("parents-export.csv", csv); -} \ No newline at end of file +} diff --git a/src/app/api/admin/results/export/route.ts b/src/app/api/admin/results/export/route.ts index 295b4c7..d16a29e 100644 --- a/src/app/api/admin/results/export/route.ts +++ b/src/app/api/admin/results/export/route.ts @@ -1,6 +1,7 @@ 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"; @@ -12,9 +13,9 @@ export async function GET(request: NextRequest) { return new NextResponse("Forbidden", { status: 403 }); } -const searchParams = Promise.resolve( - Object.fromEntries(request.nextUrl.searchParams.entries()) -); + const searchParams = Promise.resolve( + searchParamsToRecord(request.nextUrl.searchParams), + ); const { academicYearId, query, orderBy } = await buildResultQuery({ searchParams, @@ -50,4 +51,4 @@ const searchParams = Promise.resolve( ); return createCsvResponse("results.csv", csv); -} \ No newline at end of file +} diff --git a/src/app/api/admin/students/export/route.ts b/src/app/api/admin/students/export/route.ts index c62229f..3922124 100644 --- a/src/app/api/admin/students/export/route.ts +++ b/src/app/api/admin/students/export/route.ts @@ -2,6 +2,7 @@ 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"; @@ -21,7 +22,7 @@ export async function GET(req: NextRequest) { } const searchParams = Promise.resolve( - Object.fromEntries(req.nextUrl.searchParams.entries()) + searchParamsToRecord(req.nextUrl.searchParams), ); const { query, orderBy } = await buildStudentQuery({ diff --git a/src/app/api/admin/subjects/export/route.ts b/src/app/api/admin/subjects/export/route.ts index ccd943b..1d63751 100644 --- a/src/app/api/admin/subjects/export/route.ts +++ b/src/app/api/admin/subjects/export/route.ts @@ -2,6 +2,7 @@ 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) { @@ -12,7 +13,7 @@ export async function GET(req: NextRequest) { } const searchParams = Promise.resolve( - Object.fromEntries(req.nextUrl.searchParams.entries()) + searchParamsToRecord(req.nextUrl.searchParams), ); const { query, orderBy } = await buildSubjectQuery({ diff --git a/src/app/api/admin/teachers/export/route.ts b/src/app/api/admin/teachers/export/route.ts index 8e046c1..e1ca209 100644 --- a/src/app/api/admin/teachers/export/route.ts +++ b/src/app/api/admin/teachers/export/route.ts @@ -2,6 +2,7 @@ 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) { @@ -12,7 +13,7 @@ export async function GET(req: NextRequest) { } const searchParams = Promise.resolve( - Object.fromEntries(req.nextUrl.searchParams.entries()) + searchParamsToRecord(req.nextUrl.searchParams), ); const { query, orderBy } = await buildTeacherQuery({ diff --git a/src/lib/pageParams.ts b/src/lib/pageParams.ts index 1be5bbd..16bef9d 100644 --- a/src/lib/pageParams.ts +++ b/src/lib/pageParams.ts @@ -4,3 +4,20 @@ export type PageSearchParams = Promise< export const getQueryParam = (value: string | string[] | undefined) => Array.isArray(value) ? value[0] : value; + +export const searchParamsToRecord = ( + params: URLSearchParams, +): Record => { + const result: Record = {}; + + for (const key of params.keys()) { + const values = params.getAll(key); + if (values.length === 1) { + result[key] = values[0]; + } else if (values.length > 1) { + result[key] = values; + } + } + + return result; +}; From 157241463fc4bff4be1c04b5dd4e636c500dcf72 Mon Sep 17 00:00:00 2001 From: Abdullah Abusharekh Date: Wed, 13 May 2026 13:41:44 +0300 Subject: [PATCH 006/151] fix(filters): hide filter action when no filter key/value is configured --- src/components/FilterSortActions.tsx | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/components/FilterSortActions.tsx b/src/components/FilterSortActions.tsx index e60be8a..8200518 100644 --- a/src/components/FilterSortActions.tsx +++ b/src/components/FilterSortActions.tsx @@ -49,14 +49,16 @@ export default function FilterSortActions({ return (
- + {filterKey && filterValue && ( + + )}
); -} \ No newline at end of file +} From bc59ad0c96aa0f35d807ec8cf59b58d290bc99dc Mon Sep 17 00:00:00 2001 From: Abdullah Abusharekh Date: Wed, 13 May 2026 15:41:19 +0300 Subject: [PATCH 007/151] fix(list-pages): make header controls wrap and normalize spacing --- src/app/(dashboard)/admin/page.tsx | 14 +--- .../(dashboard)/list/announcements/page.tsx | 10 +-- src/app/(dashboard)/list/assignments/page.tsx | 28 +++---- src/app/(dashboard)/list/attendance/page.tsx | 81 ++++++++++--------- src/app/(dashboard)/list/classes/page.tsx | 24 +++--- src/app/(dashboard)/list/events/page.tsx | 8 +- src/app/(dashboard)/list/exams/page.tsx | 31 +++---- src/app/(dashboard)/list/lessons/page.tsx | 34 ++++---- src/app/(dashboard)/list/messages/page.tsx | 8 +- src/app/(dashboard)/list/parents/page.tsx | 18 +++-- src/app/(dashboard)/list/results/page.tsx | 17 ++-- src/app/(dashboard)/list/students/page.tsx | 23 +++--- src/app/(dashboard)/list/subjects/page.tsx | 18 +++-- src/app/(dashboard)/list/teachers/page.tsx | 18 +++-- src/components/FormModal.tsx | 10 +-- src/components/PromoteStudentsButton.tsx | 11 +-- src/components/StudentsFilters.tsx | 2 +- 17 files changed, 180 insertions(+), 175 deletions(-) diff --git a/src/app/(dashboard)/admin/page.tsx b/src/app/(dashboard)/admin/page.tsx index 9ed5a2d..6e1ad5a 100644 --- a/src/app/(dashboard)/admin/page.tsx +++ b/src/app/(dashboard)/admin/page.tsx @@ -1,8 +1,6 @@ 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"; const AdminPage = ({ @@ -11,11 +9,11 @@ const AdminPage = ({ searchParams: Promise<{ [key: string]: string }>; }) => { return ( -
+
{/* LEFT */}
{/* USER CARDS */} -
+
@@ -23,17 +21,11 @@ const AdminPage = ({
{/* MIDDLE CHARTS */}
- {/* COUNT CHART */} -
- {/* */} -
{/* ATTENDANCE CHART */} -
+
- {/* BOTTOM CHART */} -
{/* */}
{/* RIGHT */}
diff --git a/src/app/(dashboard)/list/announcements/page.tsx b/src/app/(dashboard)/list/announcements/page.tsx index 4d88531..346098c 100644 --- a/src/app/(dashboard)/list/announcements/page.tsx +++ b/src/app/(dashboard)/list/announcements/page.tsx @@ -148,13 +148,11 @@ const AnnouncementListPage = async ({ return (
{/* TOP */} -
-

- All Announcements -

-
+
+

All Announcements

+
-
+
{role === "admin" && ( diff --git a/src/app/(dashboard)/list/assignments/page.tsx b/src/app/(dashboard)/list/assignments/page.tsx index 411483d..7b29039 100644 --- a/src/app/(dashboard)/list/assignments/page.tsx +++ b/src/app/(dashboard)/list/assignments/page.tsx @@ -66,9 +66,7 @@ const renderRow = (item: AssignmentList, role: UserRole | null) => ( >
- + {role !== "student" && } @@ -76,7 +74,7 @@ const renderRow = (item: AssignmentList, role: UserRole | null) => ( )} - @@ -103,8 +101,12 @@ const AssignmentListPage = async ({ const resolvedSearchParams = await searchParams; - const { academicYearId, query, orderBy, page: p } = - await buildAssignmentQuery({ + const { + academicYearId, + query, + orderBy, + page: p, + } = await buildAssignmentQuery({ searchParams, schoolId, role, @@ -122,7 +124,7 @@ const AssignmentListPage = async ({ } return value ? [[key, value]] : []; - }) + }), ); const [data, count] = await prisma.$transaction([ @@ -148,15 +150,13 @@ const AssignmentListPage = async ({ return (
-
-

- All Assignments -

+
+

All Assignments

-
+
-
+
{(role === "admin" || role === "teacher") && ( @@ -185,4 +185,4 @@ const AssignmentListPage = async ({ ); }; -export default AssignmentListPage; \ No newline at end of file +export default AssignmentListPage; diff --git a/src/app/(dashboard)/list/attendance/page.tsx b/src/app/(dashboard)/list/attendance/page.tsx index 5f4052a..d516acc 100644 --- a/src/app/(dashboard)/list/attendance/page.tsx +++ b/src/app/(dashboard)/list/attendance/page.tsx @@ -87,10 +87,10 @@ const AttendancePage = async ({ return (
{/* TOP */} -
-

Attendance

+
+

Attendance

- + )} -
- {/* ADMIN TABS */} - {role === "admin" && ( - - )} +
+ {/* ADMIN TABS */} + {role === "admin" && ( +
+ + Students + - {/* CLASS SELECT (ADMIN) */} - {role === "admin" && scope === "students" && ( -
- -
- )} + + Teachers + +
+ )} + + {/* CLASS SELECT (ADMIN) */} + {role === "admin" && scope === "students" && ( +
+ Class: + +
+ )} +
{/* TEACHER CLASS TABS */} {role === "teacher" && ( @@ -152,7 +159,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" }`} > diff --git a/src/app/(dashboard)/list/classes/page.tsx b/src/app/(dashboard)/list/classes/page.tsx index 0a1bfa7..10e8e4e 100644 --- a/src/app/(dashboard)/list/classes/page.tsx +++ b/src/app/(dashboard)/list/classes/page.tsx @@ -79,10 +79,14 @@ const ClassListPage = async ({ const resolvedSearchParams = await searchParams; - const { query, orderBy, page: p } = await buildClassQuery({ - searchParams, - schoolId, -}); + const { + query, + orderBy, + page: p, + } = await buildClassQuery({ + searchParams, + schoolId, + }); const exportQuery = new URLSearchParams( Object.entries(resolvedSearchParams).flatMap(([key, value]) => { @@ -91,7 +95,7 @@ const ClassListPage = async ({ } return value ? [[key, value]] : []; - }) + }), ); const [data, count] = await prisma.$transaction([ @@ -116,13 +120,13 @@ const ClassListPage = async ({ return (
-
-

All Classes

+
+

All Classes

-
+
-
+
{role === "admin" && ( @@ -149,4 +153,4 @@ const ClassListPage = async ({ ); }; -export default ClassListPage; \ No newline at end of file +export default ClassListPage; diff --git a/src/app/(dashboard)/list/events/page.tsx b/src/app/(dashboard)/list/events/page.tsx index b1ee760..719e41d 100644 --- a/src/app/(dashboard)/list/events/page.tsx +++ b/src/app/(dashboard)/list/events/page.tsx @@ -173,11 +173,11 @@ const EventListPage = async ({ return (
{/* TOP */} -
-

All Events

-
+
+

All Events

+
-
+
{role === "admin" && }
diff --git a/src/app/(dashboard)/list/exams/page.tsx b/src/app/(dashboard)/list/exams/page.tsx index 1081f7a..026195d 100644 --- a/src/app/(dashboard)/list/exams/page.tsx +++ b/src/app/(dashboard)/list/exams/page.tsx @@ -69,7 +69,7 @@ const getColumns = (role: UserRole | null) => { header: "End Time", accessor: "endTime", className: "hidden md:table-cell min-w-[180px] w-[180px]", - } + }, ); columns.push({ @@ -87,25 +87,21 @@ const renderRow = (item: ExamList, role: UserRole | null) => ( >
- + {role !== "student" && ( )} {role !== "teacher" && ( - + )} - - @@ -127,8 +123,7 @@ const ExamListPage = async ({ }: { searchParams: PageSearchParams; }) => { - const { role, userId, schoolId } = - await enforceRouteAccess("/list/exams"); + const { role, userId, schoolId } = await enforceRouteAccess("/list/exams"); const resolvedSearchParams = await searchParams; @@ -155,7 +150,7 @@ const ExamListPage = async ({ } return value ? [[key, value]] : []; - }) + }), ); const [data, count] = await prisma.$transaction([ @@ -236,15 +231,13 @@ const ExamListPage = async ({ return (
-
-

- All Exams -

+
+

All Exams

-
+
-
+
{(role === "admin" || role === "teacher") && ( @@ -273,4 +266,4 @@ const ExamListPage = async ({ ); }; -export default ExamListPage; \ No newline at end of file +export default ExamListPage; diff --git a/src/app/(dashboard)/list/lessons/page.tsx b/src/app/(dashboard)/list/lessons/page.tsx index 3670199..9406ed0 100644 --- a/src/app/(dashboard)/list/lessons/page.tsx +++ b/src/app/(dashboard)/list/lessons/page.tsx @@ -61,18 +61,26 @@ const LessonListPage = async ({ return (
-
+

Lessons Calendar

- {role === "admin" && selectedClass && ( -
- + {role === "admin" && ( + -
- )} + )} + + {role === "admin" && selectedClass && ( +
+ +
+ )} +
{classes.length === 0 ? ( @@ -131,12 +139,6 @@ const LessonListPage = async ({

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

- - {role === "admin" && ( - - )}
@@ -152,4 +154,4 @@ const LessonListPage = async ({ ); }; -export default LessonListPage; \ No newline at end of file +export default LessonListPage; diff --git a/src/app/(dashboard)/list/messages/page.tsx b/src/app/(dashboard)/list/messages/page.tsx index b3592fc..cb7f8cd 100644 --- a/src/app/(dashboard)/list/messages/page.tsx +++ b/src/app/(dashboard)/list/messages/page.tsx @@ -262,11 +262,11 @@ const MessageListPage = async ({ return (
{/* TOP */} -
-

All Messages

-
+
+

All Messages

+
-
+
{role === "admin" && ( diff --git a/src/app/(dashboard)/list/parents/page.tsx b/src/app/(dashboard)/list/parents/page.tsx index 67febc1..88bcacd 100644 --- a/src/app/(dashboard)/list/parents/page.tsx +++ b/src/app/(dashboard)/list/parents/page.tsx @@ -76,7 +76,11 @@ const ParentListPage = async ({ }) => { const { role, userId, schoolId } = await enforceRouteAccess("/list/parents"); - const { query, orderBy, page: p } = await buildParentQuery({ + const { + query, + orderBy, + page: p, + } = await buildParentQuery({ searchParams, schoolId, role, @@ -92,7 +96,7 @@ const ParentListPage = async ({ } return value ? [[key, value]] : []; - }) + }), ); const [data, count] = await prisma.$transaction([ @@ -112,13 +116,13 @@ const ParentListPage = async ({ return (
-
-

All Parents

+
+

All Parents

-
+
-
+
{role === "admin" && ( @@ -145,4 +149,4 @@ const ParentListPage = async ({ ); }; -export default ParentListPage; \ No newline at end of file +export default ParentListPage; diff --git a/src/app/(dashboard)/list/results/page.tsx b/src/app/(dashboard)/list/results/page.tsx index 7ec164b..dfa6f8e 100644 --- a/src/app/(dashboard)/list/results/page.tsx +++ b/src/app/(dashboard)/list/results/page.tsx @@ -58,7 +58,7 @@ const renderRow = (item: ResultList, role: UserRole | null) => ( alt="" width={40} height={40} - className="hidden md:block object-cover rounded-full w-10 h-10" + className="hidden md:block rounded-full w-10 h-10 object-cover" /> {item.student.name} @@ -83,8 +83,7 @@ const ResultListPage = async ({ }: { searchParams: PageSearchParams; }) => { - const { role, userId, schoolId } = - await enforceRouteAccess("/list/results"); + const { role, userId, schoolId } = await enforceRouteAccess("/list/results"); const resolvedSearchParams = await searchParams; @@ -111,7 +110,7 @@ const ResultListPage = async ({ } return value ? [[key, value]] : []; - }) + }), ); const [data, count] = await prisma.$transaction([ @@ -145,13 +144,13 @@ const ResultListPage = async ({ return (
-
-

All Results

+
+

All Results

-
+
-
+
{(role === "admin" || role === "teacher") && ( @@ -180,4 +179,4 @@ const ResultListPage = async ({ ); }; -export default ResultListPage; \ No newline at end of file +export default ResultListPage; diff --git a/src/app/(dashboard)/list/students/page.tsx b/src/app/(dashboard)/list/students/page.tsx index f106d2e..987dadb 100644 --- a/src/app/(dashboard)/list/students/page.tsx +++ b/src/app/(dashboard)/list/students/page.tsx @@ -109,7 +109,11 @@ const StudentListPage = async ({ return ; } - const { query, orderBy, page: p } = await buildStudentQuery({ + const { + query, + orderBy, + page: p, + } = await buildStudentQuery({ searchParams, schoolId, currentAcademicYearId: academicYearId, @@ -126,7 +130,7 @@ const StudentListPage = async ({ } return value ? [[key, value]] : []; - }) + }), ); if (!exportQuery.get("academicYearId")) { @@ -150,11 +154,9 @@ const StudentListPage = async ({ return (
-
-

All Students

- -
- +
+
+

All Students

{role === "admin" && ( )} +
-
+
+ +
{role === "admin" && ( @@ -195,4 +200,4 @@ const StudentListPage = async ({ ); }; -export default StudentListPage; \ No newline at end of file +export default StudentListPage; diff --git a/src/app/(dashboard)/list/subjects/page.tsx b/src/app/(dashboard)/list/subjects/page.tsx index d91bd01..0d1b8b3 100644 --- a/src/app/(dashboard)/list/subjects/page.tsx +++ b/src/app/(dashboard)/list/subjects/page.tsx @@ -71,7 +71,11 @@ const SubjectListPage = async ({ }) => { const { role, userId, schoolId } = await enforceRouteAccess("/list/subjects"); - const { query, orderBy, page: p } = await buildSubjectQuery({ + const { + query, + orderBy, + page: p, + } = await buildSubjectQuery({ searchParams, schoolId, role, @@ -87,7 +91,7 @@ const SubjectListPage = async ({ } return value ? [[key, value]] : []; - }) + }), ); const [data, count] = await prisma.$transaction([ @@ -108,13 +112,13 @@ const SubjectListPage = async ({ return (
-
-

All Subjects

+
+

All Subjects

-
+
-
+
{role === "admin" && ( @@ -141,4 +145,4 @@ const SubjectListPage = async ({ ); }; -export default SubjectListPage; \ No newline at end of file +export default SubjectListPage; diff --git a/src/app/(dashboard)/list/teachers/page.tsx b/src/app/(dashboard)/list/teachers/page.tsx index f52903b..8dde62f 100644 --- a/src/app/(dashboard)/list/teachers/page.tsx +++ b/src/app/(dashboard)/list/teachers/page.tsx @@ -98,7 +98,11 @@ const TeacherListPage = async ({ }) => { const { role, schoolId } = await enforceRouteAccess("/list/teachers"); - const { query, orderBy, page: p } = await buildTeacherQuery({ + const { + query, + orderBy, + page: p, + } = await buildTeacherQuery({ searchParams, schoolId, }); @@ -112,7 +116,7 @@ const TeacherListPage = async ({ } return value ? [[key, value]] : []; - }) + }), ); const [data, count] = await prisma.$transaction([ @@ -132,13 +136,13 @@ const TeacherListPage = async ({ return (
-
-

All Teachers

+
+

All Teachers

-
+
-
+
{role === "admin" && ( @@ -165,4 +169,4 @@ const TeacherListPage = async ({ ); }; -export default TeacherListPage; \ No newline at end of file +export default TeacherListPage; diff --git a/src/components/FormModal.tsx b/src/components/FormModal.tsx index 0e06988..ee56836 100644 --- a/src/components/FormModal.tsx +++ b/src/components/FormModal.tsx @@ -201,14 +201,6 @@ const FormModal = ({ id, relatedData, }: FormContainerProps & { relatedData?: any }) => { - const size = type === "create" ? "w-8 h-8" : "w-7 h-7"; - const bgColor = - type === "create" - ? "bg-academixYellow" - : type === "update" - ? "bg-academixSky" - : "bg-academixPurple"; - const [open, setOpen] = useState(false); const Form = () => { @@ -276,7 +268,7 @@ const FormModal = ({ return ( <>
{item.title} - {item.subject?.name ?? "-"} - {item.subject?.name ?? "-"}{item.class?.name ?? "-"}{item.lesson.teacher.name} + {formatDateTime(item.endDate)} {item.title} - {item.subject?.name ?? "-"} - {item.subject?.name ?? "-"}{item.displayClasses ?? item.class?.name ?? "-"} - {item.lesson.teacher.name} - {item.lesson.teacher.name} + {formatDateTime(item.startTime)} + {formatDateTime(item.endTime)}
{isToday && ( - )} diff --git a/src/components/CountChart.tsx b/src/components/CountChart.tsx deleted file mode 100644 index 923302d..0000000 --- a/src/components/CountChart.tsx +++ /dev/null @@ -1,55 +0,0 @@ -"use client"; -import { MoreHorizontal } from "lucide-react"; -import Image from "next/image"; -import { - RadialBarChart, - RadialBar, - Legend, - ResponsiveContainer, -} from "recharts"; - -const CountChart = ({ boys, girls }: { boys: number; girls: number }) => { - const data = [ - { - name: "Total", - count: boys + girls, - fill: "white", - }, - { - name: "Girls", - count: girls, - fill: "#FAE27C", - }, - { - name: "Boys", - count: boys, - fill: "#C3EBFA", - }, - ]; - - return ( -
- - - - - - -
- ); -}; - -export default CountChart; diff --git a/src/components/CountChartContainer.tsx b/src/components/CountChartContainer.tsx deleted file mode 100644 index aed482b..0000000 --- a/src/components/CountChartContainer.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { MoreHorizontal } from "lucide-react"; -import CountChart from "./CountChart"; -import prisma from "@/lib/prisma"; - -const CountChartContainer = async () => { - const data = await prisma.student.groupBy({ - by: ["sex"], - _count: true, - }); - - const boys = data.find((s) => s.sex === "MALE")?._count || 0; - const girls = data.find((s) => s.sex === "FEMALE")?._count || 0; - - return ( -
- {/* TITLE */} -
-

Students

- -
- {/* CHART */} - - {/* BOTTOM */} -
-
-
-

{boys}

-

- Boys {Math.round((boys / (boys + girls)) * 100)}% -

-
-
-
-

{girls}

-

- Girls {Math.round((girls / (boys + girls)) * 100)}% -

-
-
-
- ); -}; - -export default CountChartContainer; diff --git a/src/components/FinanceChart.tsx b/src/components/FinanceChart.tsx deleted file mode 100644 index 5b088ff..0000000 --- a/src/components/FinanceChart.tsx +++ /dev/null @@ -1,135 +0,0 @@ -"use client"; - -import { MoreHorizontal } from "lucide-react"; -import { - LineChart, - Line, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - Legend, - ResponsiveContainer, -} from "recharts"; - -const data = [ - { - name: "Jan", - income: 4000, - expense: 2400, - }, - { - name: "Feb", - income: 3000, - expense: 1398, - }, - { - name: "Mar", - income: 2000, - expense: 9800, - }, - { - name: "Apr", - income: 2780, - expense: 3908, - }, - { - name: "May", - income: 1890, - expense: 4800, - }, - { - name: "Jun", - income: 2390, - expense: 3800, - }, - { - name: "Jul", - income: 3490, - expense: 4300, - }, - { - name: "Aug", - income: 3490, - expense: 4300, - }, - { - name: "Sep", - income: 3490, - expense: 4300, - }, - { - name: "Oct", - income: 3490, - expense: 4300, - }, - { - name: "Nov", - income: 3490, - expense: 4300, - }, - { - name: "Dec", - income: 3490, - expense: 4300, - }, -]; - -const FinanceChart = () => { - return ( -
-
-

Finance

- -
- - - - - - - - - - - -
- ); -}; - -export default FinanceChart; diff --git a/src/components/FinanceChartLoader.tsx b/src/components/FinanceChartLoader.tsx deleted file mode 100644 index 26f8132..0000000 --- a/src/components/FinanceChartLoader.tsx +++ /dev/null @@ -1,10 +0,0 @@ -"use client"; - -import dynamic from "next/dynamic"; - -const FinanceChartLoader = dynamic(() => import("@/components/FinanceChart"), { - ssr: false, - loading: () =>
, -}); - -export default FinanceChartLoader; diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 9de335e..1eaa08d 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -48,16 +48,6 @@ const Navbar = ({ authUser, schoolName }: NavbarProps) => { {schoolName} )} - -
- - - -
{/* RIGHT */} @@ -82,7 +72,7 @@ const Navbar = ({ authUser, schoolName }: NavbarProps) => {
{/* USER INFO */} -
+
{fullName} {formattedRole} diff --git a/src/components/TableSearch.tsx b/src/components/TableSearch.tsx index 98cd307..1600690 100644 --- a/src/components/TableSearch.tsx +++ b/src/components/TableSearch.tsx @@ -26,7 +26,7 @@ const TableSearch = () => { return (
{ const user = requireAuth(); @@ -29,17 +29,22 @@ const UserCard = async ({ type }: { type: UserRole }) => { }; const data = await countByRole[type](); + const schoolId = (await user).schoolId; + const currentYear = await getCurrentAcademicYearOrNull(schoolId as number); return ( -
+
- - 2024/25 - - + {currentYear && ( + + {currentYear.name} + + )} +
+
+

{data}

+

{type}s

-

{data}

-

{type}s

); }; From d5e9f99073a084c9e979c84877ffdfab35b02ec4 Mon Sep 17 00:00:00 2001 From: Abdullah Abusharekh Date: Wed, 13 May 2026 16:59:03 +0300 Subject: [PATCH 010/151] fix: fix build errors --- .../20260509100045_init/migration.sql | 682 ------------------ .../list/exams/create-workflow/page.tsx | 8 +- src/app/api/save-answer/route.ts | 29 +- src/components/exam/ExamClient.tsx | 111 ++- src/components/exam/QuestionRenderer.tsx | 46 +- src/components/forms/ExamWorkflowForm.tsx | 148 ++-- 6 files changed, 214 insertions(+), 810 deletions(-) delete mode 100644 prisma/migrations/20260509100045_init/migration.sql diff --git a/prisma/migrations/20260509100045_init/migration.sql b/prisma/migrations/20260509100045_init/migration.sql deleted file mode 100644 index e167685..0000000 --- a/prisma/migrations/20260509100045_init/migration.sql +++ /dev/null @@ -1,682 +0,0 @@ --- CreateEnum -CREATE TYPE "UserSex" AS ENUM ('MALE', 'FEMALE'); - --- CreateEnum -CREATE TYPE "Day" AS ENUM ('SATURDAY', 'SUNDAY', 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY'); - --- CreateEnum -CREATE TYPE "StudentStatus" AS ENUM ('ACTIVE', 'REPEATED', 'GRADUATED', 'LEFT'); - --- CreateEnum -CREATE TYPE "PassFailStatus" AS ENUM ('PASS', 'FAIL'); - --- 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 "Admin" ( - "id" TEXT NOT NULL, - "username" TEXT NOT NULL, - "schoolId" INTEGER NOT NULL, - - CONSTRAINT "Admin_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 "Student" ( - "id" TEXT NOT NULL, - "schoolId" INTEGER NOT NULL, - "username" TEXT NOT NULL, - "name" TEXT NOT NULL, - "email" TEXT, - "phone" TEXT, - "address" TEXT NOT NULL, - "img" TEXT, - "bloodType" TEXT NOT NULL, - "sex" "UserSex" NOT NULL, - "status" "StudentStatus" NOT NULL DEFAULT 'ACTIVE', - "repeatCount" INTEGER NOT NULL DEFAULT 0, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "parentId" TEXT, - "classId" INTEGER NOT NULL, - "gradeId" INTEGER NOT NULL, - "birthday" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "Student_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "Teacher" ( - "id" TEXT NOT NULL, - "schoolId" INTEGER NOT NULL, - "username" TEXT NOT NULL, - "name" TEXT NOT NULL, - "email" TEXT, - "phone" TEXT, - "address" TEXT NOT NULL, - "img" TEXT, - "bloodType" TEXT NOT NULL, - "sex" "UserSex" NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "birthday" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "Teacher_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "Parent" ( - "id" TEXT NOT NULL, - "schoolId" INTEGER NOT NULL, - "username" TEXT NOT NULL, - "name" TEXT NOT NULL, - "email" TEXT, - "phone" TEXT NOT NULL, - "address" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "Parent_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "Grade" ( - "id" SERIAL NOT NULL, - "schoolId" INTEGER NOT NULL, - "level" INTEGER NOT NULL, - - CONSTRAINT "Grade_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "Class" ( - "id" SERIAL NOT NULL, - "schoolId" INTEGER NOT NULL, - "name" TEXT NOT NULL, - "capacity" INTEGER NOT NULL, - "supervisorId" TEXT, - "gradeId" INTEGER NOT NULL, - - CONSTRAINT "Class_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 "Subject" ( - "id" SERIAL NOT NULL, - "schoolId" INTEGER NOT NULL, - "name" TEXT NOT NULL, - "gradeId" INTEGER NOT NULL, - - CONSTRAINT "Subject_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "Lesson" ( - "id" SERIAL NOT NULL, - "schoolId" INTEGER NOT NULL, - "name" TEXT NOT NULL, - "day" "Day" NOT NULL, - "startTime" TIMESTAMP(3) NOT NULL, - "endTime" TIMESTAMP(3) NOT NULL, - "subjectId" INTEGER NOT NULL, - "classId" INTEGER NOT NULL, - "teacherId" TEXT NOT NULL, - "academicYearId" INTEGER NOT NULL, - - CONSTRAINT "Lesson_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "Exam" ( - "id" SERIAL NOT NULL, - "schoolId" INTEGER NOT NULL, - "title" TEXT NOT NULL, - "startTime" TIMESTAMP(3) NOT NULL, - "endTime" TIMESTAMP(3) NOT NULL, - "classId" INTEGER, - "subjectId" INTEGER, - "lessonId" INTEGER NOT NULL, - "academicYearId" INTEGER NOT NULL, - - CONSTRAINT "Exam_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "Assignment" ( - "id" SERIAL NOT NULL, - "schoolId" INTEGER NOT NULL, - "title" TEXT NOT NULL, - "startDate" TIMESTAMP(3) NOT NULL, - "endDate" TIMESTAMP(3) NOT NULL, - "classId" INTEGER, - "subjectId" INTEGER, - "lessonId" INTEGER NOT NULL, - "academicYearId" INTEGER NOT NULL, - - CONSTRAINT "Assignment_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "Result" ( - "id" SERIAL NOT NULL, - "schoolId" INTEGER NOT NULL, - "score" INTEGER NOT NULL, - "examId" INTEGER, - "assignmentId" INTEGER, - "studentId" TEXT NOT NULL, - "academicYearId" INTEGER NOT NULL, - - CONSTRAINT "Result_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "Attendance" ( - "id" SERIAL NOT NULL, - "schoolId" INTEGER NOT NULL, - "date" TIMESTAMP(3) NOT NULL, - "present" BOOLEAN NOT NULL, - "academicYearId" INTEGER NOT NULL, - "studentId" TEXT, - "teacherId" TEXT, - - CONSTRAINT "Attendance_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "Event" ( - "id" SERIAL NOT NULL, - "schoolId" INTEGER NOT NULL, - "title" TEXT NOT NULL, - "description" TEXT NOT NULL, - "startDate" TIMESTAMP(3) NOT NULL, - "endDate" TIMESTAMP(3) NOT NULL, - "academicYearId" INTEGER NOT NULL, - - CONSTRAINT "Event_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "Announcement" ( - "id" SERIAL NOT NULL, - "schoolId" INTEGER NOT NULL, - "title" TEXT NOT NULL, - "description" TEXT NOT NULL, - "date" TIMESTAMP(3) NOT NULL, - "academicYearId" INTEGER NOT NULL, - - CONSTRAINT "Announcement_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 "_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 "_SubjectToTeacher" ( - "A" INTEGER NOT NULL, - "B" TEXT NOT NULL, - - CONSTRAINT "_SubjectToTeacher_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 INDEX "Admin_schoolId_idx" ON "Admin"("schoolId"); - --- CreateIndex -CREATE UNIQUE INDEX "Admin_schoolId_username_key" ON "Admin"("schoolId", "username"); - --- 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 "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 "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"); - --- 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 "Grade_schoolId_idx" ON "Grade"("schoolId"); - --- CreateIndex -CREATE UNIQUE INDEX "Grade_schoolId_level_key" ON "Grade"("schoolId", "level"); - --- CreateIndex -CREATE INDEX "Class_schoolId_idx" ON "Class"("schoolId"); - --- CreateIndex -CREATE UNIQUE INDEX "Class_schoolId_name_key" ON "Class"("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 "Subject_schoolId_idx" ON "Subject"("schoolId"); - --- CreateIndex -CREATE UNIQUE INDEX "Subject_schoolId_name_key" ON "Subject"("schoolId", "name"); - --- CreateIndex -CREATE INDEX "Lesson_schoolId_idx" ON "Lesson"("schoolId"); - --- CreateIndex -CREATE INDEX "Exam_schoolId_idx" ON "Exam"("schoolId"); - --- CreateIndex -CREATE INDEX "Assignment_schoolId_idx" ON "Assignment"("schoolId"); - --- CreateIndex -CREATE INDEX "Result_schoolId_idx" ON "Result"("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 "Event_schoolId_idx" ON "Event"("schoolId"); - --- CreateIndex -CREATE INDEX "Announcement_schoolId_idx" ON "Announcement"("schoolId"); - --- CreateIndex -CREATE INDEX "Message_schoolId_idx" ON "Message"("schoolId"); - --- 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 "_SubjectToTeacher_B_index" ON "_SubjectToTeacher"("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"); - --- 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 "Student" ADD CONSTRAINT "Student_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Student" ADD CONSTRAINT "Student_gradeId_fkey" FOREIGN KEY ("gradeId") REFERENCES "Grade"("id") ON DELETE RESTRICT 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 "Class" ADD CONSTRAINT "Class_gradeId_fkey" FOREIGN KEY ("gradeId") REFERENCES "Grade"("id") ON DELETE RESTRICT 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_subjectId_fkey" FOREIGN KEY ("subjectId") REFERENCES "Subject"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Lesson" ADD CONSTRAINT "Lesson_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Lesson" ADD CONSTRAINT "Lesson_teacherId_fkey" FOREIGN KEY ("teacherId") REFERENCES "Teacher"("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_lessonId_fkey" FOREIGN KEY ("lessonId") REFERENCES "Lesson"("id") ON DELETE RESTRICT 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_lessonId_fkey" FOREIGN KEY ("lessonId") REFERENCES "Lesson"("id") ON DELETE RESTRICT 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_examId_fkey" FOREIGN KEY ("examId") REFERENCES "Exam"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Result" ADD CONSTRAINT "Result_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "Assignment"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Result" ADD CONSTRAINT "Result_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "Student"("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 "_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 "_SubjectToTeacher" ADD CONSTRAINT "_SubjectToTeacher_A_fkey" FOREIGN KEY ("A") REFERENCES "Subject"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "_SubjectToTeacher" ADD CONSTRAINT "_SubjectToTeacher_B_fkey" FOREIGN KEY ("B") REFERENCES "Teacher"("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/src/app/(dashboard)/list/exams/create-workflow/page.tsx b/src/app/(dashboard)/list/exams/create-workflow/page.tsx index cbd2a7c..198e988 100644 --- a/src/app/(dashboard)/list/exams/create-workflow/page.tsx +++ b/src/app/(dashboard)/list/exams/create-workflow/page.tsx @@ -10,7 +10,7 @@ export default async function CreateExamWorkflowPage({ }: { 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 examId = examIdParam ? Number.parseInt(examIdParam, 10) : NaN; @@ -87,7 +87,9 @@ export default async function CreateExamWorkflowPage({ points: question.points, order: question.order, allowMultiple: question.allowMultiple, - options: Array.isArray(question.options) ? (question.options as string[]) : [], + options: Array.isArray(question.options) + ? (question.options as string[]) + : [], correctAnswer: question.correctAnswer ?? [], })), } @@ -103,7 +105,7 @@ export default async function CreateExamWorkflowPage({ return (
-

+

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

void) & { + flush: () => void; +}; + interface ExamClientProps { exam: Exam; submission: any; @@ -33,22 +37,27 @@ export default function ExamClient({ totalPages, }: ExamClientProps) { const router = useRouter(); - + const [questions, setQuestions] = useState(initialQuestions); const [answers, setAnswers] = useState>( - initialAnswers.reduce((acc, ans) => ({ ...acc, [ans.questionId]: ans.textAnswer || "" }), {}) + initialAnswers.reduce( + (acc, ans) => ({ ...acc, [ans.questionId]: ans.textAnswer || "" }), + {}, + ), ); - + const [currentPage, setCurrentPage] = useState(submission.currentPage); const [isFrozen, setIsFrozen] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [isLoadingPage, setIsLoadingPage] = useState(false); - const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle"); + const [saveStatus, setSaveStatus] = useState< + "idle" | "saving" | "saved" | "error" + >("idle"); const pendingAnswersRef = useRef>({}); const disconnectedAtRef = useRef(null); const freezeTimerRef = useRef(null); - const debouncedSaveRef = useRef | null>(null); + const debouncedSaveRef = useRef(null); useEffect(() => { const debounced = debounce(async (questionId: number, answer: string) => { @@ -59,7 +68,7 @@ export default function ExamClient({ submissionId: submission.id, questionId, textAnswer: answer, - } + }, ); if (res.error) { @@ -100,7 +109,11 @@ export default function ExamClient({ for (const [qId, ans] of Object.entries(pendingAnswersRef.current)) { await saveAnswer( { success: true, error: false }, - { submissionId: submission.id, questionId: parseInt(qId), textAnswer: ans } + { + submissionId: submission.id, + questionId: parseInt(qId), + textAnswer: ans, + }, ); } pendingAnswersRef.current = {}; @@ -124,17 +137,21 @@ export default function ExamClient({ setCurrentPage(newPage); window.scrollTo({ top: 0, behavior: "smooth" }); } - + setIsLoadingPage(false); }; const handleSubmit = async () => { if (isSubmitting) return; - - if (confirm("Are you sure you want to submit your exam? You cannot change your answers after submitting.")) { + + if ( + confirm( + "Are you sure you want to submit your exam? You cannot change your answers after submitting.", + ) + ) { setIsSubmitting(true); debouncedSaveRef.current?.flush(); - + const res = await submitExam(submission.id); if (res.error) { toast.error(res.error as string); @@ -165,14 +182,16 @@ export default function ExamClient({ const handleOnline = async () => { if (freezeTimerRef.current) clearTimeout(freezeTimerRef.current); - + if (isFrozen && disconnectedAtRef.current) { setIsFrozen(false); - const offlineSeconds = Math.floor((Date.now() - disconnectedAtRef.current) / 1000); + const offlineSeconds = Math.floor( + (Date.now() - disconnectedAtRef.current) / 1000, + ); await recordDisconnection( submission.id, offlineSeconds, - new Date(disconnectedAtRef.current) + new Date(disconnectedAtRef.current), ); disconnectedAtRef.current = null; } @@ -197,7 +216,7 @@ export default function ExamClient({ submissionId: submission.id, questionId: parseInt(qId), textAnswer: answer, - }) + }), ); } }; @@ -207,23 +226,29 @@ export default function ExamClient({ }, [submission.id]); return ( -
+
-
-
+
+
-

{exam.title}

-
+

{exam.title}

+
Page {currentPage} of {totalPages} {exam.enableAutoSave && ( - + {saveStatus === "saving" && "Saving..."} {saveStatus === "saved" && "✓ Saved"} {saveStatus === "error" && "⚠ Save failed"} @@ -231,7 +256,7 @@ export default function ExamClient({ )}
- +
{exam.enableTimer && ( )} - + @@ -255,17 +280,20 @@ export default function ExamClient({
{questions.map((q) => ( -
+
{q.order}. {q.text}
- + {q.points} {q.points === 1 ? "point" : "points"}
- +
-
+
- - + + {currentPage} / {totalPages} - + {currentPage < totalPages ? ( ) : ( -
Next
// Spacer +
Next
// Spacer )}
diff --git a/src/components/exam/QuestionRenderer.tsx b/src/components/exam/QuestionRenderer.tsx index 8978ec7..9e5822d 100644 --- a/src/components/exam/QuestionRenderer.tsx +++ b/src/components/exam/QuestionRenderer.tsx @@ -25,7 +25,7 @@ export default function QuestionRenderer({ const handleMultipleChoice = (option: string, checked: boolean) => { let currentAns = parseAnswerList(savedAnswer); - + if (question.allowMultiple) { if (checked) { if (!currentAns.includes(option)) { @@ -37,12 +37,12 @@ export default function QuestionRenderer({ } else { currentAns = [option]; } - + onChange( question.id, question.allowMultiple ? JSON.stringify(currentAns) - : serializeAnswerList(currentAns) + : serializeAnswerList(currentAns), ); }; @@ -96,30 +96,42 @@ export default function QuestionRenderer({ {(() => { const selectedAnswers = parseStoredAnswer( savedAnswer, - question.allowMultiple + question.allowMultiple, + ); + const rawOptions = Array.isArray(question.options) + ? question.options + : []; + const options = rawOptions.filter( + (o): o is string => typeof o === "string", ); - return question.options.map((option, idx) => { + + return options.map((option, idx) => { const isChecked = question.allowMultiple ? selectedAnswers.includes(option) : selectedAnswers[0] === option; return ( -