From 6daf417bff63d1ac48691ca9add446c9f5624629 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=95=98=EC=A7=80=EB=AF=BC?= <163178666+JiiminHa@users.noreply.github.com> Date: Thu, 14 May 2026 00:54:56 +0900 Subject: [PATCH 01/17] =?UTF-8?q?#115=20chore:=20=EC=97=94=EB=93=9C?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/config/endpoints.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/shared/config/endpoints.ts b/src/shared/config/endpoints.ts index 47955ac..cacee50 100644 --- a/src/shared/config/endpoints.ts +++ b/src/shared/config/endpoints.ts @@ -18,6 +18,9 @@ export const ENDPOINTS = { SUBMIT: (unitId: number | string, assignmentId: number | string) => `/assignments/${unitId}/${assignmentId}/code`, }, + TESTCASES: { + BULK: (assignmentId: number | string) => `/testcase/${assignmentId}/bulk`, + }, UNITS: { BY_COURSE: (courseId: number | string) => `/courses/${courseId}/units`, DETAIL: (id: number | string) => `/units/${id}`, @@ -36,5 +39,7 @@ export const ENDPOINTS = { `/courses/${courseId}/enrollments`, DETAIL: (courseId: number | string, memberId: number | string) => `/courses/${courseId}/enrollments/${memberId}`, + BULK: (courseId: number | string) => + `/courses/${courseId}/enrollments/bulk`, }, } as const; From 29319c2189b341a802d5fc97b6245356d2dc50f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=95=98=EC=A7=80=EB=AF=BC?= <163178666+JiiminHa@users.noreply.github.com> Date: Thu, 14 May 2026 11:14:47 +0900 Subject: [PATCH 02/17] =?UTF-8?q?#116=20fix:=20Zod=20=EC=8A=A4=ED=82=A4?= =?UTF-8?q?=EB=A7=88=EC=97=90=20.passthrough()=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EC=97=AC=20API=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=9C=A0=EC=8B=A4=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/assignment/model/schemas.ts | 32 ++++++------ src/entities/course/model/schemas.ts | 7 ++- src/entities/student/model/schemas.ts | 65 ++++++++++++++---------- 3 files changed, 60 insertions(+), 44 deletions(-) diff --git a/src/entities/assignment/model/schemas.ts b/src/entities/assignment/model/schemas.ts index 3c4e7f9..632859b 100644 --- a/src/entities/assignment/model/schemas.ts +++ b/src/entities/assignment/model/schemas.ts @@ -33,21 +33,23 @@ export const assignmentFormSchema = z.object({ ), }); -export const assignmentDetailSchema = z.object({ - id: z.number(), - title: z.string(), - score: z.number().optional(), - description: z.string(), - count: z.number(), - testcases: z.array( - z.object({ - id: z.number(), - testcase: z.string(), - answer: z.string(), - isPublic: z.boolean(), - }) - ), -}); +export const assignmentDetailSchema = z + .object({ + id: z.number(), + title: z.string(), + score: z.number().optional(), + description: z.string(), + count: z.number(), + testcases: z.array( + z.object({ + id: z.number(), + testcase: z.string(), + answer: z.string(), + isPublic: z.boolean(), + }) + ), + }) + .passthrough(); export const assignmentSubmissionResultSchema = z.object({ codeId: z.number(), diff --git a/src/entities/course/model/schemas.ts b/src/entities/course/model/schemas.ts index 144591a..ac5758a 100644 --- a/src/entities/course/model/schemas.ts +++ b/src/entities/course/model/schemas.ts @@ -1,6 +1,7 @@ import {unitSchema} from '@/entities/unit/model/schemas'; import {semesterCodeSchema} from '@/shared/model/schemas'; import {z} from 'zod'; +import {studentSchema} from '@/entities/student/model/schemas'; /** 강의 공통 핵심 필드 */ export const courseCoreSchema = z.object({ @@ -20,7 +21,10 @@ export const courseBaseSchema = courseCoreSchema.extend({ description: z .string() .nullish() - .transform((v) => v ?? ''), // 응답에서는 기본값 처리 + .transform((v) => v ?? ''), + studentCount: z.number().optional(), + unitCount: z.number().optional(), + students: z.array(studentSchema).optional(), }); /** 강의 추가/수정 요청 스키마 */ @@ -36,6 +40,7 @@ export const courseOverviewSchema = courseBaseSchema.extend({ studentCount: z.number().optional(), unitCount: z.number(), units: z.array(unitSchema), + students: z.array(studentSchema).optional(), chatRoomId: z.number().nullable().optional(), }); diff --git a/src/entities/student/model/schemas.ts b/src/entities/student/model/schemas.ts index 875872e..96c67ea 100644 --- a/src/entities/student/model/schemas.ts +++ b/src/entities/student/model/schemas.ts @@ -32,38 +32,47 @@ export const studentUnitSchema = z.object({ }); // 목록 조회용 학생 스키마 -export const studentSchema = z.object({ - id: z.number(), - studentId: z.string(), - name: z.string(), - score: z.number(), - totalScore: z.number(), - progress: z.array(studentProgressSchema), -}); +export const studentSchema = z + .object({ + id: z.number(), + studentId: z.union([z.string(), z.number()]).transform((v) => String(v)), + name: z.string(), + score: z.number(), + totalScore: z.number(), + progress: z.array(studentProgressSchema), + }) + .passthrough(); // 개별 학생 조회용 스키마 -export const studentDetailSchema = z.object({ - id: z.number(), - name: z.string(), - studentId: z.string(), - email: z.string(), - title: z.string(), - score: z.number(), - totalScore: z.number(), - unitCount: z.number(), - progress: z.array(studentProgressSchema), - units: z.array(studentUnitSchema), -}); +export const studentDetailSchema = z + .object({ + id: z.number(), + name: z.string(), + studentId: z.union([z.string(), z.number()]).transform((v) => String(v)), + email: z.string(), + title: z.string(), + score: z.number(), + totalScore: z.number(), + unitCount: z.number(), + progress: z.array(studentProgressSchema), + units: z.array(studentUnitSchema), + }) + .passthrough(); // 목록 조회 response 전체 스키마 -export const enrollmentListSchema = z.object({ - id: z.number(), - title: z.string(), - section: z.string(), - unitCount: z.number(), - studentCount: z.number(), - students: z.array(studentSchema), -}); +export const enrollmentListSchema = z + .object({ + id: z.number(), + title: z.string(), + section: z.string(), + unitCount: z.number(), + studentCount: z.number(), + students: z + .array(studentSchema) + .nullish() + .transform((v) => v ?? []), + }) + .passthrough(); export type TStudent = z.infer; export type TEnrollmentList = z.infer; From 15988c45e91cf077ef5f3a6d26b7e428def8dfb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=95=98=EC=A7=80=EB=AF=BC?= <163178666+JiiminHa@users.noreply.github.com> Date: Thu, 14 May 2026 11:17:20 +0900 Subject: [PATCH 03/17] =?UTF-8?q?#115=20refactor:=20=EA=B0=95=EC=9D=98=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=8B=9C=20=EB=AF=B8=EA=B0=80=EC=9E=85=20?= =?UTF-8?q?=ED=95=99=EC=83=9D=20=EB=93=B1=EB=A1=9D=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../create-course/model/useCreateCourse.ts | 56 +++++++++++++++++-- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/src/features/course/create-course/model/useCreateCourse.ts b/src/features/course/create-course/model/useCreateCourse.ts index f8a78bc..a742d50 100644 --- a/src/features/course/create-course/model/useCreateCourse.ts +++ b/src/features/course/create-course/model/useCreateCourse.ts @@ -1,5 +1,9 @@ import {useMutation, useQueryClient} from '@tanstack/react-query'; import {createCourse} from '@/entities/course/api/courseApi'; +import { + addEnrollment, + getEnrollments, +} from '@/entities/student/api/studentApi'; import {courseQueries} from '@/entities/course/api/courseQueries'; import {handleApiError} from '@/shared/lib/handleApiError'; import {useNavigate} from 'react-router-dom'; @@ -14,15 +18,59 @@ export const useCreateCourse = () => { const {showToast} = useToastStore(); const {mutate, isPending} = useMutation({ - mutationFn: createCourse, - onSuccess: () => { + mutationFn: async ( + payload: Parameters[0] + ): Promise<{course: any; failedIds: string[]}> => { + const course = await createCourse(payload); + const students = payload.students ?? []; + + if (students.length === 0) return {course, failedIds: []}; + + // 백엔드가 일부 학생을 이미 등록했을 수 있으므로 확인 (중복 등록 에러 방지) + const enrollments = await getEnrollments(course.id, { + page: 0, + pageSize: 1000, + }); + const enrolledStudentIds = new Set( + enrollments.response.students.map((s) => s.studentId) + ); + + // 아직 등록되지 않은 학생들만 개별 등록 시도 + const missingStudents = students.filter( + ({studentId}) => !enrolledStudentIds.has(studentId) + ); + + const results = await Promise.allSettled( + missingStudents.map(({studentId}) => addEnrollment(course.id, studentId)) + ); + + const failedIds = results + .map((result, index) => + result.status === 'rejected' ? missingStudents[index].studentId : null + ) + .filter((id): id is string => id !== null); + + return {course, failedIds}; + }, + onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: courseQueries.getAllCourses().queryKey, }); - showToast('강의가 개설되었습니다.'); + + if (data.failedIds.length > 0) { + alert( + `강의는 생성되었으나, 다음 학생들은 가입되지 않았거나 학번이 올바르지 않아 등록에 실패했습니다:\n${data.failedIds.join( + ', ' + )}` + ); + } else { + showToast('강의가 개설되었습니다.'); + } + navigate(ROUTES.ADMIN.ROOT); }, - onError: (error) => { + onError: (error: any) => { + // PARTIAL_SUCCESS 상황이 아닌 실제 API 에러인 경우에만 처리 handleApiError(error, '강의 개설에 실패했습니다. 다시 시도해주세요.'); }, }); From 7437d60bc5835d31aafd8e2c92d696d7da34712f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=95=98=EC=A7=80=EB=AF=BC?= <163178666+JiiminHa@users.noreply.github.com> Date: Thu, 14 May 2026 11:18:02 +0900 Subject: [PATCH 04/17] =?UTF-8?q?#115=20refactor:=20=EA=B0=95=EC=9D=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EC=8B=9C=20=EC=A4=91=EB=B3=B5=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EB=B0=A9=EC=A7=80=20=EB=B0=8F=20=EC=8B=A4=ED=8C=A8?= =?UTF-8?q?=20=EC=95=8C=EB=A6=BC=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../course/edit-course/model/useEditCourse.ts | 54 +++++++++++++++++-- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/src/features/course/edit-course/model/useEditCourse.ts b/src/features/course/edit-course/model/useEditCourse.ts index d00b743..ccd4014 100644 --- a/src/features/course/edit-course/model/useEditCourse.ts +++ b/src/features/course/edit-course/model/useEditCourse.ts @@ -1,5 +1,9 @@ import {useMutation, useQueryClient} from '@tanstack/react-query'; import {updateCourse} from '@/entities/course/api/courseApi'; +import { + addEnrollment, + getEnrollments, +} from '@/entities/student/api/studentApi'; import {courseQueries} from '@/entities/course/api/courseQueries'; import {handleApiError} from '@/shared/lib/handleApiError'; import {useNavigate} from 'react-router-dom'; @@ -14,16 +18,58 @@ export const useEditCourse = (courseId: number) => { const {showToast} = useToastStore(); const {mutate, isPending} = useMutation({ - mutationFn: (data: Parameters[1]) => - updateCourse(courseId, data), - onSuccess: () => { + mutationFn: async ( + data: Parameters[1] + ): Promise<{course: any; failedIds: string[]}> => { + const course = await updateCourse(courseId, data); + const students = data.students ?? []; + + if (students.length === 0) return {course, failedIds: []}; + + // 기존 수강생 목록 조회 (중복 등록 방지 및 가입 여부 확인용) + const enrollments = await getEnrollments(courseId, { + page: 0, + pageSize: 1000, + }); + const enrolledStudentIds = new Set( + enrollments.response.students.map((student) => student.studentId) + ); + + // 아직 등록되지 않은 학생들만 시도 + const missingStudents = students.filter( + ({studentId}) => !enrolledStudentIds.has(studentId) + ); + + const results = await Promise.allSettled( + missingStudents.map(({studentId}) => addEnrollment(courseId, studentId)) + ); + + const failedIds = results + .map((result, index) => + result.status === 'rejected' ? missingStudents[index].studentId : null + ) + .filter((id): id is string => id !== null); + + return {course, failedIds}; + }, + onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: courseQueries.getAllCourses().queryKey, }); queryClient.invalidateQueries({ queryKey: courseQueries.getCourseDetails(courseId).queryKey, }); - showToast('강의가 수정되었습니다.'); + + if (data.failedIds.length > 0) { + alert( + `강의 정보는 수정되었으나, 다음 학생들은 가입되지 않았거나 학번이 올바르지 않아 등록에 실패했습니다:\n${data.failedIds.join( + ', ' + )}` + ); + } else { + showToast('강의가 수정되었습니다.'); + } + navigate(ROUTES.ADMIN.ROOT); }, onError: (error) => { From 5b6460a407a455432932d477eef2601daf00a34c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=95=98=EC=A7=80=EB=AF=BC?= <163178666+JiiminHa@users.noreply.github.com> Date: Thu, 14 May 2026 11:18:48 +0900 Subject: [PATCH 05/17] =?UTF-8?q?#115=20feat:=20=ED=95=99=EC=83=9D=20?= =?UTF-8?q?=EA=B0=9C=EB=B3=84=20=EC=82=AD=EC=A0=9C=20API=20=EB=B0=8F=20?= =?UTF-8?q?=EB=B2=8C=ED=81=AC=20=EC=82=AD=EC=A0=9C=20Mutation=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/student/api/studentApi.ts | 24 +++++++++++++++++++- src/entities/student/api/studentMutations.ts | 16 ++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/entities/student/api/studentApi.ts b/src/entities/student/api/studentApi.ts index 82ca3d3..844d729 100644 --- a/src/entities/student/api/studentApi.ts +++ b/src/entities/student/api/studentApi.ts @@ -22,5 +22,27 @@ export const getEnrollmentById = async (courseId: number, memberId: number) => { export const addEnrollment = async (courseId: number, studentId: string) => { const res = await privateAxios.post(ENDPOINTS.ENROLLMENTS.BY_COURSE(courseId), {studentId}); - return apiResponseSchema(z.string()).parse(res.data); + return apiResponseSchema(z.unknown()).parse(res.data); +}; + +export const addEnrollmentsBulk = async (courseId: number, file: File) => { + const formData = new FormData(); + formData.append('file', file); + + const res = await privateAxios.post( + ENDPOINTS.ENROLLMENTS.BULK(courseId), + formData, + { + headers: {'Content-Type': 'multipart/form-data'}, + } + ); + + return apiResponseSchema(z.unknown()).parse(res.data); +}; + +export const deleteEnrollment = async (courseId: number, memberId: number) => { + const res = await privateAxios.delete( + ENDPOINTS.ENROLLMENTS.DETAIL(courseId, memberId) + ); + return apiResponseSchema(z.unknown()).parse(res.data); }; diff --git a/src/entities/student/api/studentMutations.ts b/src/entities/student/api/studentMutations.ts index e95b635..42ff987 100644 --- a/src/entities/student/api/studentMutations.ts +++ b/src/entities/student/api/studentMutations.ts @@ -1,8 +1,22 @@ -import {addEnrollment} from './studentApi'; +import { + addEnrollment, + addEnrollmentsBulk, + deleteEnrollment, +} from './studentApi'; export const studentMutations = { addEnrollment: (courseId: number) => ({ mutationKey: ['addEnrollment', courseId], mutationFn: (studentId: string) => addEnrollment(courseId, studentId), }), + + addEnrollmentsBulk: (courseId: number) => ({ + mutationKey: ['addEnrollmentsBulk', courseId], + mutationFn: (file: File) => addEnrollmentsBulk(courseId, file), + }), + deleteEnrollmentsBulk: (courseId: number) => ({ + mutationKey: ['deleteEnrollmentsBulk', courseId], + mutationFn: (memberIds: number[]) => + Promise.all(memberIds.map((id) => deleteEnrollment(courseId, id))), + }), }; From 22d345abdc122e77ef8b5566713010264e7e9af5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=95=98=EC=A7=80=EB=AF=BC?= <163178666+JiiminHa@users.noreply.github.com> Date: Thu, 14 May 2026 11:19:22 +0900 Subject: [PATCH 06/17] =?UTF-8?q?#115=20feat:=20=ED=95=99=EC=83=9D=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=EC=9D=98=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EB=A5=BC=20=EC=83=81=EC=9C=84=EB=A1=9C=20?= =?UTF-8?q?=EB=A6=AC=ED=94=84=ED=8C=85=ED=95=98=EC=97=AC=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B8=B0=EB=B0=98=20=EB=A7=88?= =?UTF-8?q?=EB=A0=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/student/ui/StudentTable.tsx | 32 +++++++++++------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/src/entities/student/ui/StudentTable.tsx b/src/entities/student/ui/StudentTable.tsx index 1bae704..0317eef 100644 --- a/src/entities/student/ui/StudentTable.tsx +++ b/src/entities/student/ui/StudentTable.tsx @@ -1,42 +1,40 @@ import {Checkbox} from '@/shared/ui/checkbox/Checkbox'; import {ProgressIndicators} from '@/shared/ui/ProgressIndicators'; -import {useState, useEffect} from 'react'; import {Link} from 'react-router-dom'; import type {Student} from '@/entities/student/model/types'; interface StudentTableProps { students: Student[]; courseId: number; + selectedIds: Set; + onSelectionChange: (newSelected: Set) => void; } -export const StudentTable = ({students, courseId}: StudentTableProps) => { - const [selectedStudents, setSelectedStudents] = useState>( - new Set() - ); - const [selectAll, setSelectAll] = useState(false); - useEffect(() => { - setSelectedStudents(new Set()); - setSelectAll(false); - }, [students]); +export const StudentTable = ({ + students, + courseId, + selectedIds, + onSelectionChange, +}: StudentTableProps) => { + const selectAll = + students.length > 0 && selectedIds.size === students.length; const handleSelectAll = (checked: boolean) => { - setSelectAll(checked); if (checked) { - setSelectedStudents(new Set(students.map((s) => s.id))); + onSelectionChange(new Set(students.map((s) => s.id))); } else { - setSelectedStudents(new Set()); + onSelectionChange(new Set()); } }; const handleSelectStudent = (id: number, checked: boolean) => { - const newSelected = new Set(selectedStudents); + const newSelected = new Set(selectedIds); if (checked) { newSelected.add(id); } else { newSelected.delete(id); } - setSelectedStudents(newSelected); - setSelectAll(newSelected.size === students.length); + onSelectionChange(newSelected); }; return ( @@ -61,7 +59,7 @@ export const StudentTable = ({students, courseId}: StudentTableProps) => { handleSelectStudent(student.id, checked)} aria-label={`${student.name} 학생 선택`} /> From 8213c291d3df790ecc4e9f89f921e8fd65456617 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=95=98=EC=A7=80=EB=AF=BC?= <163178666+JiiminHa@users.noreply.github.com> Date: Thu, 14 May 2026 11:21:54 +0900 Subject: [PATCH 07/17] =?UTF-8?q?#115=20refactor:=20=EA=B0=95=EC=9D=98=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1/=EC=88=98=EC=A0=95=20=EB=AE=A4=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EB=A1=9C=EC=A7=81=EC=9D=84=20courseMutati?= =?UTF-8?q?ons.ts=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/course/api/courseMutations.ts | 86 +++++++++++++++++++++- 1 file changed, 84 insertions(+), 2 deletions(-) diff --git a/src/entities/course/api/courseMutations.ts b/src/entities/course/api/courseMutations.ts index 6b53814..f9c3ce8 100644 --- a/src/entities/course/api/courseMutations.ts +++ b/src/entities/course/api/courseMutations.ts @@ -1,7 +1,89 @@ -import {deleteCourse} from '@/entities/course/api/courseApi'; +import { + createCourse, + deleteCourse, + updateCourse, +} from '@/entities/course/api/courseApi'; +import { + addEnrollment, + getEnrollments, +} from '@/entities/student/api/studentApi'; export const courseMutations = { - // 강의 삭제 뮤테이션 옵션 + // 강의 개설 (학생 등록 확인 로직 포함) + createCourse: { + mutationKey: ['createCourse'], + mutationFn: async ( + payload: Parameters[0] + ): Promise<{course: any; failedIds: string[]}> => { + const course = await createCourse(payload); + const students = payload.students ?? []; + + if (students.length === 0) return {course, failedIds: []}; + + const enrollments = await getEnrollments(course.id, { + page: 0, + pageSize: 1000, + }); + const enrolledStudentIds = new Set( + enrollments.response.students.map((s) => s.studentId) + ); + + const missingStudents = students.filter( + ({studentId}) => !enrolledStudentIds.has(studentId) + ); + + const results = await Promise.allSettled( + missingStudents.map(({studentId}) => addEnrollment(course.id, studentId)) + ); + + const failedIds = results + .map((result, index) => + result.status === 'rejected' ? missingStudents[index].studentId : null + ) + .filter((id): id is string => id !== null); + + return {course, failedIds}; + }, + }, + + // 강의 수정 (학생 등록 확인 로직 포함) + updateCourse: (courseId: number) => ({ + mutationKey: ['updateCourse', courseId], + mutationFn: async ( + data: Parameters[1] + ): Promise<{course: any; failedIds: string[]}> => { + const course = await updateCourse(courseId, data); + const students = data.students ?? []; + + if (students.length === 0) return {course, failedIds: []}; + + const enrollments = await getEnrollments(courseId, { + page: 0, + pageSize: 1000, + }); + const enrolledStudentIds = new Set( + enrollments.response.students.map((student) => student.studentId) + ); + + const missingStudents = students.filter( + ({studentId}) => !enrolledStudentIds.has(studentId) + ); + + const results = await Promise.allSettled( + missingStudents.map(({studentId}) => addEnrollment(courseId, studentId)) + ); + + const failedIds = results + .map((result, index) => + result.status === 'rejected' ? missingStudents[index].studentId : null + ) + .filter((id): id is string => id !== null); + + return {course, failedIds}; + }, + }), + + // 강의 삭제 deleteCourse: { mutationKey: ['deleteCourse'], mutationFn: (courseId: number) => deleteCourse(courseId), From 51701f34427f080331eecb2ea5b27bd3a26167aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=95=98=EC=A7=80=EB=AF=BC?= <163178666+JiiminHa@users.noreply.github.com> Date: Thu, 14 May 2026 11:22:36 +0900 Subject: [PATCH 08/17] =?UTF-8?q?#115=20refactor:=20useCreateCourse=20?= =?UTF-8?q?=ED=9B=85=EC=9D=B4=20=EC=A4=91=EC=95=99=ED=99=94=EB=90=9C=20?= =?UTF-8?q?=EB=AE=A4=ED=85=8C=EC=9D=B4=EC=85=98=EC=9D=84=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../create-course/model/useCreateCourse.ts | 42 +------------------ 1 file changed, 2 insertions(+), 40 deletions(-) diff --git a/src/features/course/create-course/model/useCreateCourse.ts b/src/features/course/create-course/model/useCreateCourse.ts index a742d50..d4e15cb 100644 --- a/src/features/course/create-course/model/useCreateCourse.ts +++ b/src/features/course/create-course/model/useCreateCourse.ts @@ -1,10 +1,6 @@ import {useMutation, useQueryClient} from '@tanstack/react-query'; -import {createCourse} from '@/entities/course/api/courseApi'; -import { - addEnrollment, - getEnrollments, -} from '@/entities/student/api/studentApi'; import {courseQueries} from '@/entities/course/api/courseQueries'; +import {courseMutations} from '@/entities/course/api/courseMutations'; import {handleApiError} from '@/shared/lib/handleApiError'; import {useNavigate} from 'react-router-dom'; import {ROUTES} from '@/shared/config/routes'; @@ -18,40 +14,7 @@ export const useCreateCourse = () => { const {showToast} = useToastStore(); const {mutate, isPending} = useMutation({ - mutationFn: async ( - payload: Parameters[0] - ): Promise<{course: any; failedIds: string[]}> => { - const course = await createCourse(payload); - const students = payload.students ?? []; - - if (students.length === 0) return {course, failedIds: []}; - - // 백엔드가 일부 학생을 이미 등록했을 수 있으므로 확인 (중복 등록 에러 방지) - const enrollments = await getEnrollments(course.id, { - page: 0, - pageSize: 1000, - }); - const enrolledStudentIds = new Set( - enrollments.response.students.map((s) => s.studentId) - ); - - // 아직 등록되지 않은 학생들만 개별 등록 시도 - const missingStudents = students.filter( - ({studentId}) => !enrolledStudentIds.has(studentId) - ); - - const results = await Promise.allSettled( - missingStudents.map(({studentId}) => addEnrollment(course.id, studentId)) - ); - - const failedIds = results - .map((result, index) => - result.status === 'rejected' ? missingStudents[index].studentId : null - ) - .filter((id): id is string => id !== null); - - return {course, failedIds}; - }, + ...courseMutations.createCourse, onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: courseQueries.getAllCourses().queryKey, @@ -70,7 +33,6 @@ export const useCreateCourse = () => { navigate(ROUTES.ADMIN.ROOT); }, onError: (error: any) => { - // PARTIAL_SUCCESS 상황이 아닌 실제 API 에러인 경우에만 처리 handleApiError(error, '강의 개설에 실패했습니다. 다시 시도해주세요.'); }, }); From 950ce82761b624572596c377e1c53986e38f930e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=95=98=EC=A7=80=EB=AF=BC?= <163178666+JiiminHa@users.noreply.github.com> Date: Thu, 14 May 2026 11:22:53 +0900 Subject: [PATCH 09/17] =?UTF-8?q?#115=20refactor:=20useEditCourse=20?= =?UTF-8?q?=ED=9B=85=EC=9D=B4=20=EC=A4=91=EC=95=99=ED=99=94=EB=90=9C=20?= =?UTF-8?q?=EB=AE=A4=ED=85=8C=EC=9D=B4=EC=85=98=EC=9D=84=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../course/edit-course/model/useEditCourse.ts | 41 +------------------ 1 file changed, 2 insertions(+), 39 deletions(-) diff --git a/src/features/course/edit-course/model/useEditCourse.ts b/src/features/course/edit-course/model/useEditCourse.ts index ccd4014..c6ca2d1 100644 --- a/src/features/course/edit-course/model/useEditCourse.ts +++ b/src/features/course/edit-course/model/useEditCourse.ts @@ -1,10 +1,6 @@ import {useMutation, useQueryClient} from '@tanstack/react-query'; -import {updateCourse} from '@/entities/course/api/courseApi'; -import { - addEnrollment, - getEnrollments, -} from '@/entities/student/api/studentApi'; import {courseQueries} from '@/entities/course/api/courseQueries'; +import {courseMutations} from '@/entities/course/api/courseMutations'; import {handleApiError} from '@/shared/lib/handleApiError'; import {useNavigate} from 'react-router-dom'; import {ROUTES} from '@/shared/config/routes'; @@ -18,40 +14,7 @@ export const useEditCourse = (courseId: number) => { const {showToast} = useToastStore(); const {mutate, isPending} = useMutation({ - mutationFn: async ( - data: Parameters[1] - ): Promise<{course: any; failedIds: string[]}> => { - const course = await updateCourse(courseId, data); - const students = data.students ?? []; - - if (students.length === 0) return {course, failedIds: []}; - - // 기존 수강생 목록 조회 (중복 등록 방지 및 가입 여부 확인용) - const enrollments = await getEnrollments(courseId, { - page: 0, - pageSize: 1000, - }); - const enrolledStudentIds = new Set( - enrollments.response.students.map((student) => student.studentId) - ); - - // 아직 등록되지 않은 학생들만 시도 - const missingStudents = students.filter( - ({studentId}) => !enrolledStudentIds.has(studentId) - ); - - const results = await Promise.allSettled( - missingStudents.map(({studentId}) => addEnrollment(courseId, studentId)) - ); - - const failedIds = results - .map((result, index) => - result.status === 'rejected' ? missingStudents[index].studentId : null - ) - .filter((id): id is string => id !== null); - - return {course, failedIds}; - }, + ...courseMutations.updateCourse(courseId), onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: courseQueries.getAllCourses().queryKey, From a0f6c9a18c9ac3de6fb8736479ac5aadaf99a682 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=95=98=EC=A7=80=EB=AF=BC?= <163178666+JiiminHa@users.noreply.github.com> Date: Thu, 14 May 2026 11:26:02 +0900 Subject: [PATCH 10/17] =?UTF-8?q?#115=20chore:=20FileUpload=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=EC=97=90=20label=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=8D=BC=ED=8B=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/ui/FileUpload.tsx | 85 ++++++++++++++++++++++++++++++++++-- 1 file changed, 81 insertions(+), 4 deletions(-) diff --git a/src/shared/ui/FileUpload.tsx b/src/shared/ui/FileUpload.tsx index 0da270b..7b5d419 100644 --- a/src/shared/ui/FileUpload.tsx +++ b/src/shared/ui/FileUpload.tsx @@ -1,13 +1,15 @@ import {useRef, useState} from 'react'; import type {ChangeEvent} from 'react'; import FileIcon from '@/assets/svg/file.svg?react'; +import Chevrondown from '@/assets/svg/chevrondown.svg?react'; type FileUploadProps = { - label: string; + label?: string; onFileChange: (file: File | null) => void; description?: string; accept?: string; className?: string; + variant?: 'default' | 'compact'; }; export default function FileUpload({ @@ -16,6 +18,7 @@ export default function FileUpload({ description = '업로드하기', accept = '.csv', className, + variant = 'default', }: FileUploadProps) { const inputRef = useRef(null); const [selectedFile, setSelectedFile] = useState(null); @@ -69,11 +72,85 @@ export default function FileUpload({ onFileChange(null); }; + if (variant === 'compact') { + return ( +
+ {label && ( + + {label} + + )} + + {selectedFile ? ( +
inputRef.current?.click()} + onKeyDown={(e) => + (e.key === 'Enter' || e.key === ' ') && inputRef.current?.click() + } + onDragEnter={handleDragEnter} + onDragLeave={handleDragLeave} + onDragOver={handleDragOver} + onDrop={handleDrop} + className='relative flex h-11 min-w-0 cursor-pointer items-center rounded-[9px] border border-purple-stroke bg-white px-[14px] pr-20 focus:outline-none focus:ring-2 focus:ring-primary'> + + {selectedFile.name} + + + +
+ ) : ( +
inputRef.current?.click()} + onKeyDown={(e) => + (e.key === 'Enter' || e.key === ' ') && inputRef.current?.click() + } + onDragEnter={handleDragEnter} + onDragLeave={handleDragLeave} + onDragOver={handleDragOver} + onDrop={handleDrop} + className={[ + 'relative flex h-11 min-w-0 cursor-pointer items-center rounded-[9px] border border-purple-stroke bg-white px-[14px] pr-10 text-left', + 'focus:outline-none focus:ring-2 focus:ring-primary', + ].join(' ')}> + + {description} + + +
+ )} + + +
+ ); + } + return (
- - {label} - + {label && ( + + {label} + + )} {selectedFile ? (
From 482108603fa38678e215f366dce0d1b128211b42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=95=98=EC=A7=80=EB=AF=BC?= <163178666+JiiminHa@users.noreply.github.com> Date: Thu, 14 May 2026 11:43:35 +0900 Subject: [PATCH 11/17] =?UTF-8?q?#115=20feat:=20=EA=B3=BC=EC=A0=9C=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=BC=80=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=EC=9D=BC=EA=B4=84=20=EC=97=85=EB=A1=9C=EB=93=9C=20API=20?= =?UTF-8?q?=EB=B0=8F=20Mutation=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/assignment/api/assignmentApi.ts | 19 +++++++++++++++++++ .../assignment/api/assignmentMutations.ts | 12 ++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/entities/assignment/api/assignmentApi.ts b/src/entities/assignment/api/assignmentApi.ts index 0e358a0..fabab03 100644 --- a/src/entities/assignment/api/assignmentApi.ts +++ b/src/entities/assignment/api/assignmentApi.ts @@ -84,6 +84,25 @@ export const updateAssignment = async ( return parsed.response; }; +// 테스트케이스 JSON 파일 업로드 API +export const uploadTestcasesBulk = async ( + assignmentId: number, + file: File +) => { + const formData = new FormData(); + formData.append('file', file); + + const response = await privateAxios.post( + ENDPOINTS.TESTCASES.BULK(assignmentId), + formData, + { + headers: {'Content-Type': 'multipart/form-data'}, + } + ); + const parsed = apiResponseSchema(z.unknown()).parse(response.data); + return parsed.response; +}; + // 과제 삭제 API export const deleteAssignment = async (assignmentId: number) => { const response = await privateAxios.delete( diff --git a/src/entities/assignment/api/assignmentMutations.ts b/src/entities/assignment/api/assignmentMutations.ts index a310b33..686c793 100644 --- a/src/entities/assignment/api/assignmentMutations.ts +++ b/src/entities/assignment/api/assignmentMutations.ts @@ -4,6 +4,7 @@ import { updateAssignment, deleteAssignment, submitAssignment, + uploadTestcasesBulk, } from '@/entities/assignment/api/assignmentApi'; interface UpdateAssignmentVariables { @@ -28,6 +29,17 @@ export const assignmentMutations = { updateAssignment(assignmentId, form), }, + uploadTestcasesBulk: { + mutationKey: ['uploadTestcasesBulk'], + mutationFn: ({ + assignmentId, + file, + }: { + assignmentId: number; + file: File; + }) => uploadTestcasesBulk(assignmentId, file), + }, + submitAssignment: { mutationKey: ['submitAssignment'], mutationFn: ({ From f24dca2a845ec050f9058704cf438f0f9f531e24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=95=98=EC=A7=80=EB=AF=BC?= <163178666+JiiminHa@users.noreply.github.com> Date: Thu, 14 May 2026 11:43:54 +0900 Subject: [PATCH 12/17] =?UTF-8?q?#115=20feat:=20TestcaseRow=EC=97=90=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EB=B2=84=ED=8A=BC=20=EB=B0=8F=20=EA=B3=B5?= =?UTF-8?q?=EA=B0=9C=EC=97=AC=EB=B6=80=20=EB=A1=9C=EC=A7=81=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 --- src/pages/admin/assignments/ui/TestcaseRow.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/pages/admin/assignments/ui/TestcaseRow.tsx b/src/pages/admin/assignments/ui/TestcaseRow.tsx index 1435ad3..76d8a5f 100644 --- a/src/pages/admin/assignments/ui/TestcaseRow.tsx +++ b/src/pages/admin/assignments/ui/TestcaseRow.tsx @@ -1,5 +1,6 @@ import LabeledInput from '@/shared/ui/LabeledInput'; import LabeledDropdown from '@/shared/ui/LabeledDropdown'; +import BinIcon from '@/assets/svg/binIcon.svg?react'; const PUBLIC_OPTIONS = ['공개', '비공개']; @@ -11,6 +12,7 @@ interface TestcaseRowProps { onTestcaseChange: (value: string) => void; onAnswerChange: (value: string) => void; onHiddenChange: (value: boolean) => void; + onDelete: () => void; } const TestcaseRow = ({ @@ -21,11 +23,12 @@ const TestcaseRow = ({ onTestcaseChange, onAnswerChange, onHiddenChange, + onDelete, }: TestcaseRowProps) => { const isFirst = index === 0; return ( -
+
onHiddenChange(value === '비공개')} + onSelect={(value) => onHiddenChange(value === '공개')} /> +
); }; From 985d0c35c643df98a51df1517eaaac8ddf787843 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=95=98=EC=A7=80=EB=AF=BC?= <163178666+JiiminHa@users.noreply.github.com> Date: Thu, 14 May 2026 11:45:01 +0900 Subject: [PATCH 13/17] =?UTF-8?q?#115=20refactor:=20=EA=B3=BC=EC=A0=9C=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=BC=80=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EB=A1=9C=EC=A7=81=EC=9D=84=20TestcaseFiel?= =?UTF-8?q?d=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=EC=99=80=20useAssignme?= =?UTF-8?q?ntForm=20=ED=9B=85=EC=9C=BC=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/assignments/AssignmentFormPage.tsx | 168 +++-------------- .../assignments/model/useAssignmentForm.ts | 92 ++++++++++ .../admin/assignments/ui/TestcaseField.tsx | 171 ++++++++++++++++++ 3 files changed, 291 insertions(+), 140 deletions(-) create mode 100644 src/pages/admin/assignments/model/useAssignmentForm.ts create mode 100644 src/pages/admin/assignments/ui/TestcaseField.tsx diff --git a/src/pages/admin/assignments/AssignmentFormPage.tsx b/src/pages/admin/assignments/AssignmentFormPage.tsx index 7868bc4..f220b1d 100644 --- a/src/pages/admin/assignments/AssignmentFormPage.tsx +++ b/src/pages/admin/assignments/AssignmentFormPage.tsx @@ -1,167 +1,55 @@ +import {useParams} from 'react-router-dom'; import AssignmentFormLayout from '@/widgets/assignment-form-layout/ui/AssignmentFormLayout'; -import FileUpload from '@/shared/ui/FileUpload'; import LabeledInput from '@/shared/ui/LabeledInput'; -import Button from '@/shared/ui/button/Button'; -import AddIcon from '@/assets/svg/addIcon.svg?react'; -import {useEffect, useState} from 'react'; -import {useNavigate, useParams} from 'react-router-dom'; -import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'; -import {assignmentMutations} from '@/entities/assignment/api/assignmentMutations'; -import {assignmentQueries} from '@/entities/assignment/api/assignmentQueries'; -import type {TAssignmentForm} from '@/entities/assignment/model/schemas'; -import TestcaseRow from '@/pages/admin/assignments/ui/TestcaseRow'; -import {useToastStore} from '@/shared/model/useToastStore'; +import TestcaseField from '@/pages/admin/assignments/ui/TestcaseField'; +import {useAssignmentForm} from './model/useAssignmentForm'; const AssignmentFormPage = () => { const {id} = useParams(); const assignmentId = id ? Number(id) : undefined; - const isEditMode = !!assignmentId; - const navigate = useNavigate(); - const queryClient = useQueryClient(); - const {showToast} = useToastStore(); + const { + register, + handleSubmit, + watch, + setValue, + isEditMode, + navigate, + } = useAssignmentForm(assignmentId); - const [title, setTitle] = useState(''); - const [score, setScore] = useState(''); - const [description, setDescription] = useState(''); - const [testcases, setTestcases] = useState([ - {testcase: '', answer: '', isPublic: false}, - ]); - - const {data: assignmentData} = useQuery({ - ...assignmentQueries.getAssignment(assignmentId ?? 0), - enabled: isEditMode, - }); - - useEffect(() => { - if (assignmentData) { - setTitle(assignmentData.title); - setScore((assignmentData.score ?? 0).toString()); - setDescription(assignmentData.description); - setTestcases( - assignmentData.testcases.map(({testcase, answer, isPublic}) => ({ - testcase, - answer, - isPublic, - })) - ); - } - }, [assignmentData]); - - const {mutate: createAssignment} = useMutation({ - ...assignmentMutations.createAssignment, - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: assignmentQueries.getAllAssignments().queryKey, - }); - showToast('문제가 추가되었습니다.'); - navigate(-1); - }, - onError: () => alert('문제 추가에 실패했습니다.'), - }); - - const {mutate: updateAssignment} = useMutation({ - ...assignmentMutations.updateAssignment, - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: assignmentQueries.getAllAssignments().queryKey, - }); - showToast('문제가 수정되었습니다.'); - navigate(-1); - }, - onError: () => alert('문제 수정에 실패했습니다.'), - }); - - const handleAddTestcase = () => { - setTestcases([...testcases, {testcase: '', answer: '', isPublic: true}]); - }; - - const handleConfirm = () => { - const form: TAssignmentForm = { - title, - score: Number(score), - description, - testcases, - }; - - if (isEditMode) { - updateAssignment({assignmentId: assignmentId!, form}); - } else { - createAssignment(form); - } - }; + const testcases = watch('testcases'); return (
setTitle(e.target.value)} - /> - setScore(e.target.value)} + label='문제 이름' + placeholder='입력하세요' + {...register('title')} />
+ +
+ setDescription(e.target.value)} + placeholder='입력하세요' + {...register('description')} /> -
- {testcases.map((tc, idx) => ( - { - const updated = [...testcases]; - updated[idx] = {...updated[idx], testcase: value}; - setTestcases(updated); - }} - onAnswerChange={(value) => { - const updated = [...testcases]; - updated[idx] = {...updated[idx], answer: value}; - setTestcases(updated); - }} - onHiddenChange={(value) => { - const updated = [...testcases]; - updated[idx] = {...updated[idx], isPublic: value}; - setTestcases(updated); - }} - /> - ))} -
- - {}} - className='mb-9' + + setValue('testcases', val)} />
} onCancel={() => navigate(-1)} - onConfirm={handleConfirm} + onConfirm={handleSubmit} + confirmLabel={isEditMode ? '수정' : '등록'} /> ); }; diff --git a/src/pages/admin/assignments/model/useAssignmentForm.ts b/src/pages/admin/assignments/model/useAssignmentForm.ts new file mode 100644 index 0000000..cca59fb --- /dev/null +++ b/src/pages/admin/assignments/model/useAssignmentForm.ts @@ -0,0 +1,92 @@ +import {useEffect, useRef} from 'react'; +import {useNavigate} from 'react-router-dom'; +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'; +import {useForm} from 'react-hook-form'; +import {assignmentMutations} from '@/entities/assignment/api/assignmentMutations'; +import {assignmentQueries} from '@/entities/assignment/api/assignmentQueries'; +import type {TAssignmentForm} from '@/entities/assignment/model/schemas'; +import {useToastStore} from '@/shared/model/useToastStore'; + +export const useAssignmentForm = (assignmentId?: number) => { + const isEditMode = !!assignmentId; + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const {showToast} = useToastStore(); + const hasInitialized = useRef(false); + + const {register, handleSubmit, watch, setValue, reset} = + useForm({ + defaultValues: { + title: '', + score: 0, + description: '', + testcases: [{testcase: '', answer: '', isPublic: true}], + }, + }); + + const {data} = useQuery({ + ...assignmentQueries.getAssignment(assignmentId!), + enabled: isEditMode, + }); + + useEffect(() => { + if (data && !hasInitialized.current) { + hasInitialized.current = true; + reset({ + title: data.title, + score: data.score ?? 0, + description: data.description, + testcases: data.testcases.map(({testcase, answer, isPublic}) => ({ + testcase, + answer, + isPublic, + })), + }); + } + }, [data, reset]); + + const {mutate: createAssignment, isPending: isCreating} = useMutation({ + ...assignmentMutations.createAssignment, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: assignmentQueries.getAllAssignments().queryKey, + }); + showToast('문제가 등록되었습니다.'); + navigate(-1); + }, + onError: () => alert('문제 등록에 실패했습니다.'), + }); + + const {mutate: updateAssignment, isPending: isUpdating} = useMutation({ + ...assignmentMutations.updateAssignment, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: assignmentQueries.getAllAssignments().queryKey, + }); + queryClient.invalidateQueries({ + queryKey: assignmentQueries.getAssignment(assignmentId!).queryKey, + }); + showToast('문제가 수정되었습니다.'); + navigate(-1); + }, + onError: () => alert('문제 수정에 실패했습니다.'), + }); + + const onSubmit = (form: TAssignmentForm) => { + if (isEditMode) { + updateAssignment({assignmentId: assignmentId!, form}); + } else { + createAssignment(form); + } + }; + + return { + register, + handleSubmit: handleSubmit(onSubmit), + watch, + setValue, + isEditMode, + isPending: isCreating || isUpdating, + navigate, + }; +}; diff --git a/src/pages/admin/assignments/ui/TestcaseField.tsx b/src/pages/admin/assignments/ui/TestcaseField.tsx new file mode 100644 index 0000000..a915c95 --- /dev/null +++ b/src/pages/admin/assignments/ui/TestcaseField.tsx @@ -0,0 +1,171 @@ +import {useMutation, useQueryClient} from '@tanstack/react-query'; +import TestcaseRow from './TestcaseRow'; +import Button from '@/shared/ui/button/Button'; +import FileUpload from '@/shared/ui/FileUpload'; +import AddIcon from '@/assets/svg/addIcon.svg?react'; +import {assignmentMutations} from '@/entities/assignment/api/assignmentMutations'; +import {assignmentQueries} from '@/entities/assignment/api/assignmentQueries'; +import {useToastStore} from '@/shared/model/useToastStore'; +import {handleApiError} from '@/shared/lib/handleApiError'; +import type {TAssignmentForm} from '@/entities/assignment/model/schemas'; + +export type TestcaseValue = TAssignmentForm['testcases'][number]; + +type TestcaseJsonValue = { + testcase?: unknown; + input?: unknown; + answer?: unknown; + output?: unknown; + isPublic?: unknown; + public?: unknown; +}; + +const normalizeTestcaseJson = (value: unknown): TestcaseValue[] => { + const list = Array.isArray(value) + ? value + : typeof value === 'object' && value !== null && 'testcases' in value + ? (value as {testcases?: unknown}).testcases + : null; + + if (!Array.isArray(list)) { + throw new Error('JSON은 배열이거나 testcases 배열을 포함해야 합니다.'); + } + + return list.map((item, index) => { + if (typeof item !== 'object' || item === null) { + throw new Error( + `${index + 1}번째 테스트 케이스 형식이 올바르지 않습니다.` + ); + } + + const testcase = item as TestcaseJsonValue; + const input = testcase.testcase ?? testcase.input; + const output = testcase.answer ?? testcase.output; + + if (input === undefined || output === undefined) { + throw new Error( + `${index + 1}번째 테스트 케이스에 입력 또는 출력이 없습니다.` + ); + } + + return { + testcase: String(input), + answer: String(output), + isPublic: + typeof testcase.isPublic === 'boolean' + ? testcase.isPublic + : testcase.public === true, + }; + }); +}; + +interface TestcaseFieldProps { + assignmentId?: number; + value: TestcaseValue[]; + onChange: (value: TestcaseValue[]) => void; +} + +const TestcaseField = ({assignmentId, value, onChange}: TestcaseFieldProps) => { + const queryClient = useQueryClient(); + const {showToast} = useToastStore(); + + const {mutate: uploadTestcasesBulk, isPending: isUploadingTestcases} = + useMutation({ + ...assignmentMutations.uploadTestcasesBulk, + onSuccess: () => { + if (assignmentId) { + queryClient.invalidateQueries({ + queryKey: assignmentQueries.getAssignment(assignmentId).queryKey, + }); + } + showToast('테스트 케이스 JSON을 업로드했습니다.'); + }, + onError: (error) => + handleApiError(error, '테스트 케이스 JSON 업로드에 실패했습니다.'), + }); + + const handleAddTestcase = () => { + onChange([...value, {testcase: '', answer: '', isPublic: true}]); + }; + + const handleDeleteTestcase = (index: number) => { + if (value.length === 1) { + onChange([{testcase: '', answer: '', isPublic: false}]); + return; + } + onChange(value.filter((_, idx) => idx !== index)); + }; + + const handleTestcaseFileChange = async (file: File | null) => { + if (!file) return; + + try { + const parsed = JSON.parse(await file.text()); + const normalizedTestcases = normalizeTestcaseJson(parsed); + onChange(normalizedTestcases); + + if (assignmentId) { + uploadTestcasesBulk({assignmentId, file}); + } else { + showToast('테스트 케이스 JSON을 불러왔습니다.'); + } + } catch (error) { + const message = + error instanceof Error + ? error.message + : '테스트 케이스 JSON을 불러오지 못했습니다.'; + alert(message); + } + }; + + return ( +
+
+ {value.map((tc, idx) => ( + { + const updated = [...value]; + updated[idx] = {...updated[idx], testcase: val}; + onChange(updated); + }} + onAnswerChange={(val) => { + const updated = [...value]; + updated[idx] = {...updated[idx], answer: val}; + onChange(updated); + }} + onHiddenChange={(val) => { + const updated = [...value]; + updated[idx] = {...updated[idx], isPublic: val}; + onChange(updated); + }} + onDelete={() => handleDeleteTestcase(idx)} + /> + ))} +
+ + + + +
+ ); +}; + +export default TestcaseField; From 825b69d8ce04154a7dfed567363155b1962dd133 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=95=98=EC=A7=80=EB=AF=BC?= <163178666+JiiminHa@users.noreply.github.com> Date: Thu, 14 May 2026 11:45:19 +0900 Subject: [PATCH 14/17] =?UTF-8?q?#115=20refactor:=20=ED=95=99=EC=83=9D=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EB=A1=9C=EC=A7=81=EC=9D=84=20useStudentMa?= =?UTF-8?q?nagement=20=ED=9B=85=EC=9C=BC=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=8F=20=EC=84=A0=ED=83=9D=20=EC=82=AD=EC=A0=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/student/StudentManagementPage.tsx | 70 ++++++++---------- .../student/model/useStudentManagement.ts | 74 +++++++++++++++++++ 2 files changed, 105 insertions(+), 39 deletions(-) create mode 100644 src/pages/admin/student/model/useStudentManagement.ts diff --git a/src/pages/admin/student/StudentManagementPage.tsx b/src/pages/admin/student/StudentManagementPage.tsx index 28456b8..99ef627 100644 --- a/src/pages/admin/student/StudentManagementPage.tsx +++ b/src/pages/admin/student/StudentManagementPage.tsx @@ -1,40 +1,37 @@ -import AssignmentFormLayout from '@/widgets/assignment-form-layout/ui/AssignmentFormLayout'; +import {useParams} from 'react-router-dom'; import Search from '@/assets/svg/search.svg?react'; -import {useForm} from 'react-hook-form'; +import AssignmentFormLayout from '@/widgets/assignment-form-layout/ui/AssignmentFormLayout'; import {Pagination} from '@/shared/ui/pagination/pagination'; -import {useState, useEffect} from 'react'; import Input from '@/shared/ui/Input'; import {StudentTable} from '@/entities/student/ui/StudentTable'; -import {useParams} from 'react-router-dom'; -import {useQuery} from '@tanstack/react-query'; -import {studentQueries} from '@/entities/student/api/studentQueries'; import {AddStudentPopover} from '@/entities/student/ui/AddStudentPopover'; +import {useStudentManagement} from './model/useStudentManagement'; export default function StudentManagementPage() { const {courseId} = useParams<{courseId: string}>(); - const [currentPage, setCurrentPage] = useState(1); - const {register, watch} = useForm<{studentSearch: string}>(); - const searchValue = watch('studentSearch'); - - useEffect(() => { - setCurrentPage(1); - }, [searchValue]); + const numericCourseId = Number(courseId); - const {data} = useQuery( - studentQueries.getEnrollments(Number(courseId), { - page: 0, - pageSize: 1000, - studentId: searchValue || undefined, - }) - ); + const { + paginatedStudents, + title, + section, + currentPage, + setCurrentPage, + selectedIds, + setSelectedIds, + register, + handleDelete, + totalItems, + pageSize, + } = useStudentManagement(numericCourseId); const titleExtra = (
- + ); - const pageSize = 10; - const paginatedStudents = data?.students.slice( - (currentPage - 1) * pageSize, - currentPage * pageSize - ) ?? []; - return (
+ } - onCancel={() => {}} + onCancel={handleDelete} onConfirm={() => {}} - cancelLabel='삭제' - confirmLabel='등록' + cancelLabel='선택 삭제' + confirmLabel='확인' + confirmDisabled={true} /> { - setCurrentPage(page); - }} + onPageChange={setCurrentPage} />
); diff --git a/src/pages/admin/student/model/useStudentManagement.ts b/src/pages/admin/student/model/useStudentManagement.ts new file mode 100644 index 0000000..5059562 --- /dev/null +++ b/src/pages/admin/student/model/useStudentManagement.ts @@ -0,0 +1,74 @@ +import {useState, useEffect} from 'react'; +import {useForm} from 'react-hook-form'; +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'; +import {studentQueries} from '@/entities/student/api/studentQueries'; +import {studentMutations} from '@/entities/student/api/studentMutations'; +import {useToastStore} from '@/shared/model/useToastStore'; +import {handleApiError} from '@/shared/lib/handleApiError'; + +export const useStudentManagement = (courseId: number) => { + const [currentPage, setCurrentPage] = useState(1); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const {register, watch} = useForm<{studentSearch: string}>(); + const queryClient = useQueryClient(); + const {showToast} = useToastStore(); + const searchValue = watch('studentSearch'); + + useEffect(() => { + setCurrentPage(1); + setSelectedIds(new Set()); + }, [searchValue]); + + const {data, isLoading} = useQuery( + studentQueries.getEnrollments(courseId, { + page: 0, + pageSize: 1000, + studentId: searchValue || undefined, + }) + ); + + const {mutate: deleteEnrollments, isPending: isDeletePending} = useMutation({ + ...studentMutations.deleteEnrollmentsBulk(courseId), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['enrollments', courseId], + }); + setSelectedIds(new Set()); + showToast('선택한 학생이 삭제되었습니다.'); + }, + onError: (error) => handleApiError(error, '학생 삭제에 실패했습니다.'), + }); + + const handleDelete = () => { + if (selectedIds.size === 0) { + alert('삭제할 학생을 선택해주세요.'); + return; + } + + if (confirm(`정말 선택한 ${selectedIds.size}명의 학생을 삭제하시겠습니까?`)) { + deleteEnrollments(Array.from(selectedIds)); + } + }; + + const pageSize = 10; + const paginatedStudents = + data?.students.slice((currentPage - 1) * pageSize, currentPage * pageSize) ?? + []; + + return { + students: data?.students ?? [], + paginatedStudents, + title: data?.title, + section: data?.section, + currentPage, + setCurrentPage, + selectedIds, + setSelectedIds, + register, + handleDelete, + isDeletePending, + isLoading, + totalItems: data?.students.length ?? 0, + pageSize, + }; +}; From d1659689344154136e46dfb3785a9f2cb4df3012 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=95=98=EC=A7=80=EB=AF=BC?= <163178666+JiiminHa@users.noreply.github.com> Date: Thu, 14 May 2026 11:45:35 +0900 Subject: [PATCH 15/17] =?UTF-8?q?#115=20refactor:=20=EA=B0=95=EC=9D=98=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1/=EC=88=98=EC=A0=95=20=ED=8F=BC=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=ED=95=99=EC=83=9D=20=EB=AA=A9=EB=A1=9D=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=20=EB=A1=9C=EC=A7=81=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/widgets/course-form/ui/CourseForm.tsx | 25 ++++------------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/src/widgets/course-form/ui/CourseForm.tsx b/src/widgets/course-form/ui/CourseForm.tsx index 44f73d4..50f021f 100644 --- a/src/widgets/course-form/ui/CourseForm.tsx +++ b/src/widgets/course-form/ui/CourseForm.tsx @@ -3,8 +3,6 @@ import {useForm} from 'react-hook-form'; import {zodResolver} from '@hookform/resolvers/zod'; import LabeledInput from '@/shared/ui/LabeledInput'; import LabeledDropdown from '@/shared/ui/LabeledDropdown'; -import {Controller} from 'react-hook-form'; -import MultiSelectInput from '@/shared/ui/MultiSelectInput'; import { courseFormSchema, YEAR_OPTIONS, @@ -24,11 +22,13 @@ export const CourseForm = forwardRef( handleSubmit, setValue, watch, - control, formState: {errors}, } = useForm({ resolver: zodResolver(courseFormSchema), - defaultValues, + defaultValues: { + students: [], + ...defaultValues, + }, }); return ( @@ -86,23 +86,6 @@ export const CourseForm = forwardRef( errorMessage={errors.description?.message} {...register('description')} /> - -
- - ( - - )} - />
); From f36727fd8c6128203964d8cbadd31b6457436dcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=95=98=EC=A7=80=EB=AF=BC?= <163178666+JiiminHa@users.noreply.github.com> Date: Thu, 14 May 2026 11:49:10 +0900 Subject: [PATCH 16/17] =?UTF-8?q?#115=20fix:=20courseMutations=20=EB=B0=8F?= =?UTF-8?q?=20useCreateCourse=EC=9D=98=20any=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EB=AA=85=EC=8B=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/course/api/courseMutations.ts | 5 +++-- src/features/course/create-course/model/useCreateCourse.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/entities/course/api/courseMutations.ts b/src/entities/course/api/courseMutations.ts index f9c3ce8..a4ddcf4 100644 --- a/src/entities/course/api/courseMutations.ts +++ b/src/entities/course/api/courseMutations.ts @@ -3,6 +3,7 @@ import { deleteCourse, updateCourse, } from '@/entities/course/api/courseApi'; +import type {TCourseBase} from '@/entities/course/model/schemas'; import { addEnrollment, getEnrollments, @@ -14,7 +15,7 @@ export const courseMutations = { mutationKey: ['createCourse'], mutationFn: async ( payload: Parameters[0] - ): Promise<{course: any; failedIds: string[]}> => { + ): Promise<{course: TCourseBase; failedIds: string[]}> => { const course = await createCourse(payload); const students = payload.students ?? []; @@ -51,7 +52,7 @@ export const courseMutations = { mutationKey: ['updateCourse', courseId], mutationFn: async ( data: Parameters[1] - ): Promise<{course: any; failedIds: string[]}> => { + ): Promise<{course: TCourseBase; failedIds: string[]}> => { const course = await updateCourse(courseId, data); const students = data.students ?? []; diff --git a/src/features/course/create-course/model/useCreateCourse.ts b/src/features/course/create-course/model/useCreateCourse.ts index d4e15cb..9348ff3 100644 --- a/src/features/course/create-course/model/useCreateCourse.ts +++ b/src/features/course/create-course/model/useCreateCourse.ts @@ -32,7 +32,7 @@ export const useCreateCourse = () => { navigate(ROUTES.ADMIN.ROOT); }, - onError: (error: any) => { + onError: (error: unknown) => { handleApiError(error, '강의 개설에 실패했습니다. 다시 시도해주세요.'); }, }); From 39136f3b1cd0b5d96c66220a6c0b82f68a98d761 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=95=98=EC=A7=80=EB=AF=BC?= <163178666+JiiminHa@users.noreply.github.com> Date: Thu, 14 May 2026 12:03:46 +0900 Subject: [PATCH 17/17] =?UTF-8?q?#115=20chore:=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EC=88=98=EC=A0=95=EC=82=AC=ED=95=AD=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/assignment/api/assignmentApi.ts | 5 +- src/entities/course/api/courseMutations.ts | 94 +++++++++++-------- src/entities/student/api/studentApi.ts | 5 +- src/entities/student/api/studentMutations.ts | 13 ++- .../assignments/model/useAssignmentForm.ts | 5 +- .../admin/assignments/ui/TestcaseField.tsx | 6 +- 6 files changed, 75 insertions(+), 53 deletions(-) diff --git a/src/entities/assignment/api/assignmentApi.ts b/src/entities/assignment/api/assignmentApi.ts index fabab03..13fc3af 100644 --- a/src/entities/assignment/api/assignmentApi.ts +++ b/src/entities/assignment/api/assignmentApi.ts @@ -94,10 +94,7 @@ export const uploadTestcasesBulk = async ( const response = await privateAxios.post( ENDPOINTS.TESTCASES.BULK(assignmentId), - formData, - { - headers: {'Content-Type': 'multipart/form-data'}, - } + formData ); const parsed = apiResponseSchema(z.unknown()).parse(response.data); return parsed.response; diff --git a/src/entities/course/api/courseMutations.ts b/src/entities/course/api/courseMutations.ts index a4ddcf4..963da27 100644 --- a/src/entities/course/api/courseMutations.ts +++ b/src/entities/course/api/courseMutations.ts @@ -21,29 +21,38 @@ export const courseMutations = { if (students.length === 0) return {course, failedIds: []}; - const enrollments = await getEnrollments(course.id, { - page: 0, - pageSize: 1000, - }); - const enrolledStudentIds = new Set( - enrollments.response.students.map((s) => s.studentId) - ); + try { + const enrollments = await getEnrollments(course.id, { + page: 0, + pageSize: 1000, + }); + const enrolledStudentIds = new Set( + enrollments.response.students.map((s) => String(s.studentId)) + ); - const missingStudents = students.filter( - ({studentId}) => !enrolledStudentIds.has(studentId) - ); + const missingStudents = students.filter( + ({studentId}) => !enrolledStudentIds.has(String(studentId)) + ); - const results = await Promise.allSettled( - missingStudents.map(({studentId}) => addEnrollment(course.id, studentId)) - ); + const results = await Promise.allSettled( + missingStudents.map(({studentId}) => + addEnrollment(course.id, String(studentId)) + ) + ); - const failedIds = results - .map((result, index) => - result.status === 'rejected' ? missingStudents[index].studentId : null - ) - .filter((id): id is string => id !== null); + const failedIds = results + .map((result, index) => + result.status === 'rejected' + ? String(missingStudents[index].studentId) + : null + ) + .filter((id): id is string => id !== null); - return {course, failedIds}; + return {course, failedIds}; + } catch { + // 등록 확인 실패 시에도 강의 생성 성공은 유지 + return {course, failedIds: students.map(({studentId}) => String(studentId))}; + } }, }, @@ -58,29 +67,38 @@ export const courseMutations = { if (students.length === 0) return {course, failedIds: []}; - const enrollments = await getEnrollments(courseId, { - page: 0, - pageSize: 1000, - }); - const enrolledStudentIds = new Set( - enrollments.response.students.map((student) => student.studentId) - ); + try { + const enrollments = await getEnrollments(courseId, { + page: 0, + pageSize: 1000, + }); + const enrolledStudentIds = new Set( + enrollments.response.students.map((student) => String(student.studentId)) + ); - const missingStudents = students.filter( - ({studentId}) => !enrolledStudentIds.has(studentId) - ); + const missingStudents = students.filter( + ({studentId}) => !enrolledStudentIds.has(String(studentId)) + ); - const results = await Promise.allSettled( - missingStudents.map(({studentId}) => addEnrollment(courseId, studentId)) - ); + const results = await Promise.allSettled( + missingStudents.map(({studentId}) => + addEnrollment(courseId, String(studentId)) + ) + ); - const failedIds = results - .map((result, index) => - result.status === 'rejected' ? missingStudents[index].studentId : null - ) - .filter((id): id is string => id !== null); + const failedIds = results + .map((result, index) => + result.status === 'rejected' + ? String(missingStudents[index].studentId) + : null + ) + .filter((id): id is string => id !== null); - return {course, failedIds}; + return {course, failedIds}; + } catch { + // 등록 확인 실패 시에도 강의 수정 성공은 유지 + return {course, failedIds: students.map(({studentId}) => String(studentId))}; + } }, }), diff --git a/src/entities/student/api/studentApi.ts b/src/entities/student/api/studentApi.ts index 844d729..72790e2 100644 --- a/src/entities/student/api/studentApi.ts +++ b/src/entities/student/api/studentApi.ts @@ -31,10 +31,7 @@ export const addEnrollmentsBulk = async (courseId: number, file: File) => { const res = await privateAxios.post( ENDPOINTS.ENROLLMENTS.BULK(courseId), - formData, - { - headers: {'Content-Type': 'multipart/form-data'}, - } + formData ); return apiResponseSchema(z.unknown()).parse(res.data); diff --git a/src/entities/student/api/studentMutations.ts b/src/entities/student/api/studentMutations.ts index 42ff987..a563fab 100644 --- a/src/entities/student/api/studentMutations.ts +++ b/src/entities/student/api/studentMutations.ts @@ -16,7 +16,16 @@ export const studentMutations = { }), deleteEnrollmentsBulk: (courseId: number) => ({ mutationKey: ['deleteEnrollmentsBulk', courseId], - mutationFn: (memberIds: number[]) => - Promise.all(memberIds.map((id) => deleteEnrollment(courseId, id))), + mutationFn: async (memberIds: number[]) => { + const results = await Promise.allSettled( + memberIds.map((id) => deleteEnrollment(courseId, id)) + ); + const failed = results + .map((result, index) => + result.status === 'rejected' ? memberIds[index] : null + ) + .filter((id): id is number => id !== null); + return {total: memberIds.length, failed}; + }, }), }; diff --git a/src/pages/admin/assignments/model/useAssignmentForm.ts b/src/pages/admin/assignments/model/useAssignmentForm.ts index cca59fb..90c030d 100644 --- a/src/pages/admin/assignments/model/useAssignmentForm.ts +++ b/src/pages/admin/assignments/model/useAssignmentForm.ts @@ -4,6 +4,7 @@ import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'; import {useForm} from 'react-hook-form'; import {assignmentMutations} from '@/entities/assignment/api/assignmentMutations'; import {assignmentQueries} from '@/entities/assignment/api/assignmentQueries'; +import {handleApiError} from '@/shared/lib/handleApiError'; import type {TAssignmentForm} from '@/entities/assignment/model/schemas'; import {useToastStore} from '@/shared/model/useToastStore'; @@ -54,7 +55,7 @@ export const useAssignmentForm = (assignmentId?: number) => { showToast('문제가 등록되었습니다.'); navigate(-1); }, - onError: () => alert('문제 등록에 실패했습니다.'), + onError: (error) => handleApiError(error, '문제 등록에 실패했습니다.'), }); const {mutate: updateAssignment, isPending: isUpdating} = useMutation({ @@ -69,7 +70,7 @@ export const useAssignmentForm = (assignmentId?: number) => { showToast('문제가 수정되었습니다.'); navigate(-1); }, - onError: () => alert('문제 수정에 실패했습니다.'), + onError: (error) => handleApiError(error, '문제 수정에 실패했습니다.'), }); const onSubmit = (form: TAssignmentForm) => { diff --git a/src/pages/admin/assignments/ui/TestcaseField.tsx b/src/pages/admin/assignments/ui/TestcaseField.tsx index a915c95..13e01c6 100644 --- a/src/pages/admin/assignments/ui/TestcaseField.tsx +++ b/src/pages/admin/assignments/ui/TestcaseField.tsx @@ -90,7 +90,7 @@ const TestcaseField = ({assignmentId, value, onChange}: TestcaseFieldProps) => { const handleDeleteTestcase = (index: number) => { if (value.length === 1) { - onChange([{testcase: '', answer: '', isPublic: false}]); + onChange([{testcase: '', answer: '', isPublic: true}]); return; } onChange(value.filter((_, idx) => idx !== index)); @@ -114,7 +114,7 @@ const TestcaseField = ({assignmentId, value, onChange}: TestcaseFieldProps) => { error instanceof Error ? error.message : '테스트 케이스 JSON을 불러오지 못했습니다.'; - alert(message); + showToast(message); } }; @@ -123,7 +123,7 @@ const TestcaseField = ({assignmentId, value, onChange}: TestcaseFieldProps) => {
{value.map((tc, idx) => (