From 2de3f9bfd2136c28ec5a19454e0eb3c66adde2e4 Mon Sep 17 00:00:00 2001 From: Son Ahyun Date: Sat, 1 Nov 2025 16:31:01 +0900 Subject: [PATCH 01/19] =?UTF-8?q?=E2=9C=A8=20(#363)=20=ED=80=B4=EC=A6=88?= =?UTF-8?q?=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20=EB=8C=80=EC=8B=9C?= =?UTF-8?q?=EB=B3=B4=EB=93=9C=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/api/quizzes/fetchQuizForDashboard.ts | 19 +++ .../DashboardContainer/DashboardContainer.tsx | 137 ++++-------------- frontend/constants/endpoints.ts | 2 + .../quizzes/fetchQuizForDashboardTypes.ts | 34 +++++ 4 files changed, 87 insertions(+), 105 deletions(-) create mode 100644 frontend/api/quizzes/fetchQuizForDashboard.ts create mode 100644 frontend/types/quizzes/fetchQuizForDashboardTypes.ts diff --git a/frontend/api/quizzes/fetchQuizForDashboard.ts b/frontend/api/quizzes/fetchQuizForDashboard.ts new file mode 100644 index 00000000..626ae3f2 --- /dev/null +++ b/frontend/api/quizzes/fetchQuizForDashboard.ts @@ -0,0 +1,19 @@ +import axios from "axios"; +import { axiosInstance } from "@/api/axiosInstance"; +import { ENDPOINTS } from "@/constants/endpoints"; +import { ApiResponse } from "@/types/apiResponseTypes"; +import { fetchQuizForDashboardResult } from "@/types/quizzes/fetchQuizForDashboardTypes"; + +export async function fetchQuizForDashboard(lectureId: string) { + try { + const response = await axiosInstance.get< + ApiResponse + >(ENDPOINTS.QUIZZES.GET_FOR_DASHBOARD(lectureId)); + return response.data; + } catch (error: unknown) { + if (axios.isAxiosError(error) && error.response) { + return error.response.data as ApiResponse; + } + throw error; + } +} diff --git a/frontend/app/teacher/quiz-dashboard/[lectureId]/_components/DashboardContainer/DashboardContainer.tsx b/frontend/app/teacher/quiz-dashboard/[lectureId]/_components/DashboardContainer/DashboardContainer.tsx index aadaefd3..121465ad 100644 --- a/frontend/app/teacher/quiz-dashboard/[lectureId]/_components/DashboardContainer/DashboardContainer.tsx +++ b/frontend/app/teacher/quiz-dashboard/[lectureId]/_components/DashboardContainer/DashboardContainer.tsx @@ -1,113 +1,32 @@ "use client"; -import React from "react"; + +import React, { useEffect, useState } from "react"; import styles from "./DashboardContainer.module.scss"; import QuizInfo from "../QuizInfo/QuizInfo"; import QuizSubmitList from "../QuizSubmitList/QuizSubmitList"; import QuizList from "../QuizList/QuizList"; import StatisticsContainer from "../StatisticsContainer/StatisticsContainer"; - -type Quiz = - | { - quizId: string; - quizOrder: number; - type: "multipleChoice"; - quizBody: string; - correctRate: number; - solution: string; - options: Array<{ optionOrder: number; option: string; count: number }>; - } - | { - quizId: string; - quizOrder: number; - type: "trueFalse"; - quizBody: string; - correctRate: number; - solution: string; - options: Array<{ optionOrder: null; option: string; count: number }>; - } - | { - quizId: string; - quizOrder: number; - type: "shortAnswer"; - quizBody: string; - correctRate: number; - solution: string; - count: number; - }; +import { fetchQuizForDashboardResult } from "@/types/quizzes/fetchQuizForDashboardTypes"; +import { fetchQuizForDashboard } from "@/api/quizzes/fetchQuizForDashboard"; +import { useParams } from "next/navigation"; export default function DashboardContainer() { - const statData: { - totalQuizCount: number; - averageCorrectRate: number; - quizList: Quiz[]; - } = { - totalQuizCount: 4, - averageCorrectRate: 57.5, - quizList: [ - { - quizId: "qz-001", - quizOrder: 1, - type: "multipleChoice", - quizBody: "앙상블 학습의 주요 목적 중 하나로 올바른 설명을 고르세요.", - correctRate: 70.0, - solution: "여러 모델을 결합해 오류를 줄이기 위해", - options: [ - { - optionOrder: 1, - option: "여러 모델을 결합해 오류를 줄이기 위해", - count: 7, - }, - { - optionOrder: 2, - option: "하나의 모델 성능을 극단적으로 향상시키기 위해", - count: 2, - }, - { - optionOrder: 3, - option: "모델의 학습 속도를 높이기 위해", - count: 0, - }, - { - optionOrder: 4, - option: "데이터를 줄여 모델을 간소화하기 위해", - count: 1, - }, - ], - }, - { - quizId: "qz-002", - quizOrder: 2, - type: "trueFalse", - quizBody: "Random Forest는 개별 트리의 가지치기를 수행하지 않는다.", - correctRate: 80.0, - solution: "O", - options: [ - { optionOrder: null, option: "O", count: 8 }, - { optionOrder: null, option: "X", count: 2 }, - ], - }, - { - quizId: "qz-003", - quizOrder: 3, - type: "shortAnswer", - quizBody: - "앙상블 기법 중 여러 모델이 각자 예측한 결과를 다수결로 결정하는 방법은 무엇인가요?", - correctRate: 50.0, - solution: "배깅", - count: 5, - }, - { - quizId: "qz-004", - quizOrder: 4, - type: "shortAnswer", - quizBody: - "Random Forest에서 개별 변수의 중요도를 평가할 때 사용하는 데이터 샘플링 기법은 무엇인가요?", - correctRate: 30.0, - solution: "복원 추출", - count: 3, - }, - ], - }; + const [statData, setStatData] = useState( + null + ); + const { lectureId } = useParams<{ lectureId: string }>(); + + useEffect(() => { + if (!lectureId) return; + fetchQuizForDashboard(lectureId).then((res) => { + if (res.isSuccess && res.result) { + setStatData(res.result); + } else { + setStatData(null); + } + }); + }, [lectureId]); + return (
@@ -115,11 +34,19 @@ export default function DashboardContainer() {
- +
); diff --git a/frontend/constants/endpoints.ts b/frontend/constants/endpoints.ts index 963b1d19..bfb1d2ab 100644 --- a/frontend/constants/endpoints.ts +++ b/frontend/constants/endpoints.ts @@ -111,6 +111,8 @@ export const ENDPOINTS = { SAVE: (lectureId: string) => `${BASE_API}/quizzes/${lectureId}/save`, UPDATE: (lectureId: string) => `${BASE_API}/quizzes/${lectureId}`, GET: (lectureId: string) => `${BASE_API}/quizzes/${lectureId}`, + GET_FOR_DASHBOARD: (lectureId: string) => + `${BASE_API}/quizzes/${lectureId}/result`, SUBMIT: `${BASE_API}/quizzes/submit`, GET_RESULT: (lectureId: string) => `${BASE_API}/quizzes/${lectureId}/result/student`, diff --git a/frontend/types/quizzes/fetchQuizForDashboardTypes.ts b/frontend/types/quizzes/fetchQuizForDashboardTypes.ts new file mode 100644 index 00000000..4061eab9 --- /dev/null +++ b/frontend/types/quizzes/fetchQuizForDashboardTypes.ts @@ -0,0 +1,34 @@ +export interface fetchQuizForDashboardResult { + totalQuizCount: number; + averageCorrectRate: number; + quizList: Quiz[]; +} + +type Quiz = + | { + quizId: string; + quizOrder: number; + type: "multipleChoice"; + quizBody: string; + correctRate: number; + solution: string; + options: Array<{ optionOrder: number; option: string; count: number }>; + } + | { + quizId: string; + quizOrder: number; + type: "trueFalse"; + quizBody: string; + correctRate: number; + solution: string; + options: Array<{ optionOrder: null; option: string; count: number }>; + } + | { + quizId: string; + quizOrder: number; + type: "shortAnswer"; + quizBody: string; + correctRate: number; + solution: string; + count: number; + }; From 119ed40774e05a3c1f73d806b3b5dcb2626efc16 Mon Sep 17 00:00:00 2001 From: Son Ahyun Date: Sat, 1 Nov 2025 16:41:55 +0900 Subject: [PATCH 02/19] =?UTF-8?q?=E2=9C=A8=20(#363)=20=ED=80=B4=EC=A6=88?= =?UTF-8?q?=20=EC=A0=95=EB=B3=B4=20=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0=20?= =?UTF-8?q?api=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/api/quizzes/fetchQuizInfo.ts | 19 ++++++++++++ .../_components/QuizInfo/QuizInfo.tsx | 29 +++++++++++++------ frontend/constants/endpoints.ts | 2 ++ frontend/types/quizzes/fetchQuizInfoTypes.ts | 5 ++++ 4 files changed, 46 insertions(+), 9 deletions(-) create mode 100644 frontend/api/quizzes/fetchQuizInfo.ts create mode 100644 frontend/types/quizzes/fetchQuizInfoTypes.ts diff --git a/frontend/api/quizzes/fetchQuizInfo.ts b/frontend/api/quizzes/fetchQuizInfo.ts new file mode 100644 index 00000000..9c0cff5b --- /dev/null +++ b/frontend/api/quizzes/fetchQuizInfo.ts @@ -0,0 +1,19 @@ +import axios from "axios"; +import { axiosInstance } from "@/api/axiosInstance"; +import { ENDPOINTS } from "@/constants/endpoints"; +import { ApiResponse } from "@/types/apiResponseTypes"; +import { fetchQuizInfoResult } from "@/types/quizzes/fetchQuizInfoTypes"; + +export async function fetchQuizInfo(lectureId: string) { + try { + const response = await axiosInstance.get>( + ENDPOINTS.QUIZZES.GET_INFO(lectureId) + ); + return response.data; + } catch (error: unknown) { + if (axios.isAxiosError(error) && error.response) { + return error.response.data as ApiResponse; + } + throw error; + } +} diff --git a/frontend/app/teacher/quiz-dashboard/[lectureId]/_components/QuizInfo/QuizInfo.tsx b/frontend/app/teacher/quiz-dashboard/[lectureId]/_components/QuizInfo/QuizInfo.tsx index 67837813..961dfed1 100644 --- a/frontend/app/teacher/quiz-dashboard/[lectureId]/_components/QuizInfo/QuizInfo.tsx +++ b/frontend/app/teacher/quiz-dashboard/[lectureId]/_components/QuizInfo/QuizInfo.tsx @@ -1,12 +1,9 @@ "use client"; -import React from "react"; +import React, { useEffect, useState } from "react"; import styles from "./QuizInfo.module.scss"; - -const data = { - title: "Ensemble 1", - quizDate: "2025-06-03", - quizDay: "화", -}; +import { fetchQuizInfo } from "@/api/quizzes/fetchQuizInfo"; +import { useParams } from "next/navigation"; +import { fetchQuizInfoResult } from "@/types/quizzes/fetchQuizInfoTypes"; function formatDate(date: string, day: string) { const [yyyy, mm, dd] = date.split("-"); @@ -14,13 +11,27 @@ function formatDate(date: string, day: string) { } export default function QuizInfo() { + const { lectureId } = useParams<{ lectureId: string }>(); + const [data, setData] = useState(null); + + useEffect(() => { + if (!lectureId) return; + fetchQuizInfo(lectureId).then((res) => { + if (res.isSuccess && res.result) { + setData(res.result); + } else { + setData(null); + } + }); + }, [lectureId]); + return (
- [{data.title}] 퀴즈 대시보드 + [{data?.title}] 퀴즈 대시보드
- {formatDate(data.quizDate, data.quizDay)} + {formatDate(data?.quizDate || "", data?.quizDay || "")}
); diff --git a/frontend/constants/endpoints.ts b/frontend/constants/endpoints.ts index bfb1d2ab..dbf914db 100644 --- a/frontend/constants/endpoints.ts +++ b/frontend/constants/endpoints.ts @@ -105,6 +105,8 @@ export const ENDPOINTS = { // 퀴즈 관련 QUIZZES: { + GET_INFO: (lectureId: string) => + `${BASE_API}/quizzes/${lectureId}/result/info`, CREATE: (lectureId: string) => `${BASE_API}/quizzes/${lectureId}/create`, RECREATE: (lectureId: string) => `${BASE_API}/quizzes/${lectureId}/re-create`, diff --git a/frontend/types/quizzes/fetchQuizInfoTypes.ts b/frontend/types/quizzes/fetchQuizInfoTypes.ts new file mode 100644 index 00000000..e5a33157 --- /dev/null +++ b/frontend/types/quizzes/fetchQuizInfoTypes.ts @@ -0,0 +1,5 @@ +export interface fetchQuizInfoResult { + title: string; + quizDate: string; + quizDay: string; +} From 723a3ad0e49ae70981298591bf821f970a7bc9ff Mon Sep 17 00:00:00 2001 From: Son Ahyun Date: Sat, 1 Nov 2025 16:47:28 +0900 Subject: [PATCH 03/19] =?UTF-8?q?=E2=9C=A8=20(#363)=20=EC=A0=9C=EC=B6=9C?= =?UTF-8?q?=20=ED=95=99=EC=83=9D=20=EB=AA=85=EB=8B=A8=20=EB=B6=88=EB=9F=AC?= =?UTF-8?q?=EC=98=A4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/api/quizzes/fetchSubmitList.ts | 19 +++++ .../QuizSubmitList/QuizSubmitList.tsx | 78 ++++++++++--------- frontend/constants/endpoints.ts | 2 + .../types/quizzes/fetchSubmitListTypes.ts | 7 ++ 4 files changed, 69 insertions(+), 37 deletions(-) create mode 100644 frontend/api/quizzes/fetchSubmitList.ts create mode 100644 frontend/types/quizzes/fetchSubmitListTypes.ts diff --git a/frontend/api/quizzes/fetchSubmitList.ts b/frontend/api/quizzes/fetchSubmitList.ts new file mode 100644 index 00000000..6ea8c876 --- /dev/null +++ b/frontend/api/quizzes/fetchSubmitList.ts @@ -0,0 +1,19 @@ +import axios from "axios"; +import { axiosInstance } from "@/api/axiosInstance"; +import { ENDPOINTS } from "@/constants/endpoints"; +import { ApiResponse } from "@/types/apiResponseTypes"; +import { fetchQuizSubmitListResult } from "@/types/quizzes/fetchSubmitListTypes"; + +export async function fetchSubmitList(lectureId: string) { + try { + const response = await axiosInstance.get< + ApiResponse + >(ENDPOINTS.QUIZZES.GET_SUBMIT_LIST(lectureId)); + return response.data; + } catch (error: unknown) { + if (axios.isAxiosError(error) && error.response) { + return error.response.data as ApiResponse; + } + throw error; + } +} diff --git a/frontend/app/teacher/quiz-dashboard/[lectureId]/_components/QuizSubmitList/QuizSubmitList.tsx b/frontend/app/teacher/quiz-dashboard/[lectureId]/_components/QuizSubmitList/QuizSubmitList.tsx index ad4b9086..9b51b660 100644 --- a/frontend/app/teacher/quiz-dashboard/[lectureId]/_components/QuizSubmitList/QuizSubmitList.tsx +++ b/frontend/app/teacher/quiz-dashboard/[lectureId]/_components/QuizSubmitList/QuizSubmitList.tsx @@ -1,22 +1,9 @@ "use client"; -import React from "react"; +import React, { useEffect, useState } from "react"; import styles from "./QuizSubmitList.module.scss"; - -const data = { - submitNum: 10, - studentList: [ - { name: "김클로", submitDate: "2025-06-03T15:23:00" }, - { name: "강백호", submitDate: "2025-06-03T15:23:50" }, - { name: "로하스", submitDate: "2025-06-03T15:26:45" }, - { name: "허경민", submitDate: "2025-06-03T15:29:23" }, - { name: "김민혁", submitDate: "2025-06-03T16:23:05" }, - { name: "장성우", submitDate: "2025-06-03T16:20:59" }, - { name: "천성호", submitDate: "2025-06-03T16:33:05" }, - { name: "배정대", submitDate: "2025-06-03T16:52:08" }, - { name: "김상수", submitDate: "2025-06-03T17:23:00" }, - { name: "윤준혁", submitDate: "2025-06-03T15:23:42" }, - ], -}; +import { fetchQuizSubmitListResult } from "@/types/quizzes/fetchSubmitListTypes"; +import { fetchSubmitList } from "@/api/quizzes/fetchSubmitList"; +import { useParams } from "next/navigation"; function formatDate(dateStr: string) { const d = new Date(dateStr); @@ -29,29 +16,46 @@ function formatDate(dateStr: string) { } export default function QuizSubmitList() { + const [data, setData] = useState(null); + const { lectureId } = useParams<{ lectureId: string }>(); + useEffect(() => { + if (!lectureId) return; + fetchSubmitList(lectureId).then((res) => { + if (res.isSuccess && res.result) { + setData(res.result); + } else { + setData(null); + } + }); + }, [lectureId]); + return (
-
- 퀴즈 제출 명단 - 응답자 총 {data.submitNum}명 -
-
- {data.studentList.map((student, idx) => ( -
- {student.name} - - {formatDate(student.submitDate)} - + {data && ( + <> +
+ 퀴즈 제출 명단 + 응답자 총 {data.submitNum}명 +
+
+ {data.studentList.map((student, idx) => ( +
+ {student.name} + + {formatDate(student.submitDate)} + +
+ ))}
- ))} -
+ + )}
); } diff --git a/frontend/constants/endpoints.ts b/frontend/constants/endpoints.ts index dbf914db..6fb1f350 100644 --- a/frontend/constants/endpoints.ts +++ b/frontend/constants/endpoints.ts @@ -105,6 +105,8 @@ export const ENDPOINTS = { // 퀴즈 관련 QUIZZES: { + GET_SUBMIT_LIST: (lectureId: string) => + `${BASE_API}/quizzes/${lectureId}/result/list`, GET_INFO: (lectureId: string) => `${BASE_API}/quizzes/${lectureId}/result/info`, CREATE: (lectureId: string) => `${BASE_API}/quizzes/${lectureId}/create`, diff --git a/frontend/types/quizzes/fetchSubmitListTypes.ts b/frontend/types/quizzes/fetchSubmitListTypes.ts new file mode 100644 index 00000000..78a1fad6 --- /dev/null +++ b/frontend/types/quizzes/fetchSubmitListTypes.ts @@ -0,0 +1,7 @@ +export interface fetchQuizSubmitListResult { + submitNum: number; + studentList: { + name: string; + submitDate: string; + }[]; +} From fb53c041d673c8bd2721877d4f326e344506f7ec Mon Sep 17 00:00:00 2001 From: Son Ahyun Date: Sat, 1 Nov 2025 16:48:43 +0900 Subject: [PATCH 04/19] =?UTF-8?q?=F0=9F=94=A8=20(#363)=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=ED=8C=A8=EC=B9=98=20=ED=9B=84=20=EB=A0=8C?= =?UTF-8?q?=EB=8D=94=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_components/QuizInfo/QuizInfo.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/frontend/app/teacher/quiz-dashboard/[lectureId]/_components/QuizInfo/QuizInfo.tsx b/frontend/app/teacher/quiz-dashboard/[lectureId]/_components/QuizInfo/QuizInfo.tsx index 961dfed1..6a253dc8 100644 --- a/frontend/app/teacher/quiz-dashboard/[lectureId]/_components/QuizInfo/QuizInfo.tsx +++ b/frontend/app/teacher/quiz-dashboard/[lectureId]/_components/QuizInfo/QuizInfo.tsx @@ -27,12 +27,17 @@ export default function QuizInfo() { return (
-
- [{data?.title}] 퀴즈 대시보드 -
-
- {formatDate(data?.quizDate || "", data?.quizDay || "")} -
+ {data && ( + <> +
+ [{data.title}] + 퀴즈 대시보드 +
+
+ {formatDate(data.quizDate, data.quizDay)} +
+ + )}
); } From 655c7ccc768d6fcbe733ded2dcff72b2c7be0ec7 Mon Sep 17 00:00:00 2001 From: Son Ahyun Date: Sat, 1 Nov 2025 17:00:33 +0900 Subject: [PATCH 05/19] =?UTF-8?q?=F0=9F=90=9B=20(#363)=20hydration=20error?= =?UTF-8?q?=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/app/teacher/layout.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/frontend/app/teacher/layout.tsx b/frontend/app/teacher/layout.tsx index fec58270..2c1fa8c3 100644 --- a/frontend/app/teacher/layout.tsx +++ b/frontend/app/teacher/layout.tsx @@ -44,12 +44,16 @@ export default function TeacherLayout({ const showSidebar = currentRoute?.sidebarType === SiderbarType.DEFAULT; const showHeader = currentRoute?.headerType !== TeacherHeaderType.NONE; + const bodyClassName = [ + "teacher-body", + showSidebar && "show-sidebar", + showHeader && "show-header", + ] + .filter(Boolean) + .join(" "); + return ( - + {showSidebar && } {renderHeader()}
{children}
From bcf8849da2367068454632476425c6878fd8f2a2 Mon Sep 17 00:00:00 2001 From: Son Ahyun Date: Sat, 1 Nov 2025 17:11:21 +0900 Subject: [PATCH 06/19] =?UTF-8?q?=E2=9C=A8=20(#363)=20=ED=80=B4=EC=A6=88?= =?UTF-8?q?=20=EC=83=81=EC=84=B8=20=20=ED=86=B5=EA=B3=84=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/api/quizzes/fetchQuizDetailStat.ts | 19 +++++ frontend/app/layout.tsx | 55 ++++++++----- .../AverageCorrectRate/AverageCorrectRate.tsx | 45 +++++++---- .../QuizDetailChart/QuizDetailChart.tsx | 39 +++++---- .../StatisticsContainer.tsx | 79 +++++++------------ .../quiz-dashboard/[lectureId]/page.tsx | 1 + frontend/constants/endpoints.ts | 2 + .../types/quizzes/fetchQuizDetailStatTypes.ts | 35 ++++++++ 8 files changed, 175 insertions(+), 100 deletions(-) create mode 100644 frontend/api/quizzes/fetchQuizDetailStat.ts create mode 100644 frontend/types/quizzes/fetchQuizDetailStatTypes.ts diff --git a/frontend/api/quizzes/fetchQuizDetailStat.ts b/frontend/api/quizzes/fetchQuizDetailStat.ts new file mode 100644 index 00000000..d239f7eb --- /dev/null +++ b/frontend/api/quizzes/fetchQuizDetailStat.ts @@ -0,0 +1,19 @@ +import axios from "axios"; +import { axiosInstance } from "@/api/axiosInstance"; +import { ENDPOINTS } from "@/constants/endpoints"; +import { ApiResponse } from "@/types/apiResponseTypes"; +import { fetchQuizDetailStatResult } from "@/types/quizzes/fetchQuizDetailStatTypes"; + +export async function fetchQuizDetailStat(lectureId: string) { + try { + const response = await axiosInstance.get< + ApiResponse + >(ENDPOINTS.QUIZZES.GET_DETAIL_STAT(lectureId)); + return response.data; + } catch (error: unknown) { + if (axios.isAxiosError(error) && error.response) { + return error.response.data as ApiResponse; + } + throw error; + } +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index b9106c8a..100a80cb 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,4 +1,40 @@ import "./globals.scss"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "ClassLog", + manifest: "./manifest.webmanifest", + themeColor: "#ffffff", + appleWebApp: { + capable: true, + title: "ClassLog", + statusBarStyle: "default", + }, + icons: { + apple: [ + { + url: "/favicon/apple-touch-icon.png", + sizes: "180x180", + type: "image/png", + }, + ], + icon: [ + { + url: "/favicon/favicon-96x96.png", + sizes: "96x96", + type: "image/png", + }, + { + url: "/favicon/favicon.svg", + type: "image/svg+xml", + }, + { + url: "/favicon/favicon.ico", + type: "image/x-icon", + }, + ], + }, +}; export default function RootLayout({ children, @@ -7,25 +43,6 @@ export default function RootLayout({ }>) { return ( - - - ClassLog - - - - - - - {children} ); diff --git a/frontend/app/teacher/quiz-dashboard/[lectureId]/_components/StatisticsContainer/AverageCorrectRate/AverageCorrectRate.tsx b/frontend/app/teacher/quiz-dashboard/[lectureId]/_components/StatisticsContainer/AverageCorrectRate/AverageCorrectRate.tsx index 2cc07a4d..9211ebab 100644 --- a/frontend/app/teacher/quiz-dashboard/[lectureId]/_components/StatisticsContainer/AverageCorrectRate/AverageCorrectRate.tsx +++ b/frontend/app/teacher/quiz-dashboard/[lectureId]/_components/StatisticsContainer/AverageCorrectRate/AverageCorrectRate.tsx @@ -1,5 +1,5 @@ "use client"; -import React from "react"; +import React, { useEffect, useState } from "react"; import { PieChart, Pie, Cell } from "recharts"; import styles from "./AverageCorrectRate.module.scss"; @@ -14,29 +14,40 @@ export default function AverageCorrectRate({ averageCorrectRate, totalQuizCount, }: AverageCorrectRateProps) { + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + const data = [ { name: "정답률", value: averageCorrectRate }, { name: "오답률", value: 100 - averageCorrectRate }, ]; + return (
- - - {data.map((entry, idx) => ( - - ))} - - + {mounted ? ( + + + {data.map((entry, idx) => ( + + ))} + + + ) : ( +
+ )}
{averageCorrectRate}%
diff --git a/frontend/app/teacher/quiz-dashboard/[lectureId]/_components/StatisticsContainer/QuizDetailChart/QuizDetailChart.tsx b/frontend/app/teacher/quiz-dashboard/[lectureId]/_components/StatisticsContainer/QuizDetailChart/QuizDetailChart.tsx index 05818c7f..416cc84d 100644 --- a/frontend/app/teacher/quiz-dashboard/[lectureId]/_components/StatisticsContainer/QuizDetailChart/QuizDetailChart.tsx +++ b/frontend/app/teacher/quiz-dashboard/[lectureId]/_components/StatisticsContainer/QuizDetailChart/QuizDetailChart.tsx @@ -1,23 +1,20 @@ "use client"; -import React from "react"; +import React, { useEffect, useState } from "react"; import { PieChart, Pie, Cell, Legend } from "recharts"; import styles from "./QuizDetailChart.module.scss"; - -// 타입 정의 예시 -export type QuizType = "multipleChoice" | "shortAnswer" | "trueFalse"; -export interface Quiz { - quizId: string; - quizOrder: number; - type: QuizType; - [key: string]: unknown; -} +import { + MultipleChoiceQuizDetail, + QuizDetailStat, + ShortAnswerQuizDetail, + TrueFalseQuizDetail, +} from "@/types/quizzes/fetchQuizDetailStatTypes"; // 색상 팔레트 const COLORS = ["#6C5CE7", "#4F8CFF", "#6AD1C9", "#B983FF"]; const OX_COLORS = ["#6AD1C9", "#4F8CFF"]; // MultipleChoiceChart: 파이차트 -function MultipleChoiceChart({ data }: { data: Quiz }) { +function MultipleChoiceChart({ data }: { data: MultipleChoiceQuizDetail }) { const chartData = [ { name: "1번", value: Number(data["1"] ?? 0) }, { name: "2번", value: Number(data["2"] ?? 0) }, @@ -58,7 +55,7 @@ function MultipleChoiceChart({ data }: { data: Quiz }) { } // TrueFalseChart: OX 파이차트 -function TrueFalseChart({ data }: { data: Quiz }) { +function TrueFalseChart({ data }: { data: TrueFalseQuizDetail }) { const chartData = [ { name: "O", value: Number(data.O ?? 0) }, { name: "X", value: Number(data.X ?? 0) }, @@ -100,7 +97,7 @@ function TrueFalseChart({ data }: { data: Quiz }) { } // ShortAnswerTop3: 리스트 -function ShortAnswerTop3({ data }: { data: Quiz }) { +function ShortAnswerTop3({ data }: { data: ShortAnswerQuizDetail }) { const top3 = data.top3Answers as { answer: string; rate: number }[]; const etcAnswers = data.etcAnswers as string[] | undefined; return ( @@ -135,7 +132,21 @@ function ShortAnswerTop3({ data }: { data: Quiz }) { } // 실제 QuizDetailChart 컴포넌트 -export default function QuizDetailChart({ quiz }: { quiz: Quiz }) { +export default function QuizDetailChart({ quiz }: { quiz: QuizDetailStat }) { + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + if (!mounted) { + return ( +
+
퀴즈{quiz.quizOrder}
+
+ ); + } + if (quiz.type === "multipleChoice") { return ; } diff --git a/frontend/app/teacher/quiz-dashboard/[lectureId]/_components/StatisticsContainer/StatisticsContainer.tsx b/frontend/app/teacher/quiz-dashboard/[lectureId]/_components/StatisticsContainer/StatisticsContainer.tsx index 9bfb5aca..c6946b84 100644 --- a/frontend/app/teacher/quiz-dashboard/[lectureId]/_components/StatisticsContainer/StatisticsContainer.tsx +++ b/frontend/app/teacher/quiz-dashboard/[lectureId]/_components/StatisticsContainer/StatisticsContainer.tsx @@ -1,10 +1,17 @@ "use client"; -import React from "react"; + +import React, { useEffect, useState } from "react"; import QuizCorrectRates from "./QuizCorrectRates/QuizCorrectRates"; import AverageCorrectRate from "./AverageCorrectRate/AverageCorrectRate"; import QuizDetailChart from "./QuizDetailChart/QuizDetailChart"; import styles from "./StatisticsContainer.module.scss"; import Masonry from "react-masonry-css"; +import { + fetchQuizDetailStatResult, + QuizDetailStat, +} from "@/types/quizzes/fetchQuizDetailStatTypes"; +import { useParams } from "next/navigation"; +import { fetchQuizDetailStat } from "@/api/quizzes/fetchQuizDetailStat"; interface StatData { averageCorrectRate: number; @@ -23,46 +30,20 @@ export default function StatisticsContainer({ statData, }: StatisticsContainerProps) { // 두 번째 데이터: 퀴즈별 분포/상세용 - const detailData = [ - { - quizId: "qz-001", - quizOrder: 1, - type: "multipleChoice", - 1: 70.0, - 2: 20.0, - 3: 0.0, - 4: 10.0, - }, - { - quizId: "qz-002", - quizOrder: 2, - type: "trueFalse", - O: 80.0, - X: 20.0, - }, - { - quizId: "qz-003", - quizOrder: 3, - type: "shortAnswer", - top3Answers: [ - { answer: "배깅", rate: 50.0 }, - { answer: "부스팅", rate: 20.0 }, - { answer: "스태킹", rate: 10.0 }, - ], - etcAnswers: ["Voting", "랜덤포레스트"], - }, - { - quizId: "qz-004", - quizOrder: 4, - type: "shortAnswer", - top3Answers: [ - { answer: "복원 추출", rate: 30.0 }, - { answer: "순차 샘플링", rate: 20.0 }, - { answer: "K-켭 교차 검증", rate: 10.0 }, - ], - etcAnswers: ["부스트랩 샘플링", "계층 샘플링", "단순 샘플링"], - }, - ]; + const [detailData, setDetailData] = + useState(null); + const { lectureId } = useParams<{ lectureId: string }>(); + + useEffect(() => { + if (!lectureId) return; + fetchQuizDetailStat(lectureId).then((res) => { + if (res.isSuccess && res.result) { + setDetailData(res.result); + } else { + setDetailData(null); + } + }); + }, [lectureId]); return ( - {detailData.map((quiz) => ( - - ))} + {detailData && detailData.length > 0 ? ( + detailData.map((quiz: QuizDetailStat) => ( + + )) + ) : ( +
퀴즈 상세 통계 데이터가 없습니다.
+ )}
); } diff --git a/frontend/app/teacher/quiz-dashboard/[lectureId]/page.tsx b/frontend/app/teacher/quiz-dashboard/[lectureId]/page.tsx index 1bf410e3..cfc67944 100644 --- a/frontend/app/teacher/quiz-dashboard/[lectureId]/page.tsx +++ b/frontend/app/teacher/quiz-dashboard/[lectureId]/page.tsx @@ -1,4 +1,5 @@ "use client"; + import BackButtonHeader from "./_components/BackButtonHeader/BackButtonHeader"; import DashboardContainer from "./_components/DashboardContainer/DashboardContainer"; import style from "./page.module.scss"; diff --git a/frontend/constants/endpoints.ts b/frontend/constants/endpoints.ts index 6fb1f350..20644009 100644 --- a/frontend/constants/endpoints.ts +++ b/frontend/constants/endpoints.ts @@ -105,6 +105,8 @@ export const ENDPOINTS = { // 퀴즈 관련 QUIZZES: { + GET_DETAIL_STAT: (lectureId: string) => + `${BASE_API}/quizzes/${lectureId}/result/statistics`, GET_SUBMIT_LIST: (lectureId: string) => `${BASE_API}/quizzes/${lectureId}/result/list`, GET_INFO: (lectureId: string) => diff --git a/frontend/types/quizzes/fetchQuizDetailStatTypes.ts b/frontend/types/quizzes/fetchQuizDetailStatTypes.ts new file mode 100644 index 00000000..cc3d38a9 --- /dev/null +++ b/frontend/types/quizzes/fetchQuizDetailStatTypes.ts @@ -0,0 +1,35 @@ +export type fetchQuizDetailStatResult = QuizDetailStat[]; + +export type QuizDetailStat = + | MultipleChoiceQuizDetail + | TrueFalseQuizDetail + | ShortAnswerQuizDetail; + +export interface MultipleChoiceQuizDetail { + quizId: string; + quizOrder: number; + type: "multipleChoice"; + "1": number; + "2": number; + "3": number; + "4": number; +} + +export interface TrueFalseQuizDetail { + quizId: string; + quizOrder: number; + type: "trueFalse"; + O: number; + X: number; +} + +export interface ShortAnswerQuizDetail { + quizId: string; + quizOrder: number; + type: "shortAnswer"; + top3Answers: Array<{ + answer: string; + rate: number; + }>; + etcAnswers: string[]; +} From 3c3b3add50640d734cdfcfb70b649f0c06ce61b7 Mon Sep 17 00:00:00 2001 From: Haemin Kim Date: Tue, 11 Nov 2025 18:37:46 +0900 Subject: [PATCH 07/19] =?UTF-8?q?:sparkles:=20(#365)=20=ED=80=B4=EC=A6=88?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=20=EB=A1=9C=EC=A7=81=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../quiz/controller/QuizController.java | 70 +++++----- .../domain/quiz/service/QuizServiceImpl.java | 4 + .../controller/QuizListController.java | 57 ++++++++ .../domain/quizList/entity/QuizList.java | 56 ++++++++ .../repository/QuizListRepository.java | 45 ++++++ .../quizList/service/QuizListService.java | 11 ++ .../quizList/service/QuizListServiceImpl.java | 130 ++++++++++++++++++ .../entity/QuizListOptions.java | 36 +++++ .../repository/QuizListOptionsRepository.java | 13 ++ 9 files changed, 387 insertions(+), 35 deletions(-) create mode 100644 backend/src/main/java/org/example/backend/domain/quizList/controller/QuizListController.java create mode 100644 backend/src/main/java/org/example/backend/domain/quizList/entity/QuizList.java create mode 100644 backend/src/main/java/org/example/backend/domain/quizList/repository/QuizListRepository.java create mode 100644 backend/src/main/java/org/example/backend/domain/quizList/service/QuizListService.java create mode 100644 backend/src/main/java/org/example/backend/domain/quizList/service/QuizListServiceImpl.java create mode 100644 backend/src/main/java/org/example/backend/domain/quizListOptions/entity/QuizListOptions.java create mode 100644 backend/src/main/java/org/example/backend/domain/quizListOptions/repository/QuizListOptionsRepository.java diff --git a/backend/src/main/java/org/example/backend/domain/quiz/controller/QuizController.java b/backend/src/main/java/org/example/backend/domain/quiz/controller/QuizController.java index bc8044c3..7c523c4e 100644 --- a/backend/src/main/java/org/example/backend/domain/quiz/controller/QuizController.java +++ b/backend/src/main/java/org/example/backend/domain/quiz/controller/QuizController.java @@ -26,41 +26,41 @@ public class QuizController { private final QuizService quizService; private final QuizEditService quizEditService; - // 퀴즈 생성 - @PostMapping("/{lectureId}/create") - public ResponseEntity> generateQuiz(@PathVariable("lectureId") UUID lectureId, - @RequestBody QuizRequestDTO request) { - try { - QuizResponseDTO response = quizService.generateQuiz(lectureId, request, false); - return ResponseEntity.ok(ApiResponse.onSuccess(response)); - } catch (QuizException e) { - return ResponseEntity - .status(e.getErrorCode().getReasonHttpStatus().getHttpStatus()) - .body(ApiResponse.onFailure(e.getErrorCode())); - } catch (Exception e) { - return ResponseEntity - .status(FailureCode._INTERNAL_SERVER_ERROR.getReasonHttpStatus().getHttpStatus()) - .body(ApiResponse.onFailure(FailureCode._INTERNAL_SERVER_ERROR)); - } - } - - // 퀴즈 재생성 - @PostMapping("/{lectureId}/re-create") - public ResponseEntity> regenerateQuiz(@PathVariable("lectureId") UUID lectureId, - @RequestBody QuizRequestDTO request) { - try { - QuizResponseDTO response = quizService.generateQuiz(lectureId, request, true); - return ResponseEntity.ok(ApiResponse.onSuccess(response)); - } catch (QuizException e) { - return ResponseEntity - .status(e.getErrorCode().getReasonHttpStatus().getHttpStatus()) - .body(ApiResponse.onFailure(e.getErrorCode())); - } catch (Exception e) { - return ResponseEntity - .status(FailureCode._INTERNAL_SERVER_ERROR.getReasonHttpStatus().getHttpStatus()) - .body(ApiResponse.onFailure(FailureCode._INTERNAL_SERVER_ERROR)); - } - } +// // 퀴즈 생성 +// @PostMapping("/{lectureId}/create") +// public ResponseEntity> generateQuiz(@PathVariable("lectureId") UUID lectureId, +// @RequestBody QuizRequestDTO request) { +// try { +// QuizResponseDTO response = quizService.generateQuiz(lectureId, request, false); +// return ResponseEntity.ok(ApiResponse.onSuccess(response)); +// } catch (QuizException e) { +// return ResponseEntity +// .status(e.getErrorCode().getReasonHttpStatus().getHttpStatus()) +// .body(ApiResponse.onFailure(e.getErrorCode())); +// } catch (Exception e) { +// return ResponseEntity +// .status(FailureCode._INTERNAL_SERVER_ERROR.getReasonHttpStatus().getHttpStatus()) +// .body(ApiResponse.onFailure(FailureCode._INTERNAL_SERVER_ERROR)); +// } +// } +// +// // 퀴즈 재생성 +// @PostMapping("/{lectureId}/re-create") +// public ResponseEntity> regenerateQuiz(@PathVariable("lectureId") UUID lectureId, +// @RequestBody QuizRequestDTO request) { +// try { +// QuizResponseDTO response = quizService.generateQuiz(lectureId, request, true); +// return ResponseEntity.ok(ApiResponse.onSuccess(response)); +// } catch (QuizException e) { +// return ResponseEntity +// .status(e.getErrorCode().getReasonHttpStatus().getHttpStatus()) +// .body(ApiResponse.onFailure(e.getErrorCode())); +// } catch (Exception e) { +// return ResponseEntity +// .status(FailureCode._INTERNAL_SERVER_ERROR.getReasonHttpStatus().getHttpStatus()) +// .body(ApiResponse.onFailure(FailureCode._INTERNAL_SERVER_ERROR)); +// } +// } // 퀴즈 저장 @PostMapping("/{lectureId}/save") diff --git a/backend/src/main/java/org/example/backend/domain/quiz/service/QuizServiceImpl.java b/backend/src/main/java/org/example/backend/domain/quiz/service/QuizServiceImpl.java index 9c061d14..1d124fe8 100644 --- a/backend/src/main/java/org/example/backend/domain/quiz/service/QuizServiceImpl.java +++ b/backend/src/main/java/org/example/backend/domain/quiz/service/QuizServiceImpl.java @@ -24,6 +24,7 @@ import org.example.backend.domain.quiz.exception.QuizException; import org.example.backend.domain.quiz.repository.QuizRepository; import org.example.backend.domain.quizAnswer.repository.QuizAnswerRepository; +import org.example.backend.domain.quizList.repository.QuizListRepository; import org.example.backend.domain.user.entity.Role; import org.example.backend.global.security.auth.CustomSecurityUtil; import org.example.backend.infra.langchain.LangChainClient; @@ -56,6 +57,7 @@ public class QuizServiceImpl implements QuizService { private final QuizAnswerRepository quizAnswerRepository; private final CustomSecurityUtil customSecurityUtil; private final QuizConverter quizConverter; + private final QuizListRepository quizListRepository; private final TaskScheduler taskScheduler; private final NotificationService notificationService; @@ -183,6 +185,7 @@ public QuizSaveResponseDTO saveQuiz(UUID lectureId, QuizSaveRequestDTO request) } scheduleQuizAnswerUploadNotification(lecture); + quizListRepository.resetAllUsedFlags(); return QuizSaveResponseDTO.builder() .lectureId(lectureId) @@ -190,6 +193,7 @@ public QuizSaveResponseDTO saveQuiz(UUID lectureId, QuizSaveRequestDTO request) .quizIds(savedQuizIds) .build(); } + private void scheduleQuizAnswerUploadNotification(Lecture lecture) { // 현재 시간 기준으로 "오늘 밤 12시(자정)" 계산 LocalDateTime midnight = LocalDate.now() diff --git a/backend/src/main/java/org/example/backend/domain/quizList/controller/QuizListController.java b/backend/src/main/java/org/example/backend/domain/quizList/controller/QuizListController.java new file mode 100644 index 00000000..fd1921b9 --- /dev/null +++ b/backend/src/main/java/org/example/backend/domain/quizList/controller/QuizListController.java @@ -0,0 +1,57 @@ +package org.example.backend.domain.quizList.controller; + +import lombok.RequiredArgsConstructor; +import org.example.backend.domain.quiz.dto.response.QuizResponseDTO; +import org.example.backend.domain.quiz.exception.QuizException; +import org.example.backend.domain.quizList.service.QuizListService; +import org.example.backend.global.ApiResponse; +import org.example.backend.global.code.base.FailureCode; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.UUID; + +@RestController +@RequestMapping("/api/quizzes") +@RequiredArgsConstructor +public class QuizListController { + + private final QuizListService quizListService; + + @PostMapping("/{lectureId}/create") + public ResponseEntity> createRandomQuiz( + @PathVariable("lectureId") UUID lectureId) { + try { + QuizResponseDTO response = quizListService.createRandomQuizSet(lectureId); + return ResponseEntity.ok(ApiResponse.onSuccess(response)); + } catch (QuizException e) { + return ResponseEntity + .status(e.getErrorCode().getReasonHttpStatus().getHttpStatus()) + .body(ApiResponse.onFailure(e.getErrorCode())); + } catch (Exception e) { + return ResponseEntity + .status(FailureCode._INTERNAL_SERVER_ERROR.getReasonHttpStatus().getHttpStatus()) + .body(ApiResponse.onFailure(FailureCode._INTERNAL_SERVER_ERROR)); + } + } + + @PostMapping("/{lectureId}/re-create") + public ResponseEntity> recreateRandomQuiz( + @PathVariable("lectureId") UUID lectureId) { + try { + QuizResponseDTO response = quizListService.recreateRandomQuizSet(lectureId); + return ResponseEntity.ok(ApiResponse.onSuccess(response)); + } catch (QuizException e) { + return ResponseEntity + .status(e.getErrorCode().getReasonHttpStatus().getHttpStatus()) + .body(ApiResponse.onFailure(e.getErrorCode())); + } catch (Exception e) { + return ResponseEntity + .status(FailureCode._INTERNAL_SERVER_ERROR.getReasonHttpStatus().getHttpStatus()) + .body(ApiResponse.onFailure(FailureCode._INTERNAL_SERVER_ERROR)); + } + } +} diff --git a/backend/src/main/java/org/example/backend/domain/quizList/entity/QuizList.java b/backend/src/main/java/org/example/backend/domain/quizList/entity/QuizList.java new file mode 100644 index 00000000..97ad5cf1 --- /dev/null +++ b/backend/src/main/java/org/example/backend/domain/quizList/entity/QuizList.java @@ -0,0 +1,56 @@ +package org.example.backend.domain.quizList.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.example.backend.domain.lecture.entity.Lecture; +import org.example.backend.domain.quiz.entity.QuizType; +import org.example.backend.domain.quizListOptions.entity.QuizListOptions; +import org.example.backend.global.entitiy.BaseEntity; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Entity +@Table(name = "quiz_list") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class QuizList extends BaseEntity { + + @Id + @GeneratedValue + @Column(columnDefinition = "BINARY(16)") + private UUID id; + + @ManyToOne + @JoinColumn(name = "lecture_id", nullable = false) + private Lecture lecture; + + @Column(nullable = false) + private String quiz; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private QuizType type; + + @Column(nullable = false) + private String solution; + + @Column(name = "quiz_order", nullable = false) + private Integer quizOrder; + + @Column(name = "used", length = 1, nullable = false, columnDefinition = "CHAR(1) DEFAULT 'F'") + private String used; + + @OneToMany(mappedBy = "quizListId", cascade = CascadeType.ALL, orphanRemoval = true) + private List quizListOptions = new ArrayList<>(); + + public void update(String quizBody, String solution, QuizType type) { + this.quiz = quizBody; + this.solution = solution; + this.type = type; + } +} + diff --git a/backend/src/main/java/org/example/backend/domain/quizList/repository/QuizListRepository.java b/backend/src/main/java/org/example/backend/domain/quizList/repository/QuizListRepository.java new file mode 100644 index 00000000..f8de5039 --- /dev/null +++ b/backend/src/main/java/org/example/backend/domain/quizList/repository/QuizListRepository.java @@ -0,0 +1,45 @@ +package org.example.backend.domain.quizList.repository; + +import org.example.backend.domain.quizList.entity.QuizList; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.UUID; + +@Repository +public interface QuizListRepository extends JpaRepository { + + @Query(value = """ + SELECT * FROM quiz_list + WHERE lecture_id = :lectureId + AND type = :type + AND used = 'F' + ORDER BY RAND() + LIMIT :limit + """, nativeQuery = true) + List findRandomByLectureIdAndType( + @Param("lectureId") UUID lectureId, + @Param("type") String type, + @Param("limit") int limit); + + @Query(value = """ + SELECT * FROM quiz_list + WHERE lecture_id = :lectureId + AND type = :type + AND used != 'T' + ORDER BY RAND() + LIMIT :limit + """, nativeQuery = true) + List findRandomByLectureIdAndTypeExcludeUsed( + @Param("lectureId") UUID lectureId, + @Param("type") String type, + @Param("limit") int limit); + + @Modifying + @Query(value = "UPDATE quiz_list SET used = 'F'", nativeQuery = true) + void resetAllUsedFlags(); +} \ No newline at end of file diff --git a/backend/src/main/java/org/example/backend/domain/quizList/service/QuizListService.java b/backend/src/main/java/org/example/backend/domain/quizList/service/QuizListService.java new file mode 100644 index 00000000..727ce0ed --- /dev/null +++ b/backend/src/main/java/org/example/backend/domain/quizList/service/QuizListService.java @@ -0,0 +1,11 @@ +package org.example.backend.domain.quizList.service; + + +import org.example.backend.domain.quiz.dto.response.QuizResponseDTO; + +import java.util.UUID; + +public interface QuizListService { + QuizResponseDTO createRandomQuizSet(UUID lectureId); + QuizResponseDTO recreateRandomQuizSet(UUID lectureId); +} diff --git a/backend/src/main/java/org/example/backend/domain/quizList/service/QuizListServiceImpl.java b/backend/src/main/java/org/example/backend/domain/quizList/service/QuizListServiceImpl.java new file mode 100644 index 00000000..f052e6be --- /dev/null +++ b/backend/src/main/java/org/example/backend/domain/quizList/service/QuizListServiceImpl.java @@ -0,0 +1,130 @@ +package org.example.backend.domain.quizList.service; + +import lombok.RequiredArgsConstructor; +import org.example.backend.domain.lecture.entity.Lecture; +import org.example.backend.domain.lecture.repository.LectureRepository; +import org.example.backend.domain.quiz.dto.response.QuizResponseDTO; +import org.example.backend.domain.quiz.entity.QuizType; +import org.example.backend.domain.quiz.exception.QuizErrorCode; +import org.example.backend.domain.quiz.exception.QuizException; +import org.example.backend.domain.quizList.entity.QuizList; +import org.example.backend.domain.quizList.repository.QuizListRepository; +import org.example.backend.domain.quizListOptions.entity.QuizListOptions; +import org.example.backend.domain.quizListOptions.repository.QuizListOptionsRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class QuizListServiceImpl implements QuizListService { + + private final LectureRepository lectureRepository; + private final QuizListRepository quizListRepository; + private final QuizListOptionsRepository quizListOptionsRepository; + + @Override + @Transactional + public QuizResponseDTO createRandomQuizSet(UUID lectureId) { + + Lecture lecture = lectureRepository.findById(lectureId) + .orElseThrow(() -> new QuizException(QuizErrorCode.LECTURE_NOT_FOUND)); + + List multipleChoiceList = quizListRepository.findRandomByLectureIdAndType(lectureId, QuizType.MULTIPLE_CHOICE.name(), 2); + List shortAnswerList = quizListRepository.findRandomByLectureIdAndType(lectureId, QuizType.SHORT_ANSWER.name(), 1); + List trueFalseList = quizListRepository.findRandomByLectureIdAndType(lectureId, QuizType.TRUE_FALSE.name(), 1); + + List allSelected = new ArrayList<>(); + allSelected.addAll(multipleChoiceList); + allSelected.addAll(shortAnswerList); + allSelected.addAll(trueFalseList); + + if (allSelected.isEmpty()) { + throw new QuizException(QuizErrorCode.QUIZ_NOT_GENERATED_YET); + } + + allSelected.forEach(quiz -> quiz.setUsed("T")); + quizListRepository.saveAll(allSelected); + + List quizDTOs = allSelected.stream() + .map(quiz -> { + List options = new ArrayList<>(); + + if (quiz.getType() == QuizType.MULTIPLE_CHOICE) { + options = quizListOptionsRepository.findByQuizListId_Id(quiz.getId()) + .stream() + .map(QuizListOptions::getText) + .toList(); + } + + String camelType = QuizType.toCamelCase(quiz.getType().name()); + + return QuizResponseDTO.QuizDTO.builder() + .quizBody(quiz.getQuiz()) + .solution(quiz.getSolution()) + .type(camelType) // e.g. "multipleChoice", "shortAnswer", "trueFalse" + .options(options) + .build(); + }) + .toList(); + + return QuizResponseDTO.builder() + .lectureId(lectureId) + .quizzes(quizDTOs) + .build(); + } + + @Override + @Transactional + public QuizResponseDTO recreateRandomQuizSet(UUID lectureId) { + + Lecture lecture = lectureRepository.findById(lectureId) + .orElseThrow(() -> new QuizException(QuizErrorCode.LECTURE_NOT_FOUND)); + + List multipleChoiceList = quizListRepository.findRandomByLectureIdAndTypeExcludeUsed(lectureId, QuizType.MULTIPLE_CHOICE.name(), 2); + List shortAnswerList = quizListRepository.findRandomByLectureIdAndTypeExcludeUsed(lectureId, QuizType.SHORT_ANSWER.name(), 1); + List trueFalseList = quizListRepository.findRandomByLectureIdAndTypeExcludeUsed(lectureId, QuizType.TRUE_FALSE.name(), 1); + + List allSelected = new ArrayList<>(); + allSelected.addAll(multipleChoiceList); + allSelected.addAll(shortAnswerList); + allSelected.addAll(trueFalseList); + + if (allSelected.isEmpty()) { + throw new QuizException(QuizErrorCode.QUIZ_NOT_GENERATED_YET); + } + + allSelected.forEach(quiz -> quiz.setUsed("T")); + quizListRepository.saveAll(allSelected); + + List quizDTOs = allSelected.stream() + .map(quiz -> { + List options = new ArrayList<>(); + + if (quiz.getType() == QuizType.MULTIPLE_CHOICE) { + options = quizListOptionsRepository.findByQuizListId_Id(quiz.getId()) + .stream() + .map(QuizListOptions::getText) + .toList(); + } + + String camelType = QuizType.toCamelCase(quiz.getType().name()); + + return QuizResponseDTO.QuizDTO.builder() + .quizBody(quiz.getQuiz()) + .solution(quiz.getSolution()) + .type(camelType) + .options(options) + .build(); + }) + .toList(); + + return QuizResponseDTO.builder() + .lectureId(lectureId) + .quizzes(quizDTOs) + .build(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/example/backend/domain/quizListOptions/entity/QuizListOptions.java b/backend/src/main/java/org/example/backend/domain/quizListOptions/entity/QuizListOptions.java new file mode 100644 index 00000000..441295a3 --- /dev/null +++ b/backend/src/main/java/org/example/backend/domain/quizListOptions/entity/QuizListOptions.java @@ -0,0 +1,36 @@ +package org.example.backend.domain.quizListOptions.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.example.backend.domain.quizList.entity.QuizList; +import org.example.backend.global.entitiy.BaseEntity; + +import java.util.UUID; + +@Entity +@Table(name = "quiz_list_options") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class QuizListOptions extends BaseEntity { + @Id + @GeneratedValue + @Column(name = "id", nullable = false) + private UUID id; + + @ManyToOne + @JoinColumn(name = "quiz_list_id", nullable = false) + private QuizList quizListId; + + @Column(name = "text", nullable = false) + private String text; + + @Column(name = "option_order") + private int optionOrder; + + public void updateText(String text) { + this.text = text; + } +} + diff --git a/backend/src/main/java/org/example/backend/domain/quizListOptions/repository/QuizListOptionsRepository.java b/backend/src/main/java/org/example/backend/domain/quizListOptions/repository/QuizListOptionsRepository.java new file mode 100644 index 00000000..0ce91c07 --- /dev/null +++ b/backend/src/main/java/org/example/backend/domain/quizListOptions/repository/QuizListOptionsRepository.java @@ -0,0 +1,13 @@ +package org.example.backend.domain.quizListOptions.repository; + +import org.example.backend.domain.quizListOptions.entity.QuizListOptions; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.UUID; + +@Repository +public interface QuizListOptionsRepository extends JpaRepository { + List findByQuizListId_Id(UUID quizListId); +} \ No newline at end of file From 57fb1a365b6cdf0f51344ed35117a85bc648afa2 Mon Sep 17 00:00:00 2001 From: Haemin Kim Date: Tue, 11 Nov 2025 21:06:34 +0900 Subject: [PATCH 08/19] =?UTF-8?q?=E2=9C=A8=20(#365)=20=ED=80=B4=EC=A6=88?= =?UTF-8?q?=20=EC=9E=AC=EC=83=9D=EC=84=B1=20=ED=96=88=EC=9D=84=EA=B2=BD?= =?UTF-8?q?=EC=9A=B0=20=EC=9E=AC=EC=83=9D=EC=84=B1=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EC=95=88=EB=B3=B4=EC=9D=B4=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MakeQuizModal/QuizPreview/QuizPreview.tsx | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/frontend/components/Modal/MakeQuizModal/QuizPreview/QuizPreview.tsx b/frontend/components/Modal/MakeQuizModal/QuizPreview/QuizPreview.tsx index c544cf94..eb032af5 100644 --- a/frontend/components/Modal/MakeQuizModal/QuizPreview/QuizPreview.tsx +++ b/frontend/components/Modal/MakeQuizModal/QuizPreview/QuizPreview.tsx @@ -46,6 +46,7 @@ const QuizPreview = ({ const [showAlert, setShowAlert] = useState(false); const [showPreviewModal, setShowPreviewModal] = useState(false); + const [hasClickedMore, setHasClickedMore] = useState(false); // 현재 lectureId의 퀴즈 가져오기 const quizzes = getQuizzes(lectureId); @@ -56,6 +57,10 @@ const QuizPreview = ({ setIsLoading(true); setError(null); + + // 현재 시간 + const startTime = Date.now(); + createQuiz({ lectureId, useAudio }) .then((res) => { if (res.isSuccess && res.result && Array.isArray(res.result.quizzes)) { @@ -70,8 +75,16 @@ const QuizPreview = ({ setError("퀴즈 생성 중 오류가 발생했습니다."); }) .finally(() => { + + // 로딩 시간 + const elapsed = Date.now() - startTime; + const minLoadingTime = 40000 + Math.random() * 10000; + const remaining = Math.max(0, minLoadingTime - elapsed); + + setTimeout(() => { setIsLoading(false); - }); + }, remaining); + }); }, [ lectureId, shouldGenerateQuiz, @@ -101,9 +114,10 @@ const QuizPreview = ({ }; const handleMoreQuiz = () => { - if (!quizzes) return; + if (isLoadingMore || !quizzes || hasClickedMore) return; setIsLoadingMore(true); + setHasClickedMore(true); setError(null); recreateQuiz({ lectureId, useAudio }) @@ -213,17 +227,20 @@ const QuizPreview = ({
))} -
+ + {!hasClickedMore && ( +

{isLoadingMore ? "추가 퀴즈를 생성하고 있어요..." : "+ 다른 퀴즈도 보고싶어요"}

+ )}
)}
- {quizzes !== null && ( + {quizzes !== null && !isLoading && (