From 86ccd9ea7ed926b341fd3d4a15184c11adac984e Mon Sep 17 00:00:00 2001 From: eedo_y Date: Fri, 19 Dec 2025 02:41:23 +0900 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=94=A5fix:=20=EC=83=81=EC=84=B8?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=EB=B0=8F=20=EC=A7=84=EB=8F=84=EC=9C=A8=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 122 ++++++++++++++++++++++++-------- app/page.tsx | 11 ++- components/home-calendar.tsx | 92 ++++++++++++++++++------ components/pdf-detail-view.tsx | 62 +++++++++++++++- components/pdf-upload-modal.tsx | 2 + lib/api/schedule.ts | 1 + package-lock.json | 26 +------ 7 files changed, 239 insertions(+), 77 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8621fad..629ffbe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,11 +32,12 @@ pnpm lint The app follows a multi-view state machine pattern managed in `app/page.tsx`: 1. **Splash Screen** → displays for 2.5s on initial load -2. **Login/SignUp** → authentication views (currently mock, no backend) +2. **Login/SignUp** → authentication views 3. **Home Calendar** → main dashboard with calendar and study plan management 4. **PDF Detail View** → detailed chapter/section view with integrated chatbot +5. **Celebration/Incomplete** → outcome views based on completion status -State flows between views are controlled by `currentView` state in the root `Home` component. +State flows between views are controlled by `currentView` state in the root `Home` component. Session persistence uses `sessionStorage` with the key `"zeus-auth-session"`. ### Core Data Model @@ -44,30 +45,54 @@ The application revolves around three main types defined in `app/page.tsx`: - **StudyPlan**: Top-level container for a PDF-based study schedule - Contains chapters, due date, daily hours, weekend settings + - Links to backend via `learningSourceId` - Tracks overall progress across all chapters - **Chapter**: Represents a major section of study material - - Contains multiple sections, scheduled date, completion status - - Estimated time in minutes for completion + - Contains multiple sections (tasks), scheduled date, completion status + - Maps to backend `ChapterInfoDto` structure -- **Section**: Smallest unit of study content - - Contains actual content, key points, definitions - - Individual completion tracking +- **Section**: Smallest unit of study content (called "Task" in backend) + - Contains title, content, completion status + - Corresponds to backend `TaskInfoDto` + +### Backend API Integration + +The app integrates with a REST API via `lib/api/` modules: + +**API Client** (`lib/api/client.ts`): +- Base client using `fetch` with error handling +- Reads `NEXT_PUBLIC_API_BASE_URL` from environment variables +- Custom `ApiError` class for structured error responses +- `apiFetch()` wrapper for type-safe API calls + +**API Modules**: +- `auth.ts` - Login and signup endpoints +- `home.ts` - Fetches user's study plans and chapters from `/api/home/{userId}` +- `schedule.ts` - Creates/regenerates schedules with PDF upload (`/api/schedule`, `/api/schedule/reschedule`) +- `study.ts` - Task summary management and completion status updates + +**Data Flow**: +1. Home view loads: `fetchHomeData()` → `mapHomeDataToStudyPlans()` converts DTOs to StudyPlan objects +2. PDF upload: `createSchedule()` sends multipart form data (PDF file + JSON request) +3. Task completion: `updateTaskCompletionStatus()` patches individual task status +4. Summary generation: `createTaskSummary()` and `fetchTaskSummary()` handle AI-generated study content ### Component Architecture **Main Views** (in `/components`): - `splash-screen.tsx` - Animated loading screen -- `login-page.tsx` / `signup-page.tsx` - Authentication (mock) +- `login-page.tsx` / `signup-page.tsx` - Authentication forms - `home-calendar.tsx` - Calendar UI with drag-drop scheduling, sidebar with study plan list - `pdf-detail-view.tsx` - Three-panel layout (TOC, content, chatbot) - `pdf-upload-modal.tsx` - Multi-step wizard for PDF upload and plan creation +- `celebration-page.tsx` / `incomplete-page.tsx` - Completion outcome views **Key Interactions**: -- Study plans are managed in the root `Home` component state +- Study plans are fetched from backend on home view mount via `useEffect` hook in `app/page.tsx:131-149` - Calendar view supports drag-and-drop chapter rescheduling - PDF detail view has collapsible sidebar, section navigation, and integrated chatbot -- Chatbot (`chatbot.tsx`) provides context-aware assistance based on current chapter/section +- Progress calculation triggers view transitions to celebration/incomplete pages based on completion and due date ### UI Components @@ -80,31 +105,38 @@ This project uses shadcn/ui with the "new-york" style variant. Configuration is ### State Management No global state management library is used. State is lifted to appropriate parent components: -- Root state in `app/page.tsx`: study plans, current view, modal visibility +- Root state in `app/page.tsx`: study plans (from API), current view, modal visibility, session status - Local state in views: calendar date, selected items, UI toggles +- Backend serves as source of truth for study plans and task data ### Styling - Tailwind CSS v4.1.9 with PostCSS - Custom font: Pretendard (loaded from CDN in `app/layout.tsx`) - Theme: Uses CSS variables for color scheme -- Responsive design with mobile considerations (hooks/use-mobile.ts) +- Responsive design with mobile considerations (`hooks/use-mobile.ts`) - Animations via `tailwindcss-animate` and `tw-animate-css` ## Key Technical Notes ### Next.js Configuration -- TypeScript build errors are ignored (`ignoreBuildErrors: true`) +- TypeScript build errors are ignored (`ignoreBuildErrors: true` in `next.config.mjs`) - Images are unoptimized (`images.unoptimized: true`) - Uses Next.js App Router (app directory structure) -- All components use `"use client"` directive (fully client-side) +- All components use `"use client"` directive (fully client-side rendered) + +### Environment Variables + +Required environment variable: +- `NEXT_PUBLIC_API_BASE_URL` - Backend API base URL (e.g., `http://localhost:8080`) ### Date Handling - Uses native Date objects and `date-fns` library -- Dates are stored as ISO strings in state +- Dates are stored as ISO strings (YYYY-MM-DD format) in state and API - Calendar calculations in `home-calendar.tsx` use Date arithmetic +- Backend DTOs use `studyDate` field in ISO format ### Form Handling @@ -113,18 +145,30 @@ No global state management library is used. State is lifted to appropriate paren ### PDF Processing -Currently **mock implementation only**: -- PDF upload is simulated (no actual parsing) -- Chapter/section data is hardcoded in `app/page.tsx` -- Upload modal (`pdf-upload-modal.tsx`) shows progress simulation -- Future integration point for real PDF parsing would be in the upload modal's `handleGenerate` function +**Current implementation**: +- PDF upload sends actual file to backend via `createSchedule()` in `lib/api/schedule.ts` +- Backend processes PDF and returns structured chapters/tasks +- Upload modal (`pdf-upload-modal.tsx`) handles multipart form submission +- Schedule regeneration available via `reCreateSchedule()` for existing learning sources + +### API Error Handling -### Chatbot +All API functions throw `ApiError` instances with: +- `status`: HTTP status code (0 for network errors) +- `message`: User-friendly error message (in Korean) +- `code`: Optional backend error code +- `details`: Raw error data from backend -- Basic mock chatbot implementation in `components/chatbot.tsx` -- Pattern-based response generation (no AI backend) -- Context-aware of current chapter/section -- Integration point for real AI would be in `handleSend` function +Components should catch `ApiError` and display appropriate messages to users. + +### Backend Data Mapping + +The `mapHomeDataToStudyPlans()` function in `app/page.tsx:48-101` transforms backend DTOs: +- `LearningSourceResponseDto` → `StudyPlan` +- `ChapterInfoDto` → `Chapter` +- `TaskInfoDto` → `Section` +- Scheduled dates are derived from earliest task date in each chapter +- Due date is calculated from latest chapter date ## File Organization @@ -135,7 +179,7 @@ app/ globals.css # Global styles, CSS variables components/ - *-page.tsx # Full-page views (login, signup, splash) + *-page.tsx # Full-page views (login, signup, splash, celebration, incomplete) home-calendar.tsx # Main calendar dashboard pdf-*.tsx # PDF-related components (upload, detail view) chatbot.tsx # AI assistant component @@ -143,6 +187,12 @@ components/ lib/ utils.ts # Utility functions (cn() for class merging) + api/ + client.ts # Base API client with error handling + auth.ts # Authentication endpoints + home.ts # Home data fetching + schedule.ts # Schedule creation/regeneration + study.ts # Task summary and completion APIs hooks/ use-mobile.ts # Mobile detection hook @@ -170,6 +220,21 @@ const updatedPlans = studyPlans.map(plan => ) ``` +### API Call Pattern + +Use try-catch with ApiError handling: +```typescript +try { + const response = await fetchHomeData() + // handle success +} catch (error) { + if (error instanceof ApiError) { + // show user-friendly error message + console.error(error.message) + } +} +``` + ### Conditional Rendering Uses ternary operators and conditional chaining extensively: @@ -180,8 +245,9 @@ Uses ternary operators and conditional chaining extensively: ## Important Context -- This is a hackathon project (path includes "AI:Hackathon") +- This is a hackathon project (path includes "AI-Hackathon") - Korean language UI (`lang="ko"` in layout) - Built with v0.app (noted in metadata generator field) - Uses Vercel Analytics for tracking -- No backend currently - all data is client-side mock data +- Backend API integration is active (no longer mock data) +- Session management uses sessionStorage (not persistent across browser restarts) diff --git a/app/page.tsx b/app/page.tsx index 23aa3fb..b8f3613 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -104,6 +104,7 @@ export default function Home() { const [currentView, setCurrentView] = useState("splash") const [showUploadModal, setShowUploadModal] = useState(false) const [selectedPlan, setSelectedPlan] = useState(null) + const [selectedChapterId, setSelectedChapterId] = useState(null) const [studyPlans, setStudyPlans] = useState([]) const [homeLoaded, setHomeLoaded] = useState(false) const [homeError, setHomeError] = useState(null) @@ -181,8 +182,9 @@ export default function Home() { } } - const handleViewPdf = (plan: StudyPlan) => { + const handleViewPdf = (plan: StudyPlan, chapterId?: string) => { setSelectedPlan(plan) + setSelectedChapterId(chapterId || null) setCurrentView("pdf-detail") } @@ -255,7 +257,12 @@ export default function Home() { onLogout={handleLogout} /> ) : selectedPlan ? ( - setCurrentView("home")} onUpdatePlan={handlePlanUpdate} /> + setCurrentView("home")} + onUpdatePlan={handlePlanUpdate} + /> ) : null} diff --git a/components/home-calendar.tsx b/components/home-calendar.tsx index 089fec7..8a72d1a 100644 --- a/components/home-calendar.tsx +++ b/components/home-calendar.tsx @@ -51,13 +51,14 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component import { cn } from "@/lib/utils" import { reCreateSchedule } from "@/lib/api/schedule" import { updateTaskCompletionStatus } from "@/lib/api/study" +import { fetchLearningSourceProgress, type ProgressData } from "@/lib/api/progress" import { ChatBot } from "@/components/chatbot" import type { StudyPlan, Chapter } from "@/app/page" interface HomeCalendarProps { studyPlans: StudyPlan[] onAddPdf: () => void - onViewPdf: (plan: StudyPlan, openChat?: boolean) => void + onViewPdf: (plan: StudyPlan, chapterId?: string) => void onUpdatePlans: (plans: StudyPlan[]) => void onDeletePlan: (planId: string) => void onLogout: () => void @@ -97,20 +98,70 @@ export function HomeCalendar({ studyPlans, onAddPdf, onViewPdf, onUpdatePlans, o excludeWeekends: false, }) const [qnaPlan, setQnaPlan] = useState(null) + const [progressMap, setProgressMap] = useState>(new Map()) const today = new Date() today.setHours(0, 0, 0, 0) + // 서버에서 각 학습 자료의 진도율 가져오기 + useEffect(() => { + const fetchAllProgress = async () => { + const newProgressMap = new Map() + + for (const plan of studyPlans) { + if (plan.learningSourceId) { + try { + const response = await fetchLearningSourceProgress(plan.learningSourceId) + newProgressMap.set(plan.id, response.data) + } catch (error) { + console.error(`Failed to fetch progress for plan ${plan.id}`, error) + } + } + } + + setProgressMap(newProgressMap) + } + + if (studyPlans.length > 0) { + void fetchAllProgress() + } + }, [studyPlans]) + const stats = useMemo(() => { - const totalChapters = studyPlans.reduce((acc, plan) => acc + plan.chapters.length, 0) - const completedChapters = studyPlans.reduce((acc, plan) => acc + plan.chapters.filter((c) => c.completed).length, 0) - const overallProgress = totalChapters > 0 ? Math.round((completedChapters / totalChapters) * 100) : 0 + let totalTasks = 0 + let doneTasks = 0 + + // 서버 진도율이 있으면 사용, 없으면 클라이언트에서 계산 + progressMap.forEach((progress) => { + totalTasks += progress.totalTaskCount + doneTasks += progress.doneTaskCount + }) + + // 진도율이 없으면 기존 방식으로 계산 + if (totalTasks === 0) { + const totalChapters = studyPlans.reduce((acc, plan) => acc + plan.chapters.length, 0) + const completedChapters = studyPlans.reduce((acc, plan) => acc + plan.chapters.filter((c) => c.completed).length, 0) + const overallProgress = totalChapters > 0 ? Math.round((completedChapters / totalChapters) * 100) : 0 + const remainingDays = + studyPlans.length > 0 + ? Math.max(0, Math.ceil((new Date(studyPlans[0].dueDate).getTime() - today.getTime()) / (1000 * 60 * 60 * 24))) + : 0 + return { totalChapters, completedChapters: completedChapters, overallProgress, remainingDays } + } + + const overallProgress = totalTasks > 0 ? Math.round((doneTasks / totalTasks) * 100) : 0 const remainingDays = studyPlans.length > 0 ? Math.max(0, Math.ceil((new Date(studyPlans[0].dueDate).getTime() - today.getTime()) / (1000 * 60 * 60 * 24))) : 0 - return { totalChapters, completedChapters, overallProgress, remainingDays } - }, [studyPlans, today]) + + return { + totalChapters: totalTasks, + completedChapters: doneTasks, + overallProgress, + remainingDays + } + }, [studyPlans, today, progressMap]) // 자료(title) 기준으로는 항상 펼쳐진 상태 유지 useEffect(() => { @@ -291,13 +342,15 @@ export function HomeCalendar({ studyPlans, onAddPdf, onViewPdf, onUpdatePlans, o } const handleReplan = async () => { - if (!replanTargetPlan) { + if (!replanTargetPlan || !replanTargetPlan.learningSourceId) { + alert("학습 자료 ID가 없습니다.") return } try { setReplanLoading(true) const request = { + learningSourceId: replanTargetPlan.learningSourceId, learningSourceTitle: replanTargetPlan.pdfName, startDate: replanSettings.startDate, endDate: replanSettings.dueDate, @@ -822,7 +875,17 @@ export function HomeCalendar({ studyPlans, onAddPdf, onViewPdf, onUpdatePlans, o

