diff --git a/app/api/announcements/[id]/route.ts b/app/api/announcements/[id]/route.ts index 6c32f47..784c12b 100644 --- a/app/api/announcements/[id]/route.ts +++ b/app/api/announcements/[id]/route.ts @@ -4,7 +4,7 @@ import mongoose from 'mongoose' import { connectDB } from '@/lib/mongodb' import { Announcement } from '@/models/Announcement' -const ALLOWED_FIELDS = ['title', 'content', 'body', 'audience', 'category', 'pinned', 'expiresAt'] +const ALLOWED_FIELDS = ['title', 'content', 'audience', 'category', 'pinned'] export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { const { userId } = await auth() @@ -35,8 +35,12 @@ export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string } } + if (Object.keys(sanitizedBody).length === 0) { + return NextResponse.json({ error: 'No valid announcement fields provided' }, { status: 400 }) + } + const announcement = await Announcement.findOneAndUpdate( - { _id: id }, + { _id: id, teacherId: userId }, { $set: sanitizedBody }, { new: true, runValidators: true, context: 'query' } ) @@ -46,6 +50,16 @@ export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string if (error instanceof Error) { console.error('PUT /api/announcements/[id] error:', error.message) } + if (error instanceof mongoose.Error.ValidationError) { + const firstError = Object.values(error.errors)[0] + return NextResponse.json( + { error: firstError?.message ?? 'Invalid announcement update' }, + { status: 400 } + ) + } + if (error instanceof mongoose.Error.CastError) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } @@ -63,7 +77,7 @@ export async function DELETE(_req: NextRequest, ctx: { params: Promise<{ id: str } await connectDB() - const deleted = await Announcement.findOneAndDelete({ _id: id }) + const deleted = await Announcement.findOneAndDelete({ _id: id, teacherId: userId }) if (!deleted) { return NextResponse.json({ error: 'Not found' }, { status: 404 }) diff --git a/app/api/assignments/[id]/route.ts b/app/api/assignments/[id]/route.ts index 21fca1c..05212ad 100644 --- a/app/api/assignments/[id]/route.ts +++ b/app/api/assignments/[id]/route.ts @@ -4,7 +4,7 @@ import mongoose from 'mongoose' import { connectDB } from '@/lib/mongodb' import { Assignment } from '@/models/Assignment' -const ALLOWED_UPDATE_FIELDS = ['title', 'description', 'dueDate', 'deadline', 'subject', 'class', 'status', 'kanbanStatus', 'maxMarks'] +const ALLOWED_UPDATE_FIELDS = ['title', 'description', 'deadline', 'subject', 'class', 'status', 'kanbanStatus', 'maxMarks'] export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { const { userId } = await auth() @@ -35,10 +35,14 @@ export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string } } + if (Object.keys(sanitizedBody).length === 0) { + return NextResponse.json({ error: 'No valid assignment fields provided' }, { status: 400 }) + } + const assignment = await Assignment.findOneAndUpdate( - { _id: id }, + { _id: id, teacherId: userId }, sanitizedBody, - { new: true } + { new: true, runValidators: true, context: 'query' } ) if (!assignment) return NextResponse.json({ error: 'Not found' }, { status: 404 }) return NextResponse.json(assignment) @@ -46,6 +50,16 @@ export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string if (error instanceof Error) { console.error('PUT /api/assignments/[id] error:', error.message) } + if (error instanceof mongoose.Error.ValidationError) { + const firstError = Object.values(error.errors)[0] + return NextResponse.json( + { error: firstError?.message ?? 'Invalid assignment update' }, + { status: 400 } + ) + } + if (error instanceof mongoose.Error.CastError) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } @@ -63,7 +77,7 @@ export async function DELETE(_req: NextRequest, ctx: { params: Promise<{ id: str } await connectDB() - const deleted = await Assignment.findOneAndDelete({ _id: id }) + const deleted = await Assignment.findOneAndDelete({ _id: id, teacherId: userId }) if (!deleted) { return NextResponse.json({ error: 'Not found' }, { status: 404 }) diff --git a/app/api/assignments/route.ts b/app/api/assignments/route.ts index 19021d8..37812df 100644 --- a/app/api/assignments/route.ts +++ b/app/api/assignments/route.ts @@ -1,5 +1,6 @@ import { auth } from '@clerk/nextjs/server' import { NextRequest, NextResponse } from 'next/server' +import mongoose from 'mongoose' import { connectDB } from '@/lib/mongodb' import { Assignment } from '@/models/Assignment' import { z } from 'zod' @@ -52,7 +53,7 @@ export async function GET(req: NextRequest) { if (error instanceof Error) { console.error('GET /api/assignments error:', error.message) } - return NextResponse.json({ error: error instanceof Error ? error.stack : 'Internal server error' }, { status: 500 }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } @@ -80,6 +81,16 @@ export async function POST(req: NextRequest) { if (dbError instanceof Error) { console.error('Assignment.create error:', dbError.message) } + if (dbError instanceof mongoose.Error.ValidationError) { + const firstError = Object.values(dbError.errors)[0] + return NextResponse.json( + { error: firstError?.message ?? 'Invalid assignment payload' }, + { status: 400 } + ) + } + if (dbError instanceof mongoose.Error.CastError) { + return NextResponse.json({ error: dbError.message }, { status: 400 }) + } return NextResponse.json({ error: 'Failed to create assignment' }, { status: 500 }) } } catch (error) { diff --git a/app/api/attendance/route.ts b/app/api/attendance/route.ts index 14b6c4d..5c97f32 100644 --- a/app/api/attendance/route.ts +++ b/app/api/attendance/route.ts @@ -1,18 +1,19 @@ import { auth } from '@clerk/nextjs/server' import { NextRequest, NextResponse } from 'next/server' +import mongoose from 'mongoose' import { connectDB } from '@/lib/mongodb' import { Attendance } from '@/models/Attendance' +import { Student } from '@/models/Student' +import { normalizeDateOnly } from '@/lib/date' import { z } from 'zod' const AttendanceSchema = z.object({ studentId: z.string().min(1), - studentName: z.string().min(1), - class: z.string().min(1), date: z.string().min(1), status: z.enum(['present', 'absent', 'late']), }) -const BulkSchema = z.array(AttendanceSchema) +const BulkSchema = z.array(AttendanceSchema).min(1, 'At least one attendance record is required') export async function GET(req: NextRequest) { const { userId } = await auth() @@ -30,21 +31,8 @@ export async function GET(req: NextRequest) { const query: Record = { teacherId: userId }; - // Helper to validate and normalize date strings to YYYY-MM-DD format - const normalizeDate = (dateStr: string): string | null => { - try { - // Try to parse as ISO date (YYYY-MM-DD or full ISO 8601) - const d = new Date(dateStr); - if (isNaN(d.getTime())) return null; - // Return in YYYY-MM-DD format for MongoDB string comparison - return d.toISOString().split("T")[0]; - } catch { - return null; - } - }; - if (date) { - const normalized = normalizeDate(date); + const normalized = normalizeDateOnly(date); if (normalized) { query.date = normalized; } else { @@ -56,7 +44,7 @@ export async function GET(req: NextRequest) { } else if (startDate || endDate) { const dateRange: Record = {}; if (startDate) { - const normalized = normalizeDate(startDate); + const normalized = normalizeDateOnly(startDate); if (normalized) dateRange.$gte = normalized; else return NextResponse.json( @@ -65,7 +53,7 @@ export async function GET(req: NextRequest) { ); } if (endDate) { - const normalized = normalizeDate(endDate); + const normalized = normalizeDateOnly(endDate); if (normalized) dateRange.$lte = normalized; else return NextResponse.json( @@ -76,7 +64,12 @@ export async function GET(req: NextRequest) { query.date = dateRange; } if (cls) query.class = cls; - if (studentId) query.studentId = studentId; + if (studentId) { + if (!mongoose.Types.ObjectId.isValid(studentId)) { + return NextResponse.json({ error: 'Invalid studentId' }, { status: 400 }) + } + query.studentId = studentId; + } const records = await Attendance.find(query) .sort({ date: -1, studentName: 1 }) @@ -123,20 +116,91 @@ export async function POST(req: NextRequest) { { status: 400 }, ); + const records = isBulk + ? (parsed.data as z.infer) + : [parsed.data as z.infer] + + const normalizedRecords = records.map((record) => { + const normalizedDate = normalizeDateOnly(record.date) + if (!normalizedDate) { + return null + } + + return { + ...record, + date: normalizedDate, + } + }) + + if (normalizedRecords.some((record) => record === null)) { + return NextResponse.json( + { error: 'Invalid date format. Use YYYY-MM-DD or ISO 8601' }, + { status: 400 }, + ) + } + + const safeRecords = normalizedRecords as (z.infer & { date: string })[] + + const studentIds = [...new Set(safeRecords.map((record) => record.studentId))] + if (!studentIds.every((studentId) => mongoose.Types.ObjectId.isValid(studentId))) { + return NextResponse.json({ error: 'Invalid studentId' }, { status: 400 }) + } + + const students = await Student.find({ + _id: { $in: studentIds }, + teacherId: userId, + }) + .select('_id name class') + .lean() + + const studentMap = new Map( + students.map((student) => [String(student._id), student]), + ) + + if (studentMap.size !== studentIds.length) { + return NextResponse.json( + { error: 'One or more students were not found for this teacher' }, + { status: 400 }, + ) + } + if (isBulk) { - const ops = (parsed.data as z.infer).map((record) => ({ + const ops = safeRecords.map((record) => { + const student = studentMap.get(record.studentId)! + + return { updateOne: { filter: { teacherId: userId, studentId: record.studentId, date: record.date }, - update: { $set: { ...record, teacherId: userId } }, + update: { + $set: { + teacherId: userId, + studentId: record.studentId, + studentName: student.name, + class: student.class, + date: record.date, + status: record.status, + }, + }, upsert: true, }, - })) + }}) await Attendance.bulkWrite(ops) return NextResponse.json({ success: true, count: ops.length }) } else { + const recordData = safeRecords[0] + const student = studentMap.get(recordData.studentId)! const record = await Attendance.findOneAndUpdate( - { teacherId: userId, studentId: (parsed.data as z.infer).studentId, date: (parsed.data as z.infer).date }, - { $set: { ...(parsed.data as z.infer), teacherId: userId } }, + { teacherId: userId, studentId: recordData.studentId, date: recordData.date }, + { + $set: { + teacherId: userId, + studentId: recordData.studentId, + studentName: student.name, + class: student.class, + date: recordData.date, + status: recordData.status, + }, + }, { upsert: true, new: true } ) return NextResponse.json(record, { status: 201 }) diff --git a/app/api/grades/[id]/route.ts b/app/api/grades/[id]/route.ts index 0141f63..d6b4f34 100644 --- a/app/api/grades/[id]/route.ts +++ b/app/api/grades/[id]/route.ts @@ -3,8 +3,20 @@ import { NextRequest, NextResponse } from 'next/server' import mongoose from 'mongoose' import { connectDB } from '@/lib/mongodb' import { Grade } from '@/models/Grade' +import { Student } from '@/models/Student' -const ALLOWED_UPDATE_FIELDS = ['marks', 'maxMarks', 'grade'] +const ALLOWED_UPDATE_FIELDS = ['studentId', 'subject', 'term', 'marks', 'maxMarks'] + +function calcGrade(marks: number, max: number): string { + const pct = (marks / max) * 100 + if (pct >= 90) return 'A+' + if (pct >= 80) return 'A' + if (pct >= 70) return 'B+' + if (pct >= 60) return 'B' + if (pct >= 50) return 'C' + if (pct >= 40) return 'D' + return 'F' +} export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { const { userId } = await auth() @@ -33,18 +45,74 @@ export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string } } + if (Object.keys(sanitizedBody).length === 0) { + return NextResponse.json({ error: 'No valid grade fields provided' }, { status: 400 }) + } + await connectDB() + + const existingGrade = await Grade.findOne({ _id: id, teacherId: userId }) + if (!existingGrade) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + + if (typeof sanitizedBody.studentId === 'string') { + if (!mongoose.Types.ObjectId.isValid(sanitizedBody.studentId)) { + return NextResponse.json({ error: 'Invalid studentId' }, { status: 400 }) + } + + const student = await Student.findOne({ + _id: sanitizedBody.studentId, + teacherId: userId, + }) + .select('_id name') + .lean() + + if (!student) { + return NextResponse.json({ error: 'Student not found for this teacher' }, { status: 400 }) + } + + sanitizedBody.studentName = student.name + } + + const nextMarks = + typeof sanitizedBody.marks === 'number' ? sanitizedBody.marks : existingGrade.marks + const nextMaxMarks = + typeof sanitizedBody.maxMarks === 'number' ? sanitizedBody.maxMarks : existingGrade.maxMarks + + if (nextMarks > nextMaxMarks) { + return NextResponse.json( + { error: 'marks must be less than or equal to maxMarks' }, + { status: 400 } + ) + } + + sanitizedBody.grade = calcGrade(nextMarks, nextMaxMarks) + const grade = await Grade.findOneAndUpdate( - { _id: id }, - sanitizedBody, - { new: true } + { _id: id, teacherId: userId }, + { $set: sanitizedBody }, + { new: true, runValidators: true, context: 'query' } ) - if (!grade) return NextResponse.json({ error: 'Not found' }, { status: 404 }) return NextResponse.json(grade) } catch (error) { if (error instanceof Error) { console.error('PUT /api/grades/[id] error:', error.message) } + if (error instanceof mongoose.Error.ValidationError) { + const firstError = Object.values(error.errors)[0] + return NextResponse.json( + { error: firstError?.message ?? 'Invalid grade update' }, + { status: 400 } + ) + } + if (error instanceof mongoose.Error.CastError) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } + if (error instanceof Error && error.message === 'marks must be less than or equal to maxMarks') { + return NextResponse.json({ error: error.message }, { status: 400 }) + } + if ((error as { code?: number }).code === 11000) { + return NextResponse.json({ error: 'A grade already exists for this student, subject, and term' }, { status: 409 }) + } return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } @@ -55,8 +123,13 @@ export async function DELETE(_req: NextRequest, ctx: { params: Promise<{ id: str try { const { id } = await ctx.params + + if (!mongoose.Types.ObjectId.isValid(id)) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + await connectDB() - const deleted = await Grade.findOneAndDelete({ _id: id }) + const deleted = await Grade.findOneAndDelete({ _id: id, teacherId: userId }) if (!deleted) { return NextResponse.json({ error: 'Grade not found' }, { status: 404 }) diff --git a/app/api/grades/route.ts b/app/api/grades/route.ts index b9da63d..38c51c9 100644 --- a/app/api/grades/route.ts +++ b/app/api/grades/route.ts @@ -1,12 +1,13 @@ import { auth } from '@clerk/nextjs/server' import { NextRequest, NextResponse } from 'next/server' +import mongoose from 'mongoose' import { connectDB } from '@/lib/mongodb' import { Grade } from '@/models/Grade' +import { Student } from '@/models/Student' import { z } from 'zod' const GradeSchema = z.object({ studentId: z.string().min(1), - studentName: z.string().min(1), subject: z.string().min(1), marks: z.number().min(0), maxMarks: z.number().min(1).optional(), @@ -21,7 +22,7 @@ const GradeSchema = z.object({ function calcGrade(marks: number, max: number): string { const pct = (marks / max) * 100 - if (pct > 90) return 'A+' + if (pct >= 90) return 'A+' if (pct >= 80) return 'A' if (pct >= 70) return 'B+' if (pct >= 60) return 'B' @@ -41,14 +42,19 @@ export async function GET(req: NextRequest) { const subject = searchParams.get('subject') const query: Record = { teacherId: userId } - if (studentId) query.studentId = studentId + if (studentId) { + if (!mongoose.Types.ObjectId.isValid(studentId)) { + return NextResponse.json({ error: 'Invalid studentId' }, { status: 400 }) + } + query.studentId = studentId + } if (subject) query.subject = subject const grades = await Grade.find(query).sort({ createdAt: -1 }).lean() return NextResponse.json(grades) } catch (error) { console.error('GET /api/grades error:', error instanceof Error ? error.message : error) - return NextResponse.json({ error: error instanceof Error ? error.stack : 'Internal server error' }, { status: 500 }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } @@ -70,19 +76,52 @@ export async function POST(req: NextRequest) { if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }) const data = parsed.data - const max = data.maxMarks! + if (!mongoose.Types.ObjectId.isValid(data.studentId)) { + return NextResponse.json({ error: 'Invalid studentId' }, { status: 400 }) + } + + const student = await Student.findOne({ + _id: data.studentId, + teacherId: userId, + }) + .select('_id name') + .lean() + + if (!student) { + return NextResponse.json({ error: 'Student not found for this teacher' }, { status: 400 }) + } + + const max = data.maxMarks ?? 100 const term = data.term ?? 'Term 1' + + if (data.marks > max) { + return NextResponse.json( + { error: 'marks must be less than or equal to maxMarks' }, + { status: 400 }, + ) + } - const grade = Grade.findOneAndUpdate( + const grade = await Grade.findOneAndUpdate( { teacherId: userId, studentId: data.studentId, subject: data.subject, term }, - { $set: { ...data, term, teacherId: userId, grade: calcGrade(data.marks, max) } }, - { upsert: true, new: true } + { + $set: { + ...data, + studentName: student.name, + term, + teacherId: userId, + grade: calcGrade(data.marks, max), + }, + }, + { upsert: true, new: true, runValidators: true, setDefaultsOnInsert: true } ) return NextResponse.json(grade, { status: 201 }) } catch (error) { if (error instanceof Error) { console.error('POST /api/grades error:', error.message) } - return NextResponse.json({ error: error instanceof Error ? error.stack : 'Internal server error' }, { status: 500 }) + if ((error as { code?: number }).code === 11000) { + return NextResponse.json({ error: 'A grade already exists for this student, subject, and term' }, { status: 409 }) + } + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } diff --git a/app/api/profile/route.ts b/app/api/profile/route.ts index a3d98bf..dbdebdc 100644 --- a/app/api/profile/route.ts +++ b/app/api/profile/route.ts @@ -3,15 +3,8 @@ import { NextRequest, NextResponse } from 'next/server' import { connectDB } from '@/lib/mongodb' import { Teacher } from '@/models/Teacher' -export async function GET(req: NextRequest) { - const { searchParams } = new URL(req.url) - const queryUserId = searchParams.get('userId') - - let userId: string | null = queryUserId - if (!userId) { - const session = await auth() - userId = session.userId - } +export async function GET() { + const { userId } = await auth() if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) try { diff --git a/app/api/students/[id]/route.ts b/app/api/students/[id]/route.ts index 2eaaf93..4609887 100644 --- a/app/api/students/[id]/route.ts +++ b/app/api/students/[id]/route.ts @@ -3,8 +3,10 @@ import { NextRequest, NextResponse } from 'next/server' import mongoose from 'mongoose' import { connectDB } from '@/lib/mongodb' import { Student } from '@/models/Student' +import { Attendance } from '@/models/Attendance' +import { Grade } from '@/models/Grade' -const ALLOWED_UPDATE_FIELDS = ['name', 'email', 'grade', 'rollNo', 'class', 'phone', 'address', 'parentName', 'parentPhone'] +const ALLOWED_UPDATE_FIELDS = ['name', 'email', 'rollNo', 'class', 'phone', 'address', 'parentName', 'parentPhone'] export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { const { userId } = await auth() @@ -33,11 +35,15 @@ export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string } } + if (Object.keys(sanitizedBody).length === 0) { + return NextResponse.json({ error: 'No valid student fields provided' }, { status: 400 }) + } + await connectDB() const student = await Student.findOneAndUpdate( - { _id: id }, + { _id: id, teacherId: userId }, sanitizedBody, - { new: true } + { new: true, runValidators: true, context: 'query' } ) if (!student) return NextResponse.json({ error: 'Not found' }, { status: 404 }) return NextResponse.json(student) @@ -45,6 +51,16 @@ export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string if (error instanceof Error) { console.error('PUT /api/students/[id] error:', error.message) } + if (error instanceof mongoose.Error.ValidationError) { + const firstError = Object.values(error.errors)[0] + return NextResponse.json( + { error: firstError?.message ?? 'Invalid student update' }, + { status: 400 } + ) + } + if (error instanceof mongoose.Error.CastError) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } if ((error as { code?: number }).code === 11000) { return NextResponse.json({ error: 'A student with this roll number already exists' }, { status: 409 }) } @@ -65,11 +81,16 @@ export async function DELETE(_req: NextRequest, ctx: { params: Promise<{ id: str } await connectDB() - const deleted = await Student.findOneAndDelete({ _id: id }) + const deleted = await Student.findOneAndDelete({ _id: id, teacherId: userId }) if (!deleted) { return NextResponse.json({ error: 'Student not found' }, { status: 404 }) } + + await Promise.all([ + Attendance.deleteMany({ teacherId: userId, studentId: id }), + Grade.deleteMany({ teacherId: userId, studentId: id }), + ]) return NextResponse.json({ success: true }) } catch (error) { diff --git a/app/api/students/route.ts b/app/api/students/route.ts index 8f3dcc2..bed5f30 100644 --- a/app/api/students/route.ts +++ b/app/api/students/route.ts @@ -92,9 +92,12 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'Malformed JSON' }, { status: 400 }) } - StudentSchema.safeParse(body) + const parsed = StudentSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }) + } - const student = await Student.create({ ...(body as Record), teacherId: userId }) + const student = await Student.create({ ...parsed.data, teacherId: userId }) return NextResponse.json(student, { status: 201 }) } catch (error) { if (error instanceof Error) { diff --git a/app/dashboard/OverviewClient.tsx b/app/dashboard/OverviewClient.tsx index 169795e..a2ceb92 100644 --- a/app/dashboard/OverviewClient.tsx +++ b/app/dashboard/OverviewClient.tsx @@ -8,6 +8,7 @@ import { } from 'recharts' import { CardSkeleton } from '@/components/ui/Skeleton' import { Badge } from '@/components/ui/Badge' +import { daysUntilDateOnly } from '@/lib/date' interface Stats { totalStudents: number @@ -186,30 +187,135 @@ export function OverviewClient() { else setLoading(true); setError(null); try { + const fetchAllAssignments = async () => { + const parseAssignments = ( + data: unknown, + ): { + assignments: { + _id: string; + title: string; + subject: string; + class: string; + deadline: string; + status: string; + }[]; + total: number | null; + } => { + if ( + data && + typeof data === "object" && + Array.isArray((data as { assignments?: unknown }).assignments) + ) { + return { + assignments: (data as { + assignments: { + _id: string; + title: string; + subject: string; + class: string; + deadline: string; + status: string; + }[]; + }).assignments, + total: + typeof (data as { total?: unknown }).total === "number" + ? (data as { total: number }).total + : null, + }; + } + + return { + assignments: Array.isArray(data) + ? (data as { + _id: string; + title: string; + subject: string; + class: string; + deadline: string; + status: string; + }[]) + : [], + total: null, + }; + }; + + const firstRes = await fetch("/api/assignments?limit=100&page=1"); + if (!firstRes.ok) { + throw new Error(`Assignments: ${firstRes.status}`); + } + + const firstData = await firstRes.json(); + const firstPage = parseAssignments(firstData); + const totalPages = + firstData && + typeof firstData === "object" && + !Array.isArray(firstData) && + typeof (firstData as { pages?: unknown }).pages === "number" + ? Math.max(1, (firstData as { pages: number }).pages) + : 1; + + let allAssignments = firstPage.assignments; + + if (totalPages > 1) { + const remainingPages = await Promise.all( + Array.from({ length: totalPages - 1 }, (_, index) => + fetch(`/api/assignments?limit=100&page=${index + 2}`).then( + async (res) => { + if (!res.ok) { + throw new Error(`Assignments: ${res.status}`); + } + + return res.json(); + }, + ), + ), + ); + + allAssignments = allAssignments.concat( + ...remainingPages.map((page) => parseAssignments(page).assignments), + ); + } + + return { + assignments: allAssignments, + total: firstPage.total, + }; + }; + const [ studentsRes, - assignmentsRes, + assignmentsData, + activeAssignmentsRes, attendanceRes, gradesRes, announcementsRes, ] = await Promise.all([ fetch("/api/students?limit=5"), - fetch("/api/assignments"), + fetchAllAssignments(), + fetch("/api/assignments?status=active&limit=1"), fetch("/api/attendance"), fetch("/api/grades"), fetch("/api/announcements?limit=5"), ]); - const [students, assignmentsData, attendance, grades, announcements] = + const [ + students, + activeAssignmentsData, + attendance, + grades, + announcements, + ] = await Promise.all([ studentsRes.json(), - assignmentsRes.json(), + activeAssignmentsRes.json(), attendanceRes.json(), gradesRes.json(), announcementsRes.json(), ]); - const assignments = assignmentsData.assignments ?? assignmentsData; + const assignments = assignmentsData.assignments; + const activeAssignments = + activeAssignmentsData.assignments ?? activeAssignmentsData; // ── Attendance ── const dateMap: Record< @@ -254,7 +360,7 @@ export function OverviewClient() { "B+": 8, B: 7, C: 6, - D: 4, + D: 5, F: 0, }; const termMap: Record = {}; @@ -265,10 +371,15 @@ export function OverviewClient() { "Term 1", "Term 2", "Term 3", + "Term 4", "Semester 1", "Semester 2", "Semester 3", "Semester 4", + "Semester 5", + "Semester 6", + "Semester 7", + "Semester 8", ]; const cgpaTrend = Object.entries(termMap) .sort(([a], [b]) => { @@ -289,12 +400,12 @@ export function OverviewClient() { for (const g of grades) gradeCounts[g.grade || "N/A"] = (gradeCounts[g.grade || "N/A"] || 0) + 1; - const gradeDistribution = Object.entries(gradeCounts).map( - ([grade, count]) => ({ grade, count }), - ); + const GRADE_ORDER = ["A+", "A", "B+", "B", "C", "D", "F", "N/A"]; + const gradeDistribution = Object.entries(gradeCounts) + .sort(([a], [b]) => GRADE_ORDER.indexOf(a) - GRADE_ORDER.indexOf(b)) + .map(([grade, count]) => ({ grade, count })); // ── Upcoming deadlines ── - const now = Date.now(); const upcomingDeadlines = ( assignments as { _id: string; @@ -308,21 +419,24 @@ export function OverviewClient() { .filter((a) => a.status === "active") .map((a) => ({ ...a, - daysLeft: Math.ceil( - (new Date(a.deadline).getTime() - now) / 86400000, - ), + daysLeft: daysUntilDateOnly(a.deadline) ?? 0, })) .sort((a, b) => a.daysLeft - b.daysLeft) .slice(0, 5); setStats({ - totalStudents: students.students?.length ?? 0, - totalAssignments: Array.isArray(assignments) - ? assignments.length - : (assignments.length ?? 0), - pendingAssignments: assignments.filter( - (a: { status: string }) => a.status === "active", - ).length, + totalStudents: + typeof students.total === "number" + ? students.total + : (students.students?.length ?? 0), + totalAssignments: + typeof assignmentsData.total === "number" + ? assignmentsData.total + : (Array.isArray(assignments) ? assignments.length : 0), + pendingAssignments: + typeof activeAssignmentsData.total === "number" + ? activeAssignmentsData.total + : (Array.isArray(activeAssignments) ? activeAssignments.length : 0), attendancePct, attendanceBreakdown: { present: totalPresent, diff --git a/app/dashboard/announcements/AnnouncementsClient.tsx b/app/dashboard/announcements/AnnouncementsClient.tsx index 0de73bb..032d5a4 100644 --- a/app/dashboard/announcements/AnnouncementsClient.tsx +++ b/app/dashboard/announcements/AnnouncementsClient.tsx @@ -53,6 +53,27 @@ function timeAgo(date: string) { return `${Math.floor(hrs / 24)}d ago` } +async function getErrorMessage(res: Response, fallback: string) { + try { + const payload = await res.json() + if (typeof payload?.error === 'string') { + return payload.error + } + if (payload?.error?.fieldErrors) { + const firstError = Object.values( + payload.error.fieldErrors as Record, + )[0] + if (firstError?.[0]) { + return firstError[0] + } + } + } catch { + // Ignore malformed error bodies and fall back to the generic message. + } + + return fallback +} + function useReadState() { const [readIds, setReadIds] = useState>(() => { if (typeof window === 'undefined') return new Set() @@ -153,7 +174,7 @@ export function AnnouncementsClient() { setModalOpen(false) fetchAnnouncements() } else { - toast('Failed to save announcement', 'error') + toast(await getErrorMessage(res, 'Failed to save announcement'), 'error') } } catch (error) { toast(error instanceof Error ? error.message : 'Network error', 'error') @@ -168,7 +189,7 @@ export function AnnouncementsClient() { body: JSON.stringify({ pinned: !a.pinned }), }) if (res.ok) { toast(a.pinned ? 'Unpinned' : 'Pinned!', 'success'); fetchAnnouncements() } - else toast('Failed to update', 'error') + else toast(await getErrorMessage(res, 'Failed to update announcement'), 'error') } catch (error) { toast(error instanceof Error ? error.message : 'Network error', 'error') } @@ -183,7 +204,9 @@ export function AnnouncementsClient() { if (res.ok) { toast("Deleted", "success"); fetchAnnouncements(); - } else toast("Failed to delete", "error"); + } else { + toast(await getErrorMessage(res, "Failed to delete announcement"), "error"); + } } catch (error) { toast(error instanceof Error ? error.message : "Network error", "error"); } finally { diff --git a/app/dashboard/assignments/AssignmentsClient.tsx b/app/dashboard/assignments/AssignmentsClient.tsx index e0e7f13..6777d8e 100644 --- a/app/dashboard/assignments/AssignmentsClient.tsx +++ b/app/dashboard/assignments/AssignmentsClient.tsx @@ -7,6 +7,7 @@ import { Modal } from '@/components/ui/Modal' import { Badge } from '@/components/ui/Badge' import { useToast } from '@/components/ui/Toast' import { ConfirmModal } from "@/components/ui/ConfirmModal"; +import { daysUntilDateOnly, formatDateOnly, normalizeDateOnly } from '@/lib/date' interface Assignment { _id: string @@ -59,7 +60,28 @@ const COLUMNS: { ]; function daysUntil(deadline: string) { - return Math.ceil((new Date(deadline).getTime() - Date.now()) / (1000 * 60 * 60 * 24)) + return daysUntilDateOnly(deadline) ?? 0 +} + +async function getErrorMessage(res: Response, fallback: string) { + try { + const payload = await res.json() + if (typeof payload?.error === 'string') { + return payload.error + } + if (payload?.error?.fieldErrors) { + const firstError = Object.values( + payload.error.fieldErrors as Record, + )[0] + if (firstError?.[0]) { + return firstError[0] + } + } + } catch { + // Ignore malformed error bodies and fall back to the generic message. + } + + return fallback } function DeadlineBadge({ deadline }: { deadline: string }) { @@ -170,15 +192,12 @@ function AssignmentDrawer({ { label: "Class", value: assignment.class }, { label: "Deadline", - value: new Date(assignment.deadline).toLocaleDateString( - "en-IN", - { - weekday: "short", - year: "numeric", - month: "short", - day: "numeric", - }, - ), + value: formatDateOnly(assignment.deadline, "en-IN", { + weekday: "short", + year: "numeric", + month: "short", + day: "numeric", + }), }, { label: "Max Marks", value: String(assignment.maxMarks) }, { @@ -272,14 +291,52 @@ export function AssignmentsClient() { const fetchAssignments = useCallback(async () => { setLoading(true); try { - const res = await fetch("/api/assignments?limit=100"); - if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`); - const data = await res.json(); - const raw: Assignment[] = Array.isArray(data.assignments) - ? data.assignments - : Array.isArray(data) - ? data - : []; + const parseAssignments = (data: unknown): Assignment[] => { + if ( + data && + typeof data === "object" && + Array.isArray((data as { assignments?: unknown }).assignments) + ) { + return (data as { assignments: Assignment[] }).assignments + } + + return Array.isArray(data) ? (data as Assignment[]) : [] + } + + const firstRes = await fetch("/api/assignments?limit=100&page=1"); + if (!firstRes.ok) throw new Error(`Failed to fetch: ${firstRes.status}`); + + const firstData = await firstRes.json(); + const totalPages = + firstData && + typeof firstData === "object" && + !Array.isArray(firstData) && + typeof (firstData as { pages?: unknown }).pages === "number" + ? Math.max(1, (firstData as { pages: number }).pages) + : 1; + + let raw = parseAssignments(firstData); + + if (totalPages > 1) { + const remainingPages = await Promise.all( + Array.from({ length: totalPages - 1 }, (_, index) => + fetch(`/api/assignments?limit=100&page=${index + 2}`).then( + async (res) => { + if (!res.ok) { + throw new Error( + `Failed to fetch page ${index + 2}: ${res.status}`, + ); + } + + return res.json(); + }, + ), + ), + ); + + raw = raw.concat(...remainingPages.map(parseAssignments)); + } + setAssignments( raw.map((a) => ({ ...a, @@ -344,7 +401,7 @@ export function AssignmentsClient() { description: a.description, subject: a.subject, class: a.class, - deadline: a.deadline, + deadline: normalizeDateOnly(a.deadline) ?? "", maxMarks: a.maxMarks, }); setModalOpen(true); @@ -368,7 +425,9 @@ export function AssignmentsClient() { ); setModalOpen(false); fetchAssignments(); - } else toast("Failed to save", "error"); + } else { + toast(await getErrorMessage(res, "Failed to save assignment"), "error"); + } } catch (error) { toast(error instanceof Error ? error.message : "Network error", "error"); } @@ -387,7 +446,7 @@ export function AssignmentsClient() { ), ); try { - await fetch(`/api/assignments/${id}`, { + const res = await fetch(`/api/assignments/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -395,6 +454,9 @@ export function AssignmentsClient() { status: col === "submitted" ? "closed" : "active", }), }); + if (!res.ok) { + throw new Error(await getErrorMessage(res, `Failed to move assignment: ${res.status}`)); + } } catch (error) { fetchAssignments(); toast( @@ -414,7 +476,9 @@ export function AssignmentsClient() { if (res.ok) { toast("Deleted", "success"); fetchAssignments(); - } else toast("Failed to delete", "error"); + } else { + toast(await getErrorMessage(res, "Failed to delete assignment"), "error"); + } } catch (error) { toast(error instanceof Error ? error.message : "Network error", "error"); } finally { @@ -575,6 +639,20 @@ export function AssignmentsClient() { Create first assignment + ) : filtered.length === 0 ? ( +
+

No assignments match the current filters.

+ +
) : (
{COLUMNS.map((col) => { @@ -678,7 +756,7 @@ export function AssignmentsClient() {

Due{" "} - {new Date(a.deadline).toLocaleDateString("en-IN", { + {formatDateOnly(a.deadline, "en-IN", { day: "numeric", month: "short", })} diff --git a/app/dashboard/attendance/AttendanceClient.tsx b/app/dashboard/attendance/AttendanceClient.tsx index f97e7db..486b51c 100644 --- a/app/dashboard/attendance/AttendanceClient.tsx +++ b/app/dashboard/attendance/AttendanceClient.tsx @@ -5,6 +5,7 @@ import { Button } from '@/components/ui/Button' import { Badge } from '@/components/ui/Badge' import { TableSkeleton } from '@/components/ui/Skeleton' import { useToast } from '@/components/ui/Toast' +import { getLocalDateInputValue } from '@/lib/date' interface Student { _id: string @@ -31,6 +32,30 @@ const STATUS_BADGE: Record = late: 'warning', } +async function getErrorMessage(res: Response, fallback: string) { + try { + const payload = await res.json() + if (typeof payload?.error === 'string') { + return payload.error + } + if (payload?.error?.fieldErrors) { + const firstError = Object.values( + payload.error.fieldErrors as Record, + )[0] + if (firstError?.[0]) { + return firstError[0] + } + } + if (Array.isArray(payload?.error?.formErrors) && payload.error.formErrors[0]) { + return payload.error.formErrors[0] + } + } catch { + // Ignore malformed error bodies and fall back to the generic message. + } + + return fallback +} + // Heatmap cell color based on attendance rate function heatColor(rate: number | null) { if (rate === null) return 'bg-gray-100 dark:bg-slate-800 text-gray-400 dark:text-slate-600' @@ -43,17 +68,18 @@ function heatColor(rate: number | null) { export function AttendanceClient() { const { toast } = useToast() const [tab, setTab] = useState('mark') + const today = getLocalDateInputValue() // ── Mark tab state ── const [students, setStudents] = useState([]) const [selectedClass, setSelectedClass] = useState('') - const [date, setDate] = useState(new Date().toISOString().split('T')[0]) + const [date, setDate] = useState(today) const [statuses, setStatuses] = useState>({}) const [saving, setSaving] = useState(false) // ── History tab state ── const [records, setRecords] = useState([]) - const [historyDate, setHistoryDate] = useState(new Date().toISOString().split('T')[0]) + const [historyDate, setHistoryDate] = useState(today) const [historyClass, setHistoryClass] = useState('') const [loadingHistory, setLoadingHistory] = useState(false) @@ -74,14 +100,38 @@ export function AttendanceClient() { return; } try { - const res = await fetch( - `/api/students?search=${encodeURIComponent(selectedClass)}&limit=100`, - ); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const data = await res.json(); - const classStudents = (data.students ?? []).filter( - (s: Student) => s.class === selectedClass, + const firstRes = await fetch( + `/api/students?class=${encodeURIComponent(selectedClass)}&limit=100&page=1`, ); + if (!firstRes.ok) throw new Error(`HTTP ${firstRes.status}`); + + const firstData = await firstRes.json(); + const totalPages = + firstData && + typeof firstData === "object" && + typeof firstData.pages === "number" + ? Math.max(1, firstData.pages) + : 1; + + let classStudents: Student[] = firstData.students ?? []; + + if (totalPages > 1) { + const remainingPages = await Promise.all( + Array.from({ length: totalPages - 1 }, (_, index) => + fetch( + `/api/students?class=${encodeURIComponent(selectedClass)}&limit=100&page=${index + 2}`, + ).then(async (res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }), + ), + ); + + classStudents = classStudents.concat( + ...remainingPages.map((page) => page.students ?? []), + ); + } + setStudents(classStudents); const init: Record = {}; for (const s of classStudents) init[s._id] = "present"; @@ -166,7 +216,7 @@ export function AttendanceClient() { }); if (res.ok) toast(`Attendance saved for ${payload.length} students!`, "success"); - else toast("Failed to save attendance", "error"); + else toast(await getErrorMessage(res, "Failed to save attendance"), "error"); } catch { toast("Failed to save attendance", "error"); } finally { diff --git a/app/dashboard/grades/GradesClient.tsx b/app/dashboard/grades/GradesClient.tsx index fdaa00b..dac0d14 100644 --- a/app/dashboard/grades/GradesClient.tsx +++ b/app/dashboard/grades/GradesClient.tsx @@ -71,6 +71,27 @@ function cgpaFromGrades(gradeList: Grade[]) { const LINE_COLORS = ['#6366f1', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'] +async function getErrorMessage(res: Response, fallback: string) { + try { + const payload = await res.json() + if (typeof payload?.error === 'string') { + return payload.error + } + if (payload?.error?.fieldErrors) { + const firstError = Object.values( + payload.error.fieldErrors as Record, + )[0] + if (firstError?.[0]) { + return firstError[0] + } + } + } catch { + // Ignore malformed error bodies and fall back to the generic message. + } + + return fallback +} + function exportCsv(grades: Grade[], filename = "grades.csv") { const headers = [ "Student", @@ -131,16 +152,46 @@ export function GradesClient() { const fetchGrades = useCallback(async () => { setLoading(true); try { - const [gradesRes, studentsRes] = await Promise.all([ - fetch("/api/grades"), - fetch("/api/students?limit=200"), - ]); + const gradesRes = await fetch("/api/grades"); + const firstStudentsRes = await fetch("/api/students?limit=100&page=1"); if (!gradesRes.ok) throw new Error(`Grades: ${gradesRes.status}`); - if (!studentsRes.ok) throw new Error(`Students: ${studentsRes.status}`); + if (!firstStudentsRes.ok) { + throw new Error(`Students: ${firstStudentsRes.status}`); + } + const gradesData = await gradesRes.json(); - const studentsData = await studentsRes.json(); + const firstStudentsData = await firstStudentsRes.json(); + const totalPages = + firstStudentsData && + typeof firstStudentsData === "object" && + typeof firstStudentsData.pages === "number" + ? Math.max(1, firstStudentsData.pages) + : 1; + + let allStudents: Student[] = firstStudentsData.students ?? []; + + if (totalPages > 1) { + const remainingPages = await Promise.all( + Array.from({ length: totalPages - 1 }, (_, index) => + fetch(`/api/students?limit=100&page=${index + 2}`).then( + async (res) => { + if (!res.ok) { + throw new Error(`Students: ${res.status}`); + } + + return res.json(); + }, + ), + ), + ); + + allStudents = allStudents.concat( + ...remainingPages.map((page) => page.students ?? []), + ); + } + setGrades(Array.isArray(gradesData) ? gradesData : []); - setStudents(studentsData.students ?? []); + setStudents(allStudents); } catch (error) { toast(error instanceof Error ? error.message : "Failed to load", "error"); } finally { @@ -227,7 +278,7 @@ export function GradesClient() { const comparisonData = useMemo(() => { const sg = gradesBySubject[activeTab] ?? []; return [...sg] - .sort((a, b) => b.marks - a.marks) + .sort((a, b) => pct(b.marks, b.maxMarks) - pct(a.marks, a.maxMarks)) .slice(0, 20) // cap at 20 students for readability .map((g) => ({ name: g.studentName.split(" ")[0], // first name only for axis @@ -312,7 +363,12 @@ export function GradesClient() { toast(editing ? "Grade updated!" : "Grade added!", "success"); setModalOpen(false); fetchGrades(); - } else toast("Failed to save grade", "error"); + } else { + toast( + await getErrorMessage(res, `Failed to save grade (${res.status})`), + "error", + ); + } } catch (error) { toast(error instanceof Error ? error.message : "Network error", "error"); } @@ -327,7 +383,9 @@ export function GradesClient() { if (res.ok) { toast("Deleted", "success"); fetchGrades(); - } else toast("Failed to delete", "error"); + } else { + toast(await getErrorMessage(res, "Failed to delete grade"), "error"); + } } catch (error) { toast(error instanceof Error ? error.message : "Network error", "error"); } finally { diff --git a/app/dashboard/profile/ProfileClient.tsx b/app/dashboard/profile/ProfileClient.tsx index 71e235e..4b3be30 100644 --- a/app/dashboard/profile/ProfileClient.tsx +++ b/app/dashboard/profile/ProfileClient.tsx @@ -40,6 +40,27 @@ interface TimelineFormData { description: string } +async function getErrorMessage(res: Response, fallback: string) { + try { + const payload = await res.json() + if (typeof payload?.error === 'string') { + return payload.error + } + if (payload?.error?.fieldErrors) { + const firstError = Object.values( + payload.error.fieldErrors as Record, + )[0] + if (firstError?.[0]) { + return firstError[0] + } + } + } catch { + // Ignore malformed error bodies and fall back to the generic message. + } + + return fallback +} + export function ProfileClient() { const { user } = useUser() const { toast } = useToast() @@ -130,7 +151,7 @@ export function ProfileClient() { toast("Profile updated!", "success"); setEditing(false); } else { - toast("Failed to update profile", "error"); + toast(await getErrorMessage(res, "Failed to update profile"), "error"); } } catch (error) { toast( @@ -159,7 +180,7 @@ export function ProfileClient() { setProfile(updated); return true; } else { - toast("Failed to save history", "error"); + toast(await getErrorMessage(res, "Failed to save history"), "error"); return false; } } catch (error) { diff --git a/app/dashboard/students/StudentsClient.tsx b/app/dashboard/students/StudentsClient.tsx index 82a1d96..4cae273 100644 --- a/app/dashboard/students/StudentsClient.tsx +++ b/app/dashboard/students/StudentsClient.tsx @@ -8,6 +8,7 @@ import { Badge } from '@/components/ui/Badge' import { TableSkeleton } from '@/components/ui/Skeleton' import { useToast } from '@/components/ui/Toast' import { ConfirmModal } from "@/components/ui/ConfirmModal"; +import { getLocalDateInputValue } from '@/lib/date' interface Student { _id: string @@ -55,6 +56,21 @@ const GRADE_COLOR: Record, + )[0] + if (firstError?.[0]) { + return firstError[0] + } + } + } catch { + // Ignore malformed error bodies and fall back to the generic message. + } + + return fallback +} + // Mini attendance heatmap — last 5 weeks (35 days) function AttendanceHeatmap({ records }: { records: AttendanceRecord[] }) { const byDate = useMemo(() => { @@ -87,7 +124,7 @@ function AttendanceHeatmap({ records }: { records: AttendanceRecord[] }) { for (let i = 34; i >= 0; i--) { const d = new Date(today) d.setDate(today.getDate() - i) - arr.push(d.toISOString().slice(0, 10)) + arr.push(getLocalDateInputValue(d)) } return arr }, []) @@ -188,24 +225,15 @@ function StudentDrawer({ }, [attendance]) const recentGrades = useMemo(() => { - const SEASON_ORDER: Record = { - Spring: 1, - Summer: 2, - Fall: 3, - Winter: 4, - }; const sortedGrades = [...grades].sort((a, b) => { - // Parse term string (e.g., "Spring 2024") to extract season and year - const parseTermKey = (term: string): number => { - const parts = term.split(" "); - const season = parts[0] || ""; - const year = parseInt(parts[1] || "0", 10); - const seasonVal = SEASON_ORDER[season] ?? 0; - return year * 100 + seasonVal; // e.g., 202401 for Spring 2024 - }; - const aKey = parseTermKey(a.term); - const bKey = parseTermKey(b.term); - return bKey - aKey; // Descending chronological order + const aIndex = TERM_ORDER.indexOf(a.term); + const bIndex = TERM_ORDER.indexOf(b.term); + if (aIndex !== -1 && bIndex !== -1) { + return bIndex - aIndex; + } + if (aIndex !== -1) return -1; + if (bIndex !== -1) return 1; + return b.term.localeCompare(a.term); }); return sortedGrades.slice(0, 6); }, [grades]); @@ -513,6 +541,7 @@ export function StudentsClient() { // Class filter const [classFilter, setClassFilter] = useState("all"); + const [classOptions, setClassOptions] = useState(["all"]); // Bulk selection const [selected, setSelected] = useState>(new Set()); @@ -548,24 +577,79 @@ export function StudentsClient() { if (debouncedSearch) params.set("search", debouncedSearch); if (classFilter !== "all") params.set("class", classFilter); const res = await fetch(`/api/students?${params}`); + if (!res.ok) { + throw new Error(`Failed to load students: ${res.status}`); + } const data = await res.json(); setStudents(data.students ?? []); setTotal(data.total ?? 0); setPages(data.pages ?? 1); + } catch (error) { + toast(error instanceof Error ? error.message : "Failed to load students", "error"); + setStudents([]); + setTotal(0); + setPages(1); } finally { setLoading(false); } - }, [page, debouncedSearch, classFilter]); + }, [page, debouncedSearch, classFilter, toast]); + + const fetchClassOptions = useCallback(async () => { + try { + const firstRes = await fetch("/api/students?limit=100&page=1"); + if (!firstRes.ok) return; + + const firstData = await firstRes.json(); + const totalPages = + firstData && + typeof firstData === "object" && + typeof firstData.pages === "number" + ? Math.max(1, firstData.pages) + : 1; + + let allStudents: Student[] = firstData.students ?? []; + + if (totalPages > 1) { + const remainingPages = await Promise.all( + Array.from({ length: totalPages - 1 }, (_, index) => + fetch(`/api/students?limit=100&page=${index + 2}`).then( + async (res) => { + if (!res.ok) return { students: [] as Student[] }; + return res.json(); + }, + ), + ), + ); + + allStudents = allStudents.concat( + ...remainingPages.map((page) => page.students ?? []), + ); + } + + const classes = [ + "all", + ...Array.from(new Set(allStudents.map((student) => student.class))).sort(), + ]; + + setClassOptions(classes); + } catch { + // Keep the existing options if this background refresh fails. + } + }, []); useEffect(() => { fetchStudents(); }, [fetchStudents]); - // Unique classes for filter dropdown — derive from all loaded students on current page - const uniqueClasses = useMemo(() => { - const set = new Set(students.map((s) => s.class)); - return ["all", ...Array.from(set).sort()]; - }, [students]); + useEffect(() => { + fetchClassOptions(); + }, [fetchClassOptions]); + + useEffect(() => { + if (classFilter !== "all" && !classOptions.includes(classFilter)) { + setClassFilter("all"); + } + }, [classFilter, classOptions]); // Sort + filter pipeline (client-side sorting, server-side filtering now handles class filter) const visibleStudents = useMemo(() => { @@ -650,6 +734,7 @@ export function StudentsClient() { toast(editing ? "Student updated!" : "Student added!", "success"); setModalOpen(false); fetchStudents(); + fetchClassOptions(); } else { let msg = "Something went wrong"; try { @@ -686,7 +771,10 @@ export function StudentsClient() { return n; }); fetchStudents(); - } else toast("Failed to delete", "error"); + fetchClassOptions(); + } else { + toast(await getErrorMessage(res, "Failed to delete student"), "error"); + } }; const executeBulkDelete = async () => { @@ -709,6 +797,7 @@ export function StudentsClient() { ); else toast(`${failed} deletion${failed !== 1 ? "s" : ""} failed`, "error"); fetchStudents(); + fetchClassOptions(); }; // Optional columns config @@ -880,7 +969,7 @@ export function StudentsClient() { }} className="py-2 pl-3 pr-8 text-sm rounded-xl border border-gray-200 dark:border-slate-600 bg-white dark:bg-slate-800 text-gray-700 dark:text-slate-300 focus:outline-none focus:ring-2 focus:ring-indigo-500" > - {uniqueClasses.map((c) => ( + {classOptions.map((c) => ( diff --git a/components/dashboard/Navbar.tsx b/components/dashboard/Navbar.tsx index 49b4a0c..987a2c5 100644 --- a/components/dashboard/Navbar.tsx +++ b/components/dashboard/Navbar.tsx @@ -8,22 +8,13 @@ interface NavbarProps { } export function Navbar({ onMenuClick, title }: NavbarProps) { - const [dark, setDark] = useState(false); - - // Initialize theme from localStorage and system preference on client-side only - useEffect(() => { - try { - const stored = localStorage.getItem("theme"); - const prefersDark = window.matchMedia( - "(prefers-color-scheme: dark)", - ).matches; - const isDark = stored ? stored === "dark" : prefersDark; - setDark(isDark); - document.documentElement.classList.toggle("dark", isDark); - } catch (e) { - // Silently fail if localStorage is not available + const [dark, setDark] = useState(() => { + if (typeof document === 'undefined') { + return false } - }, []); + + return document.documentElement.classList.contains('dark') + }) // Sync dark class to whenever dark state changes useEffect(() => { diff --git a/lib/date.ts b/lib/date.ts new file mode 100644 index 0000000..ec6933e --- /dev/null +++ b/lib/date.ts @@ -0,0 +1,53 @@ +const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/ + +export function normalizeDateOnly(value: string): string | null { + const trimmed = value.trim() + if (!trimmed) return null + + if (DATE_ONLY_RE.test(trimmed)) { + return trimmed + } + + const parsed = new Date(trimmed) + if (Number.isNaN(parsed.getTime())) { + return null + } + + return parsed.toISOString().slice(0, 10) +} + +export function getLocalDateInputValue(date = new Date()): string { + return new Date(date.getTime() - date.getTimezoneOffset() * 60000) + .toISOString() + .slice(0, 10) +} + +function getDateOnlyTimestamp(value: string): number | null { + const normalized = normalizeDateOnly(value) + if (!normalized) return null + + const [year, month, day] = normalized.split('-').map(Number) + return Date.UTC(year, month - 1, day) +} + +export function daysUntilDateOnly(value: string, now = new Date()): number | null { + const target = getDateOnlyTimestamp(value) + if (target === null) return null + + const today = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()) + return Math.round((target - today) / 86400000) +} + +export function formatDateOnly( + value: string, + locale: string, + options: Intl.DateTimeFormatOptions = {}, +): string { + const timestamp = getDateOnlyTimestamp(value) + if (timestamp === null) return value + + return new Intl.DateTimeFormat(locale, { + ...options, + timeZone: 'UTC', + }).format(new Date(timestamp)) +} diff --git a/lib/mongodb.ts b/lib/mongodb.ts index 01dddf9..c2612c7 100644 --- a/lib/mongodb.ts +++ b/lib/mongodb.ts @@ -1,11 +1,5 @@ import mongoose from 'mongoose' -const MONGODB_URI = process.env.MONGODB_URI! - -if (!MONGODB_URI) { - throw new Error('Please define the MONGODB_URI environment variable') -} - interface MongooseCache { conn: typeof mongoose | null promise: Promise | null @@ -18,12 +12,22 @@ declare global { const cached: MongooseCache = global.mongooseCache ?? { conn: null, promise: null } global.mongooseCache = cached +function getMongoUri(): string { + const mongoUri = process.env.MONGODB_URI + + if (!mongoUri) { + throw new Error('Please define the MONGODB_URI environment variable') + } + + return mongoUri +} + export async function connectDB(): Promise { if (cached.conn) return cached.conn if (!cached.promise) { cached.promise = mongoose - .connect(MONGODB_URI, { bufferCommands: false }) + .connect(getMongoUri(), { bufferCommands: false }) .catch((error) => { cached.promise = null throw error diff --git a/models/Assignment.ts b/models/Assignment.ts index 874f1fc..98f6dc2 100644 --- a/models/Assignment.ts +++ b/models/Assignment.ts @@ -25,7 +25,7 @@ const AssignmentSchema = new Schema( deadline: { type: Date, required: true }, status: { type: String, enum: ['active', 'closed'], default: 'active' }, kanbanStatus: { type: String, enum: ['todo', 'in_progress', 'submitted'], default: 'todo' }, - maxMarks: { type: Number, default: 100 }, + maxMarks: { type: Number, default: 100, min: 1 }, }, { timestamps: true } ) diff --git a/models/Grade.ts b/models/Grade.ts index 311b757..08dd40f 100644 --- a/models/Grade.ts +++ b/models/Grade.ts @@ -41,8 +41,10 @@ GradeSchema.pre("save", function () { GradeSchema.pre("findOneAndUpdate", function () { const update = this.getUpdate() as Record; if (update && typeof update === "object") { - const marks = update.marks; - const maxMarks = update.maxMarks; + const set = update.$set as Record | undefined; + const marks = typeof update.marks === "number" ? update.marks : set?.marks; + const maxMarks = + typeof update.maxMarks === "number" ? update.maxMarks : set?.maxMarks; if ( marks !== undefined && typeof marks === "number" && maxMarks !== undefined && typeof maxMarks === "number" && @@ -56,8 +58,10 @@ GradeSchema.pre("findOneAndUpdate", function () { GradeSchema.pre("updateOne", function () { const update = this.getUpdate() as Record; if (update && typeof update === "object") { - const marks = update.marks; - const maxMarks = update.maxMarks; + const set = update.$set as Record | undefined; + const marks = typeof update.marks === "number" ? update.marks : set?.marks; + const maxMarks = + typeof update.maxMarks === "number" ? update.maxMarks : set?.maxMarks; if ( marks !== undefined && typeof marks === "number" && maxMarks !== undefined && typeof maxMarks === "number" && diff --git a/middleware.ts b/proxy.ts similarity index 100% rename from middleware.ts rename to proxy.ts