import { useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { Bot, CheckCircle2, CirclePlay, Clock3, FileCode2, FileText, KeyRound, Loader2, Map, Plus, RadioTower, Save, Target, Trash2, Upload, UsersRound, type LucideIcon } from 'lucide-react'; import { ConfirmDialog } from '../../components/ConfirmDialog'; import { WeekCoverEditor } from '../../components/WeekCoverEditor'; import { PageHeader } from '../../components/PageHeader'; import { useToast } from '../../components/ToastProvider'; import { createInstructorCurriculumMap, deleteCurriculumReference, deleteInstructorCurriculumWeek, getCourseAiSetting, getInstructorCurriculum, updateCourseAiSetting, updateInstructorCurriculum, uploadCurriculumExampleCode, uploadCurriculumReference, type CourseAiSetting, 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'; const AI_MODEL_DEFAULTS: Record = { OPENAI: 'gpt-4.1-mini', ANTHROPIC: 'claude-sonnet-4-5', GEMINI: 'gemini-2.5-flash', QWEN: 'qwen3.7-plus', }; const AI_PROVIDER_LABELS: Record = { OPENAI: 'OpenAI', ANTHROPIC: 'Claude', GEMINI: 'Gemini', QWEN: 'Qwen', }; const AI_MODEL_OPTIONS: Record> = { OPENAI: [ { value: 'gpt-4.1-mini', label: 'GPT-4.1 Mini' }, { value: 'gpt-4.1', label: 'GPT-4.1' }, { value: 'gpt-4o-mini', label: 'GPT-4o Mini' }, ], ANTHROPIC: [ { value: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5' }, { value: 'claude-haiku-4-5', label: 'Claude Haiku 4.5' }, { value: 'claude-opus-4-1', label: 'Claude Opus 4.1' }, ], GEMINI: [ { value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' }, { value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' }, { value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' }, ], QWEN: [ { value: 'qwen3.7-plus', label: 'Qwen 3.7 Plus' }, { value: 'qwen3.7-flash', label: 'Qwen 3.7 Flash' }, { value: 'qwen-vl-plus', label: 'Qwen VL Plus' }, { value: 'qwen-vl-max', label: 'Qwen VL Max' }, { value: 'qwen-plus', label: 'Qwen Plus (text)' }, ], }; 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 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 [coverOverrides, setCoverOverrides] = 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 [draftCameraUrl, setDraftCameraUrl] = 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' | 'ai'>('apply'); const [aiEnabled, setAiEnabled] = useState(false); const [aiProvider, setAiProvider] = useState('OPENAI'); const [aiModel, setAiModel] = useState(AI_MODEL_DEFAULTS.OPENAI); const [aiApiKey, setAiApiKey] = useState(''); const [aiHasApiKey, setAiHasApiKey] = useState(false); const [aiConfiguredProvider, setAiConfiguredProvider] = useState(null); const [aiSettingLoading, setAiSettingLoading] = useState(false); 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 selectedWeekCompleted = selectedWeek?.state === 'done'; const primaryActionDisabled = saving || aiSettingLoading || (activeTab !== 'ai' && !selectedWeek) || (activeTab === 'apply' && !selectedGroup); const selectedTopics = activeTab === 'edit' ? draftTopics : selectedWeek?.topics ?? []; const selectedTopicDetails = activeTab === 'edit' ? draftTopicDetails : selectedWeek?.detail.topicDetails ?? []; const selectedWeekDeletable = selectedWeek?.canDelete === true; 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 (!selectedCourseId) return; let cancelled = false; setAiSettingLoading(true); getCourseAiSetting(session.accessToken, selectedCourseId) .then((setting) => { if (cancelled) return; setAiEnabled(setting.enabled); setAiProvider(setting.provider); setAiModel(setting.model); setAiHasApiKey(setting.hasApiKey); setAiConfiguredProvider(setting.hasApiKey ? setting.provider : null); setAiApiKey(''); }) .catch((reason: Error) => { if (!cancelled) setError(reason.message); }) .finally(() => { if (!cancelled) setAiSettingLoading(false); }); return () => { cancelled = true; }; }, [selectedCourseId, session.accessToken]); const handleSaveAiSetting = async () => { if (!selectedCourseId) return; setSaving(true); setError(null); try { const setting = await updateCourseAiSetting(session.accessToken, selectedCourseId, { enabled: aiEnabled, provider: aiProvider, model: aiModel.trim(), ...(aiApiKey.trim() ? { apiKey: aiApiKey.trim() } : {}), }); setAiEnabled(setting.enabled); setAiProvider(setting.provider); setAiModel(setting.model); setAiHasApiKey(setting.hasApiKey); setAiConfiguredProvider(setting.hasApiKey ? setting.provider : null); setAiApiKey(''); showToast({ title: 'AI 에이전트 설정이 저장되었습니다.', message: setting.enabled ? `${setting.provider} · ${setting.model}` : '사용 안 함' }); } catch (reason) { setError(reason instanceof Error ? reason.message : 'AI 에이전트 설정을 저장하지 못했습니다.'); } finally { setSaving(false); } }; useEffect(() => { if (!selectedWeek) return; setDraftTagLabel(selectedWeek.tags[0] ?? ''); setDraftTitle(selectedWeek.title); setDraftDuration(selectedWeek.duration); setDraftOverview(selectedWeek.detail.overview); setDraftCameraUrl(selectedWeek.detail.camera?.url ?? ''); 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, cameraViewUrl: draftCameraUrl, 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, cameraViewUrl: draftCameraUrl, 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, cameraViewUrl: draftCameraUrl, 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, selectedCourseId); 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 ? (
그룹을 생성하면 선택한 학습단계를 수강생에게 적용할 수 있습니다.
) : null}

현재 진행 학습단계

{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) => (
)) : (

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

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

학생이 현재 모듈의 코드를 AI에게 분석시키고, 검토한 변경안을 코드 에디터에 적용할 수 있습니다.

AI 공급자
{(Object.keys(AI_PROVIDER_LABELS) as CourseAiSetting['provider'][]).map((provider) => ( ))}

학생 적용 절차

  1. 1. 학생이 에이전트 탭에서 요청을 입력합니다.
  2. 2. AI가 현재 모듈 파일을 읽고 파일별 변경안을 만듭니다.
  3. 3. 학생이 변경 내용을 확인하고 적용 버튼을 누릅니다.
  4. 4. 변경 파일이 코드 에디터에 열리며 빌드와 실행은 학생이 직접 수행합니다.
) : (