diff --git a/src/App.tsx b/src/App.tsx index f9fe6d3..05debee 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import KakaoCallbackPage from '@/pages/common/KakaoCallbackPage'; import PrivateRoute from '@/widgets/private-route/ui/PrivateRoute'; import {useSyncUserRole} from '@/features/auth/sync-user-role/model/useSyncUserRole'; import UnitEditorPage from '@/pages/unit-editor/UnitEditorPage'; +import AssignmentManagePage from '@/pages/manage-assignment/AssignmentManagePage'; const AppRoutes = () => { useSyncUserRole(); @@ -37,7 +38,10 @@ const AppRoutes = () => { }> } /> - {/* } /> */} + } + /> } diff --git a/src/entities/assignment/api/assignmentApi.ts b/src/entities/assignment/api/assignmentApi.ts index 23940b5..d118435 100644 --- a/src/entities/assignment/api/assignmentApi.ts +++ b/src/entities/assignment/api/assignmentApi.ts @@ -1,8 +1,53 @@ -import type {DashboardScheduleListResponse} from '@/entities/course/model/types'; +import type { + AssignmentSelectResponse, + DashboardScheduleListResponse, +} from '@/entities/course/model/types'; import {privateAxios} from '@/shared/api/axiosInstance'; +import type {ApiResponse} from '@/shared/model'; +import type {AssignmentsResponse} from '../model/types'; +// 과제 일정 조회 API export const getAssignmentSchedules = async (): Promise => { const response = await privateAxios.get('/assignments/schedule'); return response.data; }; + +// 전체 과제 목록 조회 API +export const getAllAssignments = async (): Promise< + ApiResponse +> => { + const response = await privateAxios.get< + ApiResponse<{ + count: number; + assignments: {assignmentId: number; title: string}[]; + }> + >('/assignments/my'); + const raw = response.data; + return { + ...raw, + response: { + count: raw.response.count, + assignments: raw.response.assignments.map(({assignmentId, title}) => ({ + id: assignmentId, + title, + })), + }, + }; +}; + +// 강의별 과제 목록 조회 API +export const getAssignmentsByCourse = async ( + courseId: number +): Promise => { + const response = await privateAxios.get(`/courses/${courseId}/assignments`); + return response.data; +}; + +// 과제 삭제 API +export const deleteAssignment = async ( + assignmentId: number +): Promise> => { + const response = await privateAxios.delete(`/assignments/${assignmentId}`); + return response.data; +}; diff --git a/src/entities/assignment/api/assignmentMutations.ts b/src/entities/assignment/api/assignmentMutations.ts new file mode 100644 index 0000000..5ce85f1 --- /dev/null +++ b/src/entities/assignment/api/assignmentMutations.ts @@ -0,0 +1,8 @@ +import {deleteAssignment} from './assignmentApi'; + +export const assignmentMutations = { + deleteAssignment: { + mutationKey: ['deleteAssignment'], + mutationFn: (assignmentId: number) => deleteAssignment(assignmentId), + }, +}; diff --git a/src/entities/assignment/api/assignmentQueries.ts b/src/entities/assignment/api/assignmentQueries.ts new file mode 100644 index 0000000..8fe1d9d --- /dev/null +++ b/src/entities/assignment/api/assignmentQueries.ts @@ -0,0 +1,37 @@ +import {queryOptions} from '@tanstack/react-query'; +import { + getAllAssignments, + getAssignmentsByCourse, + getAssignmentSchedules, +} from './assignmentApi'; + +export const assignmentQueries = { + // 과제 일정 조회 쿼리 옵션 + getAssignmentSchedules: () => + queryOptions({ + queryKey: ['schedules'], + queryFn: getAssignmentSchedules, + select: (data) => ({ + scheduleCount: data.response.count, + schedules: data.response.schedule, + }), + }), + + // 전체 과제 목록 조회 쿼리 옵션 + getAllAssignments: () => + queryOptions({ + queryKey: ['assignments'], + queryFn: getAllAssignments, + select: (data) => data.response.assignments, + }), + + // 강의별 과제 목록 조회 쿼리 옵션 + getAssignmentsByCourse: (courseId: number) => + queryOptions({ + queryKey: ['courses', courseId, 'assignments'], + queryFn: () => getAssignmentsByCourse(courseId), + enabled: !!courseId, + select: (data) => + data.response.courses.flatMap((course) => course.assignments), + }), +}; diff --git a/src/entities/assignment/api/assignmentQueryOptions.ts b/src/entities/assignment/api/assignmentQueryOptions.ts deleted file mode 100644 index 89af95e..0000000 --- a/src/entities/assignment/api/assignmentQueryOptions.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {queryOptions} from '@tanstack/react-query'; -import {getAssignmentSchedules} from './assignmentApi'; - -export default function assignmentQueryOptions() { - return queryOptions({ - queryKey: ['schedules'], - queryFn: getAssignmentSchedules, - }); -} diff --git a/src/entities/assignment/model/types.ts b/src/entities/assignment/model/types.ts index 331c549..bdbf0a8 100644 --- a/src/entities/assignment/model/types.ts +++ b/src/entities/assignment/model/types.ts @@ -8,3 +8,8 @@ export interface Assignment { title: string; submittedStatus?: SubmissionStatus; } + +export interface AssignmentsResponse { + count: number; + assignments: Assignment[]; +} diff --git a/src/entities/course/api/courseApi.ts b/src/entities/course/api/courseApi.ts index deae300..a1afec0 100644 --- a/src/entities/course/api/courseApi.ts +++ b/src/entities/course/api/courseApi.ts @@ -2,11 +2,13 @@ import type {ApiResponse} from '@/shared/model/common'; import type {DashboardCourseListResponse} from '@/entities/course/model/types'; import {privateAxios} from '@/shared/api/axiosInstance'; +// 전체 강의 목록 조회 API export const getAllCourses = async (): Promise => { const response = await privateAxios.get('/courses/my'); return response.data; }; +// 강의 삭제 API export const deleteCourse = async ( courseId: number ): Promise> => { diff --git a/src/entities/course/api/courseMutations.ts b/src/entities/course/api/courseMutations.ts new file mode 100644 index 0000000..0eea045 --- /dev/null +++ b/src/entities/course/api/courseMutations.ts @@ -0,0 +1,9 @@ +import {deleteCourse} from './courseApi'; + +export const courseMutations = { + // 강의 삭제 뮤테이션 옵션 + deleteCourse: { + mutationKey: ['deleteCourse'], + mutationFn: (courseId: number) => deleteCourse(courseId), + }, +}; diff --git a/src/entities/course/api/courseQueries.ts b/src/entities/course/api/courseQueries.ts new file mode 100644 index 0000000..97669c7 --- /dev/null +++ b/src/entities/course/api/courseQueries.ts @@ -0,0 +1,15 @@ +import {getAllCourses} from './courseApi'; +import {queryOptions} from '@tanstack/react-query'; + +export const courseQueries = { + // 전체 강의 조회 쿼리 옵션 + getAllCourses: () => + queryOptions({ + queryKey: ['courses'], + queryFn: getAllCourses, + select: (data) => ({ + courseCount: data.response.count, + courses: data.response.courses, + }), + }), +}; diff --git a/src/entities/course/api/courseQueryOptions.ts b/src/entities/course/api/courseQueryOptions.ts deleted file mode 100644 index ab5d82f..0000000 --- a/src/entities/course/api/courseQueryOptions.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {getAllCourses} from './courseApi'; -import {queryOptions} from '@tanstack/react-query'; - -export default function courseQueryOptions() { - return queryOptions({ - queryKey: ['courses'], - queryFn: getAllCourses, - }); -} diff --git a/src/entities/course/index.ts b/src/entities/course/index.ts deleted file mode 100644 index 6b5cfff..0000000 --- a/src/entities/course/index.ts +++ /dev/null @@ -1 +0,0 @@ -export {getAllCourses, deleteCourse} from './api/courseApi'; diff --git a/src/entities/unit/api/unitApi.ts b/src/entities/unit/api/unitApi.ts index b716386..428720c 100644 --- a/src/entities/unit/api/unitApi.ts +++ b/src/entities/unit/api/unitApi.ts @@ -44,3 +44,14 @@ export const updateUnit = async ( const response = await privateAxios.put(`/units/${unitId}`, unit); return response.data; }; + +// 단원에 등록된 과제 삭제 +export const deleteAssignmentFromUnit = async ( + unitId: number, + assignmentId: number +): Promise> => { + const response = await privateAxios.delete( + `/units/${unitId}/assignments/${assignmentId}` + ); + return response.data; +}; diff --git a/src/entities/unit/api/unitMutations.ts b/src/entities/unit/api/unitMutations.ts index a0e6c91..d114344 100644 --- a/src/entities/unit/api/unitMutations.ts +++ b/src/entities/unit/api/unitMutations.ts @@ -5,8 +5,13 @@ export const unitMutations = { // 단원 추가 뮤테이션 옵션 createUnit: { mutationKey: ['createUnit'], - mutationFn: ({courseId, unit}: {courseId: number; unit: TUnitFormSchema}) => - createUnit(courseId, unit), + mutationFn: ({ + courseId, + unitForm, + }: { + courseId: number; + unitForm: TUnitFormSchema; + }) => createUnit(courseId, unitForm), }, // 단원 수정 뮤테이션 옵션 diff --git a/src/entities/unit/api/unitQueries.ts b/src/entities/unit/api/unitQueries.ts index ff10576..8eeaa7b 100644 --- a/src/entities/unit/api/unitQueries.ts +++ b/src/entities/unit/api/unitQueries.ts @@ -7,6 +7,11 @@ export const unitQueries = { queryOptions({ queryKey: ['units', courseId], queryFn: () => getAllUnitsByCourseId(courseId), + select: (data) => ({ + unitList: data.response.units, + unitCount: data.response.count, + firstUnitId: data.response.units[0]?.id ?? null, + }), }), // 단일 단원 조회 쿼리 옵션 @@ -15,5 +20,8 @@ export const unitQueries = { queryKey: ['units', 'detail', unitId], queryFn: () => getUnitById(unitId), enabled: !!unitId, + select: (data) => { + return data.response; + }, }), }; diff --git a/src/entities/unit/model/types.ts b/src/entities/unit/model/types.ts index d6b1c63..e0fd49c 100644 --- a/src/entities/unit/model/types.ts +++ b/src/entities/unit/model/types.ts @@ -10,7 +10,7 @@ export const unitFormSchema = z }) .refine((data) => data.releaseDate <= data.dueDate, { message: '날짜 범위가 올바르지 않습니다.', - path: ['dueDate'], + path: ['releaseDate'], }); // 단원 생성/수정 폼 타입 diff --git a/src/entities/unit/model/useUnitStore.ts b/src/entities/unit/model/useUnitStore.ts new file mode 100644 index 0000000..ea0def8 --- /dev/null +++ b/src/entities/unit/model/useUnitStore.ts @@ -0,0 +1,51 @@ +import type {Assignment} from '@/entities/assignment/model/types'; +import {create} from 'zustand'; +import {createJSONStorage, persist} from 'zustand/middleware'; + +interface UnitState { + title: string; + releaseDate: string; + dueDate: string; + assignments: Assignment[]; + storeFormData: (title: string, releaseDate: string, dueDate: string) => void; + resetStore: () => void; + setAssignments: (assignments: Assignment[]) => void; +} + +export const useUnitStore = create()( + persist( + (set) => ({ + title: '', + releaseDate: '', + dueDate: '', + assignments: [], + + // 단원 폼 임시 저장 + storeFormData: (title, releaseDate, dueDate) => + set({ + title: title, + releaseDate: releaseDate, + dueDate: dueDate, + }), + + // 선택된 과제 ID 저장 + setAssignments: (assignments) => set({assignments}), + + // 단원 폼 초기화 + resetStore: () => + set({title: '', releaseDate: '', dueDate: '', assignments: []}), + }), + { + name: 'unit-session-storage', + storage: createJSONStorage(() => sessionStorage), + partialize: (state) => ({ + title: state.title, + releaseDate: state.releaseDate, + dueDate: state.dueDate, + assignments: state.assignments, + }), + } + ) +); + +export default useUnitStore; diff --git a/src/features/assignment/filter-assignment/lib/useAssignmentList.ts b/src/features/assignment/filter-assignment/lib/useAssignmentList.ts new file mode 100644 index 0000000..acd8458 --- /dev/null +++ b/src/features/assignment/filter-assignment/lib/useAssignmentList.ts @@ -0,0 +1,22 @@ +import {useQuery} from '@tanstack/react-query'; +import {assignmentQueries} from '@/entities/assignment/api/assignmentQueries'; +import type {Assignment} from '@/entities/assignment/model/types'; + +// 중복 제거 +const unique = (list: Assignment[]) => + Array.from(new Map(list.map((a) => [a.id, a])).values()); + +export const useAssignmentList = ( + selectedCourseId: number | null +): Assignment[] => { + const {data: allAssignments} = useQuery( + assignmentQueries.getAllAssignments() + ); + const {data: assignments} = useQuery( + assignmentQueries.getAssignmentsByCourse(selectedCourseId ?? 0) + ); + + return unique( + selectedCourseId ? (assignments ?? []) : (allAssignments ?? []) + ); +}; diff --git a/src/pages/dashboard/Dashboard.tsx b/src/pages/dashboard/Dashboard.tsx index e1ca73a..58d8baa 100644 --- a/src/pages/dashboard/Dashboard.tsx +++ b/src/pages/dashboard/Dashboard.tsx @@ -5,35 +5,45 @@ import AddIcon from '@/assets/svg/addIcon.svg?react'; import ScheduleList from './ui/ScheduleList'; import {Link} from 'react-router-dom'; import {useUserStore} from '@/entities/auth/model/useUserStore'; -import courseQueryOptions from '@/entities/course/api/courseQueryOptions'; import { useMutation, useQueryClient, useSuspenseQueries, } from '@tanstack/react-query'; -import assignmentQueryOptions from '@/entities/assignment/api/assignmentQueryOptions'; -import {deleteCourse} from '@/entities/course'; import {EmptyState} from '@/shared/ui/EmptyState'; +import {courseQueries} from '@/entities/course/api/courseQueries'; +import {courseMutations} from '@/entities/course/api/courseMutations'; +import {assignmentQueries} from '@/entities/assignment/api/assignmentQueries'; const Dashboard = () => { const userType = useUserStore((state) => state.userType); const queryClient = useQueryClient(); // 강의 및 스케쥴 데이터 패칭 - const [{data: courses}, {data: schedules}] = useSuspenseQueries({ - queries: [courseQueryOptions(), assignmentQueryOptions()], + const [ + { + data: {courseCount, courses}, + }, + { + data: {scheduleCount, schedules}, + }, + ] = useSuspenseQueries({ + queries: [ + courseQueries.getAllCourses(), + assignmentQueries.getAssignmentSchedules(), + ], }); - // 강의 삭제 뮤테이션 + // 강의 삭제 const {mutate} = useMutation({ - mutationFn: (courseId: number) => deleteCourse(courseId), + ...courseMutations.deleteCourse, onSuccess: () => { // 강의 목록 및 스케쥴 목록 갱신 queryClient.invalidateQueries({ - queryKey: courseQueryOptions().queryKey, + queryKey: courseQueries.getAllCourses().queryKey, }); queryClient.invalidateQueries({ - queryKey: assignmentQueryOptions().queryKey, + queryKey: assignmentQueries.getAssignmentSchedules().queryKey, }); alert('강의가 성공적으로 삭제되었습니다.'); }, @@ -61,13 +71,10 @@ const Dashboard = () => { {userType === 'admin' && } - {courses.response.count === 0 ? ( + {courseCount === 0 ? ( 등록된 강의가 없습니다. ) : ( - + )} @@ -77,10 +84,10 @@ const Dashboard = () => { - {schedules.response.count === 0 ? ( + {scheduleCount === 0 ? ( 예정된 과제가 없습니다. ) : ( - + )} diff --git a/src/pages/manage-assignment/AssignmentManagePage.tsx b/src/pages/manage-assignment/AssignmentManagePage.tsx new file mode 100644 index 0000000..5bbc7ec --- /dev/null +++ b/src/pages/manage-assignment/AssignmentManagePage.tsx @@ -0,0 +1,108 @@ +import {AssignmentPageLayout} from '@/widgets/assignment-page-layout'; +import AssignmentListContainer from '../select-assignment/ui/AssignmentListContainer'; +import ListRow from '@/shared/ui/list-row/ListRow'; +import {useCourseFilter} from '@/features/course/filter-course'; +import { + useMutation, + useQueryClient, + useSuspenseQuery, +} from '@tanstack/react-query'; +import {courseQueries} from '@/entities/course/api/courseQueries'; +import {useAssignmentList} from '@/features/assignment/filter-assignment/lib/useAssignmentList'; +import {assignmentMutations} from '@/entities/assignment/api/assignmentMutations'; +import {assignmentQueries} from '@/entities/assignment/api/assignmentQueries'; +import AssignmentManageActionsBar from './ui/AssignmentManageActionsBar'; +import AddIcon from '@/assets/svg/addIcon.svg?react'; +import {Link, useNavigate} from 'react-router-dom'; +import {buttonStyles} from '@/shared/ui/button/button-styles'; +import Button from '@/shared/ui/button/Button'; + +const AssignmentManagePage = () => { + const navigate = useNavigate(); + const { + data: {courses}, + } = useSuspenseQuery(courseQueries.getAllCourses()); + const {courseOptions, handleCourseSelect, selectedCourseId} = + useCourseFilter(courses); + const assignmentList = useAssignmentList(selectedCourseId); + const queryClient = useQueryClient(); + + // 문제 삭제 뮤테이션 + const {mutate: deleteAssignment} = useMutation({ + ...assignmentMutations.deleteAssignment, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: assignmentQueries.getAllAssignments().queryKey, + }); + queryClient.invalidateQueries({ + queryKey: assignmentQueries.getAssignmentsByCourse( + selectedCourseId ?? 0 + ).queryKey, + }); + alert('문제가 성공적으로 삭제되었습니다.'); + }, + onError: (error) => { + console.error('문제 삭제 실패', error); + alert('문제 삭제에 실패했습니다. 다시 시도해주세요.'); + }, + }); + + // 문제 삭제 핸들러 + const onDeleteAssignment = (id: number) => { + if ( + window.confirm( + '문제를 삭제하시겠습니까? 삭제된 문제는 복구할 수 없습니다.' + ) + ) { + deleteAssignment(id); + } + }; + + return ( + + ( + + } + /> + )} + /> + + + 문제 추가 + + + } + buttons={ + + } + /> + ); +}; + +export default AssignmentManagePage; diff --git a/src/pages/manage-assignment/ui/AssignmentManageActionsBar.tsx b/src/pages/manage-assignment/ui/AssignmentManageActionsBar.tsx new file mode 100644 index 0000000..e1f4815 --- /dev/null +++ b/src/pages/manage-assignment/ui/AssignmentManageActionsBar.tsx @@ -0,0 +1,38 @@ +import EditIcon from '@/assets/svg/editIcon.svg?react'; +import DeleteIcon from '@/assets/svg/deleteIcon.svg?react'; +import {useNavigate} from 'react-router-dom'; + +interface AssignmentManageActionsBarProps { + id: number; + onDelete: (id: number) => void; +} + +const AssignmentManageActionsBar = ({ + id, + onDelete, +}: AssignmentManageActionsBarProps) => { + const navigate = useNavigate(); + + const handleOnEdit = () => { + navigate(`/admin/assignments/${id}`); + }; + + const handleOnDelete = () => { + onDelete(id); + }; + + return ( +
e.stopPropagation()}> + + +
+ ); +}; + +export default AssignmentManageActionsBar; diff --git a/src/pages/select-assignment/AssignmentSelectPage.tsx b/src/pages/select-assignment/AssignmentSelectPage.tsx index b84b174..f9456f1 100644 --- a/src/pages/select-assignment/AssignmentSelectPage.tsx +++ b/src/pages/select-assignment/AssignmentSelectPage.tsx @@ -1,35 +1,53 @@ import AssignmentListContainer from './ui/AssignmentListContainer'; import {useState} from 'react'; -import { - response, - courseOptionsResponse, -} from '@/shared/mocks/assignmentSelectResponse'; import {useCourseFilter} from '@/features/course/filter-course/lib/useCourseFilter'; import {AssignmentPageLayout} from '@/widgets/assignment-page-layout'; import ListRow from '@/shared/ui/list-row/ListRow'; +import {useSuspenseQuery} from '@tanstack/react-query'; +import {courseQueries} from '@/entities/course/api/courseQueries'; +import useUnitStore from '@/entities/unit/model/useUnitStore'; +import {useLocation, useNavigate} from 'react-router-dom'; +import type {Assignment} from '@/entities/assignment/model/types'; +import {useAssignmentList} from '@/features/assignment/filter-assignment/lib/useAssignmentList'; +import Button from '@/shared/ui/button/Button'; const AssignmentSelectPage = () => { - const {courses} = courseOptionsResponse.response; // /courses/my API 응답 모킹 - const [selectedAssignments, setSelectedAssignments] = useState([]); // 선택된 문제 ID 목록 - - const {courseOptions, handleCourseSelect} = useCourseFilter(courses); - - // 문제 목록 /courses/{courseId}/assignments API 응답 모킹 - const assignmentList = response.response.courses.flatMap( - (course) => course.assignments + const navigate = useNavigate(); + const location = useLocation(); + const { + data: {courses}, + } = useSuspenseQuery(courseQueries.getAllCourses()); + const {setAssignments, assignments: currentSelectedAssignments} = + useUnitStore(); + const [selectedAssignments, setSelectedAssignments] = useState( + currentSelectedAssignments ); + const {courseOptions, handleCourseSelect, selectedCourseId} = + useCourseFilter(courses); + + const assignmentList = useAssignmentList(selectedCourseId); // 문제 선택 핸들러 - const handleAssignmentSelect = (assignmentId: number) => { + const handleAssignmentSelect = (assignment: Assignment) => { setSelectedAssignments((prev) => { - if (prev.includes(assignmentId)) { - return prev.filter((id) => id !== assignmentId); // 선택 해제 - } else { - return [...prev, assignmentId]; // 선택 추가 + if (prev.some((a) => a.id === assignment.id)) { + return prev.filter((a) => a.id !== assignment.id); } + return [...prev, assignment]; }); }; + // 이전 페이지로 돌아가기 + const returnToPreviousPage = () => { + navigate(location.state?.backPath ?? -1); + }; + + // 등록 핸들러 + const handleConfirm = () => { + setAssignments(selectedAssignments); + returnToPreviousPage(); + }; + return ( { renderItem={(assignment) => ( a.id === assignment.id)} + className='cursor-pointer' /> )} /> } - onCancel={() => {}} - onConfirm={() => {}} + buttons={ +
+ + +
+ } /> ); }; diff --git a/src/pages/select-assignment/ui/AssignmentListContainer.tsx b/src/pages/select-assignment/ui/AssignmentListContainer.tsx index 94eb164..93129ca 100644 --- a/src/pages/select-assignment/ui/AssignmentListContainer.tsx +++ b/src/pages/select-assignment/ui/AssignmentListContainer.tsx @@ -1,12 +1,14 @@ +import type {Assignment} from '@/entities/assignment/model/types'; import type {AssignmentSelectCourse} from '@/entities/course/model/types'; +import type {MouseEvent, ReactNode} from 'react'; type T = AssignmentSelectCourse['assignments'][number]; interface AssignmentListContainerProps { items: T[]; - renderItem: (item: T) => React.ReactNode; + renderItem: (item: T) => ReactNode; title: string; - onSelect: (id: number) => void; + onSelect?: (item: Assignment) => void; } const AssignmentListContainer = ({ @@ -15,16 +17,16 @@ const AssignmentListContainer = ({ renderItem, title, }: AssignmentListContainerProps) => { - const handleSelect = (id: number, event: React.MouseEvent) => { + const handleSelect = (item: Assignment, event: MouseEvent) => { event.stopPropagation(); - onSelect(id); + onSelect?.(item); }; return (

{title}

    {items.map((item) => ( -
  • handleSelect(item.id, e)} key={item.id}> +
  • handleSelect(item, e)} key={item.id}> {renderItem(item)}
  • ))} diff --git a/src/pages/unit-editor/UnitEditorPage.tsx b/src/pages/unit-editor/UnitEditorPage.tsx index 1e632b9..5fee03c 100644 --- a/src/pages/unit-editor/UnitEditorPage.tsx +++ b/src/pages/unit-editor/UnitEditorPage.tsx @@ -3,30 +3,57 @@ import {UnitForm} from './ui/UnitForm'; import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'; import {unitQueries} from '@/entities/unit/api/unitQueries'; import {useParams} from 'react-router-dom'; -import {useEffect, useState} from 'react'; +import {useState} from 'react'; import type {Mode} from './model/types'; import {unitMutations} from '@/entities/unit/api/unitMutations'; import type {TUnitFormSchema} from '@/entities/unit/model/types'; import {EmptyState} from '@/shared/ui/EmptyState'; import SurfaceCard from '@/shared/ui/SurfaceCard'; +import useUnitStore from '@/entities/unit/model/useUnitStore'; const UnitEditorPage = () => { const {id} = useParams(); // 강의 ID const courseId = Number(id); - const [mode, setMode] = useState('idle'); + + const { + resetStore, + title: storedTitle, + assignments: storedAssignments, + releaseDate: storedReleaseDate, + dueDate: storedDueDate, + } = useUnitStore(); + const {data} = useQuery(unitQueries.getUnitList(courseId)); + const initialMode = + data === undefined ? null : data.unitCount === 0 ? 'creating' : 'editing'; + + const [activeMode, setActiveMode] = useState(null); const [selectedUnitId, setSelectedUnitId] = useState(null); - const [currentIndex, setCurrentIndex] = useState(1); - const {data: unitList} = useQuery(unitQueries.getUnitList(courseId)); - const {data: unit} = useQuery(unitQueries.getUnitDetails(selectedUnitId)); - - useEffect(() => { - if (unitList && unitList?.response.count !== 0 && mode === 'idle') { - setSelectedUnitId(unitList.response.units[0].id); - setMode('editing'); // 편집 모드 - } else if (unitList?.response.count === 0 && mode === 'idle') { - setMode('creating'); // 생성 모드 + + const hasOngoingCreation = + storedTitle !== '' || + storedReleaseDate !== '' || + storedDueDate !== '' || + storedAssignments.length > 0; + + const currentMode: Mode | null = + activeMode ?? (hasOngoingCreation ? 'creating' : initialMode); + const currentUnitId = + currentMode === 'creating' + ? null + : (selectedUnitId ?? data?.firstUnitId ?? null); + + // 현재 단원 인덱스 계산 + const currentIndex = (() => { + if (currentMode === 'creating') { + return (data?.unitCount ?? 0) + 1; // 항상 마지막 + 1 } - }, [unitList, mode]); + const index = data?.unitList?.findIndex((u) => u.id === currentUnitId); + return index !== undefined && index >= 0 ? index + 1 : 1; + })(); + + const {data: unitDetail} = useQuery( + unitQueries.getUnitDetails(currentUnitId) + ); const queryClient = useQueryClient(); const invalidateUnitList = () => { @@ -36,13 +63,14 @@ const UnitEditorPage = () => { }; // 단원 생성 - const {mutate: addUnit} = useMutation({ + const {mutate: addUnit, isPending: isCreating} = useMutation({ ...unitMutations.createUnit, onSuccess: (data) => { // 단원 목록 갱신 invalidateUnitList(); setSelectedUnitId(data.response.id); - setMode('editing'); // 생성 후 편집 모드로 전환 + setActiveMode('editing'); // 생성 후 편집 모드로 전환 + resetStore(); // 단원 폼 초기화 alert('새 단원이 성공적으로 생성되었습니다.'); }, onError: (error) => { @@ -52,7 +80,7 @@ const UnitEditorPage = () => { }); // 단원 업데이트 - const {mutate: updateUnit} = useMutation({ + const {mutate: updateUnit, isPending: isUpdating} = useMutation({ ...unitMutations.updateUnit, onSuccess: () => { invalidateUnitList(); @@ -69,8 +97,8 @@ const UnitEditorPage = () => { ...unitMutations.deleteUnit, onSuccess: () => { invalidateUnitList(); - setMode('idle'); // 삭제 후 대기 모드로 전환 setSelectedUnitId(null); // 선택된 단원 초기화 + setActiveMode('creating'); alert('단원이 성공적으로 삭제되었습니다.'); }, onError: (error) => { @@ -82,19 +110,18 @@ const UnitEditorPage = () => { // 단원 선택 핸들러 const onUnitClick = (id: number) => { setSelectedUnitId(id); - setMode('editing'); // 편집 모드로 전환 + setActiveMode('editing'); // 편집 모드로 전환 }; // 단원 추가 핸들러 const onAddNewUnit = () => { setSelectedUnitId(null); - setCurrentIndex(unitList ? unitList.response.count + 1 : 1); // 새 단원의 인덱스 설정 - setMode('creating'); // 생성 모드로 전환 + setActiveMode('creating'); // 생성 모드로 전환 }; // 단원 생성 핸들러 - const onCreateUnit = (unit: TUnitFormSchema) => { - addUnit({courseId: courseId, unit}); + const onCreateUnit = (unitForm: TUnitFormSchema) => { + addUnit({courseId, unitForm}); }; // 단원 업데이트 핸들러 @@ -119,10 +146,9 @@ const UnitEditorPage = () => { {/* 단원 목차 섹션 */} setCurrentIndex(index)} - selectedUnitId={selectedUnitId} + selectedUnitId={currentUnitId} onAddNewUnit={onAddNewUnit} /> @@ -130,12 +156,13 @@ const UnitEditorPage = () => { {/* 단원 폼 섹션 */}
diff --git a/src/pages/unit-editor/model/types.ts b/src/pages/unit-editor/model/types.ts index c845cef..f505c89 100644 --- a/src/pages/unit-editor/model/types.ts +++ b/src/pages/unit-editor/model/types.ts @@ -2,13 +2,14 @@ import type {Unit} from '@/entities/course/model/types'; import type {TUnitFormSchema} from '@/entities/unit/model/types'; // 단원 편집 모드 타입 -export type Mode = 'idle' | 'creating' | 'editing'; +export type Mode = 'creating' | 'editing'; export interface UnitFormProps { unit?: Unit; unitIndex: number; - mode: Mode; + mode: Mode | null; onCreateUnit: (unit: TUnitFormSchema) => void; onUpdateUnit: (unitId: number, unit: TUnitFormSchema) => void; onDeleteUnit: (unitId: number) => void; + isPending: boolean; } diff --git a/src/pages/unit-editor/ui/UnitAssignmentList.tsx b/src/pages/unit-editor/ui/UnitAssignmentList.tsx index 510fc1c..98ce847 100644 --- a/src/pages/unit-editor/ui/UnitAssignmentList.tsx +++ b/src/pages/unit-editor/ui/UnitAssignmentList.tsx @@ -2,7 +2,6 @@ import ListRow from '@/shared/ui/list-row/ListRow'; import type {Assignment} from '@/entities/assignment/model/types'; import {useState} from 'react'; import DragAndDropIcon from '@/assets/svg/dragAndDropIcon.svg?react'; -import DeleteIcon from '@/assets/svg/deleteIcon.svg?react'; import { closestCorners, DndContext, @@ -95,7 +94,6 @@ const DraggableAssignmentItem = ({id, title}: Assignment) => { } - rightIcon={} className={`cursor-grab touch-none bg-white shadow-box active:cursor-grabbing ${isDragging ? 'z-10 opacity-50' : ''}`} /> diff --git a/src/pages/unit-editor/ui/UnitForm.tsx b/src/pages/unit-editor/ui/UnitForm.tsx index 66cbec5..a55d0b1 100644 --- a/src/pages/unit-editor/ui/UnitForm.tsx +++ b/src/pages/unit-editor/ui/UnitForm.tsx @@ -7,11 +7,12 @@ import {zodResolver} from '@hookform/resolvers/zod'; import {type UnitFormProps} from '../model/types'; import AddIcon from '@/assets/svg/addIcon.svg?react'; import {EmptyState} from '@/shared/ui/EmptyState'; -// import {useState} from 'react'; import { unitFormSchema, type TUnitFormSchema, } from '@/entities/unit/model/types'; +import {useLocation, useNavigate} from 'react-router-dom'; +import useUnitStore from '@/entities/unit/model/useUnitStore'; export const UnitForm = ({ unit, @@ -20,23 +21,40 @@ export const UnitForm = ({ onCreateUnit, onUpdateUnit, onDeleteUnit, + isPending, }: UnitFormProps) => { - // const [assignmentIds, setAssignmentIds] = useState([]); + const location = useLocation(); + const navigate = useNavigate(); + const { + storeFormData, + resetStore, + title: storedTitle, + releaseDate: storedReleaseDate, + dueDate: storedDueDate, + assignments: storedAssignments, + } = useUnitStore(); + + const currentAssignmentList = + mode === 'editing' ? (unit?.assignments ?? []) : storedAssignments; + + const assignmentListKey = + mode === 'editing' ? `edit-${unit?.id}` : 'creating-assignments'; const { register, handleSubmit, reset, - formState: {errors, isSubmitting}, + getValues, + formState: {errors}, } = useForm({ resolver: zodResolver(unitFormSchema), values: mode === 'creating' ? { - title: '', - releaseDate: '', - dueDate: '', - assignmentIds: [], + title: storedTitle, + releaseDate: storedReleaseDate, + dueDate: storedDueDate, + assignmentIds: storedAssignments.map((a) => a.id), } : { title: unit?.title || '', @@ -46,7 +64,7 @@ export const UnitForm = ({ }); // 단원 생성/업데이트 핸들러 - const onSubmit = async (data: TUnitFormSchema) => { + const onSubmit = (data: TUnitFormSchema) => { if (mode === 'editing' && unit) { onUpdateUnit(unit.id, data); return; @@ -64,9 +82,21 @@ export const UnitForm = ({ // 단원 편집 취소 핸들러 const handleCancel = () => { + if (mode === 'creating') resetStore(); reset(); }; + // 문제 선택 페이지로 이동 핸들러 + const handleAssignmentSelect = () => { + const {title, releaseDate, dueDate} = getValues(); + storeFormData(title, releaseDate, dueDate); + navigate('/admin/assignments/select', { + state: { + backPath: location.pathname, + }, + }); + }; + return (
{/* 단원 편집 폼 */} @@ -88,13 +118,14 @@ export const UnitForm = ({
{/* 폼 본문 */} -
+
{/* 단원 제목 섹션 */}
@@ -105,40 +136,48 @@ export const UnitForm = ({ label='공개일' type='date' placeholder='날짜를 선택하세요' + errorMessage={errors.releaseDate?.message} /> - - {errors.dueDate?.message} - -
+
{/* 문제 등록 섹션 */}

문제 등록

{/* 드래그 앤 드롭 가능한 문제 리스트 */} - {!unit || unit.assignmentCount === 0 ? ( + {currentAssignmentList.length > 0 ? ( + + ) : ( 등록된 문제가 없습니다. - ) : ( - )} {/* 문제 연결 버튼 */} -
- -
+ {mode === 'creating' && ( +
+ +
+ )}
@@ -150,8 +189,8 @@ export const UnitForm = ({
diff --git a/src/pages/unit-editor/ui/UnitList.tsx b/src/pages/unit-editor/ui/UnitList.tsx index dab86a7..3485bf8 100644 --- a/src/pages/unit-editor/ui/UnitList.tsx +++ b/src/pages/unit-editor/ui/UnitList.tsx @@ -8,7 +8,6 @@ interface UnitListProps { unitList: AllUnitsResponse['response']['units'] | undefined; onUnitClick: (id: number) => void; selectedUnitId?: number | null; - onChangeIndex: (index: number) => void; onAddNewUnit?: () => void; } @@ -16,17 +15,8 @@ export const UnitList = ({ unitList, onUnitClick, selectedUnitId, - onChangeIndex, onAddNewUnit, }: UnitListProps) => { - const handleSelectUnit = (id: number) => { - onUnitClick(id); - - // 선택된 단원의 인덱스 찾기 - const index = unitList?.findIndex((unit) => unit.id === id) ?? 0; - onChangeIndex(index + 1); - }; - return (
{/* 단원 리스트 헤더 */} @@ -37,7 +27,7 @@ export const UnitList = ({ {/* 단원 아이템 */} {unitList?.map(({id, title, assignmentCount}) => (
  • handleSelectUnit(id)} + onClick={() => onUnitClick(id)} key={id} className={`flex flex-col py-5 px-12 gap-2.5 cursor-pointer ${selectedUnitId === id ? 'bg-background' : ''}`}> {/* 과제 수 배지 */} diff --git a/src/shared/ui/LabeledInput.tsx b/src/shared/ui/LabeledInput.tsx index aad4139..4b51106 100644 --- a/src/shared/ui/LabeledInput.tsx +++ b/src/shared/ui/LabeledInput.tsx @@ -1,14 +1,16 @@ -interface LabeledInputProps - extends React.InputHTMLAttributes { +interface LabeledInputProps extends React.InputHTMLAttributes { label: string; className?: string; showLabel?: boolean; + errorMessage?: string; } const LabeledInput = ({ label, className, showLabel = true, + errorMessage, + required = true, ...rest }: LabeledInputProps) => { return ( @@ -18,14 +20,17 @@ const LabeledInput = ({ showLabel ? '' : 'sr-only' }`}> {label} + {required && *} +

    {errorMessage}

    ); }; diff --git a/src/shared/ui/Layout.tsx b/src/shared/ui/Layout.tsx index bf302a7..45913e5 100644 --- a/src/shared/ui/Layout.tsx +++ b/src/shared/ui/Layout.tsx @@ -9,7 +9,7 @@ const Layout = () => { const showHeader = !noHeaderPages.includes(pathname); return ( -
    +
    {showHeader && (
    diff --git a/src/shared/ui/button/Button.tsx b/src/shared/ui/button/Button.tsx index b9320f2..5e62c0c 100644 --- a/src/shared/ui/button/Button.tsx +++ b/src/shared/ui/button/Button.tsx @@ -6,7 +6,7 @@ interface ButtonProps extends ButtonVariants { className?: string; type?: 'button' | 'submit'; disabled?: boolean; - formID?: string; + form?: string; onClick?: () => void; onMouseEnter?: () => void; onMouseLeave?: () => void; @@ -17,7 +17,7 @@ const Button = ({ onClick, type = 'button', disabled = false, - formID, + form, className, ...props }: ButtonProps) => { @@ -26,7 +26,7 @@ const Button = ({ type={type} onClick={onClick} disabled={disabled} - form={formID} + form={form} className={twMerge(buttonStyles(props), className)}> {children} diff --git a/src/shared/ui/list-row/ListRow.tsx b/src/shared/ui/list-row/ListRow.tsx index 91316f0..f81b247 100644 --- a/src/shared/ui/list-row/ListRow.tsx +++ b/src/shared/ui/list-row/ListRow.tsx @@ -1,10 +1,11 @@ import {ListRowStyles, type ListRowVariants} from './list-row-styles'; +import type {ReactNode} from 'react'; interface ListRowProps extends ListRowVariants { selected?: boolean; - leftIcon?: React.ReactNode; + leftIcon?: ReactNode; title: string; - rightIcon?: React.ReactNode; + rightIcon?: ReactNode; className?: string; } diff --git a/src/shared/ui/list-row/list-row-styles.ts b/src/shared/ui/list-row/list-row-styles.ts index 5b4135f..aea2286 100644 --- a/src/shared/ui/list-row/list-row-styles.ts +++ b/src/shared/ui/list-row/list-row-styles.ts @@ -1,7 +1,7 @@ import {tv, type VariantProps} from 'tailwind-variants'; export const ListRowStyles = tv({ - base: 'cursor-pointer bg-background w-full flex items-center rounded-[9px] pl-4.5 pr-7.5 py-4 gap-4 border', + base: 'bg-background w-full flex items-center rounded-[9px] pl-4.5 pr-7.5 py-4 gap-4 border', variants: { selected: { true: 'border-primary', diff --git a/src/widgets/assignment-page-layout/ui/AssignmentPageLayout.tsx b/src/widgets/assignment-page-layout/ui/AssignmentPageLayout.tsx index 6dc960a..e9e80ed 100644 --- a/src/widgets/assignment-page-layout/ui/AssignmentPageLayout.tsx +++ b/src/widgets/assignment-page-layout/ui/AssignmentPageLayout.tsx @@ -1,23 +1,21 @@ import SurfaceCard from '@/shared/ui/SurfaceCard'; -import Button from '@/shared/ui/button/Button'; import {CourseSelector} from '@/features/course/filter-course'; +import type {ReactNode} from 'react'; interface AssignmentPageLayoutProps { title: string; - list: React.ReactNode; + list: ReactNode; + buttons: ReactNode; courseOptions: string[]; onCourseSelect: (value: string) => void; - onCancel: () => void; - onConfirm: () => void; } export const AssignmentPageLayout = ({ title, list, + buttons, courseOptions, onCourseSelect, - onCancel, - onConfirm, }: AssignmentPageLayoutProps) => { return ( @@ -31,14 +29,7 @@ export const AssignmentPageLayout = ({
    {list}
    {/* 하단 버튼 영역 */} -
    - - -
    +
    {buttons}
    ); };