diff --git a/apps/site/pages/dashboard/courseOverview.tsx b/apps/site/pages/dashboard/courseOverview.tsx new file mode 100644 index 000000000..b31c65ce6 --- /dev/null +++ b/apps/site/pages/dashboard/courseOverview.tsx @@ -0,0 +1,41 @@ +import { GetServerSideProps } from "next"; +import { getSession } from "next-auth/react"; +import { EnrollmentWithDetails } from "@self-learning/types"; +import { CourseOverview } from "@self-learning/teaching"; +import { getEnrollmentsOfUserWithCourseDetails } from "@self-learning/api"; + +export default function Start({ enrollments }: { enrollments: EnrollmentWithDetails[] | null }) { + return ; +} + +export const getServerSideProps: GetServerSideProps = async context => { + const session = await getSession(context); + + if (!session || !session.user) { + return { + redirect: { + destination: "/api/auth/signin", + permanent: false + } + }; + } + + const username = session.user.name; + + try { + const enrollments = await getEnrollmentsOfUserWithCourseDetails(username); + + return { + props: { + enrollments + } + }; + } catch (error) { + console.error("Error fetching enrollments:", error); + return { + props: { + enrollments: null + } + }; + } +}; diff --git a/libs/data-access/api/src/index.ts b/libs/data-access/api/src/index.ts index 21e76fc7d..17fad5084 100644 --- a/libs/data-access/api/src/index.ts +++ b/libs/data-access/api/src/index.ts @@ -1,6 +1,7 @@ export * from "./lib/trpc/app.router"; export * from "./lib/trpc/trpc"; export * from "./lib/auth"; +export * from "./lib/trpc/routers/enrollment.router"; // TODO maybe find a better place for those types export type { SkillResolved, SkillUnresolved } from "./lib/trpc/routers/skill.router"; diff --git a/libs/data-access/api/src/lib/trpc/routers/course.router.ts b/libs/data-access/api/src/lib/trpc/routers/course.router.ts index ce79e381d..f24240760 100644 --- a/libs/data-access/api/src/lib/trpc/routers/course.router.ts +++ b/libs/data-access/api/src/lib/trpc/routers/course.router.ts @@ -96,7 +96,7 @@ export const courseRouter = t.router({ } = {}; for (const lesson of lessons) { - lessonMap[lesson.lessonId] = lesson as typeof lessons[0] & { meta: LessonMeta }; + lessonMap[lesson.lessonId] = lesson as (typeof lessons)[0] & { meta: LessonMeta }; } return { content, lessonMap }; diff --git a/libs/data-access/api/src/lib/trpc/routers/enrollment.router.ts b/libs/data-access/api/src/lib/trpc/routers/enrollment.router.ts index 089439ae6..9049e9f6b 100644 --- a/libs/data-access/api/src/lib/trpc/routers/enrollment.router.ts +++ b/libs/data-access/api/src/lib/trpc/routers/enrollment.router.ts @@ -1,8 +1,9 @@ import { database } from "@self-learning/database"; -import { CourseEnrollment } from "@self-learning/types"; +import { CourseEnrollment, EnrollmentWithDetails } from "@self-learning/types"; import { AlreadyExists } from "@self-learning/util/http"; import { z } from "zod"; import { authProcedure, t } from "../trpc"; +import { getCourseCompletionOfStudent } from "@self-learning/completion"; export const enrollmentRouter = t.router({ getEnrollments: authProcedure.query(async ({ ctx }) => { @@ -34,6 +35,54 @@ export const enrollmentRouter = t.router({ }) }); +export async function getEnrollmentsOfUserWithCourseDetails( + username: string +): Promise { + const enrollments = await database.enrollment.findMany({ + where: { username }, + select: { + completedAt: true, + status: true, + lastProgressUpdate: true, + course: { + select: { + title: true, + slug: true, + imgUrl: true, + authors: { + select: { + displayName: true + } + } + } + } + } + }); + + const enrollmentsWithDetails = await Promise.all( + enrollments.map(async enrollment => { + const completions = await getCourseCompletionOfStudent( + enrollment.course.slug, + username + ); + + return { + ...enrollment, + completedAt: enrollment.completedAt ? enrollment.completedAt.toISOString() : null, + lastProgressUpdate: enrollment.lastProgressUpdate.toISOString(), + completions + }; + }) + ); + + enrollmentsWithDetails.sort( + (a, b) => + new Date(b.lastProgressUpdate).getTime() - new Date(a.lastProgressUpdate).getTime() + ); + + return enrollmentsWithDetails as EnrollmentWithDetails[]; +} + export async function getEnrollmentsOfUser(username: string): Promise { const enrollments = await database.enrollment.findMany({ where: { username }, diff --git a/libs/feature/teaching/src/index.ts b/libs/feature/teaching/src/index.ts index 3475ba439..367f8e515 100644 --- a/libs/feature/teaching/src/index.ts +++ b/libs/feature/teaching/src/index.ts @@ -9,3 +9,4 @@ export * from "./lib/lesson/forms/license-form"; export * from "./lib/skills/repository/repository-overview"; export * from "./lib/skills/folder-editor"; export * from "./lib/course/full-course-export"; +export * from "./lib/course/courseOverview"; diff --git a/libs/feature/teaching/src/lib/course/course-export/export-progress-dialog.tsx b/libs/feature/teaching/src/lib/course/course-export/export-progress-dialog.tsx index 67fe97053..7954b46a6 100644 --- a/libs/feature/teaching/src/lib/course/course-export/export-progress-dialog.tsx +++ b/libs/feature/teaching/src/lib/course/course-export/export-progress-dialog.tsx @@ -100,7 +100,7 @@ export function ExportCourseProgressDialog({ // Prevent closing the dialog by clicking on the backdrop onClose={() => {}} > - {progress < 100 && } + {progress < 100 && } {message} No enrollments found; + } + + const inProgress = enrollments.filter( + enrollment => enrollment.completions.courseCompletion.completionPercentage < 100 + ); + const complete = enrollments.filter( + enrollment => enrollment.completions.courseCompletion.completionPercentage >= 100 + ); + + return ( + + {selectedTab === 0 && ( + + )} + {selectedTab === 1 && ( + + )} + + ); +} + +function EnrollmentOverview({ + enrollments, + notFoundMessage +}: { + enrollments: EnrollmentWithDetails[] | null; + notFoundMessage: string; +}) { + if (!enrollments) { + return Keine Kurse Gefunden.; + } + + return ( + + {enrollments.length > 0 ? ( + + {enrollments.map((enrollment, index) => ( + + {enrollment.course.imgUrl && ( + + )} + + + + + + + + {enrollment.course.title} + + + {enrollment.course.authors[0].displayName} + + + + + + + Fortfahren + + + + + + + + + + + + + ))} + + ) : ( + {notFoundMessage} + )} + + ); +} + +function TabContent({ + selectedTab, + setSelectedTab, + enrollments, + notFoundMessage +}: { + selectedTab: number; + setSelectedTab: (v: number) => void; + enrollments: EnrollmentWithDetails[] | null; + notFoundMessage: string; +}) { + return ( + + + setSelectedTab(v)}> + In Bearbeitung + Abgeschlossen + + + + + + + ); +} diff --git a/libs/feature/teaching/src/lib/course/courseOverview/index.ts b/libs/feature/teaching/src/lib/course/courseOverview/index.ts new file mode 100644 index 000000000..e8bcd245e --- /dev/null +++ b/libs/feature/teaching/src/lib/course/courseOverview/index.ts @@ -0,0 +1 @@ +export { CourseOverview } from "./courseOverview"; diff --git a/libs/ui/common/src/lib/progress-bar/progress-bar.tsx b/libs/ui/common/src/lib/progress-bar/progress-bar.tsx index 187374948..1b8a52a8a 100644 --- a/libs/ui/common/src/lib/progress-bar/progress-bar.tsx +++ b/libs/ui/common/src/lib/progress-bar/progress-bar.tsx @@ -1,15 +1,25 @@ -/** - * A reusable progress bar component, which uses the platform's color scheme (emerald). - * @param progress A function which indicates the current progress in percent (0 = not started; 100 = finished) - * @returns A React component to visualize the progress - */ -export function ProgressBar({ progress }: { progress: number }) { +import { motion } from "framer-motion"; +import React from "react"; + +export function ProgressBar({ completionPercentage }: { completionPercentage: number }) { return ( - + + + className={`absolute top-0 w-full px-2 text-start text-sm font-semibold ${ + completionPercentage === 0 ? "text-secondary" : "text-white" + }`} + style={{ justifyContent: "flex-start" }} + > + {completionPercentage}% + ); } diff --git a/libs/ui/forms/src/lib/upload-progress-dialog.tsx b/libs/ui/forms/src/lib/upload-progress-dialog.tsx index a430aa151..38847e481 100644 --- a/libs/ui/forms/src/lib/upload-progress-dialog.tsx +++ b/libs/ui/forms/src/lib/upload-progress-dialog.tsx @@ -18,7 +18,7 @@ export function UploadProgressDialog({ name, progress }: { name: string; progres // This makes the dialog modal (i.e. the user cannot click outside of the dialog to close it) onClose={() => {}} > - + ); diff --git a/libs/ui/lesson/src/lib/playlist/playlist.tsx b/libs/ui/lesson/src/lib/playlist/playlist.tsx index f68602e15..84b8476fe 100644 --- a/libs/ui/lesson/src/lib/playlist/playlist.tsx +++ b/libs/ui/lesson/src/lib/playlist/playlist.tsx @@ -6,7 +6,7 @@ import { PlayIcon } from "@heroicons/react/24/solid"; import { CourseCompletion, extractLessonIds, LessonMeta } from "@self-learning/types"; -import { Divider } from "@self-learning/ui/common"; +import { Divider, ProgressBar } from "@self-learning/ui/common"; import { motion } from "framer-motion"; import Link from "next/link"; import { useRouter } from "next/router"; @@ -174,21 +174,8 @@ function PlaylistHeader({ content, course, lesson, completion }: PlaylistProps) {extractLessonIds(content).length} Lerneinheiten abgeschlossen - - - - {completionPercentage}% - - + + @@ -241,18 +228,16 @@ function CurrentlyPlaying({ lesson, content, course }: PlaylistProps) { - {lesson.meta.hasQuiz && ( - - - {router.pathname.endsWith("quiz") ? "Zum Lernhinhalt" : "Zur Lernkontrolle"} - + + {router.pathname.endsWith("quiz") ? "Zum Lernhinhalt" : "Zur Lernkontrolle"} + )} diff --git a/libs/util/types/src/lib/course.ts b/libs/util/types/src/lib/course.ts index 6d1850e76..81952a2ef 100644 --- a/libs/util/types/src/lib/course.ts +++ b/libs/util/types/src/lib/course.ts @@ -19,6 +19,23 @@ export type CourseCompletion = { completedLessons: CompletedLessonsMap; }; +export type EnrollmentWithDetails = { + completedAt: string | null; + status: EnrollmentStatus; + lastProgressUpdate: string; + course: { + title: string; + slug: string; + imgUrl: string | null; + authors: { displayName: string }[]; + }; + completions: { + courseCompletion: { + completionPercentage: number; + }; + }; +}; + export type CourseEnrollment = { completedAt: Date | null; status: EnrollmentStatus;
Keine Kurse Gefunden.
{notFoundMessage}