import { useEffect, useMemo, useRef, useState } from 'react'; import { CheckCircle2, CirclePlay, Clock3, FileCode2, FileText, Loader2, Plus, RadioTower, Save, Target, Trash2, Upload, type LucideIcon } from 'lucide-react'; import { ConfirmDialog } from '../../components/ConfirmDialog'; import { PageHeader } from '../../components/PageHeader'; import { useToast } from '../../components/ToastProvider'; import { deleteCurriculumReference, deleteInstructorCurriculumWeek, getInstructorCurriculum, updateInstructorCurriculum, uploadCurriculumExampleCode, uploadCurriculumReference, type CurriculumWeek, type InstructorCurriculumResponse } from '../../lib/curriculum'; import { listInstructorGroups, type InstructorGroup } from '../../lib/groups'; import type { AuthSession } from '../../types/auth'; const CURRICULUM_SELECTED_GROUP_STORAGE_PREFIX = 'lms-admin-curriculum-selected-group-id'; export function CurriculumPage({ session, selectedCourseId }: { session: AuthSession; selectedCourseId: string }) { const { showToast } = useToast(); const [weeks, setWeeks] = useState([]); const [groups, setGroups] = useState([]); const [selectedGroupId, setSelectedGroupId] = useState(''); const [activeWeek, setActiveWeek] = useState(1); const [activeLearningStep, setActiveLearningStep] = useState(null); const [currentProgressWeek, setCurrentProgressWeek] = useState(1); const [currentProgressLearningStep, setCurrentProgressLearningStep] = useState(null); const [draftTagLabel, setDraftTagLabel] = useState(''); const [draftTitle, setDraftTitle] = useState(''); const [draftDuration, setDraftDuration] = useState(''); const [draftOverview, setDraftOverview] = useState(''); const [draftDeliverable, setDraftDeliverable] = useState(''); const [draftRequiresSourceSubmission, setDraftRequiresSourceSubmission] = useState(true); const [draftTopics, setDraftTopics] = useState([]); const [draftTopicDetails, setDraftTopicDetails] = useState([]); const [draftTopicVideoUrls, setDraftTopicVideoUrls] = useState([]); const [draftTopicVideoEnabled, setDraftTopicVideoEnabled] = useState([]); const [draftTopicConcepts, setDraftTopicConcepts] = useState([]); const [draftPracticeContents, setDraftPracticeContents] = useState>([]); const [moduleEnabledById, setModuleEnabledById] = useState>({}); const [tagOptions, setTagOptions] = useState>([]); const [visualizationPlatforms, setVisualizationPlatforms] = useState>([]); const [visualizationEnabledByKey, setVisualizationEnabledByKey] = useState>({}); const [referenceTitle, setReferenceTitle] = useState(''); const [referenceFile, setReferenceFile] = useState(null); const [exampleCodeTitle, setExampleCodeTitle] = useState(''); const [exampleCodeFile, setExampleCodeFile] = useState(null); const [activeTab, setActiveTab] = useState<'apply' | 'edit' | 'modules' | 'examples'>('apply'); const [error, setError] = useState(null); const [saving, setSaving] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [completeConfirmOpen, setCompleteConfirmOpen] = useState(false); const referenceFileInputRef = useRef(null); const exampleCodeFileInputRef = useRef(null); const selectedWeek = useMemo(() => weeks.find((week) => week.week === activeWeek) ?? weeks[0] ?? null, [activeWeek, weeks]); const selectedGroup = useMemo(() => groups.find((group) => group.id === selectedGroupId) ?? null, [groups, selectedGroupId]); const hasGroups = groups.length > 0; const applyTabDisabled = !hasGroups; const selectedWeekCompleted = selectedWeek?.state === 'done'; const primaryActionDisabled = saving || !selectedWeek || (activeTab === 'apply' && !selectedGroup); const selectedTopics = activeTab === 'edit' ? draftTopics : selectedWeek?.topics ?? []; const selectedTopicDetails = activeTab === 'edit' ? draftTopicDetails : selectedWeek?.detail.topicDetails ?? []; const selectedWeekDeletable = Boolean(selectedWeek?.tags.includes('추가')); const tagOptionLabels = useMemo(() => new Set(tagOptions.map((option) => option.label)), [tagOptions]); const selectedTagLabel = tagOptionLabels.has(draftTagLabel) ? draftTagLabel : tagOptions[0]?.label ?? '없음'; const selectedGroupStorageKey = useMemo( () => `${CURRICULUM_SELECTED_GROUP_STORAGE_PREFIX}:${session.user.id}:${selectedCourseId || 'default'}`, [selectedCourseId, session.user.id], ); const resolveProgressSelection = (nextWeeks: CurriculumWeek[], setting: InstructorCurriculumResponse['setting']) => { const nextWeek = nextWeeks.find((week) => week.state === 'current')?.week ?? nextWeeks.find((week) => week.state !== 'done')?.week ?? setting?.activeWeek ?? nextWeeks[0]?.week ?? 1; const nextWeekData = nextWeeks.find((week) => week.week === nextWeek) ?? nextWeeks[0]; return { week: nextWeek, learningStep: setting?.activeLearningStep ?? nextWeekData?.topics[0] ?? null, }; }; const selectProgressWeek = (nextWeeks = weeks, setting?: InstructorCurriculumResponse['setting'] | null) => { const progress = setting === undefined ? { week: currentProgressWeek, learningStep: currentProgressLearningStep } : resolveProgressSelection(nextWeeks, setting); const progressWeek = nextWeeks.find((week) => week.week === progress.week) ?? nextWeeks[0] ?? null; setActiveWeek(progressWeek?.week ?? progress.week); setActiveLearningStep(progress.learningStep ?? progressWeek?.topics[0] ?? null); }; const applyPayload = (payload: InstructorCurriculumResponse, options: { selectProgress?: boolean } = {}) => { setError(null); setWeeks(payload.weeks); setTagOptions(payload.tagOptions ?? []); setVisualizationPlatforms(payload.visualizationPlatforms ?? []); const progress = resolveProgressSelection(payload.weeks, payload.setting); setCurrentProgressWeek(progress.week); setCurrentProgressLearningStep(progress.learningStep); if (options.selectProgress) { selectProgressWeek(payload.weeks, payload.setting); } }; useEffect(() => { let cancelled = false; listInstructorGroups(session.accessToken, selectedCourseId || null) .then((payload) => { if (cancelled) return; setGroups(payload.groups); if (!payload.groups.length) { window.localStorage.removeItem(selectedGroupStorageKey); } setSelectedGroupId((current) => { if (payload.groups.some((group) => group.id === current)) return current; const storedGroupId = window.localStorage.getItem(selectedGroupStorageKey) ?? ''; if (payload.groups.some((group) => group.id === storedGroupId)) return storedGroupId; return payload.groups[0]?.id || ''; }); }) .catch((reason: Error) => { if (cancelled) return; setGroups([]); setError(reason.message); }); return () => { cancelled = true; }; }, [session.accessToken, selectedCourseId, selectedGroupStorageKey]); useEffect(() => { if (selectedGroupId) { window.localStorage.setItem(selectedGroupStorageKey, selectedGroupId); } }, [selectedGroupId, selectedGroupStorageKey]); useEffect(() => { let cancelled = false; getInstructorCurriculum(session.accessToken, selectedGroupId || null, selectedCourseId || null) .then((payload) => { if (!cancelled) applyPayload(payload, { selectProgress: true }); }) .catch((reason: Error) => { if (cancelled) return; setWeeks([]); setError(reason.message); }); return () => { cancelled = true; }; }, [session.accessToken, selectedGroupId, selectedCourseId]); useEffect(() => { if (!selectedWeek) return; setDraftTagLabel(selectedWeek.tags[0] ?? ''); setDraftTitle(selectedWeek.title); setDraftDuration(selectedWeek.duration); setDraftOverview(selectedWeek.detail.overview); setDraftDeliverable(selectedWeek.detail.deliverable); setDraftRequiresSourceSubmission(selectedWeek.detail.requiresSourceSubmission ?? true); setDraftTopics(selectedWeek.topics.length ? selectedWeek.topics : ['']); setDraftTopicDetails(selectedWeek.detail.topicDetails.length ? selectedWeek.detail.topicDetails : ['']); const topicVideoUrls = selectedWeek.topics.map((_, index) => selectedWeek.detail.topicVideoUrls?.[index] ?? ''); setDraftTopicVideoUrls(topicVideoUrls); setDraftTopicVideoEnabled(topicVideoUrls.map((url) => Boolean(url.trim()))); setDraftTopicConcepts(selectedWeek.topics.map((_, index) => selectedWeek.detail.learningSteps?.[index]?.concepts?.join('\n') ?? '')); setDraftPracticeContents(selectedWeek.detail.practiceContents?.length ? selectedWeek.detail.practiceContents : []); setModuleEnabledById(Object.fromEntries((selectedWeek.modules ?? []).map((module) => [module.id, module.enabled !== false]))); setVisualizationEnabledByKey(Object.fromEntries((selectedWeek.visualizationPlatforms ?? []).map((platform) => [platform.key, true]))); setReferenceTitle(''); setReferenceFile(null); setExampleCodeTitle(''); setExampleCodeFile(null); if (referenceFileInputRef.current) referenceFileInputRef.current.value = ''; if (exampleCodeFileInputRef.current) exampleCodeFileInputRef.current.value = ''; }, [selectedWeek?.week]); useEffect(() => { if (!selectedWeek) return; if (!activeLearningStep || !selectedTopics.includes(activeLearningStep)) { setActiveLearningStep(selectedTopics[0] ?? null); } }, [activeLearningStep, selectedTopics, selectedWeek]); const buildDraftLearningSteps = () => draftTopics .map((topic, index) => { const title = topic.trim(); const description = draftTopicDetails[index]?.trim() ?? ''; const videoEnabled = draftTopicVideoEnabled[index] || Boolean(draftTopicVideoUrls[index]?.trim()); const videoUrl = videoEnabled ? (draftTopicVideoUrls[index]?.trim() ?? '') : ''; const concepts = (draftTopicConcepts[index] ?? '') .split('\n') .map((concept) => concept.trim()) .filter(Boolean); return title || description || videoUrl || concepts.length ? { title, description, videoEnabled, videoUrl, concepts } : null; }) .filter((step): step is { title: string; description: string; videoEnabled: boolean; videoUrl: string; concepts: string[] } => Boolean(step)); const handleApply = async () => { if (!selectedWeek || !selectedGroup) return; setSaving(true); setError(null); try { const payload = await updateInstructorCurriculum(session.accessToken, { courseId: selectedCourseId || null, groupId: selectedGroupId || null, activeWeek: selectedWeek.week, activeLearningStep, applyToStudents: true, tagLabel: selectedTagLabel, title: draftTitle, duration: draftDuration, overview: draftOverview, deliverable: draftDeliverable, requiresSourceSubmission: draftRequiresSourceSubmission, topics: draftTopics.map((topic) => topic.trim()).filter(Boolean), learningSteps: buildDraftLearningSteps(), topicDetails: draftTopicDetails, topicVideoUrls: draftTopicVideoUrls.map((url, index) => draftTopicVideoEnabled[index] ? url.trim() : ''), practiceContents: draftPracticeContents, }); applyPayload(payload, { selectProgress: true }); showToast({ title: '커리큘럼이 적용되었습니다.', message: `${selectedGroup.name} · ${selectedWeek.week}주차 · ${activeLearningStep ?? '학습단계 미선택'}`, }); } catch (reason) { setError(reason instanceof Error ? reason.message : '커리큘럼을 적용하지 못했습니다.'); } finally { setSaving(false); } }; const handleChangeGroup = (groupId: string) => { setSelectedGroupId(groupId); if (groupId) { window.localStorage.setItem(selectedGroupStorageKey, groupId); } else { window.localStorage.removeItem(selectedGroupStorageKey); } }; const handleCompleteWeek = async () => { if (!selectedWeek || !selectedGroup) return; const topics = draftTopics.map((topic) => topic.trim()).filter(Boolean); const finalLearningStep = topics[topics.length - 1] ?? selectedWeek.topics[selectedWeek.topics.length - 1] ?? activeLearningStep; setSaving(true); setError(null); try { const payload = await updateInstructorCurriculum(session.accessToken, { courseId: selectedCourseId || null, groupId: selectedGroupId || null, activeWeek: selectedWeek.week, activeLearningStep: finalLearningStep, applyToStudents: true, completed: true, tagLabel: selectedTagLabel, title: draftTitle, duration: draftDuration, overview: draftOverview, deliverable: draftDeliverable, requiresSourceSubmission: draftRequiresSourceSubmission, topics, learningSteps: buildDraftLearningSteps(), topicDetails: draftTopicDetails, topicVideoUrls: draftTopicVideoUrls.map((url, index) => draftTopicVideoEnabled[index] ? url.trim() : ''), practiceContents: draftPracticeContents, }); applyPayload(payload, { selectProgress: true }); showToast({ title: '주차가 완료되었습니다.', message: `${selectedGroup.name} · ${selectedWeek.week}주차`, }); } catch (reason) { setError(reason instanceof Error ? reason.message : '주차를 완료하지 못했습니다.'); } finally { setSaving(false); } }; const handleRequestCompleteWeek = () => { if (!selectedWeek || !selectedGroup) return; setCompleteConfirmOpen(true); }; const handleSaveContent = async () => { if (!selectedWeek) return; setSaving(true); setError(null); try { const payload = await updateInstructorCurriculum(session.accessToken, { courseId: selectedCourseId || null, groupId: selectedGroupId || null, activeWeek: selectedWeek.week, activeLearningStep, applyToStudents: false, tagLabel: selectedTagLabel, title: draftTitle, duration: draftDuration, overview: draftOverview, deliverable: draftDeliverable, requiresSourceSubmission: draftRequiresSourceSubmission, topics: draftTopics.map((topic) => topic.trim()).filter(Boolean), learningSteps: buildDraftLearningSteps(), topicDetails: draftTopicDetails, topicVideoUrls: draftTopicVideoUrls.map((url, index) => draftTopicVideoEnabled[index] ? url.trim() : ''), practiceContents: draftPracticeContents, }); applyPayload(payload); showToast({ title: '커리큘럼 내용이 저장되었습니다.', message: `${selectedWeek.week}주차`, }); } catch (reason) { setError(reason instanceof Error ? reason.message : '커리큘럼을 저장하지 못했습니다.'); } finally { setSaving(false); } }; const handleSaveModules = async () => { if (!selectedWeek) return; setSaving(true); setError(null); try { const disabledModuleIds = (selectedWeek.modules ?? []) .filter((module) => moduleEnabledById[module.id] === false) .map((module) => module.id); const payload = await updateInstructorCurriculum(session.accessToken, { courseId: selectedCourseId || null, groupId: selectedGroupId || null, activeWeek: selectedWeek.week, activeLearningStep, applyToStudents: false, disabledModuleIds, visualizationPlatformKeys: visualizationPlatforms .filter((platform) => visualizationEnabledByKey[platform.key]) .map((platform) => platform.key), }); applyPayload(payload); setActiveWeek(selectedWeek.week); showToast({ title: '모듈 설정이 저장되었습니다.', message: `${selectedWeek.week}주차`, }); } catch (reason) { setError(reason instanceof Error ? reason.message : '모듈 설정을 저장하지 못했습니다.'); } finally { setSaving(false); } }; const handleAddWeek = async () => { const nextWeek = Math.max(0, ...weeks.map((week) => week.week)) + 1; setSaving(true); setError(null); try { const payload = await updateInstructorCurriculum(session.accessToken, { courseId: selectedCourseId || null, groupId: selectedGroupId || null, activeWeek: nextWeek, activeLearningStep: '학습단계 1', applyToStudents: false, tagLabel: tagOptions[0]?.label ?? '없음', title: `${nextWeek}주차 커리큘럼`, duration: '-', overview: '', deliverable: '', requiresSourceSubmission: true, topics: ['학습단계 1'], learningSteps: [{ title: '학습단계 1', description: '', videoEnabled: false, videoUrl: '', concepts: [] }], topicDetails: [''], topicVideoUrls: [''], practiceContents: [], }); applyPayload(payload); setActiveWeek(nextWeek); setActiveLearningStep('학습단계 1'); setActiveTab('edit'); showToast({ title: '주차가 추가되었습니다.', message: `${nextWeek}주차`, }); } catch (reason) { setError(reason instanceof Error ? reason.message : '주차를 추가하지 못했습니다.'); } finally { setSaving(false); } }; const handleDeleteWeek = async () => { if (!selectedWeek || !selectedWeekDeletable) return; setSaving(true); setError(null); try { const payload = await deleteInstructorCurriculumWeek(session.accessToken, selectedWeek.week); applyPayload(payload, { selectProgress: true }); showToast({ title: '주차가 삭제되었습니다.', message: `${selectedWeek.week}주차`, }); setDeleteConfirmOpen(false); } catch (reason) { setError(reason instanceof Error ? reason.message : '주차를 삭제하지 못했습니다.'); } finally { setSaving(false); } }; const handleAddLearningStep = () => { const nextIndex = draftTopics.length + 1; const nextTopic = `학습단계 ${nextIndex}`; setDraftTopics((current) => [...current, nextTopic]); setDraftTopicDetails((current) => [...current, '']); setDraftTopicVideoUrls((current) => [...current, '']); setDraftTopicVideoEnabled((current) => [...current, false]); setDraftTopicConcepts((current) => [...current, '']); }; const handleDeleteLearningStep = (index: number) => { setDraftTopics((currentTopics) => { const removedTopic = currentTopics[index]; const nextTopics = currentTopics.filter((_, topicIndex) => topicIndex !== index); setActiveLearningStep((currentStep) => { if (currentStep && currentStep !== removedTopic && nextTopics.includes(currentStep)) return currentStep; return nextTopics[Math.min(index, nextTopics.length - 1)] ?? null; }); return nextTopics; }); setDraftTopicDetails((currentDetails) => currentDetails.filter((_, detailIndex) => detailIndex !== index)); setDraftTopicVideoUrls((currentUrls) => currentUrls.filter((_, urlIndex) => urlIndex !== index)); setDraftTopicVideoEnabled((currentEnabled) => currentEnabled.filter((_, enabledIndex) => enabledIndex !== index)); setDraftTopicConcepts((currentConcepts) => currentConcepts.filter((_, conceptIndex) => conceptIndex !== index)); }; const handleAddPracticeContent = () => { setDraftPracticeContents((current) => [...current, { title: '', description: '' }]); }; const handleUpdatePracticeContent = (index: number, field: 'title' | 'description', value: string) => { setDraftPracticeContents((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, [field]: value } : item)); }; const handleDeletePracticeContent = (index: number) => { setDraftPracticeContents((current) => current.filter((_, itemIndex) => itemIndex !== index)); }; const handleUploadReference = async () => { if (!selectedWeek || !referenceFile) return; setSaving(true); setError(null); try { const payload = await uploadCurriculumReference(session.accessToken, { courseId: selectedCourseId || null, week: selectedWeek.week, title: referenceTitle, file: referenceFile, }); applyPayload(payload); setActiveWeek(selectedWeek.week); setReferenceTitle(''); setReferenceFile(null); if (referenceFileInputRef.current) referenceFileInputRef.current.value = ''; showToast({ title: '참고자료가 업로드되었습니다.', message: referenceFile.name, }); } catch (reason) { setError(reason instanceof Error ? reason.message : '참고자료를 업로드하지 못했습니다.'); } finally { setSaving(false); } }; const handleUploadExampleCode = async () => { if (!selectedWeek || !exampleCodeFile) return; setSaving(true); setError(null); try { const payload = await uploadCurriculumExampleCode(session.accessToken, { courseId: selectedCourseId || null, week: selectedWeek.week, title: exampleCodeTitle, file: exampleCodeFile, }); applyPayload(payload); setActiveWeek(selectedWeek.week); setExampleCodeTitle(''); setExampleCodeFile(null); if (exampleCodeFileInputRef.current) exampleCodeFileInputRef.current.value = ''; showToast({ title: '예제 코드가 업로드되었습니다.', message: exampleCodeFile.name, }); } catch (reason) { setError(reason instanceof Error ? reason.message : '예제 코드를 업로드하지 못했습니다.'); } finally { setSaving(false); } }; const handleDeleteReference = async (referenceId: string, label = '참고자료') => { if (!selectedWeek) return; setSaving(true); setError(null); try { const payload = await deleteCurriculumReference(session.accessToken, referenceId, selectedCourseId || null); applyPayload(payload); setActiveWeek(selectedWeek.week); showToast({ title: `${label}가 삭제되었습니다.`, message: `${selectedWeek.week}주차`, }); } catch (reason) { setError(reason instanceof Error ? reason.message : `${label}를 삭제하지 못했습니다.`); } finally { setSaving(false); } }; return (
{error ? (
{error}
) : null}
{activeTab !== 'examples' && !(activeTab === 'apply' && selectedWeekCompleted) ? ( ) : null} {activeTab === 'apply' ? ( ) : null}

주차별 커리큘럼

강사가 선택한 주차가 학생 앱에서 자동 선택됩니다.

{weeks.length ? ( weeks.map((week) => { const completed = week.state === 'done'; return ( ); }) ) : (

등록된 커리큘럼이 없습니다.

)} {activeTab === 'edit' ? ( ) : null}

{selectedWeek ? `${selectedWeek.week}주차` : '-'}

{draftTitle || selectedWeek?.title || '커리큘럼 없음'}

{activeTab === 'edit' ? ( ) : null}
{activeTab === 'apply' ? ( !hasGroups ? (

등록된 그룹이 없습니다

강의 진행을 적용하려면 먼저 그룹을 생성해 주세요.

) : (

현재 진행 학습단계

{selectedTopics.length ? ( selectedTopics.map((topic, index) => { const selected = topic === activeLearningStep; return ( ); }) ) : (

학습단계가 없습니다.

)}
) ) : activeTab === 'modules' ? (

모듈 관리

체크된 모듈만 학생 앱의 모듈 리스트에 표시됩니다.

{selectedWeek?.modules?.length ? ( {(selectedWeek.modules ?? []).filter((module) => moduleEnabledById[module.id] !== false).length}/{selectedWeek.modules.length} ) : null}
{selectedWeek?.modules?.length ? selectedWeek.modules.map((module) => { const enabled = moduleEnabledById[module.id] !== false; return ( ); }) : (

등록된 모듈이 없습니다.

)}

시각화 플랫폼

시각화 화면을 사용할 플랫폼을 선택하세요.

{visualizationPlatforms.length ? ( {visualizationPlatforms.filter((platform) => visualizationEnabledByKey[platform.key]).length}/{visualizationPlatforms.length} ) : null}
{visualizationPlatforms.length ? visualizationPlatforms.map((platform) => { const enabled = Boolean(visualizationEnabledByKey[platform.key]); return ( ); }) : (

등록된 시각화 플랫폼이 없습니다.

)}
) : activeTab === 'examples' ? (

예제 코드

학생 앱의 예제 코드 화면에 표시할 주차별 파일을 업로드합니다.

{ const file = event.target.files?.[0] ?? null; setExampleCodeFile(file); if (file && !exampleCodeTitle.trim()) setExampleCodeTitle(file.name); }} />
setExampleCodeTitle(event.target.value)} />
{exampleCodeFile?.name ?? '선택된 파일 없음'}
{selectedWeek.detail.exampleCodes?.length ? selectedWeek.detail.exampleCodes.map((example) => (
)) : (

등록된 예제 코드가 없습니다.

)}
) : (