Skip to content
101 changes: 101 additions & 0 deletions app/api/export/[courseId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { Role } from "@prisma/client";
import { parse } from "json2csv";
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { csvAdvancedFieldNames, csvBasicFieldNames } from "@/lib/constants";
import prisma from "@/lib/prisma";
import { ExportCSVType } from "@/types/ExportCSVType";

export async function GET(req: NextRequest, context: { params: Promise<{ courseId: string }> }) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const { courseId } = await context.params;

if (!courseId || Number.isNaN(+courseId)) {
return NextResponse.json({ error: "Course Id is required" }, { status: 403 });
}

const courseLecturers = await prisma.userCourse.findMany({
where: {
courseId: +courseId,
role: Role.LECTURER,
},
select: {
userId: true,
},
});

if (!courseLecturers.find((lecturer) => lecturer.userId === session.user.id)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const url = new URL(req.url);
const modeParam = url.searchParams.get("mode");
const mode: ExportCSVType =
modeParam === null || Number.isNaN(modeParam) ? ExportCSVType.BASIC : +modeParam;

const responses = await prisma.response.findMany({
where: { question: { session: { courseId: +courseId } } },
include: {
user: true,
question: {
include: {
session: true,
},
},
option: true,
},
});

console.log(responses);

const userQuestionMap = new Map<string, Set<number>>();
const advancedRows = [];

for (const res of responses) {
const email = res.user.email ?? "[unknown]";
const sessionDate = res.question.session.startTime.toDateString();
const questionId = res.question.id;

const setKey = email + "--" + sessionDate;

if (!userQuestionMap.has(setKey)) {
userQuestionMap.set(setKey, new Set());
}

userQuestionMap.get(setKey)?.add(questionId);

advancedRows.push({
email,
question: res.question.text,
answer: res.option.text,
is_correct: res.option.isCorrect,
date_of_session: sessionDate,
});
}

const basicRows = Array.from(userQuestionMap.entries()).map(([setKey, questionSet]) => {
const [email, sessionDate] = setKey.split("--");
return {
email,
num_questions_answered: questionSet.size,
date_of_session: sessionDate,
};
});

const csv =
mode === ExportCSVType.ADVANCED
? parse(advancedRows, { fields: csvAdvancedFieldNames })
: parse(basicRows, { fields: csvBasicFieldNames });

return new NextResponse(csv, {
headers: {
"Content-Type": "text/csv",
"Content-Disposition": `attachment; filename=sessions_export_${mode === ExportCSVType.ADVANCED ? "advanced" : "basic"}.csv`,
},
});
}
22 changes: 18 additions & 4 deletions app/dashboard/course/[courseId]/(courseInfo)/analytics/page.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,33 @@
"use client";

import { useParams } from "next/navigation";
import React, { useState } from "react";
import { useState } from "react";
import { ExportCSVDropdown } from "@/components/ExportCSVDropdown";
import AttendanceLineChart from "@/components/ui/AttendanceLineChart";
import PerformanceData from "@/components/ui/PerformanceData";
import StudentTable from "@/components/ui/StudentTable";
import { toast } from "@/hooks/use-toast";
import { analyticsPages } from "@/lib/constants";
import { ExportCSVType } from "@/types/ExportCSVType";

export default function Page() {
const params = useParams();
const courseId = parseInt((params.courseId as string) ?? "0");
const [page, setPage] = useState<string>("Performance");

const downloadCsv = (mode: ExportCSVType) => {
try {
const downloadUrl = `/api/export/${courseId}?mode=${mode}`;
window.open(downloadUrl, "_blank");
} catch (err) {
toast({
variant: "destructive",
description: "Export failed.",
});
console.error("Export error:", err);
}
};

return (
<div className="w-full flex flex-col">
<div className="flex flex-row gap-2 bg-slate-200 h-fit w-fit p-1 rounded-md mb-4">
Expand Down Expand Up @@ -41,9 +57,7 @@ export default function Page() {
</div>
<div className="flex flex-row justify-between mt-8 pl-1">
<h1 className="text-2xl font-normal">Student Data</h1>
<button className="h-10 w-40 px-3 bg-[hsl(var(--primary))] text-white rounded-lg focus:outline-none">
Export CSV
</button>
<ExportCSVDropdown onSelect={downloadCsv} />
</div>
{/* Student Data Table */}
<StudentTable courseId={courseId} />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
"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";
Expand All @@ -12,6 +9,9 @@ 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();
Expand All @@ -20,6 +20,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 = () => {
setRefreshCalendar((prev) => !prev);
Expand Down Expand Up @@ -77,7 +78,12 @@ export default function Page() {
)}
</div>
</section>
<SlidingCalendar courseId={courseId} refreshTrigger={refreshCalendar} />
<SlidingCalendar
courseId={courseId}
selectedDate={selectedDate}
onDateChange={setSelectedDate}
refreshTrigger={refreshCalendar}
/>
<AddInstructorForm />
</div>
);
Expand Down
50 changes: 50 additions & 0 deletions components/ExportCSVDropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { JSX } from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ExportCSVType } from "@/types/ExportCSVType";

interface ExportCSVDropdownProps {
onSelect: (selectedType: ExportCSVType) => void;
label?: JSX.Element | string;
}

export function ExportCSVDropdown({ onSelect, label }: ExportCSVDropdownProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
{label ?? (
<button className="h-10 w-40 px-3 bg-primary text-white rounded-lg focus:outline-none">
Export CSV
</button>
)}
</DropdownMenuTrigger>
<DropdownMenuContent className="w-40">
<DropdownMenuLabel>CSV Type</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
onSelect={() => {
onSelect(ExportCSVType.BASIC);
}}
>
Basic CSV
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
onSelect(ExportCSVType.ADVANCED);
}}
>
Advanced CSV
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
24 changes: 19 additions & 5 deletions components/ui/SlidingCalendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,17 @@ import { formatDateToISO } from "@/lib/utils";

interface Props {
courseId: number;
selectedDate?: Date;
onDateChange?: (date: Date) => void;
refreshTrigger?: boolean;
}

function SlidingCalendar({ courseId, refreshTrigger }: Props) {
function SlidingCalendar({
courseId,
selectedDate: selectedDateProp,
onDateChange,
refreshTrigger,
}: Props) {
const [startDate, setStartDate] = useState<Dayjs>(dayjs().startOf("week"));
const [selectedDate, setSelectedDate] = useState<Dayjs>(dayjs());
const [questions, setQuestions] = useState<
Expand Down Expand Up @@ -62,10 +69,12 @@ function SlidingCalendar({ courseId, refreshTrigger }: Props) {
};

useEffect(() => {
if (selectedDate) {
fetchQuestions(selectedDate.toDate());
const newDate = selectedDateProp ? dayjs(selectedDateProp) : selectedDate;
setSelectedDate(newDate);
if (newDate) {
fetchQuestions(newDate.toDate());
}
}, [selectedDate, refreshTrigger]);
}, [selectedDateProp, refreshTrigger]);

// fetch incorrect and correct options of selected question
useEffect(() => {
Expand Down Expand Up @@ -157,7 +166,12 @@ function SlidingCalendar({ courseId, refreshTrigger }: Props) {
? "bg-[#18328D] text-white"
: "bg-white text-black"
}`}
onClick={() => setSelectedDate(date)}
onClick={() => {
setSelectedDate(date);
if (onDateChange) {
onDateChange(date.toDate());
}
}}
>
<span
className={`text-xs sm:text-lg font-normal ${
Expand Down
9 changes: 9 additions & 0 deletions lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,12 @@ export type Student = {
firstName: string;
lastName: string | null;
};

export const csvBasicFieldNames = ["email", "num_questions_answered", "date_of_session"];
export const csvAdvancedFieldNames = [
"email",
"question",
"answer",
"is_correct",
"date_of_session",
];
Loading