diff --git a/app/api/updateCourse/[id]/route.ts b/app/api/updateCourse/[id]/route.ts index 33c230a..36cecc4 100644 --- a/app/api/updateCourse/[id]/route.ts +++ b/app/api/updateCourse/[id]/route.ts @@ -5,173 +5,167 @@ import { authOptions } from "@/lib/auth"; import prisma from "@/lib/prisma"; const updateSchema = z.object({ - title: z.string().min(2), - color: z.string().length(7), - days: z.array(z.string()).min(1), - startTime: z.string(), - endTime: z.string(), + title: z.string().min(2), + color: z.string().length(7), + days: z.array(z.string()).min(1), + startTime: z.string(), + endTime: z.string(), }); function getCourseId(request: Request): number { - const url = new URL(request.url); - const id = url.pathname.split('/').pop(); - const courseId = parseInt(id ?? ''); - - if (isNaN(courseId)) { - throw new Error('Invalid course ID'); - } - return courseId; + const url = new URL(request.url); + const id = url.pathname.split("/").pop(); + const courseId = parseInt(id ?? ""); + + if (isNaN(courseId)) { + throw new Error("Invalid course ID"); + } + return courseId; } // PUT - Update Course export async function PUT(request: Request) { - const session = await getServerSession(authOptions); - if (!session?.user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - try { - const courseId = getCourseId(request); - - // Verify user has permission - const userCourse = await prisma.userCourse.findUnique({ - where: { - userId_courseId: { - userId: session.user.id, - courseId, - }, - }, - }); - - if (!userCourse || userCourse.role !== "LECTURER") { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - // Validate request body - const body: unknown = await request.json(); - const parsed = updateSchema.safeParse(body); - if (!parsed.success) { - return NextResponse.json( - { error: "Invalid input", details: parsed.error.flatten() }, - { status: 400 } - ); - } + try { + const courseId = getCourseId(request); - const { title, color, days, startTime, endTime } = parsed.data; - - // Update course and schedule in transaction - const [updatedCourse] = await prisma.$transaction([ - prisma.course.update({ - where: { id: courseId }, - data: { title, color }, - }), - prisma.schedule.updateMany({ - where: { courseId }, - data: { dayOfWeek: days, startTime, endTime }, - }), - ]); - - return NextResponse.json(updatedCourse); - } catch (error) { - if (error instanceof Error && error.message === 'Invalid course ID') { - return NextResponse.json({ error: "Invalid course ID" }, { status: 400 }); + // Verify user has permission + const userCourse = await prisma.userCourse.findUnique({ + where: { + userId_courseId: { + userId: session.user.id, + courseId, + }, + }, + }); + + if (!userCourse || userCourse.role !== "LECTURER") { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + // Validate request body + const body: unknown = await request.json(); + const parsed = updateSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid input", details: parsed.error.flatten() }, + { status: 400 }, + ); + } + + const { title, color, days, startTime, endTime } = parsed.data; + + // Update course and schedule in transaction + const [updatedCourse] = await prisma.$transaction([ + prisma.course.update({ + where: { id: courseId }, + data: { title, color }, + }), + prisma.schedule.updateMany({ + where: { courseId }, + data: { dayOfWeek: days, startTime, endTime }, + }), + ]); + + return NextResponse.json(updatedCourse); + } catch (error) { + if (error instanceof Error && error.message === "Invalid course ID") { + return NextResponse.json({ error: "Invalid course ID" }, { status: 400 }); + } + + console.error("Error updating course:", error); + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); } - - console.error("Error updating course:", error); - return NextResponse.json( - { error: "Internal Server Error" }, - { status: 500 } - ); - } } // DELETE - Delete Course export async function DELETE(request: Request) { - const session = await getServerSession(authOptions); - if (!session?.user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - try { - const courseId = getCourseId(request); - - // Verify user has permission - const userCourse = await prisma.userCourse.findUnique({ - where: { - userId_courseId: { - userId: session.user.id, - courseId, - }, - }, - }); - - if (!userCourse) { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - if (userCourse.role === "LECTURER") { - // Lecturer - delete all related records in proper order - await prisma.$transaction([ - prisma.response.deleteMany({ - where: { - question: { - session: { - courseId, - }, - }, - }, - }), - prisma.option.deleteMany({ - where: { - question: { - session: { - courseId, - }, - }, - }, - }), - prisma.question.deleteMany({ - where: { - session: { - courseId, - }, - }, - }), - prisma.courseSession.deleteMany({ - where: { courseId }, - }), - prisma.schedule.deleteMany({ - where: { courseId }, - }), - prisma.userCourse.deleteMany({ - where: { courseId }, - }), - prisma.course.delete({ - where: { id: courseId }, - }), - ]); - } else { - // Student - only remove their association - await prisma.userCourse.delete({ - where: { - userId_courseId: { - userId: session.user.id, - courseId, - }, - }, - }); - } + try { + const courseId = getCourseId(request); - return NextResponse.json({ success: true }); - } catch (error) { - if (error instanceof Error && error.message === 'Invalid course ID') { - return NextResponse.json({ error: "Invalid course ID" }, { status: 400 }); + // Verify user has permission + const userCourse = await prisma.userCourse.findUnique({ + where: { + userId_courseId: { + userId: session.user.id, + courseId, + }, + }, + }); + + if (!userCourse) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + if (userCourse.role === "LECTURER") { + // Lecturer - delete all related records in proper order + await prisma.$transaction([ + prisma.response.deleteMany({ + where: { + question: { + session: { + courseId, + }, + }, + }, + }), + prisma.option.deleteMany({ + where: { + question: { + session: { + courseId, + }, + }, + }, + }), + prisma.question.deleteMany({ + where: { + session: { + courseId, + }, + }, + }), + prisma.courseSession.deleteMany({ + where: { courseId }, + }), + prisma.schedule.deleteMany({ + where: { courseId }, + }), + prisma.userCourse.deleteMany({ + where: { courseId }, + }), + prisma.course.delete({ + where: { id: courseId }, + }), + ]); + } else { + // Student - only remove their association + await prisma.userCourse.delete({ + where: { + userId_courseId: { + userId: session.user.id, + courseId, + }, + }, + }); + } + + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof Error && error.message === "Invalid course ID") { + return NextResponse.json({ error: "Invalid course ID" }, { status: 400 }); + } + + console.error("Error deleting course:", error); + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); } - - console.error("Error deleting course:", error); - return NextResponse.json( - { error: "Internal Server Error" }, - { status: 500 } - ); - } -} \ No newline at end of file +} diff --git a/app/dashboard/course/[courseId]/(courseInfo)/analytics/page.tsx b/app/dashboard/course/[courseId]/(courseInfo)/analytics/page.tsx new file mode 100644 index 0000000..4004be7 --- /dev/null +++ b/app/dashboard/course/[courseId]/(courseInfo)/analytics/page.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useParams } from "next/navigation"; +import React, { useState } from "react"; +import AttendanceLineChart from "@/components/ui/AttendanceLineChart"; +import PerformanceData from "@/components/ui/PerformanceData"; +import StudentTable from "@/components/ui/StudentTable"; +import { analyticsPages } from "@/lib/constants"; + +export default function Page() { + const params = useParams(); + const courseId = parseInt((params.courseId as string) ?? "0"); + const [page, setPage] = useState("Performance"); + + return ( +
+
+ {analyticsPages.map((pageTitle: string) => ( + + ))} +
+ {/* Performance and Attendance Data */} +
+ {page === "Performance" ? ( + + ) : ( + + )} +
+
+

