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..38dba2c2 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; @@ -129,6 +131,7 @@ public QuizResponseDTO generateQuiz(UUID lectureId, QuizRequestDTO request, bool // 퀴즈 저장 @Override + @Transactional public QuizSaveResponseDTO saveQuiz(UUID lectureId, QuizSaveRequestDTO request) { Role role = customSecurityUtil.getUserRole(); @@ -183,6 +186,7 @@ public QuizSaveResponseDTO saveQuiz(UUID lectureId, QuizSaveRequestDTO request) } scheduleQuizAnswerUploadNotification(lecture); + quizListRepository.resetAllUsedFlags(); return QuizSaveResponseDTO.builder() .lectureId(lectureId) @@ -190,6 +194,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..3f1befe8 --- /dev/null +++ b/backend/src/main/java/org/example/backend/domain/quizList/service/QuizListServiceImpl.java @@ -0,0 +1,132 @@ +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(); + + quizListRepository.resetAllUsedFlags(); + + 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 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/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/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/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/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/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}
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/app/teacher/quiz-dashboard/[lectureId]/_components/QuizInfo/QuizInfo.tsx b/frontend/app/teacher/quiz-dashboard/[lectureId]/_components/QuizInfo/QuizInfo.tsx index 67837813..6a253dc8 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,14 +11,33 @@ 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}] 퀴즈 대시보드 -
-
- {formatDate(data.quizDate, data.quizDay)} -
+ {data && ( + <> +
+ [{data.title}] + 퀴즈 대시보드 +
+
+ {formatDate(data.quizDate, data.quizDay)} +
+ + )}
); } 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/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/app/teacher/quiz-list/[lectureId]/_components/BackButtonHeader/BackButtonHeader.module.scss b/frontend/app/teacher/quiz-list/[lectureId]/_components/BackButtonHeader/BackButtonHeader.module.scss new file mode 100644 index 00000000..5453df59 --- /dev/null +++ b/frontend/app/teacher/quiz-list/[lectureId]/_components/BackButtonHeader/BackButtonHeader.module.scss @@ -0,0 +1,18 @@ +.quizHeader { + width: 100%; + display: flex; + align-items: center; + height: 6vh; + } + + .backBtn { + display: flex; + align-items: center; + justify-content: center; + border: none; + width: 40px; + height: 40px; + cursor: pointer; + background-color: white; + } + \ No newline at end of file diff --git a/frontend/app/teacher/quiz-list/[lectureId]/_components/BackButtonHeader/BackButtonHeader.tsx b/frontend/app/teacher/quiz-list/[lectureId]/_components/BackButtonHeader/BackButtonHeader.tsx new file mode 100644 index 00000000..0fe20a49 --- /dev/null +++ b/frontend/app/teacher/quiz-list/[lectureId]/_components/BackButtonHeader/BackButtonHeader.tsx @@ -0,0 +1,21 @@ +"use client"; +import React from "react"; +import { ArrowLeft } from "lucide-react"; +import styles from "./BackButtonHeader.module.scss"; +import IconButton from "@/components/Button/IconButton/IconButton"; +import { useRouter } from "next/navigation"; +import { ROUTES } from "@/constants/routes"; + +export default function BackButtonHeader() { + const router = useRouter(); + + return ( +
+ } + onClick={() => router.push(ROUTES.teacherQuizManagement)} + ariaLabel={""} + > +
+ ); +} diff --git a/frontend/app/teacher/quiz-list/[lectureId]/_components/QuizInfo/QuizInfo.module.scss b/frontend/app/teacher/quiz-list/[lectureId]/_components/QuizInfo/QuizInfo.module.scss new file mode 100644 index 00000000..3e1599b0 --- /dev/null +++ b/frontend/app/teacher/quiz-list/[lectureId]/_components/QuizInfo/QuizInfo.module.scss @@ -0,0 +1,20 @@ +.infoRow { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; +} + +.title { + font-size: $font-size-xl; + font-weight: $font-weight-bold; +} + +.dashboard { + font-weight: $font-weight-medium; +} + +.date { + font-size: $font-size-lg; + color: $color-blue; +} diff --git a/frontend/app/teacher/quiz-list/[lectureId]/_components/QuizInfo/QuizInfo.tsx b/frontend/app/teacher/quiz-list/[lectureId]/_components/QuizInfo/QuizInfo.tsx new file mode 100644 index 00000000..b6c9f997 --- /dev/null +++ b/frontend/app/teacher/quiz-list/[lectureId]/_components/QuizInfo/QuizInfo.tsx @@ -0,0 +1,43 @@ +"use client"; +import React, { useEffect, useState } from "react"; +import styles from "./QuizInfo.module.scss"; +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("-"); + return `${yyyy}.${mm}.${dd} (${day})`; +} + +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 && ( + <> +
+ [{data.title}] + 퀴즈 +
+
+ {formatDate(data.quizDate, data.quizDay)} +
+ + )} +
+ ); +} diff --git a/frontend/app/teacher/quiz-list/[lectureId]/_components/QuizListContainer/QuizListContainer.module.scss b/frontend/app/teacher/quiz-list/[lectureId]/_components/QuizListContainer/QuizListContainer.module.scss new file mode 100644 index 00000000..7fbcb679 --- /dev/null +++ b/frontend/app/teacher/quiz-list/[lectureId]/_components/QuizListContainer/QuizListContainer.module.scss @@ -0,0 +1,32 @@ +.quizListContainer { + background-color: white; + border-radius: $radius-lg; + padding: $spacing-lg; + border: 1px solid $color-neutral-7; + height: calc(94vh - $spacing-lg); + overflow-y: auto; + display: flex; + flex-direction: column; + gap: $spacing-md; +} + +.quizListContainerInner { + height: 100%; + display: flex; + gap: $spacing-md; +} + +.leftSection { + width: 55%; + height: 100%; + display: flex; + gap: $spacing-md; +} + +.loading { + display: flex; + justify-content: center; + align-items: center; + height: 60vh; +} + diff --git a/frontend/app/teacher/quiz-list/[lectureId]/_components/QuizListContainer/QuizListContainer.tsx b/frontend/app/teacher/quiz-list/[lectureId]/_components/QuizListContainer/QuizListContainer.tsx new file mode 100644 index 00000000..23d25965 --- /dev/null +++ b/frontend/app/teacher/quiz-list/[lectureId]/_components/QuizListContainer/QuizListContainer.tsx @@ -0,0 +1,16 @@ +"use client"; +import React from "react"; +import styles from "./QuizListContainer.module.scss"; +import QuizSection from "../QuizSection/QuizSection"; +import QuizInfo from "../QuizInfo/QuizInfo"; + +export default function QuizListContainer() { + return ( +
+ +
+ +
+
+ ); +} \ No newline at end of file diff --git a/frontend/app/teacher/quiz-list/[lectureId]/_components/QuizSection/QuizSection.module.scss b/frontend/app/teacher/quiz-list/[lectureId]/_components/QuizSection/QuizSection.module.scss new file mode 100644 index 00000000..19235b6e --- /dev/null +++ b/frontend/app/teacher/quiz-list/[lectureId]/_components/QuizSection/QuizSection.module.scss @@ -0,0 +1,118 @@ +.wrapper { + width: 100%; + margin: 0; + max-width: none; + padding: 0; + display: flex; + flex-direction: column; + gap: $spacing-md; +} + +.sectionTitle { + font-size: $font-size-lg; + font-weight: bold; + margin-bottom: $spacing-sm; + border-bottom: 1px solid $color-neutral-6; + padding-bottom: $spacing-xs; + color: $color-neutral-2; +} + +.quizGrid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: $spacing-lg; + + @include respond-to(md) { + grid-template-columns: 1fr; + } +} + +.quizBox { + background: $color-skyblue; + border-radius: $radius-md; + padding: $spacing-lg; + display: flex; + flex-direction: column; + gap: $spacing-sm; + border: 1px solid lighten($color-blue, 35%); +} + +.question::before { + content: "문제: "; + color: $color-blue; + font-weight: 800; + margin-right: $spacing-xs; +} + +.optionList { + display: flex; + flex-direction: column; + gap: $spacing-xs; +} + +.optionRow { + display: flex; + align-items: center; + gap: $spacing-sm; + + .optionNumber { + font-weight: bold; + color: $color-blue; + width: $spacing-lg; + text-align: right; + } + + .optionText { + flex: 1; + background: $color-white; + border-radius: $radius-sm; + padding: $spacing-xs $spacing-sm; + border: 1px solid $color-neutral-6; + font-size: $font-size-md; + } +} + +.answerRow { + display: flex; + align-items: center; + gap: $spacing-sm; + margin-top: $spacing-xs; + + .label { + font-weight: bold; + color: $color-neutral-5; + } + + .answerBtn { + border: 1px solid $color-neutral-6; + background: $color-white; + border-radius: $radius-full; + padding: $spacing-xs $spacing-sm; + font-size: $font-size-md; + color: $color-neutral-5; + transition: border 0.2s, color 0.2s; + + &.selected { + border: 2px solid $color-blue; + color: $color-blue; + font-weight: bold; + background: $color-skyblue; + } + } + + .shortAnswer { + display: inline-block; + background: $color-white; + border: 1px solid $color-neutral-6; + border-radius: $radius-sm; + padding: $spacing-xs $spacing-sm; + font-size: $font-size-sm; + color: $color-neutral-2; + } +} + +.oxBox, +.shortBox { + background: $color-skyblue; + border: 1px solid lighten($color-blue, 35%); +} \ No newline at end of file diff --git a/frontend/app/teacher/quiz-list/[lectureId]/_components/QuizSection/QuizSection.tsx b/frontend/app/teacher/quiz-list/[lectureId]/_components/QuizSection/QuizSection.tsx new file mode 100644 index 00000000..0b83d3e4 --- /dev/null +++ b/frontend/app/teacher/quiz-list/[lectureId]/_components/QuizSection/QuizSection.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useParams } from "next/navigation"; +import { fetchQuizList } from "@/api/quizzes/fetchQuizList"; +import { fetchQuizListResult } from "@/types/quizzes/fetchQuizListTypes"; +import styles from "./QuizSection.module.scss"; +import LoadingSpinner from "@/components/LoadingSpinner/LoadingSpinner"; +import AlertModal from "@/components/Modal/AlertModal/AlertModal"; + +export default function QuizSection() { + const { lectureId } = useParams(); + const [quizzes, setQuizzes] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!lectureId || typeof lectureId !== "string") return; + + const loadQuizzes = async () => { + try { + const res = await fetchQuizList(lectureId); + + if (res.isSuccess && res.result?.quizzes) { + const mapped = res.result.quizzes.map((q) => ({ + quizId: q.quizId, + quizOrder: q.quizOrder, + quizBody: q.quizBody, + solution: q.solution, + type: q.type, + options: q.options, + })); + + setQuizzes(mapped); + } else { + setError("퀴즈 목록을 불러오지 못했습니다."); + } + } catch { + setError("퀴즈 데이터를 불러오는 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + loadQuizzes(); + }, [lectureId]); + + if (isLoading) + return ( +
+ +
+ ); + + if (error) + return setError(null)}>{error}; + + if (quizzes.length === 0) + return
등록된 퀴즈가 없습니다.
; + + const multipleChoice = quizzes.filter((q) => q.type === "multipleChoice"); + const ox = quizzes.filter((q) => q.type === "trueFalse"); + const short = quizzes.filter((q) => q.type === "shortAnswer"); + + return ( +
+ {/* 객관식 */} +

객관식

+
+ {multipleChoice.map((quiz) => ( +
+
{quiz.quizBody}
+ +
+ {quiz.options?.map((opt, i) => ( +
+ {i + 1} +
+ {opt.text} +
+
+ ))} +
+ +
+ 정답: + {quiz.solution} +
+
+ ))} +
+ + {/* OX */} +

O/X

+
+ {ox.map((quiz) => ( +
+
{quiz.quizBody}
+
+ 정답: + {["O", "X"].map((v) => ( + + {v} + + ))} +
+
+ ))} +
+ + {/* 단답형 */} +

단답형

+
+ {short.map((quiz) => ( +
+
{quiz.quizBody}
+
+ 정답: + {quiz.solution} +
+
+ ))} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/app/teacher/quiz-list/[lectureId]/page.module.scss b/frontend/app/teacher/quiz-list/[lectureId]/page.module.scss new file mode 100644 index 00000000..971da0ce --- /dev/null +++ b/frontend/app/teacher/quiz-list/[lectureId]/page.module.scss @@ -0,0 +1,5 @@ +.quizListPage { + width: 100%; + height: 100%; + padding: 0px $spacing-lg $spacing-lg $spacing-lg; +} diff --git a/frontend/app/teacher/quiz-list/[lectureId]/page.tsx b/frontend/app/teacher/quiz-list/[lectureId]/page.tsx new file mode 100644 index 00000000..9bc2e460 --- /dev/null +++ b/frontend/app/teacher/quiz-list/[lectureId]/page.tsx @@ -0,0 +1,13 @@ +"use client"; +import BackButtonHeader from "./_components/BackButtonHeader/BackButtonHeader"; +import QuizListContainer from "./_components/QuizListContainer/QuizListContainer"; +import style from "./page.module.scss"; + +export default function TeacherQuizListPage() { + return ( +
+ + +
+ ); +} \ No newline at end of file diff --git a/frontend/app/teacher/quiz-management/page.module.scss b/frontend/app/teacher/quiz-management/page.module.scss index 91ef171d..e75c0b86 100644 --- a/frontend/app/teacher/quiz-management/page.module.scss +++ b/frontend/app/teacher/quiz-management/page.module.scss @@ -21,7 +21,7 @@ display: flex; flex-direction: column; gap: $spacing-lg; - padding-bottom: $spacing-xl; + padding: $spacing-xl 0; } .lectureItem { 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 && (