{plan.pdfName}

-

{chapters.length}개 챕터

+
+

{chapters.length}개 챕터

+ {progressMap.has(plan.id) && ( + <> + +

+ {progressMap.get(plan.id)?.progressRate}% +

+ + )} +
{ e.stopPropagation() - onViewPdf(plan) + onViewPdf(plan, chapter.id) }} className="h-8 w-8 rounded-full border border-slate-300/60 bg-white/70 flex items-center justify-center hover:bg-indigo-50 hover:border-indigo-300 transition-all shadow-sm" > @@ -1019,17 +1082,6 @@ export function HomeCalendar({ studyPlans, onAddPdf, onViewPdf, onUpdatePlans, o )} ))} - - {/* 상세 보기 버튼 */} -
- -
diff --git a/components/pdf-detail-view.tsx b/components/pdf-detail-view.tsx index 0b2deb6..612571c 100644 --- a/components/pdf-detail-view.tsx +++ b/components/pdf-detail-view.tsx @@ -12,10 +12,12 @@ import { ScrollArea } from "@/components/ui/scroll-area" import { cn } from "@/lib/utils" import { ChatBot } from "@/components/chatbot" import { fetchTaskSummary, createTaskSummary, updateTaskCompletionStatus } from "@/lib/api/study" +import { fetchChapterProgress, type ProgressData } from "@/lib/api/progress" import type { StudyPlan, Chapter, Section } from "@/app/page" interface PdfDetailViewProps { plan: StudyPlan + initialChapterId?: string | null onBack: () => void onUpdatePlan: (plan: StudyPlan) => void } @@ -28,15 +30,31 @@ const parseTaskIdFromSectionId = (sectionId: string): number | null => { return match ? Number(match[1]) : null } -export function PdfDetailView({ plan, onBack, onUpdatePlan }: PdfDetailViewProps) { - const [selectedChapter, setSelectedChapter] = useState(plan.chapters[0]) - const [selectedSection, setSelectedSection] = useState
(plan.chapters[0]?.sections[0] || null) +const parseChapterIdFromChapterId = (chapterId: string): number | null => { + const match = chapterId.match(/chapter-(\d+)/) + return match ? Number(match[1]) : null +} + +export function PdfDetailView({ plan, initialChapterId, onBack, onUpdatePlan }: PdfDetailViewProps) { + // initialChapterId가 있으면 해당 챕터를 찾아서 초기값으로 설정, 없으면 첫 번째 챕터 사용 + const getInitialChapter = () => { + if (initialChapterId) { + const foundChapter = plan.chapters.find((ch) => ch.id === initialChapterId) + if (foundChapter) return foundChapter + } + return plan.chapters[0] + } + + const initialChapter = getInitialChapter() + const [selectedChapter, setSelectedChapter] = useState(initialChapter) + const [selectedSection, setSelectedSection] = useState
(initialChapter?.sections[0] || null) const [expandedSections, setExpandedSections] = useState>(new Set()) const [showChatbot, setShowChatbot] = useState(false) const [chapterPage, setChapterPage] = useState(0) const [summaryContent, setSummaryContent] = useState("") const [summaryLoading, setSummaryLoading] = useState(false) const [summaryGenerating, setSummaryGenerating] = useState(false) + const [chapterProgressMap, setChapterProgressMap] = useState>(new Map()) const totalProgress = Math.round((plan.chapters.filter((c) => c.completed).length / plan.chapters.length) * 100) @@ -52,6 +70,39 @@ export function PdfDetailView({ plan, onBack, onUpdatePlan }: PdfDetailViewProps setChapterPage(0) }, [plan.id]) + // initialChapterId가 변경되면 해당 챕터로 이동 + useEffect(() => { + const newChapter = getInitialChapter() + setSelectedChapter(newChapter) + setSelectedSection(newChapter?.sections[0] || null) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [plan.id, initialChapterId]) + + // 챕터별 진도율 가져오기 + useEffect(() => { + const fetchAllChapterProgress = async () => { + const newProgressMap = new Map() + + for (const chapter of plan.chapters) { + const chapterId = parseChapterIdFromChapterId(chapter.id) + if (chapterId !== null) { + try { + const response = await fetchChapterProgress(chapterId) + newProgressMap.set(chapter.id, response.data) + } catch (error) { + console.error(`Failed to fetch progress for chapter ${chapter.id}`, error) + } + } + } + + setChapterProgressMap(newProgressMap) + } + + if (plan.chapters.length > 0) { + void fetchAllChapterProgress() + } + }, [plan.chapters, plan.id]) + // 상세 페이지 진입 시, 현재 선택된 섹션에 대해 요약 조회 useEffect(() => { if (selectedChapter && selectedSection) { @@ -276,6 +327,11 @@ export function PdfDetailView({ plan, onBack, onUpdatePlan }: PdfDetailViewProps day: "numeric", })} + {chapterProgressMap.has(chapter.id) && ( + + {chapterProgressMap.get(chapter.id)?.progressRate}% + + )} =0.10.0" } @@ -10260,7 +10244,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -10273,7 +10256,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.68.0.tgz", "integrity": "sha512-oNN3fjrZ/Xo40SWlHf1yCjlMK417JxoSJVUXQjGdvdRCU07NTFei1i1f8ApUAts+IVh14e4EdakeLEA+BEAs/Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -11286,8 +11268,7 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tailwindcss-animate": { "version": "1.0.7", @@ -11359,7 +11340,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -11562,7 +11542,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12090,7 +12069,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } From baaed25d4d7bf7b057c36b9756ecedf3bceb8a33 Mon Sep 17 00:00:00 2001 From: eedo_y Date: Fri, 19 Dec 2025 03:15:10 +0900 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=94=A5fix:=20PDF=20=EB=B0=94=EB=A1=9C?= =?UTF-8?q?=20=EC=83=81=EC=84=B8=EB=B3=B4=EA=B8=B0=20=EB=90=98=EB=8F=84?= =?UTF-8?q?=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 --- app/page.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/page.tsx b/app/page.tsx index b8f3613..9b6d608 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -149,9 +149,18 @@ export default function Home() { load() }, [currentView, homeLoaded]) - const handlePlanCreated = (newPlan: StudyPlan) => { + const handlePlanCreated = async (newPlan: StudyPlan) => { setStudyPlans([...studyPlans, newPlan]) setShowUploadModal(false) + + // PDF 생성 후 홈 데이터를 다시 불러와서 실제 learningSourceId 반영 + try { + const res = await fetchHomeData() + const plans = mapHomeDataToStudyPlans(res.data) + setStudyPlans(plans) + } catch (error: any) { + console.error("Failed to reload home data after plan creation", error) + } } const handlePlanUpdate = (updatedPlan: StudyPlan) => { From 0576697a41e7ccb15b64a96d375d723915f602a4 Mon Sep 17 00:00:00 2001 From: eedo_y Date: Fri, 19 Dec 2025 03:38:17 +0900 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=94=A5fix:=20=EC=8A=A4=EC=BC=80?= =?UTF-8?q?=EC=A5=B4=20=EC=9E=AC=EC=83=9D=EC=84=B1=20=EA=B3=A0=EC=B9=98?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/home-calendar.tsx | 5 ++--- lib/api/schedule.ts | 8 ++++---- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/components/home-calendar.tsx b/components/home-calendar.tsx index 8a72d1a..5f59bea 100644 --- a/components/home-calendar.tsx +++ b/components/home-calendar.tsx @@ -342,15 +342,14 @@ export function HomeCalendar({ studyPlans, onAddPdf, onViewPdf, onUpdatePlans, o } const handleReplan = async () => { - if (!replanTargetPlan || !replanTargetPlan.learningSourceId) { - alert("학습 자료 ID가 없습니다.") + if (!replanTargetPlan) { + alert("재생성할 학습 자료를 찾을 수 없습니다.") return } try { setReplanLoading(true) const request = { - learningSourceId: replanTargetPlan.learningSourceId, learningSourceTitle: replanTargetPlan.pdfName, startDate: replanSettings.startDate, endDate: replanSettings.dueDate, diff --git a/lib/api/schedule.ts b/lib/api/schedule.ts index 6f33664..142b517 100644 --- a/lib/api/schedule.ts +++ b/lib/api/schedule.ts @@ -74,14 +74,15 @@ export async function createSchedule( // 스케줄 재생성: POST /api/schedule/reschedule -export async function reCreateSchedule(request: { - learningSourceId: number +type ReCreateScheduleRequest = { learningSourceTitle: string startDate: string endDate: string dailyStudyTime: number excludeWeekend: boolean -}) { +} + +export async function reCreateSchedule(request: ReCreateScheduleRequest) { if (!BASE_URL) { throw new ApiError({ status: 0, @@ -134,4 +135,3 @@ export async function reCreateSchedule(request: { // DataResponseDto 구조를 그대로 반환 return data as any } - From 7dcb173a26c0f8435a70ef3486ceb62baf8a9272 Mon Sep 17 00:00:00 2001 From: eedo_y Date: Fri, 19 Dec 2025 03:45:04 +0900 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=94=A5fix:=20=EC=8A=A4=ED=94=BC?= =?UTF-8?q?=EB=84=88=20=EB=8F=8C=EC=95=84=EA=B0=80=EA=B2=8C=20=ED=95=98?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/pdf-upload-modal.tsx | 67 +++++++++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 8 deletions(-) diff --git a/components/pdf-upload-modal.tsx b/components/pdf-upload-modal.tsx index 3303af1..006027f 100644 --- a/components/pdf-upload-modal.tsx +++ b/components/pdf-upload-modal.tsx @@ -4,6 +4,7 @@ import type React from "react" import { useState, useCallback } from "react" import { Upload, Calendar, Clock, Zap, FileText } from "lucide-react" +import { useEffect } from "react" import { Dialog, DialogContent, @@ -43,6 +44,32 @@ export function PdfUploadModal({ open, onOpenChange, onPlanCreated }: PdfUploadM }) const [generatingProgress, setGeneratingProgress] = useState(0) const [generatedPlan, setGeneratedPlan] = useState(null) + const [progressIntervalId, setProgressIntervalId] = useState | null>(null) + const [progressStatusText, setProgressStatusText] = useState("PDF 구조 분석 중...") + + // 진행도에 따라 상태 문구 업데이트 + useEffect(() => { + if (generatingProgress < 40) { + setProgressStatusText("PDF 구조 분석 중...") + } else if (generatingProgress < 65) { + setProgressStatusText("챕터 및 섹션 추출 중...") + } else if (generatingProgress < 85) { + setProgressStatusText("학습 분량 계산 중...") + } else if (generatingProgress < 100) { + setProgressStatusText("일정 배치 중...") + } else { + setProgressStatusText("완료!") + } + }, [generatingProgress]) + + // 컴포넌트 언마운트 시 인터벌 정리 + useEffect(() => { + return () => { + if (progressIntervalId) { + clearInterval(progressIntervalId) + } + } + }, [progressIntervalId]) const isGenerateDisabled = !settings.startDate || @@ -131,12 +158,25 @@ export function PdfUploadModal({ open, onOpenChange, onPlanCreated }: PdfUploadM try { setStep("generating") - setGeneratingProgress(30) + setGeneratingProgress(10) + if (progressIntervalId) { + clearInterval(progressIntervalId) + } + // 네트워크 응답을 기다리는 동안 체감 진행도를 천천히 올려줌 + const intervalId = setInterval(() => { + setGeneratingProgress((prev) => { + if (prev >= 90) return prev + const increment = prev < 50 ? 5 : 3 + return Math.min(prev + increment, 90) + }) + }, 600) + setProgressIntervalId(intervalId) // 백엔드에 학습 스케줄 생성 요청 (멀티파트) const response: any = await createSchedule(uploadedFile, requestPayload) - setGeneratingProgress(80) + setGeneratingProgress(95) + setProgressStatusText("일정 배치 중...") const chapterInfoDtos = response?.data?.chapterInfoDtos ?? [] const learningSourceId = response?.data?.learningSourceId @@ -158,14 +198,25 @@ export function PdfUploadModal({ open, onOpenChange, onPlanCreated }: PdfUploadM chapters: chaptersFromApi, } + if (progressIntervalId) { + clearInterval(progressIntervalId) + setProgressIntervalId(null) + } + setGeneratedPlan(generated) setGeneratingProgress(100) + setProgressStatusText("완료!") setStep("preview") } catch (error) { console.error("Failed to create schedule", error) alert("학습 스케줄 생성 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.") + if (progressIntervalId) { + clearInterval(progressIntervalId) + setProgressIntervalId(null) + } setStep("settings") setGeneratingProgress(0) + setProgressStatusText("PDF 구조 분석 중...") return } @@ -179,11 +230,16 @@ export function PdfUploadModal({ open, onOpenChange, onPlanCreated }: PdfUploadM } const handleClose = () => { + if (progressIntervalId) { + clearInterval(progressIntervalId) + setProgressIntervalId(null) + } setStep("upload") setUploadedFile(null) setPdfName("") setGeneratingProgress(0) setGeneratedPlan(null) + setProgressStatusText("PDF 구조 분석 중...") setSettings({ startDate: "", dueDate: "", @@ -366,12 +422,7 @@ export function PdfUploadModal({ open, onOpenChange, onPlanCreated }: PdfUploadM -
- {generatingProgress < 30 && "PDF 구조 분석 중..."} - {generatingProgress >= 30 && generatingProgress < 60 && "챕터 및 섹션 추출 중..."} - {generatingProgress >= 60 && generatingProgress < 90 && "학습 분량 계산 중..."} - {generatingProgress >= 90 && "일정 배치 중..."} -
+
{progressStatusText}
)}