diff --git a/app/api/courses/[courseId]/past-questions/route.ts b/app/api/courses/[courseId]/past-questions/route.ts new file mode 100644 index 0000000..5e7703a --- /dev/null +++ b/app/api/courses/[courseId]/past-questions/route.ts @@ -0,0 +1,55 @@ +import { Role } from "@prisma/client"; +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import prisma from "@/lib/prisma"; +import { validateUser } from "@/services/userCourse"; + +export async function GET(request: NextRequest, context: { params: Promise<{ courseId: string }> }) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { courseId: courseIdStr } = await context.params; + + const courseId = parseInt(courseIdStr); + + if (!courseId || isNaN(Number(courseId))) { + return NextResponse.json( + { error: "Invalid or missing sessionId parameter" }, + { status: 400 }, + ); + } + + if (!(await validateUser(session.user.id, courseId, Role.LECTURER))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const pastQuestions = await prisma.question.findMany({ + where: { + session: { + courseId, + endTime: { not: null }, + }, + }, + include: { + session: { select: { startTime: true } }, + options: true, + responses: { + include: { + option: true, + user: { select: { firstName: true, lastName: true } }, + }, + }, + }, + orderBy: { session: { startTime: "desc" } }, + }); + + return NextResponse.json(pastQuestions); + } catch (error) { + console.error("Failed to fetch past questions", error); + return NextResponse.json({ error: "Failed to fetch past questions" }, { status: 500 }); + } +} diff --git a/app/dashboard/course/[courseId]/(courseInfo)/questionnaire/[questionId]/responses/page.tsx b/app/dashboard/course/[courseId]/(courseInfo)/questionnaire/[questionId]/responses/page.tsx new file mode 100644 index 0000000..8240a80 --- /dev/null +++ b/app/dashboard/course/[courseId]/(courseInfo)/questionnaire/[questionId]/responses/page.tsx @@ -0,0 +1,340 @@ +import { TooltipContent } from "@radix-ui/react-tooltip"; +import { ArrowLeft } from "lucide-react"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import React from "react"; +import { CircularProgress } from "@/components/ui/circular-progress"; +import { StringTooltipContainer, Tooltip, TooltipTrigger } from "@/components/ui/tooltip"; +import prisma from "@/lib/prisma"; + +interface Props { + params: Promise<{ + courseId: string; + questionId: string; + }>; +} + +const questionTypeStyles = { + MCQ: { + bgColor: "#FFFED3", + textColor: "#58560B", + borderColor: "#58570B", + label: "Multiple Choice", + }, + MSQ: { + bgColor: "#EBCFFF", + textColor: "#602E84", + borderColor: "#602E84", + label: "Select-All", + }, +}; + +const formatDate = (date: Date) => { + return new Date(date).toLocaleDateString("en-US", { + month: "long", + day: "numeric", + year: "numeric", + }); +}; + +export default async function QuestionResponsesPage({ params }: Props) { + const questionId = parseInt((await params).questionId); + const courseId = parseInt((await params).courseId); + + const question = await prisma.question.findUnique({ + where: { id: questionId }, + include: { + options: { + orderBy: { + id: "asc", + }, + }, + responses: { + include: { + option: true, + user: { + select: { + firstName: true, + lastName: true, + email: true, + }, + }, + }, + orderBy: { + answeredAt: "desc", + }, + }, + session: { + select: { + startTime: true, + course: { + select: { + title: true, + users: { + where: { + role: "STUDENT", + }, + select: { + userId: true, + }, + }, + }, + }, + }, + }, + }, + }); + + if (!question) { + return notFound(); + } + + const correctOptionIds = question.options + .filter((option) => option.isCorrect) + .map((option) => option.id); + + const latestResponseSets = question.responses.reduce((acc, response) => { + if (!acc.has(response.userId)) { + const userResponses = question.responses + .filter((r) => r.userId === response.userId && r.questionId === questionId) + .sort((a, b) => b.answeredAt.getTime() - a.answeredAt.getTime()); + + if (question.type === "MSQ") { + const latestAnsweredAt = userResponses[0]?.answeredAt; + const latestSubmission = userResponses.filter( + (r) => r.answeredAt.getTime() === latestAnsweredAt?.getTime(), + ); + acc.set(response.userId, latestSubmission); + } else { + acc.set(response.userId, [userResponses[0]]); + } + } + return acc; + }, new Map()); + + const allLatestResponses = Array.from(latestResponseSets.values()).flat(); + + const studentResults = Array.from(latestResponseSets.entries()).map(([userId, responses]) => { + if (question.type === "MSQ") { + const selectedOptionIds = responses.map((r) => r.optionId); + const isCorrect = + selectedOptionIds.length === correctOptionIds.length && + correctOptionIds.every((id) => selectedOptionIds.includes(id)); + return { userId, isCorrect }; + } else { + const isCorrect = responses[0]?.option.isCorrect ?? false; + return { userId, isCorrect }; + } + }); + + const totalStudents = question.session.course.users.length; + const answeredStudents = latestResponseSets.size; + const correctCount = studentResults.filter((r) => r.isCorrect).length; + const correctPercentage = + answeredStudents > 0 ? Math.round((correctCount / answeredStudents) * 100) : 0; + + const optionCounts = question.options.map((option) => { + const count = allLatestResponses.filter((r) => r.optionId === option.id).length; + return { + optionId: option.id, + count, + percentage: answeredStudents > 0 ? Math.round((count / answeredStudents) * 100) : 0, + }; + }); + + return ( +
+
+ + + Back to Past Questions + +
+ + {/* Header Section */} +
+
+ + {questionTypeStyles[question.type].label} + + + {formatDate(question.session.startTime)} + +
+
+ Total Students Answered: {answeredStudents}/{totalStudents} +
+
+ + {/* Question/Average Section */} +
+
+ {/* Question and Answer Choices */} +
+ + +

+ {question.text} +

+
+ + + +
+
+ {question.options.map((option) => { + const optionCount = optionCounts.find( + (oc) => oc.optionId === option.id, + ); + const count = optionCount?.count ?? 0; + const percentage = optionCount?.percentage ?? 0; + const hasResponses = count > 0; + + return ( +
+
+ + + + {option.text} + + + + + + +
+
+
+
+ {hasResponses && ( +
+ )} +
+
+ + {percentage}% ({count}) + +
+
+ ); + })} +
+
+ + {/* Circular Progress */} +
+ {/* Mobile View */} +
+ +
+ {/* Desktop View */} +
+ +
+
+
+
+ + {/* Student Responses Table */} +
+
+ + + + + + + + + + + + + + + {Array.from(latestResponseSets.entries()).map( + ([userId, responses], index) => { + const firstResponse = responses[0]; + const fullName = + `${firstResponse.user.firstName} ${firstResponse.user.lastName ?? ""}`.trim(); + + const studentResult = studentResults.find( + (r) => r.userId === userId, + ); + const isCorrect = studentResult?.isCorrect ?? false; + + const selectedOptions = responses + .map((r) => r.option.text) + .join(", "); + + return ( + + + + + + ); + }, + )} + +
+ Student + + Answer Provided + + Status +
+
+
+ {fullName} +
+
+ {firstResponse.user.email} +
+
+
+
+ {question.type === "MSQ" + ? selectedOptions + : firstResponse.option.text} +
+
+ + {isCorrect ? "Correct" : "Incorrect"} + +
+
+
+
+ ); +} diff --git a/app/dashboard/course/[courseId]/(courseInfo)/questionnaire/page.tsx b/app/dashboard/course/[courseId]/(courseInfo)/questionnaire/page.tsx index 44ef420..c94e175 100644 --- a/app/dashboard/course/[courseId]/(courseInfo)/questionnaire/page.tsx +++ b/app/dashboard/course/[courseId]/(courseInfo)/questionnaire/page.tsx @@ -1,17 +1,18 @@ "use client"; +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; import { AddEditQuestionForm } from "@/components/AddEditQuestionForm"; import { AddInstructorForm } from "@/components/AddInstuctorForm"; import BeginPollDialog from "@/components/BeginPollDialog"; +import PastQuestions from "@/components/ui/PastQuestions"; import SlidingCalendar from "@/components/ui/SlidingCalendar"; import { Button } from "@/components/ui/button"; import { GlobalLoadingSpinner } from "@/components/ui/global-loading-spinner"; import { useToast } from "@/hooks/use-toast"; import { formatDateToISO } from "@/lib/utils"; import { getCourseSessionByDate } from "@/services/session"; -import Link from "next/link"; -import { useParams, useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; export default function Page() { const params = useParams(); @@ -20,6 +21,7 @@ export default function Page() { const [isLoading, setIsLoading] = useState(true); const router = useRouter(); const { toast } = useToast(); + const [selectedDate, setSelectedDate] = useState(new Date()); const [refreshCalendar, setRefreshCalendar] = useState(false); const handleQuestionUpdate = () => { @@ -84,6 +86,7 @@ export default function Page() { onDateChange={setSelectedDate} refreshTrigger={refreshCalendar} /> + ); diff --git a/app/page.tsx b/app/page.tsx index 6fc7faa..083592b 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -13,7 +13,9 @@ export default function Home() { asChild className="w-40 bg-secondary text-primary hover:bg-secondary hover:text-primary" > - Join a Class + + Join a Class + diff --git a/components/ui/PastQuestions.tsx b/components/ui/PastQuestions.tsx new file mode 100644 index 0000000..0d1dffd --- /dev/null +++ b/components/ui/PastQuestions.tsx @@ -0,0 +1,203 @@ +import React, { useEffect, useState } from "react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Question, QuestionType } from "@prisma/client"; +import Link from "next/link"; + +const questionTypeStyles = { + [QuestionType.MCQ]: { + bgColor: "#FFFED3", + textColor: "#58560B", + borderColor: "#58570B", + label: "Multiple Choice", + }, + [QuestionType.MSQ]: { + bgColor: "#EBCFFF", + textColor: "#602E84", + borderColor: "#602E84", + label: "Select-All", + }, +}; + +interface PastQuestion extends Question { + session: { startTime: Date }; + options: { id: number; text: string; isCorrect: boolean }[]; + responses: { + optionId: number; + user: { firstName: string; lastName?: string }; + answeredAt: string; + }[]; +} + +interface Props { + courseId: number; +} + +function PastQuestions({ courseId }: Props) { + const [questions, setQuestions] = useState([]); + const [filterType, setFilterType] = useState("ALL"); + + useEffect(() => { + const fetchPastQuestions = async () => { + try { + const response = await fetch(`/api/courses/${courseId}/past-questions`); + if (!response.ok) throw new Error("Failed to fetch questions"); + const data = await response.json(); + setQuestions(data); + } catch (error) { + console.error("Error fetching past questions:", error); + } + }; + + fetchPastQuestions(); + }, [courseId]); + + const filteredQuestions = questions.filter( + (question) => filterType === "ALL" || question.type === filterType, + ); + + const calculateScore = (question: PastQuestion) => { + if (question.responses.length === 0) return 0; + + const correctOptionIds = question.options + .filter((option) => option.isCorrect) + .map((option) => option.id); + + const userResponses = new Map(); + const userResponseTimes = new Map(); + + question.responses.forEach((response) => { + const userId = response.user.firstName + (response.user.lastName || ""); + const responseTime = response.answeredAt ? new Date(response.answeredAt) : new Date(0); + + if (!userResponseTimes.has(userId)) { + userResponseTimes.set(userId, responseTime); + userResponses.set(userId, [response.optionId]); + } else if (responseTime > userResponseTimes.get(userId)!) { + userResponseTimes.set(userId, responseTime); + userResponses.set(userId, [response.optionId]); + } else if (responseTime.getTime() === userResponseTimes.get(userId)?.getTime()) { + userResponses.get(userId)?.push(response.optionId); + } + }); + + let correctCount = 0; + userResponses.forEach((selectedOptionIds, userId) => { + if (question.type === QuestionType.MSQ) { + const isCorrect = + selectedOptionIds.length === correctOptionIds.length && + correctOptionIds.every((id) => selectedOptionIds.includes(id)); + if (isCorrect) correctCount++; + } else { + const isCorrect = question.options.some( + (opt) => opt.id === selectedOptionIds[0] && opt.isCorrect, + ); + if (isCorrect) correctCount++; + } + }); + + const totalUsers = userResponses.size; + return totalUsers > 0 ? Math.round((correctCount / totalUsers) * 100) : 0; + }; + + const formatDate = (date: Date) => { + return new Date(date).toLocaleDateString("en-US"); + }; + + return ( +
+
+

Past Questions

+
+ +
+ {/* Filter row */} +
+
+ Question Type: + +
+
+ Date +
+
+ Avg. Score +
+
+
+ + {/* Questions table */} +
+ {filteredQuestions.length === 0 ? ( +
No past questions found
+ ) : ( + filteredQuestions.map((question) => ( +
+ {/* Question column */} +
+ + {questionTypeStyles[question.type].label} + +

+ {question.text} +

+
+ + {/* Date column */} +
+ {formatDate(question.session.startTime)} +
+ + {/* Score column */} +
+ {calculateScore(question)}% +
+ + {/* Student Answers column */} +
+ + Student Answers → + +
+
+ )) + )} +
+
+
+ ); +} + +export default PastQuestions; diff --git a/components/ui/StudentTable.tsx b/components/ui/StudentTable.tsx index 88b6e65..8f8a481 100644 --- a/components/ui/StudentTable.tsx +++ b/components/ui/StudentTable.tsx @@ -12,6 +12,7 @@ import { useToast } from "@/hooks/use-toast"; import { getStudents } from "@/services/userCourse"; import { getAllSessionIds } from "@/services/session"; import { getStudentsWithScores } from "@/lib/utils"; +import { GlobalLoadingSpinner } from "./global-loading-spinner"; import LoaderComponent from "./loader"; import { StudentAnalyticsDrawer } from "../StudentAnalyticsDrawer"; diff --git a/components/ui/circular-progress.tsx b/components/ui/circular-progress.tsx new file mode 100644 index 0000000..d745f2e --- /dev/null +++ b/components/ui/circular-progress.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import React from "react"; + +export function CircularProgress({ + value, + size = 160, + thickness = 12, + className, +}: { + value: number; + size?: number; + thickness?: number; + className?: string; +}) { + const radius = (size - thickness) / 2; + const circumference = 2 * Math.PI * radius; + const strokeDashoffset = circumference - (value / 100) * circumference; + + return ( +
+ + 0 ? "#E6F6EC" : "#E5E7EB"} + strokeWidth={thickness} + className="transition-colors duration-300" + /> + + + + + + +
+ Class Average: + {Math.round(value)}% +
+
+ ); +} diff --git a/components/ui/tooltip.tsx b/components/ui/tooltip.tsx index 20d8fbf..d07e1ce 100644 --- a/components/ui/tooltip.tsx +++ b/components/ui/tooltip.tsx @@ -29,4 +29,12 @@ const TooltipContent = React.forwardRef< )); TooltipContent.displayName = TooltipPrimitive.Content.displayName; -export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; +const StringTooltipContainer = ({ text }: { text: string }) => { + return ( +
+ {text} +
+ ); +}; + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider, StringTooltipContainer }; diff --git a/services/course.ts b/services/course.ts index 3f1148f..e1e1a9e 100644 --- a/services/course.ts +++ b/services/course.ts @@ -67,7 +67,7 @@ export async function addCourse( const courseCodes = new Set(courseResponse.map((course) => course.code)); while (i < 100) { // Limit to 100 iterations - Prevent infinite loop with unbounded search - const tempCode = String(Math.round(Math.random() * (1e6 - 1))).padStart(6, '0'); + const tempCode = String(Math.round(Math.random() * (1e6 - 1))).padStart(6, "0"); if (!courseCodes.has(tempCode)) { code = tempCode;