Conversation
|
Caution Review failedThe pull request is closed. 📝 WalkthroughSummary by CodeRabbit새로운 기능
의존성
WalkthroughReact Query 의존성을 추가하고 대시보드 컴포넌트를 서버 기반 데이터 페칭으로 전환했습니다. 강좌 및 일정 데이터를 Suspense와 React Query를 통해 동적으로 로드하고, 강좌 삭제 기능을 뮤테이션으로 구현했습니다. Changes
Sequence Diagram(s)sequenceDiagram
participant User as 사용자
participant Dashboard as Dashboard
participant QueryClient as QueryClient
participant API as API
participant Server as 서버
User->>Dashboard: 페이지 진입
Dashboard->>QueryClient: useSuspenseQueries 호출
QueryClient->>API: getAssignmentSchedules(), getAllCourses()
API->>Server: GET /assignments/schedule, GET /courses/my
Server-->>API: 응답 데이터
API-->>QueryClient: Promise 반환
QueryClient-->>Dashboard: 데이터 해석
Dashboard->>Dashboard: CourseList/ScheduleList 렌더링
User->>Dashboard: 강좌 삭제 버튼 클릭
Dashboard->>QueryClient: deleteCourse 뮤테이션 실행
QueryClient->>API: privateAxios.delete(/courses/{id})
API->>Server: DELETE 요청
Server-->>API: 성공 응답
API-->>QueryClient: 데이터 반환
QueryClient->>QueryClient: 쿼리 무효화 (courses, schedules)
QueryClient->>Dashboard: 데이터 갱신
Dashboard->>Dashboard: UI 업데이트
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes 이유: 다양한 파일에 걸친 혼합 복잡도의 변경사항입니다. React Query 통합은 명확하지만, 대시보드 컴포넌트의 상태 관리 변경과 여러 하위 컴포넌트의 props 전파를 함께 검증해야 합니다. 삭제 뮤테이션의 에러 처리, 쿼리 무효화 전략, 그리고 Suspense 경계 처리를 세심하게 확인할 필요가 있습니다. Possibly related PRs
💡 코드 리뷰 포인트개선 권장사항:
칭찬: 🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/pages/dashboard/ui/CourseManagementDropdown.tsx (1)
25-29:⚠️ Potential issue | 🟠 Major드롭다운 트리거의 접근성을 개선하세요.
현재
CourseMenuTrigger는<div>로 구성되어 있고,Dropdown컴포넌트도 이를 일반<div>로 래핑하고 있어 다음과 같은 접근성 문제가 발생합니다:
- 키보드 사용자: Tab으로 포커스할 수 없고, Enter/Space로 활성화할 수 없습니다.
- 스크린 리더 사용자: 상호작용 가능한 요소로 인식되지 않습니다.
- WCAG 2.1 위반: 키보드 접근성(Level A)의 기본 요구사항을 충족하지 않습니다.
권장 해결책:
Dropdown컴포넌트 자체를 개선하는 것이 근본적 해결책입니다. 다음과 같이 수정하면 모든Dropdown사용처에서 접근성이 확보됩니다:- <div onClick={() => setIsOpen(!isOpen)}>{dropDownButton}</div> + <button + type='button' + onClick={() => setIsOpen(!isOpen)} + className='w-full text-left' + aria-haspopup='listbox' + aria-expanded={isOpen}> + {dropDownButton} + </button>즉각적 해결이 필요하다면,
CourseManagementDropdown에서 트리거를<button>으로 변경할 수도 있습니다:♻️ 임시 개선 제안
const CourseMenuTrigger = ( - <div className='cursor-pointer p-2'> + <button type='button' className='cursor-pointer p-2' aria-label='강의 관리 메뉴'> <EllipsisIcon className='w-[21.2px] h-[5px]' /> - </div> + </button> );참고 자료:
🤖 Fix all issues with AI agents
In `@src/features/course/filter-course/lib/useCourseFilter.ts`:
- Around line 11-25: The current courseOptionMap build uses
formatCourseOptionLabel as the Map key so duplicate labels overwrite entries
(see courseOptionMap and Map.set); change to a stable ID-based mapping by
producing options as { label: string; value: number }[] (build from courses
using formatCourseOptionLabel for label and course.id for value) instead of
mapping label→id, and update CourseSelector to read option.value as the selected
course id; alternatively append course.id to the label string to guarantee
uniqueness if you cannot change CourseSelector.
In `@src/pages/dashboard/Dashboard.tsx`:
- Around line 23-25: Dashboard.tsx uses useSuspenseQueries (const [{data:
courses}, {data: schedules}] = useSuspenseQueries(...)) but the app lacks a
surrounding Suspense + ErrorBoundary which can cause blank screens or crashes;
wrap your route outlet (e.g., in Layout.tsx or App.tsx) with a <Suspense
fallback={...}> and an <ErrorBoundary fallback={...}> around <Outlet /> so all
children including Dashboard can suspend or surface errors safely. Also consider
the optional refinements called out: simplify mutationFn from (courseId: number)
=> deleteCourse(courseId) to mutationFn: deleteCourse when signatures match, and
replace confirm()/alert() uses with a toast library for better UX.
In `@src/pages/select-assignment/AssignmentSelectPage.tsx`:
- Around line 12-13: selectedCourseId from the useCourseFilter hook is declared
but never used, causing a lint error; either remove selectedCourseId from the
destructuring (change "const {courseOptions, handleCourseSelect,
selectedCourseId} = useCourseFilter(courses)" to omit selectedCourseId) or
implement the intended filtering logic that uses selectedCourseId (e.g., apply
it when deriving courseOptions or when calling the API) so that selectedCourseId
is actually referenced; update code around useCourseFilter, courseOptions, and
handleCourseSelect accordingly to keep usage consistent and eliminate the unused
variable.
🧹 Nitpick comments (14)
src/models/course.ts (1)
92-97:CourseOptionsResponse는DashboardCourseListResponse와 동일한 타입입니다.Line 76-79의
DashboardCourseListResponse와 Line 94-97의CourseOptionsResponse가ApiResponse<{ count: number; courses: DashboardCourse[] }>로 완전히 동일한 shape입니다. 중복 타입은 향후 유지보수 시 불일치를 유발할 수 있으므로, 기존 타입을 재사용하거나 type alias로 연결하는 것을 권장합니다.♻️ 개선 제안
-// 강의 옵션 목록 응답 타입 정의 -export type CourseOptionsResponse = ApiResponse<{ - count: number; - courses: DashboardCourse[]; -}>; +// 강의 옵션 목록 응답 타입 정의 (대시보드 강의 목록과 동일 shape) +export type CourseOptionsResponse = DashboardCourseListResponse;만약 향후 shape이 달라질 예정이라면 현재 상태도 괜찮지만, 그 의도를 주석으로 명시해주세요.
src/features/course/filter-course/ui/CourseSelector.tsx (1)
3-9: Props 타입을CourseSelectorProps인터페이스로 분리하는 것을 권장합니다.코딩 가이드라인에 따르면 Props 타입은
컴포넌트명Props네이밍 컨벤션을 따라야 합니다. 인라인 타입 대신 명명된 인터페이스를 사용하면 재사용성과 가독성이 향상됩니다.♻️ 개선 제안
+interface CourseSelectorProps { + options: string[]; + onSelect: (value: string) => void; +} + -export const CourseSelector = ({ - options, - onSelect, -}: { - options: string[]; - onSelect: (value: string) => void; -}) => { +export const CourseSelector = ({options, onSelect}: CourseSelectorProps) => {As per coding guidelines, "Props 타입: 컴포넌트명Props".
src/entities/course/index.ts (1)
1-1: Barrel export 확인 완료.
courseQueryOptions도 이 barrel에서 함께 re-export하면 소비자 측에서 일관된 경로로 import할 수 있습니다. 현재 구조가 의도된 것이라면 무시하셔도 됩니다.#!/bin/bash # courseQueryOptions가 어디서 import되고 있는지 확인 rg -n "courseQueryOptions" --type=ts -C2src/widgets/assignment-page-layout/ui/AssignmentPageLayout.tsx (1)
5-12:React.ReactNode사용 시Reactimport가 필요할 수 있습니다.Line 7에서
React.ReactNode를 타입으로 사용하고 있지만, 파일 상단에Reactimport가 없습니다. 새로운 JSX Transform에서는 JSX를 위한 React import는 불필요하지만,React.ReactNode같은 타입 참조는 명시적 import가 필요할 수 있습니다(tsconfig 설정에 따라 다름).♻️ 개선 제안
import SurfaceCard from '@/components/common/SurfaceCard'; import Button from '@/components/common/Button'; import {CourseSelector} from '@/features/course/filter-course'; +import type {ReactNode} from 'react'; interface AssignmentPageLayoutProps { title: string; - list: React.ReactNode; + list: ReactNode; courseOptions: string[];
type-only import을 활용하면 코딩 가이드라인의 "type-only import 적극 사용" 원칙도 함께 준수할 수 있습니다. 참고: React TypeScript CheatsheetAs per coding guidelines, "type-only import 적극 사용".
src/App.tsx (1)
43-43: 주석 처리된 라우트를 정리해 주세요.
AssignmentsPage라우트가 주석 처리되어 있는데, 이렇게 남겨두면 나중에 dead code가 될 수 있어요. 아직 작업 중이라면 TODO 주석과 함께 이슈 번호를 명시하거나, 사용하지 않는다면 삭제하는 것이 좋겠습니다.- {/* <Route path='assignments' element={<AssignmentsPage />} /> */} + {/* TODO(`#XX`): assignments 목록 페이지 API 연동 후 복원 */}src/components/common/SelectableItem.tsx (1)
3-3: Fast Refresh 호환성을 위해selectableItemStyles를 별도 파일로 분리하세요.ESLint가 지적한 대로, 컴포넌트와 상수를 같은 파일에서 export하면 React Fast Refresh가 정상 동작하지 않아요. 개발 중 HMR이 전체 리로드로 fallback되어 DX가 저하됩니다.
selectableItemStyles를 별도 파일(예:selectableItem.styles.ts)로 분리하고, 컴포넌트 파일에서 import하는 구조를 권장합니다.src/main.tsx (1)
7-7: QueryClient에 기본 옵션 설정을 고려해 보세요.현재 기본값(
staleTime: 0,retry: 3등)으로 동작하는데, 프로젝트 특성에 맞게 기본 옵션을 설정하면 불필요한 refetch를 줄이고 UX를 개선할 수 있어요.const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 1000 * 60, // 1분 retry: 1, }, }, });src/entities/course/api/courseQueryOptions.ts (1)
1-9: 파일명이 코딩 가이드라인의 kebab-case 규칙과 다릅니다.프로젝트 네이밍 컨벤션에 따르면 일반 파일명은 kebab-case를 사용해야 합니다.
courseQueryOptions.ts→course-query-options.ts로 변경을 고려해 주세요. 같은 디렉토리의courseApi.ts도 동일하게 해당됩니다.As per coding guidelines: "일반 파일명: kebab-case"
src/entities/assignment/api/assignmentApi.ts (1)
4-8:privateAxios.get에 제네릭 타입 파라미터 추가를 권장합니다.현재
response.data는any로 추론되고 리턴 타입 선언으로만 타입이 보장됩니다.get<DashboardScheduleListResponse>를 명시하면 Axios 레벨에서 타입 안전성을 확보할 수 있습니다.♻️ 개선 제안
export const getAssignmentSchedules = async (): Promise<DashboardScheduleListResponse> => { - const response = await privateAxios.get('/assignments/schedule'); + const response = await privateAxios.get<DashboardScheduleListResponse>('/assignments/schedule'); return response.data; };src/components/common/EmptyState.tsx (1)
6-12:className병합 시 유틸리티 함수 사용을 고려해 보세요.현재 템플릿 리터럴 방식도 동작하지만,
className이undefined일 때 불필요한 공백이 포함될 수 있습니다. 프로젝트에clsx나cn유틸리티가 있다면 활용하면 더 깔끔합니다.♻️ 개선 제안 (cn 유틸리티 사용 시)
+import {cn} from '@/utils/cn'; // 프로젝트에 해당 유틸이 있다면 + export const EmptyState = ({children, className}: EmptyStateProps) => { return ( <div - className={`text-center text-light-black text-base font-medium ${className ?? ''}`}> + className={cn('text-center text-light-black text-base font-medium', className)}> {children} </div> ); };src/entities/assignment/api/assignmentQueryOptions.ts (1)
4-9:queryKey를 좀 더 계층적으로 구성하는 것을 권장합니다.현재
['schedules']는 범용적이어서, 추후 스케줄 관련 쿼리가 추가되면 의도치 않은 캐시 충돌이 발생할 수 있습니다. TanStack Query의 Query Key 계층 구조 패턴을 참고해 보세요.♻️ 개선 제안
export default function assignmentQueryOptions() { return queryOptions({ - queryKey: ['schedules'], + queryKey: ['assignments', 'schedules'], queryFn: getAssignmentSchedules, }); }변경 시
Dashboard.tsx의invalidateQueries호출부도 자동으로 반영됩니다 (assignmentQueryOptions().queryKey를 사용하고 있으므로).src/pages/dashboard/Dashboard.tsx (1)
28-44:mutationFn간소화 및alert/confirm개선 여지
mutationFn의 래퍼 함수는 불필요합니다 —deleteCourse를 직접 전달할 수 있습니다.alert()/confirm()은 브라우저 모달로 UX가 제한적입니다. 당장은 괜찮지만, 추후 토스트 라이브러리(예:react-hot-toast)로 전환을 고려해 보세요.♻️ mutationFn 간소화 제안
const {mutate} = useMutation({ - mutationFn: (courseId: number) => deleteCourse(courseId), + mutationFn: deleteCourse, onSuccess: () => {src/pages/dashboard/ui/ScheduleList.tsx (1)
11-12: 리스트key에index사용은 주의가 필요합니다.
schedule항목에 고유 식별자(예:date또는 서버 제공id)가 있다면index대신 사용하는 것이 안전합니다. 리스트가 재정렬되거나 항목이 추가/삭제될 때index기반 key는 불필요한 리렌더링이나 상태 불일치를 유발할 수 있습니다.♻️ 개선 제안 (date가 유니크하다면)
{scheduleList.map((schedule, index) => ( - <li className='flex items-start justify-start gap-5' key={index}> + <li className='flex items-start justify-start gap-5' key={schedule.date}>As per coding guidelines, "리스트 렌더링 시 key 안정성 확인".
src/pages/dashboard/ui/CourseCard.tsx (1)
38-38: className 문자열 앞 불필요한 공백
className에 앞쪽 공백이 포함되어 있습니다. 기능에는 영향 없지만 정리하면 깔끔합니다.- <div className=' bg-gray flex-center px-7.5 py-6 rounded-r-3xl text-center text-base font-normal whitespace-nowrap'> + <div className='bg-gray flex-center px-7.5 py-6 rounded-r-3xl text-center text-base font-normal whitespace-nowrap'>
| const courseOptionMap = useMemo(() => { | ||
| const map = new Map<string, number>(); | ||
|
|
||
| courses.forEach((course) => { | ||
| const label = formatCourseOptionLabel( | ||
| course.title, | ||
| course.year, | ||
| course.semester, | ||
| course.section | ||
| ); | ||
| map.set(label, course.id); | ||
| }); | ||
|
|
||
| return map; | ||
| }, [courses]); |
There was a problem hiding this comment.
동일한 라벨을 가진 강의가 있을 경우 Map에서 덮어씌워집니다.
formatCourseOptionLabel로 생성한 라벨이 동일한 강의가 2개 이상 존재하면(예: 같은 제목·연도·학기·분반), Map.set이 이전 항목을 덮어써서 하나의 강의만 선택 가능해집니다. 현재 데이터에서 발생 가능성이 낮더라도, 방어적으로 라벨에 고유성을 부여하거나 ID 기반 매핑으로 전환하는 것이 안전합니다.
♻️ 대안: ID를 라벨 키에 포함
라벨 충돌을 방지하려면 내부적으로 ID를 함께 관리하는 방식을 고려해보세요. 예를 들어 options를 { label: string; value: number }[] 형태로 변경하면 드롭다운에서 label을 표시하고 value(ID)로 선택을 처리할 수 있습니다. 이 경우 CourseSelector 컴포넌트도 함께 수정이 필요합니다.
🤖 Prompt for AI Agents
In `@src/features/course/filter-course/lib/useCourseFilter.ts` around lines 11 -
25, The current courseOptionMap build uses formatCourseOptionLabel as the Map
key so duplicate labels overwrite entries (see courseOptionMap and Map.set);
change to a stable ID-based mapping by producing options as { label: string;
value: number }[] (build from courses using formatCourseOptionLabel for label
and course.id for value) instead of mapping label→id, and update CourseSelector
to read option.value as the selected course id; alternatively append course.id
to the label string to guarantee uniqueness if you cannot change CourseSelector.
| const [{data: courses}, {data: schedules}] = useSuspenseQueries({ | ||
| queries: [courseQueryOptions(), assignmentQueryOptions()], | ||
| }); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Find Dashboard.tsx and examine its content
find . -name "Dashboard.tsx" -type fRepository: 2025-snowCode/snowCode-Client
Length of output: 108
🏁 Script executed:
# Check the structure around the queries and examine the actual code
cat -n src/pages/dashboard/Dashboard.tsx | head -60Repository: 2025-snowCode/snowCode-Client
Length of output: 2472
🏁 Script executed:
# Search for Suspense and ErrorBoundary usage in the codebase
rg -n "Suspense|ErrorBoundary" --type tsx --type ts -C 2Repository: 2025-snowCode/snowCode-Client
Length of output: 100
🏁 Script executed:
# Check how Dashboard is imported and used in routing or parent components
rg -n "from.*Dashboard|import.*Dashboard" --type tsx --type ts -C 3Repository: 2025-snowCode/snowCode-Client
Length of output: 100
🏁 Script executed:
# Search for Suspense and ErrorBoundary with correct syntax
rg "Suspense|ErrorBoundary" --type=tsx --type=ts -C 2Repository: 2025-snowCode/snowCode-Client
Length of output: 100
🏁 Script executed:
# Check how Dashboard is imported and used - corrected syntax
rg "Dashboard" --type=tsx --type=ts -C 3 | grep -v "Dashboard.tsx" | head -50Repository: 2025-snowCode/snowCode-Client
Length of output: 100
🏁 Script executed:
# Look for route configuration files
find . -name "*route*" -o -name "*layout*" -o -name "*app*" | grep -E "\.(tsx|ts|jsx|js)$" | head -20Repository: 2025-snowCode/snowCode-Client
Length of output: 2991
🏁 Script executed:
# Check main.tsx or app entry point
find . -name "main.tsx" -o -name "App.tsx" -o -name "index.tsx" | head -10Repository: 2025-snowCode/snowCode-Client
Length of output: 101
🏁 Script executed:
# Check App.tsx for routing and Suspense/ErrorBoundary setup
cat -n src/App.tsxRepository: 2025-snowCode/snowCode-Client
Length of output: 2731
🏁 Script executed:
# Check main.tsx
cat -n src/main.tsxRepository: 2025-snowCode/snowCode-Client
Length of output: 591
🏁 Script executed:
# Search for Suspense in the codebase using basic grep
grep -r "Suspense" src/ --include="*.tsx" --include="*.ts" -n | head -20Repository: 2025-snowCode/snowCode-Client
Length of output: 236
🏁 Script executed:
# Search for ErrorBoundary in the codebase
grep -r "ErrorBoundary" src/ --include="*.tsx" --include="*.ts" -n | head -20Repository: 2025-snowCode/snowCode-Client
Length of output: 55
🏁 Script executed:
# Check Layout component for Suspense/ErrorBoundary
cat -n src/layout/Layout.tsxRepository: 2025-snowCode/snowCode-Client
Length of output: 921
🏁 Script executed:
# Check if there are any other files in the layout directory
ls -la src/layout/Repository: 2025-snowCode/snowCode-Client
Length of output: 243
useSuspenseQueries 사용 시 상위에 Suspense + ErrorBoundary가 필수입니다.
현재 코드는 useSuspenseQueries를 사용하지만, 앱 전체에 <Suspense> 또는 <ErrorBoundary>가 없어 다음 문제가 발생할 수 있습니다:
- 데이터 로딩 중 빈 화면 또는 런타임 에러
- 쿼리 에러 발생 시 컴포넌트 전체 크래시
해결책: Layout.tsx 또는 App.tsx의 <Outlet /> 위에 Suspense와 ErrorBoundary를 추가하세요.
<Suspense fallback={<LoadingSpinner />}>
<ErrorBoundary fallback={<ErrorPage />}>
<Outlet />
</ErrorBoundary>
</Suspense>추가 개선 사항 (선택):
mutationFn: (courseId: number) => deleteCourse(courseId)→mutationFn: deleteCourse(서명이 일치하면 간소화 가능)confirm(),alert()대신 토스트 라이브러리 사용 추천 (더 나은 UX)
🤖 Prompt for AI Agents
In `@src/pages/dashboard/Dashboard.tsx` around lines 23 - 25, Dashboard.tsx uses
useSuspenseQueries (const [{data: courses}, {data: schedules}] =
useSuspenseQueries(...)) but the app lacks a surrounding Suspense +
ErrorBoundary which can cause blank screens or crashes; wrap your route outlet
(e.g., in Layout.tsx or App.tsx) with a <Suspense fallback={...}> and an
<ErrorBoundary fallback={...}> around <Outlet /> so all children including
Dashboard can suspend or surface errors safely. Also consider the optional
refinements called out: simplify mutationFn from (courseId: number) =>
deleteCourse(courseId) to mutationFn: deleteCourse when signatures match, and
replace confirm()/alert() uses with a toast library for better UX.
| const {courseOptions, handleCourseSelect, selectedCourseId} = | ||
| useCourseFilter(courses); |
There was a problem hiding this comment.
selectedCourseId가 선언만 되고 사용되지 않고 있어요.
이전 리뷰에서 "API 연동 시 필터링 로직 구현 예정"이라고 했는데, 이번 PR이 바로 그 API 연동 PR이네요! 아직 이 페이지는 mock 데이터를 사용 중이라면, 린트 에러 방지를 위해 사용하지 않는 변수는 destructuring에서 제거하거나, 필터링 로직을 함께 구현하는 것을 권장합니다.
🔧 린트 에러 해결을 위한 임시 수정
- const {courseOptions, handleCourseSelect, selectedCourseId} =
+ const {courseOptions, handleCourseSelect} =
useCourseFilter(courses);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const {courseOptions, handleCourseSelect, selectedCourseId} = | |
| useCourseFilter(courses); | |
| const {courseOptions, handleCourseSelect} = | |
| useCourseFilter(courses); |
🧰 Tools
🪛 ESLint
[error] 12-12: 'selectedCourseId' is assigned a value but never used.
(@typescript-eslint/no-unused-vars)
🤖 Prompt for AI Agents
In `@src/pages/select-assignment/AssignmentSelectPage.tsx` around lines 12 - 13,
selectedCourseId from the useCourseFilter hook is declared but never used,
causing a lint error; either remove selectedCourseId from the destructuring
(change "const {courseOptions, handleCourseSelect, selectedCourseId} =
useCourseFilter(courses)" to omit selectedCourseId) or implement the intended
filtering logic that uses selectedCourseId (e.g., apply it when deriving
courseOptions or when calling the API) so that selectedCourseId is actually
referenced; update code around useCourseFilter, courseOptions, and
handleCourseSelect accordingly to keep usage consistent and eliminate the unused
variable.
⚙️ Related ISSUE Number
Related #38
📄 Work Description
1. TanStack Query 도입
tanstack-query패키지 추가 및QueryClientProvider설정2. Entity API 레이어 구성
entities/course— 강의 목록 조회(getAllCourses), 강의 삭제(deleteCourse) API 및courseQueryOptions추가entities/assignment— 스케줄 목록 조회(getAssignmentSchedules) API 및assignmentQueryOptions추가3. 대시보드 API 연동
useSuspenseQueries를 사용하여 강의 목록 + 스케줄 목록 병렬 조회useMutation기반 삭제 요청 처리4. UI 개선
EmptyState컴포넌트 추가 및 빈 상태 처리CourseCard스타일 개선 및 강의 관리 메뉴 위치 조정CourseCardProps,CourseManagementDropdownProps,ScheduleListProps)📷 Screenshot
💬 To Reviewers
🔗 Reference