import { useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { CheckCircle2, CirclePlay, Clock3, FileCode2, FileText, Loader2, Map, Plus, RadioTower, Save, Target, Trash2, Upload, UsersRound, type LucideIcon } from 'lucide-react'; import { ConfirmDialog } from '../../components/ConfirmDialog'; import { PageHeader } from '../../components/PageHeader'; import { useToast } from '../../components/ToastProvider'; import { createInstructorCurriculumMap, deleteCurriculumReference, deleteInstructorCurriculumWeek, getInstructorCurriculum, updateInstructorCurriculum, uploadCurriculumExampleCode, uploadCurriculumReference, type CurriculumEnvironmentProfile, 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'; type ModuleParameterDraft = { key: string; value: string; }; type EditableEnvironmentProfile = CurriculumEnvironmentProfile & { storageKey: 'local' | 'platform'; platformKeys: string[]; }; function isPlatformEnvironment(config: unknown): boolean { if (!config || typeof config !== 'object' || Array.isArray(config)) return false; const target = (config as Record).target; return target === 'remote-jetson' || target === 'platform'; } function environmentStorageKey(profile: Pick): 'local' | 'platform' { return profile.profileType.toUpperCase() === 'PLATFORM' || isPlatformEnvironment(profile.config) ? 'platform' : 'local'; } function parseModuleParameter(parameter: string): ModuleParameterDraft { const separatorIndex = parameter.indexOf(':='); if (separatorIndex < 0) return { key: parameter.trim(), value: '' }; return { key: parameter.slice(0, separatorIndex).trim(), value: parameter.slice(separatorIndex + 2).trim(), }; } function readEnvironmentModuleIds(profile: CurriculumEnvironmentProfile, modules: NonNullable): string[] { const config = profile.config; const moduleSet = config && typeof config.moduleSet === 'object' && config.moduleSet !== null && !Array.isArray(config.moduleSet) ? config.moduleSet as Record : null; if (!moduleSet || moduleSet.includeUnlisted !== false || !Array.isArray(moduleSet.modules)) { return modules.map((module) => module.id); } return moduleSet.modules.flatMap((item) => ( item && typeof item === 'object' && typeof (item as Record).id === 'string' ? [(item as Record).id as string] : [] )); } function readEnvironmentModuleParameters( profile: CurriculumEnvironmentProfile, modules: NonNullable, ): Record { const config = profile.config; const moduleSet = config && typeof config.moduleSet === 'object' && config.moduleSet !== null && !Array.isArray(config.moduleSet) ? config.moduleSet as Record : null; const configuredModules = new globalThis.Map>( !moduleSet || !Array.isArray(moduleSet.modules) ? [] : moduleSet.modules.flatMap((item) => ( item && typeof item === 'object' && typeof (item as Record).id === 'string' ? [[(item as Record).id as string, item as Record]] : [] )), ); const moduleDefaults = new globalThis.Map(modules.map((module) => [module.id, module.defaultParameters ?? []])); return Object.fromEntries(readEnvironmentModuleIds(profile, modules).map((moduleId) => { const configured = configuredModules.get(moduleId); const parameters = Array.isArray(configured?.defaultParameters) ? configured.defaultParameters.filter((value): value is string => typeof value === 'string') : moduleDefaults.get(moduleId) ?? []; return [moduleId, parameters.map(parseModuleParameter)]; })); } function readEnvironmentModuleDockerImages( profile: CurriculumEnvironmentProfile, modules: NonNullable, ): Record { const config = profile.config; const moduleSet = config && typeof config.moduleSet === 'object' && config.moduleSet !== null && !Array.isArray(config.moduleSet) ? config.moduleSet as Record : null; const configuredModules = new globalThis.Map>( !moduleSet || !Array.isArray(moduleSet.modules) ? [] : moduleSet.modules.flatMap((item) => ( item && typeof item === 'object' && typeof (item as Record).id === 'string' ? [[(item as Record).id as string, item as Record]] : [] )), ); const moduleDefaults = new globalThis.Map(modules.map((module) => [module.id, module.dockerImage ?? ''])); return Object.fromEntries(readEnvironmentModuleIds(profile, modules).map((moduleId) => { const configuredImage = configuredModules.get(moduleId)?.dockerImage; return [moduleId, typeof configuredImage === 'string' ? configuredImage : moduleDefaults.get(moduleId) ?? '']; })); } 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 [draftIsTeamProject, setDraftIsTeamProject] = useState(false); 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 [moduleDefaultParametersById, setModuleDefaultParametersById] = useState>({}); const [environmentModuleIdsByPlatformKey, setEnvironmentModuleIdsByPlatformKey] = useState>({}); const [environmentModuleParametersByPlatformKey, setEnvironmentModuleParametersByPlatformKey] = useState>>({}); const [environmentModuleDockerImagesByPlatformKey, setEnvironmentModuleDockerImagesByPlatformKey] = useState>>({}); const [tagOptions, setTagOptions] = useState>([]); const [visualizationPlatforms, setVisualizationPlatforms] = useState>([]); const [visualizationEnabledByKey, setVisualizationEnabledByKey] = useState>({}); const [mapOptions, setMapOptions] = useState>([]); const [mapEnabledByKey, setMapEnabledByKey] = useState>({}); const [mapCreateOpen, setMapCreateOpen] = useState(false); const [mapName, setMapName] = useState(''); const [mapDescription, setMapDescription] = useState(''); const [creatingMap, setCreatingMap] = useState(false); 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' | 'maps' | '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 editableEnvironmentProfiles = useMemo(() => { if (!selectedWeek) return []; return (['local', 'platform'] as const).map((storageKey) => { const savedProfile = (selectedWeek.environmentProfiles ?? []).find((profile) => environmentStorageKey(profile) === storageKey); return { id: `draft:${selectedWeek.week}:${storageKey}`, platformKey: storageKey, profileType: storageKey === 'local' ? 'LOCAL' : 'PLATFORM', name: storageKey === 'local' ? '로컬' : '플랫폼', config: savedProfile?.config ?? (storageKey === 'platform' ? { target: 'remote-jetson' } : { target: 'local' }), storageKey, platformKeys: [storageKey], }; }); }, [selectedWeek]); 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 ?? []); setMapOptions(payload.mapOptions ?? []); 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); setDraftIsTeamProject(Boolean(selectedWeek.isTeamProject)); 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]))); setModuleDefaultParametersById(Object.fromEntries((selectedWeek.modules ?? []).map((module) => [module.id, (module.defaultParameters ?? []).map(parseModuleParameter)]))); const profilesByEnvironment = (selectedWeek.environmentProfiles ?? []).reduce>((groups, profile) => { groups[environmentStorageKey(profile)].push(profile); return groups; }, { local: [], platform: [] }); setEnvironmentModuleIdsByPlatformKey(Object.fromEntries(Object.entries(profilesByEnvironment).map(([key, profiles]) => [ key, [...new Set(profiles.flatMap((profile) => readEnvironmentModuleIds(profile, selectedWeek.modules ?? [])))], ]))); setEnvironmentModuleParametersByPlatformKey(Object.fromEntries(Object.entries(profilesByEnvironment).map(([key, profiles]) => [ key, Object.assign({}, ...profiles.map((profile) => readEnvironmentModuleParameters(profile, selectedWeek.modules ?? []))), ]))); setEnvironmentModuleDockerImagesByPlatformKey(Object.fromEntries(Object.entries(profilesByEnvironment).map(([key, profiles]) => [ key, Object.assign({}, ...profiles.map((profile) => readEnvironmentModuleDockerImages(profile, selectedWeek.modules ?? []))), ]))); setVisualizationEnabledByKey(Object.fromEntries((selectedWeek.visualizationPlatforms ?? []).map((platform) => [platform.key, true]))); setMapEnabledByKey(Object.fromEntries((selectedWeek.mapKeys ?? []).map((key) => [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, isTeamProject: draftIsTeamProject, 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, isTeamProject: draftIsTeamProject, 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, isTeamProject: draftIsTeamProject, 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 moduleDefaultParameters = (selectedWeek.modules ?? []).map((module) => ({ moduleId: module.id, parameters: (moduleDefaultParametersById[module.id] ?? []) .filter((parameter) => parameter.key.trim()) .map((parameter) => `${parameter.key.trim()}:=${parameter.value.trim()}`), })); const environmentModuleSets = editableEnvironmentProfiles.flatMap((profile) => { const moduleIds = environmentModuleIdsByPlatformKey[profile.storageKey] ?? (selectedWeek.modules ?? []).map((module) => module.id); return profile.platformKeys.map((platformKey) => ({ platformKey, moduleIds, modules: moduleIds.map((id) => ({ id, defaultParameters: (environmentModuleParametersByPlatformKey[profile.storageKey]?.[id] ?? []) .filter((parameter) => parameter.key.trim()) .map((parameter) => `${parameter.key.trim()}:=${parameter.value.trim()}`), dockerImage: environmentModuleDockerImagesByPlatformKey[profile.storageKey]?.[id]?.trim() || undefined, })), })); }); const payload = await updateInstructorCurriculum(session.accessToken, { courseId: selectedCourseId || null, groupId: selectedGroupId || null, activeWeek: selectedWeek.week, activeLearningStep, applyToStudents: false, disabledModuleIds, moduleDefaultParameters, environmentModuleSets, mapKeys: mapOptions.filter((map) => mapEnabledByKey[map.key]).map((map) => map.key), }); applyPayload(payload); setActiveWeek(selectedWeek.week); showToast({ title: '모듈 설정이 저장되었습니다.', message: `${selectedWeek.week}주차`, }); } catch (reason) { setError(reason instanceof Error ? reason.message : '모듈 설정을 저장하지 못했습니다.'); } finally { setSaving(false); } }; const handleCreateMap = async () => { const name = mapName.trim(); if (!name) return; setCreatingMap(true); try { const map = await createInstructorCurriculumMap(session.accessToken, { courseId: selectedCourseId || null, name, description: mapDescription.trim(), }); setMapOptions((current) => [...current, map]); setMapEnabledByKey((current) => ({ ...current, [map.key]: true })); setMapCreateOpen(false); setMapName(''); setMapDescription(''); showToast({ title: '학습 맵이 추가되었습니다.', message: '현재 주차에 선택된 상태입니다.' }); } catch (reason) { setError(reason instanceof Error ? reason.message : '학습 맵을 등록하지 못했습니다.'); } finally { setCreatingMap(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: '', 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) { const message = reason instanceof Error ? reason.message : '참고자료를 업로드하지 못했습니다.'; setError(message); showToast({ title: '참고자료 업로드 실패', message, variant: 'error', }); } 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) { const message = reason instanceof Error ? reason.message : '예제 코드를 업로드하지 못했습니다.'; setError(message); showToast({ title: '예제 코드 업로드 실패', message, variant: 'error', }); } 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' ? (
{editableEnvironmentProfiles.length > 0 ? (

환경별 모듈 구성

소스 저장소는 공통으로 유지하고, 로컬과 플랫폼에서 실행할 모듈만 다르게 선택합니다.

{editableEnvironmentProfiles.map((profile) => { const selectedModuleIds = new Set(environmentModuleIdsByPlatformKey[profile.storageKey] ?? (selectedWeek.modules ?? []).map((module) => module.id)); const selectedCount = (selectedWeek.modules ?? []).filter((module) => selectedModuleIds.has(module.id)).length; return (
{profile.name} {profile.storageKey === 'local' ? '로컬 실행 환경' : '플랫폼 실행 환경'}
{selectedCount}/{selectedWeek.modules?.length ?? 0}
{(selectedWeek.modules ?? []).map((module) => { const globallyEnabled = moduleEnabledById[module.id] !== false; const checked = selectedModuleIds.has(module.id); const parameterRows = environmentModuleParametersByPlatformKey[profile.storageKey]?.[module.id] ?? []; const dockerImage = environmentModuleDockerImagesByPlatformKey[profile.storageKey]?.[module.id] || module.dockerImage || '-'; return (
{checked ? (
실행 이미지 {dockerImage}
실행 파라미터
{parameterRows.map((parameter, parameterIndex) => (
setEnvironmentModuleParametersByPlatformKey((current) => { const rows = [...(current[profile.storageKey]?.[module.id] ?? [])]; rows[parameterIndex] = { ...rows[parameterIndex], key: event.target.value }; return { ...current, [profile.storageKey]: { ...(current[profile.storageKey] ?? {}), [module.id]: rows } }; })} /> setEnvironmentModuleParametersByPlatformKey((current) => { const rows = [...(current[profile.storageKey]?.[module.id] ?? [])]; rows[parameterIndex] = { ...rows[parameterIndex], value: event.target.value }; return { ...current, [profile.storageKey]: { ...(current[profile.storageKey] ?? {}), [module.id]: rows } }; })} />
))}
) : null}
); })}
); })}
) : null}
) : activeTab === 'maps' ? (

학습 맵

학생이 맵 선택 화면에서 사용할 NAV2 학습 환경을 선택하세요.

{mapOptions.map((map) => { const enabled = Boolean(mapEnabledByKey[map.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) => (
)) : (

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

)}
) : (