Student Data

+ +
+ {/* Student Data Table */} + +
+ ); +} diff --git a/app/dashboard/course/[courseId]/(courseInfo)/layout.tsx b/app/dashboard/course/[courseId]/(courseInfo)/layout.tsx new file mode 100644 index 0000000..8ffdf91 --- /dev/null +++ b/app/dashboard/course/[courseId]/(courseInfo)/layout.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { useParams, usePathname, useRouter } from "next/navigation"; +import React, { useEffect, useState } from "react"; +import { GlobalLoadingSpinner } from "@/components/ui/global-loading-spinner"; +import useAccess from "@/hooks/use-access"; +import { useToast } from "@/hooks/use-toast"; +import { coursePages } from "@/lib/constants"; +import { getCourseWithId } from "@/services/course"; + +export default function CourseInfoLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + const router = useRouter(); + const params = useParams(); + const path = usePathname(); + const courseId = parseInt(params.courseId as string); + const { hasAccess, isLoading: isAccessLoading } = useAccess({ courseId, role: "LECTURER" }); + const { toast } = useToast(); + const [courseInfo, setCourseInfo] = useState<{ name: string; code: string }>({ + name: "", + code: "", + }); + + useEffect(() => { + if (isAccessLoading) { + return; + } + if (!isAccessLoading && !hasAccess) { + toast({ variant: "destructive", description: "Access denied!" }); + router.push("/dashboard"); + return; + } + const fetchCourseName = async () => { + try { + const res = await getCourseWithId(courseId); + if ("error" in res) { + return toast({ + variant: "destructive", + description: "Unable to fetch course information", + }); + } else { + setCourseInfo({ name: res.title, code: res.code }); + } + } catch (err) { + console.error(err); + toast({ + variant: "destructive", + description: "Unknown error occurred.", + }); + } + }; + void fetchCourseName(); + }, [isAccessLoading, hasAccess, courseId]); + + if (isAccessLoading || !hasAccess) { + return ; + } + + return ( + <> +

{`${courseInfo?.name} (${courseInfo?.code})`}

+
+ {coursePages.map((tab) => ( + + ))} +
+
{children}
+ + ); +} diff --git a/app/dashboard/course/[courseId]/questionnaire/page.tsx b/app/dashboard/course/[courseId]/(courseInfo)/questionnaire/page.tsx similarity index 75% rename from app/dashboard/course/[courseId]/questionnaire/page.tsx rename to app/dashboard/course/[courseId]/(courseInfo)/questionnaire/page.tsx index 7b56b38..6881adf 100644 --- a/app/dashboard/course/[courseId]/questionnaire/page.tsx +++ b/app/dashboard/course/[courseId]/(courseInfo)/questionnaire/page.tsx @@ -9,40 +9,27 @@ import BeginPollDialog from "@/components/BeginPollDialog"; import SlidingCalendar from "@/components/ui/SlidingCalendar"; import { Button } from "@/components/ui/button"; import { GlobalLoadingSpinner } from "@/components/ui/global-loading-spinner"; -import useAccess from "@/hooks/use-access"; import { useToast } from "@/hooks/use-toast"; import { formatDateToISO } from "@/lib/utils"; -import { getCourseWithId } from "@/services/course"; import { getCourseSessionByDate } from "@/services/session"; export default function Page() { const params = useParams(); const courseId = parseInt((params.courseId as string) ?? "0"); - const [courseInfo, setCourseInfo] = useState<{ name: string; code: string }>(); const [hasActiveSession, setHasActiveSession] = useState(false); const [isLoading, setIsLoading] = useState(true); const router = useRouter(); const { toast } = useToast(); - const { hasAccess, isLoading: isAccessLoading } = useAccess({ courseId, role: "LECTURER" }); const [refreshCalendar, setRefreshCalendar] = useState(false); const handleQuestionUpdate = () => { - setRefreshCalendar(prev => !prev); + setRefreshCalendar((prev) => !prev); }; useEffect(() => { - if (isAccessLoading) { - return; - } - if (!isAccessLoading && !hasAccess) { - toast({ variant: "destructive", description: "Access denied!" }); - router.push("/dashboard"); - return; - } - const getCourseName = async () => { + const getCourseInfo = async () => { setHasActiveSession(false); setIsLoading(true); try { - const course = await getCourseWithId(courseId); const courseSession = await getCourseSessionByDate( courseId, formatDateToISO(new Date()), @@ -50,7 +37,6 @@ export default function Page() { if (courseSession?.activeQuestionId) { setHasActiveSession(true); } - setCourseInfo({ name: course.title, code: course.code }); } catch (error) { toast({ variant: "destructive", description: "Could not get course information." }); console.error("Failed to fetch course:", error); @@ -59,19 +45,16 @@ export default function Page() { setIsLoading(false); } }; - void getCourseName(); - }, [courseId, hasAccess, isAccessLoading]); + void getCourseInfo(); + }, [courseId]); - if (isAccessLoading || !hasAccess || isLoading) { + if (isLoading) { return ; } return (
-

- {`${courseInfo?.name} (${courseInfo?.code})`}{" "} -

{ async function fetchSessionData() { const session = await getCourseSessionByDate(courseId, utcDate); diff --git a/components/AddEditCourseForm.tsx b/components/AddEditCourseForm.tsx index 0745b16..5b88ef5 100644 --- a/components/AddEditCourseForm.tsx +++ b/components/AddEditCourseForm.tsx @@ -62,7 +62,6 @@ export const AddEditCourseForm = ({ }, }); - const handleSubmit = async (values: z.infer) => { const { title, color, days, endTime, startTime } = values; setLoading(true); diff --git a/components/AddEditQuestionForm.tsx b/components/AddEditQuestionForm.tsx index d6d7d1d..250a96d 100644 --- a/components/AddEditQuestionForm.tsx +++ b/components/AddEditQuestionForm.tsx @@ -130,7 +130,6 @@ export const AddEditQuestionForm: React.FC = ({ }); } }, [prevData]); - useEffect(() => { if (!defaultDate) return; diff --git a/components/ui/AttendanceLineChart.tsx b/components/ui/AttendanceLineChart.tsx new file mode 100644 index 0000000..9e1d02b --- /dev/null +++ b/components/ui/AttendanceLineChart.tsx @@ -0,0 +1,115 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { ChartContainer } from "@/components/ui/chart"; +import { CartesianGrid, Line, LineChart, ResponsiveContainer, XAxis, YAxis } from "recharts"; +import dayjs from "dayjs"; +import { attendanceChartConfig } from "@/lib/constants"; +import { calculateWeekAttendance } from "@/lib/utils"; +import { useToast } from "@/hooks/use-toast"; +import LoaderComponent from "./loader"; + +interface Props { + courseId: number; +} + +export default function AttendanceLineChart({ courseId }: Props) { + const [weekStart, setWeekStart] = useState(dayjs().startOf("week").toDate()); + const [isLoading, setIsLoading] = useState(false); + const [chartData, setChartData] = useState<{ date: string; attendance: number }[]>(); + const { toast } = useToast(); + + const fetchWeekData = useCallback( + async (start: Date) => { + setIsLoading(true); + await calculateWeekAttendance(start, courseId) + .then((res) => { + if (res && "error" in res) { + toast({ + variant: "destructive", + description: res.error ?? "Unknown error occurred", + }); + } else { + setChartData(res); + } + setIsLoading(false); + }) + .catch((err: unknown) => { + console.error(err); + toast({ variant: "destructive", description: "Unknown error occurred" }); + setIsLoading(false); + }); + }, + [courseId], + ); + + useEffect(() => { + fetchWeekData(weekStart); + }, [weekStart]); + + const handleNextWeek = () => { + setWeekStart(dayjs(weekStart).add(7, "day").toDate()); + }; + const handlePrevWeek = () => { + setWeekStart(dayjs(weekStart).subtract(7, "day").toDate()); + }; + + return ( +
+ + {isLoading ? ( + + ) : ( + + + + + `${value}`} + /> + `${value}%`} + /> + + + + + )} + +
+ ); +} diff --git a/components/ui/CourseCard.tsx b/components/ui/CourseCard.tsx index a3ab2d5..0ff6e98 100644 --- a/components/ui/CourseCard.tsx +++ b/components/ui/CourseCard.tsx @@ -5,187 +5,186 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { MoreVertical, Edit, Trash2 } from "lucide-react"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { AddEditCourseForm } from "@/components/AddEditCourseForm"; import { dayLabels } from "@/lib/constants"; export type CourseCardProps = { - color: string; - days: string[]; - title: string; - timeStart: string; - timeEnd: string; - code: string; - role: Role; - id: number; - onEdit?: () => void; - onDelete?: () => void; + color: string; + days: string[]; + title: string; + timeStart: string; + timeEnd: string; + code: string; + role: Role; + id: number; + onEdit?: () => void; + onDelete?: () => void; }; export default function CourseCard({ - color, - days, - title, - timeStart, - timeEnd, - code, - role, - id, - onEdit, - onDelete, + color, + days, + title, + timeStart, + timeEnd, + code, + role, + id, + onEdit, + onDelete, }: CourseCardProps) { - const router = useRouter(); - const [isEditOpen, setIsEditOpen] = useState(false); + const router = useRouter(); + const [isEditOpen, setIsEditOpen] = useState(false); - const shortDays = days - .map((fullDay) => { - const entry = Object.entries(dayLabels).find(([, label]) => label === fullDay); - return entry ? entry[0] : undefined; - }) - .filter(Boolean) as ("M" | "T" | "W" | "Th" | "F")[]; + const shortDays = days + .map((fullDay) => { + const entry = Object.entries(dayLabels).find(([, label]) => label === fullDay); + return entry ? entry[0] : undefined; + }) + .filter(Boolean) as ("M" | "T" | "W" | "Th" | "F")[]; - const handleCardClick = () => { - if (!isEditOpen) { - router.push( - role === "LECTURER" - ? `/dashboard/course/${id}/questionnaire` - : `/dashboard/course/${id}/live-poll` - ); - } - }; + const handleCardClick = () => { + if (!isEditOpen) { + router.push( + role === "LECTURER" + ? `/dashboard/course/${id}/questionnaire` + : `/dashboard/course/${id}/live-poll`, + ); + } + }; - const handleEdit = (e: React.MouseEvent) => { - e.stopPropagation(); - setIsEditOpen(true); - }; + const handleEdit = (e: React.MouseEvent) => { + e.stopPropagation(); + setIsEditOpen(true); + }; - const handleDelete = (e: React.MouseEvent) => { - e.stopPropagation(); - onDelete?.(); - }; + const handleDelete = (e: React.MouseEvent) => { + e.stopPropagation(); + onDelete?.(); + }; - const handleEditClose = () => { - setIsEditOpen(false); - onEdit?.(); - }; + const handleEditClose = () => { + setIsEditOpen(false); + onEdit?.(); + }; - const CardContent = () => ( - <> -
-
-

- Time: {timeStart} - {timeEnd} -

-

{title}

- -

- Code:{" "} - - {(+code).toLocaleString("en-US", { - minimumIntegerDigits: 6, - useGrouping: false, - })} - -

-

- Role: {role.toLocaleLowerCase()} -

-
-

{days.join(", ")}

-
- - ); + const CardContent = () => ( + <> +
+
+

+ Time: {timeStart} - {timeEnd} +

+

{title}

+ +

+ Code:{" "} + + {(+code).toLocaleString("en-US", { + minimumIntegerDigits: 6, + useGrouping: false, + })} + +

+

+ Role: {role.toLocaleLowerCase()} +

+
+

{days.join(", ")}

+
+ + ); - const MobileCardContent = () => ( - <> -
-
-

- Time: {timeStart} - {timeEnd} -

-

{title}

-
-

{days.join(", ")}

- - ); + const MobileCardContent = () => ( + <> +
+
+

+ Time: {timeStart} - {timeEnd} +

+

{title}

+
+

{days.join(", ")}

+ + ); - const MenuDropdown = () => ( - - - - - - {role === "LECTURER" && ( - { - event.preventDefault(); - handleEdit(event as unknown as React.MouseEvent); - }} - > - - Edit - - )} - handleDelete(event as unknown as React.MouseEvent)}> - - {role === "LECTURER" ? "Delete" : "Leave"} - - - - ); + const MenuDropdown = () => ( + + + + + + {role === "LECTURER" && ( + { + event.preventDefault(); + handleEdit(event as unknown as React.MouseEvent); + }} + > + + Edit + + )} + handleDelete(event as unknown as React.MouseEvent)} + > + + {role === "LECTURER" ? "Delete" : "Leave"} + + + + ); - return ( - <> - {/* Mobile View */} -
- - -
+ return ( + <> + {/* Mobile View */} +
+ + +
- {/* Desktop View */} -
- - -
+ {/* Desktop View */} +
+ + +
- !open && handleEditClose()} - defaultValues={{ - title, - color, - days: shortDays, - startTime: timeStart, - endTime: timeEnd, - }} - onSuccess={handleEditClose} - /> - - ); -} \ No newline at end of file + !open && handleEditClose()} + defaultValues={{ + title, + color, + days: shortDays, + startTime: timeStart, + endTime: timeEnd, + }} + onSuccess={handleEditClose} + /> + + ); +} diff --git a/components/ui/DonutChart.tsx b/components/ui/DonutChart.tsx new file mode 100644 index 0000000..629efc2 --- /dev/null +++ b/components/ui/DonutChart.tsx @@ -0,0 +1,71 @@ +import React from "react"; +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import { Label, Pie, PieChart } from "recharts"; + +interface Props { + chartData: {}[]; + chartConfig: ChartConfig; + dataKey: string; + nameKey: string; + description: string; + descriptionStatistic: number; +} + +export default function DonutChart({ + chartData, + chartConfig, + dataKey, + nameKey, + description, + descriptionStatistic, +}: Props) { + return ( + + + } /> + + + + + ); +} diff --git a/components/ui/PerformanceData.tsx b/components/ui/PerformanceData.tsx new file mode 100644 index 0000000..504aafb --- /dev/null +++ b/components/ui/PerformanceData.tsx @@ -0,0 +1,133 @@ +import React, { useCallback, useEffect, useState } from "react"; +import DonutChart from "@/components/ui/DonutChart"; +import { + dataKey, + description, + nameKey, + performanceChartConfig, + questionTypeColors, + questionTypeMap, +} from "@/lib/constants"; +import { useToast } from "@/hooks/use-toast"; +import { getLimitedPastQuestions, getResponses } from "@/services/question"; +import { getIncorrectAndCorrectResponseCounts, getQuestionsWithAverageScore } from "@/lib/utils"; +import { GlobalLoadingSpinner } from "./global-loading-spinner"; + +interface Props { + courseId: number; +} +export default function PerformanceData({ courseId }: Props) { + const [pastQuestions, setPastQuestions] = useState< + { type: keyof typeof questionTypeMap; title: string; average: number }[] + >([]); + const [responseStatistics, setResponseStatistics] = useState<{ + incorrect: number; + correct: number; + }>({ + incorrect: 0, + correct: 0, + }); + const { toast } = useToast(); + const [isLoading, setIsLoading] = useState(false); + + const performanceChartData = [ + { result: "correct", count: responseStatistics.correct, fill: "#BAFF7E" }, + { result: "incorrect", count: responseStatistics.incorrect, fill: "#CCCCCC" }, + ]; + + const fetchCourseStatistics = useCallback(async () => { + try { + setIsLoading(true); + const pastQuestionsRes = await getLimitedPastQuestions(courseId, 2); + if ("error" in pastQuestionsRes) { + return toast({ + variant: "destructive", + description: pastQuestionsRes?.error ?? "Unknown error occurred.", + }); + } else { + setPastQuestions(getQuestionsWithAverageScore(pastQuestionsRes)); + } + + const responses = await getResponses(courseId); + if (!responses || (typeof responses !== "number" && "error" in responses)) { + return toast({ + variant: "destructive", + description: responses?.error ?? "Unknown error occurred.", + }); + } else if (responses) { + setResponseStatistics(getIncorrectAndCorrectResponseCounts(responses)); + } + } catch (err) { + console.error(err); + toast({ + variant: "destructive", + description: "Unknown error occurred.", + }); + } finally { + setIsLoading(false); + } + }, [courseId]); + + useEffect(() => { + void fetchCourseStatistics(); + }, []); + + if (isLoading) { + return ; + } + + return ( + <> +
+ {/* Donut Chart */} + {responseStatistics.correct + responseStatistics.incorrect === 0 ? ( + + No Responses + + ) : ( + + )} +
+ {/* Past Questions */} +
+ {pastQuestions.map((question, idx) => ( +
+
+

+ {questionTypeMap[question.type]} +

+

{question.average}

+
+

{question.title}

+
+ ))} +
+ + ); +} diff --git a/components/ui/SlidingCalendar.tsx b/components/ui/SlidingCalendar.tsx index f5252ef..82aa56c 100644 --- a/components/ui/SlidingCalendar.tsx +++ b/components/ui/SlidingCalendar.tsx @@ -253,7 +253,11 @@ function SlidingCalendar({ courseId, refreshTrigger }: Props) { courseId={courseId} location="page" questionId={question.id} - onUpdate={() => fetchQuestions(selectedDate.toDate())} + onUpdate={() => + fetchQuestions( + selectedDate.toDate(), + ) + } prevData={{ question: question.text, selectedQuestionType: diff --git a/components/ui/StudentTable.tsx b/components/ui/StudentTable.tsx new file mode 100644 index 0000000..3d8b479 --- /dev/null +++ b/components/ui/StudentTable.tsx @@ -0,0 +1,154 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { + Table, + TableBody, + TableCaption, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useToast } from "@/hooks/use-toast"; +import { getStudents } from "@/services/userCourse"; +import { getAllSessionIds } from "@/services/session"; +import { getStudentsWithScores } from "@/lib/utils"; +import LoaderComponent from "./loader"; + +interface Props { + courseId: number; +} +export default function StudentTable({ courseId }: Props) { + const [students, setStudents] = useState< + { + name: string; + email: string | null; + attendance: number; + pollScore: number; + }[] + >([]); + const [studentQuery, setStudentQuery] = useState(undefined); + const [isLoading, setIsLoading] = useState(false); + const { toast } = useToast(); + + const fetchStudentData = useCallback(async () => { + setIsLoading(true); + await getStudents(courseId, studentQuery) + .then(async (studentData) => { + if ("error" in studentData) + return toast({ + variant: "destructive", + description: studentData?.error ?? "Unknown error occurred.", + }); + else { + await getAllSessionIds(courseId).then((sessions) => { + if ("error" in sessions) { + return toast({ + variant: "destructive", + description: sessions?.error ?? "Unknown error occurred.", + }); + } else { + setStudents(getStudentsWithScores(studentData, sessions)); + } + }); + } + }) + .catch((err: unknown) => { + console.error(err); + return toast({ + variant: "destructive", + description: "Unknown error occurred.", + }); + }) + .finally(() => { + setIsLoading(false); + }); + }, [courseId, studentQuery]); + + useEffect(() => { + void fetchStudentData(); + }, [studentQuery]); + + return ( +
+ + + + + Student + + + Attendance + + + Poll Score + + + { + setStudentQuery(e.target.value); + }} + type="text" + placeholder="Search student..." + className="h-8 w-full md:max-w-[12vw] px-3 bg-white text-black border border-slate-300 rounded-lg focus:outline-none" + /> + + + + {isLoading ? ( + + + + ) : students?.length === 0 ? ( + No students enrolled + ) : ( + + {students.map((student, idx) => ( + + +

{student.name}

+

+ {student.email ?? "No email provided"} +

+
+ +

50 && + student.attendance <= 75 + ? "bg-[#F8ECA1]" + : "bg-[#BFF2A6]" + }`} + > + {student.attendance}% +

+
+ +

50 && student.pollScore <= 75 + ? "bg-[#F8ECA1]" + : "bg-[#BFF2A6]" + }`} + > + {student.pollScore}% +

+
+ +
+ +
+
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/components/ui/answerOptions.tsx b/components/ui/answerOptions.tsx index ecd56f8..9467497 100644 --- a/components/ui/answerOptions.tsx +++ b/components/ui/answerOptions.tsx @@ -16,7 +16,6 @@ interface AnswerOptionsProps { onSelectionChange: (value: number | number[]) => void; } - const AnswerOptions: React.FC = ({ options, questionType, diff --git a/components/ui/loader.tsx b/components/ui/loader.tsx new file mode 100644 index 0000000..14f131c --- /dev/null +++ b/components/ui/loader.tsx @@ -0,0 +1,11 @@ +import { Loader } from "lucide-react"; + +const LoaderComponent = ({ size = 40 }: { size: number }) => { + return ( +
+ +
+ ); +}; + +export default LoaderComponent; diff --git a/components/ui/table.tsx b/components/ui/table.tsx new file mode 100644 index 0000000..f42ae52 --- /dev/null +++ b/components/ui/table.tsx @@ -0,0 +1,98 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Table = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ + + ), +); +Table.displayName = "Table"; + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableHeader.displayName = "TableHeader"; + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableBody.displayName = "TableBody"; + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0", className)} + {...props} + /> +)); +TableFooter.displayName = "TableFooter"; + +const TableRow = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ), +); +TableRow.displayName = "TableRow"; + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
[role=checkbox]]:translate-y-[2px]", + className, + )} + {...props} + /> +)); +TableHead.displayName = "TableHead"; + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + [role=checkbox]]:translate-y-[2px]", + className, + )} + {...props} + /> +)); +TableCell.displayName = "TableCell"; + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +TableCaption.displayName = "TableCaption"; + +export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }; diff --git a/hooks/use-debounce.tsx b/hooks/use-debounce.tsx new file mode 100644 index 0000000..534768f --- /dev/null +++ b/hooks/use-debounce.tsx @@ -0,0 +1,19 @@ +import { useEffect, useState } from "react"; + +function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +} + +export default useDebounce; diff --git a/lib/constants.ts b/lib/constants.ts index f74448d..9daa0eb 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -1,3 +1,6 @@ +import { $Enums } from "@prisma/client"; +import { ChartConfig } from "@/components/ui/chart"; + export const questionTypes = ["Multiple Choice", "Select All"] as const; export const colorOptions = ["#ED9D9D", "#F3AB7E", "#EEF583", "#94ED79", "#8E87F2"]; @@ -16,4 +19,88 @@ export const questionTypeMap = { MCQ: "Multiple Choice", }; +export const questionTypeColors = { + MSQ: { bg: "#FFFED3", fg: "#58560B" }, + MCQ: { bg: "#EBCFFF", fg: "#602E84" }, +}; + +// donut chart config +export const dataKey = "count"; +export const nameKey = "result"; +export const description = "Class Average"; +export const performanceChartConfig = { + count: { + label: "Count", + }, + correct: { + label: "Correct", + color: "green", + }, + incorrect: { + label: "Incorrect", + color: "gray", + }, +} satisfies ChartConfig; + +export const attendanceChartConfig = { + attendance: { + label: "Attendance", + color: "black", + }, +} satisfies ChartConfig; + +export const analyticsPages = ["Performance", "Attendance Rate"]; +export const coursePages = ["Questionnaire", "Analytics"]; + export const DEFAULT_SHOW_RESULTS = false; + +export type QuestionWithResponesAndOptions = { + options: { + isCorrect: boolean; + text: string; + id: number; + questionId: number; + }[]; + responses: { + userId: string; + questionId: number; + optionId: number; + answeredAt: Date; + }[]; +} & { + text: string; + id: number; + type: $Enums.QuestionType; + sessionId: number; + position: number; +}; + +export type Response = { + options: { + isCorrect: boolean; + id: number; + text: string; + questionId: number; + }[]; + responses: { + optionId: number; + questionId: number; + userId: string; + answeredAt: Date; + }[]; +}; + +export type Student = { + id: string; + responses: { + question: { + sessionId: number; + options: { + isCorrect: boolean; + }[]; + }; + }[]; + email: string | null; + firstName: string; + lastName: string | null; +}; diff --git a/lib/utils.ts b/lib/utils.ts index 012c43a..5d3281b 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,7 +1,9 @@ import { clsx } from "clsx"; import type { ClassValue } from "clsx"; +import dayjs from "dayjs"; import { twMerge } from "tailwind-merge"; - +import { QuestionWithResponesAndOptions, Response, Student } from "./constants"; +import { getAttendanceCount, getStudentCount } from "@/services/userCourse"; /** * A utility function that merges tailwind classes with conditional classes combining functionalities of twMerge and clsx. * @@ -32,7 +34,6 @@ export function formatDateToISO(date: Date) { return new Date(date.setHours(0, 0, 0, 0)).toISOString(); } - export function shuffleArray(array: T[]): T[] { const copy = [...array]; for (let i = copy.length - 1; i > 0; i--) { @@ -41,3 +42,133 @@ export function shuffleArray(array: T[]): T[] { } return copy; } + +export function getQuestionsWithAverageScore(questions: QuestionWithResponesAndOptions[]) { + const questionsWithScores = []; + + for (const question of questions) { + // get the IDs of the correct options + const correctOptionIds = question.options + .filter((option) => option.isCorrect) + .map((option) => option.id); + + // check if at least one option is correct + let correctCount = 0; + question.responses.forEach((response) => { + if (correctOptionIds.includes(response.optionId)) { + correctCount++; + } + }); + + // add question and score information to result + questionsWithScores.push({ + type: question.type, + title: question.text, + average: + question.responses.length === 0 + ? 0 + : Math.trunc((correctCount / question.responses.length) * 100), + }); + } + + return questionsWithScores; +} + +export function getIncorrectAndCorrectResponseCounts(responses: Response[]) { + let correctResponses = 0; + let incorrectReponses = 0; + + for (const question of responses) { + // get the IDs of the correct options + const correctOptionIds = question.options + .filter((option) => option.isCorrect) + .map((option) => option.id); + + // increment correctResponses if the option is correct, else increment incorrect responses + question.responses.forEach((response) => { + if (correctOptionIds.includes(response.optionId)) { + correctResponses++; + } else { + incorrectReponses++; + } + }); + } + + return { incorrect: incorrectReponses, correct: correctResponses }; +} + +export function getStudentsWithScores(students: Student[], sessionIds: number[]) { + const studentData = students.map((student) => { + // get total number of sessions + const totalSessions = sessionIds.length; + + // get all student responses for this course + const studentResponses = student.responses.filter((response) => + sessionIds.includes(response.question.sessionId), + ); + + // get number of sessions attended + const attendedSessions = new Set( + studentResponses.map((response) => response.question.sessionId), + ).size; + + // get number of correct responses + const correctResponses = studentResponses.filter((response) => + response.question.options.some((option) => option.isCorrect), + ).length; + + // calculate attendance and poll score + const attendance = + totalSessions > 0 ? Math.trunc((attendedSessions / totalSessions) * 100) : 0; + + const pollScore = + studentResponses.length > 0 + ? Math.trunc((correctResponses / studentResponses.length) * 100) + : 0; + + return { + name: String(student.firstName) + " " + String(student.lastName), + email: student.email, + attendance, + pollScore, + }; + }); + + return studentData; +} + +export function calculateDayAttendance(attendanceCount: number, totalStudentsCount: number) { + if (totalStudentsCount === 0) { + return 0; + } + return Math.trunc((attendanceCount / totalStudentsCount) * 100); +} + +export async function calculateWeekAttendance(start: Date, courseId: number) { + try { + const promises = Array.from({ length: 7 }).map(async (_, i) => { + const day = dayjs(start).add(i, "day"); + try { + const studentCount = await getStudentCount(courseId); + if (typeof studentCount !== "number") { + throw Error(); + } + const attendanceCount = await getAttendanceCount(courseId, day.toDate()); + if (typeof attendanceCount !== "number") { + throw Error(); + } + const attendance = calculateDayAttendance(attendanceCount, studentCount); + return { date: day.format("M/D"), attendance }; + } catch (err) { + console.error(err); + throw Error(); + } + }); + + const weekData = await Promise.all(promises); + return weekData; + } catch (err: unknown) { + console.error(err); + return { error: "Error calculating attendance" }; + } +} diff --git a/services/course.ts b/services/course.ts index 544a326..9ef88b4 100644 --- a/services/course.ts +++ b/services/course.ts @@ -115,7 +115,7 @@ type UpdateCourseParams = { export async function updateCourse( courseId: number, - data: UpdateCourseParams + data: UpdateCourseParams, ): Promise { try { // First update the course details @@ -148,10 +148,8 @@ export async function updateCourse( return updatedCourse; } catch (error) { console.error("Error updating course:", error); - return { - error: error instanceof Error - ? error.message - : "Failed to update course" + return { + error: error instanceof Error ? error.message : "Failed to update course", }; } -} \ No newline at end of file +} diff --git a/services/question.ts b/services/question.ts index f3575b6..2e1165d 100644 --- a/services/question.ts +++ b/services/question.ts @@ -119,3 +119,54 @@ export async function deleteQuestion(questionId: number) { return { error: "Error deleting question." }; } } + +export async function getLimitedPastQuestions(courseId: number, limit: number) { + try { + const questions = await prisma.question.findMany({ + where: { + session: { + courseId, + }, + }, + orderBy: [ + { + session: { + startTime: "desc", + }, + }, + { position: "desc" }, + ], + take: limit, + include: { + responses: true, + options: true, + }, + }); + + return questions; + } catch (err) { + console.error(err); + return { error: "Error fetching past questions." }; + } +} + +export async function getResponses(courseId: number) { + try { + const responses = await prisma.question.findMany({ + where: { + session: { + courseId, + }, + }, + include: { + responses: true, + options: true, + }, + }); + + return responses; + } catch (err) { + console.error(err); + return { error: "Error calculating class average." }; + } +} diff --git a/services/session.ts b/services/session.ts index 57b9768..7dd598a 100644 --- a/services/session.ts +++ b/services/session.ts @@ -94,3 +94,51 @@ export async function getQuestionById(questionId: number) { throw error; } } + +export async function getAllSessionIds(courseId: number) { + try { + const sessions = await prisma.courseSession.findMany({ + where: { + courseId, + }, + select: { + id: true, + }, + }); + + return sessions.map((session) => session.id); + } catch (error) { + console.error(error); + return { error: "Error fetching sessions" }; + } +} + +export async function getSessionIdsByDate(courseId: number, date: Date) { + try { + const dayStart = new Date(date); + dayStart.setHours(0, 0, 0, 0); + + const dayEnd = new Date(date); + dayEnd.setHours(23, 59, 59, 999); + + const sessions = await prisma.courseSession + .findMany({ + where: { + courseId, + startTime: { + gte: dayStart, + lte: dayEnd, + }, + }, + select: { + id: true, + }, + }) + .then((res) => res.map((session) => session.id)); + + return sessions; + } catch (error) { + console.error(error); + return { error: "Error fetching session information" }; + } +} diff --git a/services/userCourse.ts b/services/userCourse.ts index deb9dc5..1df2936 100644 --- a/services/userCourse.ts +++ b/services/userCourse.ts @@ -1,5 +1,6 @@ "use server"; import { Role } from "@prisma/client"; +import { getSessionIdsByDate } from "./session"; import prisma from "@/lib/prisma"; export async function addUserToCourse(courseId: number, userId: string, role: Role = "STUDENT") { @@ -96,3 +97,128 @@ export async function addUserToCourseByEmail( return { error: "User is already enrolled in this course" }; } } + +export async function getStudents(courseId: number, query: string | undefined) { + try { + const students = await prisma.user.findMany({ + where: { + courses: { + some: { + courseId, + role: "STUDENT", + }, + }, + ...(query + ? { + OR: [ + { + email: { + contains: query, + mode: "insensitive", + }, + }, + { + firstName: { + contains: query, + mode: "insensitive", + }, + }, + { + lastName: { + contains: query, + mode: "insensitive", + }, + }, + ], + } + : {}), + }, + select: { + id: true, + firstName: true, + lastName: true, + email: true, + responses: { + select: { + question: { + select: { + sessionId: true, + options: { + select: { + isCorrect: true, + }, + }, + }, + }, + }, + }, + }, + }); + + return students; + } catch (err) { + console.error(err); + return { error: "Error fetching students." }; + } +} + +export async function getStudentCount(courseId: number) { + try { + const totalStudents = await prisma.user.count({ + where: { + courses: { + some: { + courseId, + role: "STUDENT", + }, + }, + }, + }); + + return totalStudents; + } catch (err) { + console.error(err); + return { error: "Error fetching student information" }; + } +} + +export async function getAttendanceCount(courseId: number, date: Date) { + try { + const sessionIds = await getSessionIdsByDate(courseId, date); + + if ("error" in sessionIds) { + throw Error(); + } + + if (sessionIds.length === 0) { + return 0; + } + + const attendedStudents = await prisma.response.findMany({ + where: { + question: { + sessionId: { + in: sessionIds, + }, + }, + user: { + courses: { + some: { + courseId, + role: "STUDENT", + }, + }, + }, + }, + distinct: ["userId"], + select: { + userId: true, + }, + }); + + return attendedStudents.length; + } catch (err) { + console.error(err); + return { error: "Error fetching student information" }; + } +}