Files
lms-adm-fe/src/pages/curriculum/CurriculumPage.tsx
T

1780 lines
95 KiB
TypeScript

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<CourseAiSetting['provider'], string> = {
OPENAI: 'gpt-4.1-mini',
ANTHROPIC: 'claude-sonnet-4-5',
GEMINI: 'gemini-2.5-flash',
QWEN: 'qwen3.7-plus',
};
const AI_PROVIDER_LABELS: Record<CourseAiSetting['provider'], string> = {
OPENAI: 'OpenAI',
ANTHROPIC: 'Claude',
GEMINI: 'Gemini',
QWEN: 'Qwen',
};
const AI_MODEL_OPTIONS: Record<CourseAiSetting['provider'], Array<{ value: string; label: string }>> = {
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<string, unknown>).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<CurriculumWeek['modules']>): string[] {
const config = profile.config;
const moduleSet = config && typeof config.moduleSet === 'object' && config.moduleSet !== null && !Array.isArray(config.moduleSet)
? config.moduleSet as Record<string, unknown>
: 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<string, unknown>).id === 'string'
? [(item as Record<string, unknown>).id as string]
: []
));
}
function readEnvironmentModuleParameters(
profile: CurriculumEnvironmentProfile,
modules: NonNullable<CurriculumWeek['modules']>,
): Record<string, ModuleParameterDraft[]> {
const config = profile.config;
const moduleSet = config && typeof config.moduleSet === 'object' && config.moduleSet !== null && !Array.isArray(config.moduleSet)
? config.moduleSet as Record<string, unknown>
: null;
const configuredModules = new globalThis.Map<string, Record<string, unknown>>(
!moduleSet || !Array.isArray(moduleSet.modules)
? []
: moduleSet.modules.flatMap((item) => (
item && typeof item === 'object' && typeof (item as Record<string, unknown>).id === 'string'
? [[(item as Record<string, unknown>).id as string, item as Record<string, unknown>]]
: []
)),
);
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<CurriculumWeek['modules']>,
): Record<string, string> {
const config = profile.config;
const moduleSet = config && typeof config.moduleSet === 'object' && config.moduleSet !== null && !Array.isArray(config.moduleSet)
? config.moduleSet as Record<string, unknown>
: null;
const configuredModules = new globalThis.Map<string, Record<string, unknown>>(
!moduleSet || !Array.isArray(moduleSet.modules)
? []
: moduleSet.modules.flatMap((item) => (
item && typeof item === 'object' && typeof (item as Record<string, unknown>).id === 'string'
? [[(item as Record<string, unknown>).id as string, item as Record<string, unknown>]]
: []
)),
);
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<CurriculumWeek[]>([]);
const [coverOverrides, setCoverOverrides] = useState<Record<string, CurriculumWeek['detail']['coverImage']>>({});
const [groups, setGroups] = useState<InstructorGroup[]>([]);
const [selectedGroupId, setSelectedGroupId] = useState<string>('');
const [activeWeek, setActiveWeek] = useState(1);
const [activeLearningStep, setActiveLearningStep] = useState<string | null>(null);
const [currentProgressWeek, setCurrentProgressWeek] = useState(1);
const [currentProgressLearningStep, setCurrentProgressLearningStep] = useState<string | null>(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<string[]>([]);
const [draftTopicDetails, setDraftTopicDetails] = useState<string[]>([]);
const [draftTopicVideoUrls, setDraftTopicVideoUrls] = useState<string[]>([]);
const [draftTopicVideoEnabled, setDraftTopicVideoEnabled] = useState<boolean[]>([]);
const [draftTopicConcepts, setDraftTopicConcepts] = useState<string[]>([]);
const [draftPracticeContents, setDraftPracticeContents] = useState<Array<{ title: string; description: string }>>([]);
const [moduleEnabledById, setModuleEnabledById] = useState<Record<string, boolean>>({});
const [moduleDefaultParametersById, setModuleDefaultParametersById] = useState<Record<string, ModuleParameterDraft[]>>({});
const [environmentModuleIdsByPlatformKey, setEnvironmentModuleIdsByPlatformKey] = useState<Record<string, string[]>>({});
const [environmentModuleParametersByPlatformKey, setEnvironmentModuleParametersByPlatformKey] = useState<Record<string, Record<string, ModuleParameterDraft[]>>>({});
const [environmentModuleDockerImagesByPlatformKey, setEnvironmentModuleDockerImagesByPlatformKey] = useState<Record<string, Record<string, string>>>({});
const [tagOptions, setTagOptions] = useState<NonNullable<InstructorCurriculumResponse['tagOptions']>>([]);
const [visualizationPlatforms, setVisualizationPlatforms] = useState<NonNullable<InstructorCurriculumResponse['visualizationPlatforms']>>([]);
const [visualizationEnabledByKey, setVisualizationEnabledByKey] = useState<Record<string, boolean>>({});
const [mapOptions, setMapOptions] = useState<NonNullable<InstructorCurriculumResponse['mapOptions']>>([]);
const [mapEnabledByKey, setMapEnabledByKey] = useState<Record<string, boolean>>({});
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<File | null>(null);
const [exampleCodeTitle, setExampleCodeTitle] = useState('');
const [exampleCodeFile, setExampleCodeFile] = useState<File | null>(null);
const [activeTab, setActiveTab] = useState<'apply' | 'edit' | 'modules' | 'maps' | 'examples' | 'ai'>('apply');
const [aiEnabled, setAiEnabled] = useState(false);
const [aiProvider, setAiProvider] = useState<CourseAiSetting['provider']>('OPENAI');
const [aiModel, setAiModel] = useState(AI_MODEL_DEFAULTS.OPENAI);
const [aiApiKey, setAiApiKey] = useState('');
const [aiHasApiKey, setAiHasApiKey] = useState(false);
const [aiConfiguredProvider, setAiConfiguredProvider] = useState<CourseAiSetting['provider'] | null>(null);
const [aiSettingLoading, setAiSettingLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [completeConfirmOpen, setCompleteConfirmOpen] = useState(false);
const referenceFileInputRef = useRef<HTMLInputElement>(null);
const exampleCodeFileInputRef = useRef<HTMLInputElement>(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<EditableEnvironmentProfile[]>(() => {
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<Record<'local' | 'platform', CurriculumEnvironmentProfile[]>>((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 (
<div className="mx-auto max-w-[1220px]">
<PageHeader route="curriculum" />
<section className="mb-4 grid gap-3 md:grid-cols-3">
<SummaryCard label="선택 그룹" value={selectedGroup?.name ?? '등록된 그룹 없음'} icon={UsersRound} />
<SummaryCard label="현재 진행 주차" value={currentProgressWeek ? `${currentProgressWeek}주차` : '-'} icon={RadioTower} />
<SummaryCard label="현재 학습단계" value={currentProgressLearningStep ?? '-'} icon={Target} />
</section>
{error ? (
<div className="mb-4 rounded-lg border border-[#fecdd3] bg-[#fff1f2] px-4 py-3 text-[13px] font-bold text-[#e11d48]">{error}</div>
) : null}
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-3">
<div className="inline-flex rounded-lg border border-[#cbd5e1] bg-white p-1 shadow-sm">
<button
type="button"
className={`h-8 rounded-md px-4 text-[13px] font-black transition ${activeTab === 'apply' ? 'bg-[#2563eb] text-white shadow-[0_8px_18px_rgba(37,99,235,0.18)]' : 'text-[#475569] hover:bg-[#e2e8f0] hover:text-[#111827]'}`}
onClick={() => {
setActiveTab('apply');
selectProgressWeek();
}}
>
강의 진행
</button>
<button
type="button"
className={`h-8 rounded-md px-4 text-[13px] font-black transition ${activeTab === 'edit' ? 'bg-[#2563eb] text-white shadow-[0_8px_18px_rgba(37,99,235,0.18)]' : 'text-[#475569] hover:bg-[#e2e8f0] hover:text-[#111827]'}`}
onClick={() => setActiveTab('edit')}
>
내용 편집
</button>
<button
type="button"
className={`h-8 rounded-md px-4 text-[13px] font-black transition ${activeTab === 'modules' ? 'bg-[#2563eb] text-white shadow-[0_8px_18px_rgba(37,99,235,0.18)]' : 'text-[#475569] hover:bg-[#e2e8f0] hover:text-[#111827]'}`}
onClick={() => setActiveTab('modules')}
>
모듈 관리
</button>
<button
type="button"
className={`h-8 rounded-md px-4 text-[13px] font-black transition ${activeTab === 'maps' ? 'bg-[#2563eb] text-white shadow-[0_8px_18px_rgba(37,99,235,0.18)]' : 'text-[#475569] hover:bg-[#e2e8f0] hover:text-[#111827]'}`}
onClick={() => setActiveTab('maps')}
>
맵 설정
</button>
<button
type="button"
className={`h-8 rounded-md px-4 text-[13px] font-black transition ${activeTab === 'examples' ? 'bg-[#2563eb] text-white shadow-[0_8px_18px_rgba(37,99,235,0.18)]' : 'text-[#475569] hover:bg-[#e2e8f0] hover:text-[#111827]'}`}
onClick={() => setActiveTab('examples')}
>
예제 코드
</button>
<button
type="button"
className={`h-8 rounded-md px-4 text-[13px] font-black transition ${activeTab === 'ai' ? 'bg-[#2563eb] text-white shadow-[0_8px_18px_rgba(37,99,235,0.18)]' : 'text-[#475569] hover:bg-[#e2e8f0] hover:text-[#111827]'}`}
onClick={() => setActiveTab('ai')}
>
AI 에이전트
</button>
</div>
<select
className="h-10 min-w-[190px] rounded-md border border-[#cbd5e1] bg-white px-3 text-[13px] font-bold text-[#111827] shadow-sm outline-none transition hover:border-[#94a3b8] focus:border-[#2563eb] focus:ring-2 focus:ring-[#bfdbfe] disabled:border-[#d1d9e6] disabled:bg-[#f8fafc] disabled:text-[#94a3b8]"
value={selectedGroupId}
onChange={(event) => handleChangeGroup(event.target.value)}
disabled={saving || !hasGroups}
>
{groups.length ? groups.map((group) => (
<option key={group.id} value={group.id}>{group.name}</option>
)) : (
<option value="">등록된 그룹 없음</option>
)}
</select>
</div>
<div className="flex items-center gap-2">
{activeTab !== 'examples' && !(activeTab === 'apply' && selectedWeekCompleted) ? (
<button className="inline-flex h-10 items-center gap-2 rounded-md bg-[#2563eb] px-4 text-[13px] font-bold text-white hover:bg-[#1d4ed8] disabled:cursor-not-allowed disabled:opacity-60" type="button" onClick={() => void (activeTab === 'ai' ? handleSaveAiSetting() : activeTab === 'apply' ? handleApply() : activeTab === 'modules' || activeTab === 'maps' ? handleSaveModules() : handleSaveContent())} disabled={primaryActionDisabled}>
{saving ? <Loader2 className="animate-spin" size={15} aria-hidden="true" /> : activeTab === 'apply' ? <CirclePlay size={15} aria-hidden="true" /> : <Save size={15} aria-hidden="true" />}
{activeTab === 'apply' ? '진행하기' : activeTab === 'modules' ? '모듈 저장' : activeTab === 'maps' ? '맵 저장' : activeTab === 'ai' ? 'AI 설정 저장' : '내용 저장'}
</button>
) : null}
{activeTab === 'apply' ? (
<button className="inline-flex h-10 items-center gap-2 rounded-md border border-[#2fbda0] bg-white px-4 text-[13px] font-bold text-[#178c78] transition hover:bg-[#effbf7] disabled:cursor-not-allowed disabled:opacity-60" type="button" onClick={handleRequestCompleteWeek} disabled={primaryActionDisabled || selectedWeekCompleted}>
{saving ? <Loader2 className="animate-spin" size={15} aria-hidden="true" /> : <CheckCircle2 size={15} aria-hidden="true" />}
완료처리
</button>
) : null}
</div>
</div>
<section className="grid items-start gap-4 xl:grid-cols-[360px_minmax(0,1fr)]">
<div className="rounded-lg border border-[#c7d2e0] bg-white">
<div className="border-b border-[#cbd5e1] px-4 py-3">
<h2 className="text-[15px] font-black text-[#111827]">주차별 커리큘럼</h2>
<p className="mt-1 text-[12px] font-bold text-[#64748b]">강사가 선택한 주차가 학생 앱에서 자동 선택됩니다.</p>
</div>
<div className="p-3">
{weeks.length ? (
weeks.map((week) => {
const completed = week.state === 'done';
return (
<button
key={week.week}
type="button"
className={`mb-2 w-full rounded-lg border px-4 py-3 text-left transition last:mb-0 ${
week.week === activeWeek
? 'border-[#2563eb] bg-[#eff6ff] shadow-[0_12px_28px_rgba(37,99,235,0.12)]'
: 'border-[#d1d9e6] bg-white hover:bg-[#f8fafc]'
}`}
onClick={() => {
setActiveWeek(week.week);
setActiveLearningStep(week.topics[0] ?? null);
}}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-[12px] font-black text-[#4f46e5]">{week.week}주차</p>
<h3 className="mt-1 truncate text-[14px] font-black text-[#111827]">{week.title}</h3>
<p className="mt-2 line-clamp-2 text-[12px] font-semibold leading-relaxed text-[#64748b]">{week.detail.overview}</p>
</div>
{week.week === activeWeek ? <CheckCircle2 className="shrink-0 text-[#2563eb]" size={18} aria-hidden="true" /> : null}
{completed ? <span className="shrink-0 rounded-md bg-[#e2e8f0] px-2 py-1 text-[11px] font-black text-[#64748b]">완료</span> : null}
</div>
</button>
);
})
) : (
<div className="grid h-[220px] place-items-center text-center">
<p className="text-[13px] font-bold text-[#64748b]">등록된 커리큘럼이 없습니다.</p>
</div>
)}
{activeTab === 'edit' ? (
<button
type="button"
className="mt-2 flex min-h-[104px] w-full items-center justify-center gap-2 rounded-lg border border-dashed border-[#b7c4d8] bg-[#f8fafc] px-4 py-3 text-[13px] font-black text-[#64748b] transition hover:border-[#2563eb] hover:bg-[#eff6ff] hover:text-[#2563eb] disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => void handleAddWeek()}
disabled={saving}
>
{saving ? <Loader2 className="animate-spin" size={16} aria-hidden="true" /> : <Plus size={16} aria-hidden="true" />}
추가
</button>
) : null}
</div>
</div>
<div className="rounded-lg border border-[#c7d2e0] bg-white">
<div className="border-b border-[#cbd5e1] px-5 py-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-[12px] font-black text-[#4f46e5]">{selectedWeek ? `${selectedWeek.week}주차` : '-'}</p>
<h2 className="mt-1 truncate text-[18px] font-black text-[#111827]">{draftTitle || selectedWeek?.title || '커리큘럼 없음'}</h2>
</div>
{activeTab === 'edit' ? (
<button
type="button"
onClick={() => setDeleteConfirmOpen(true)}
disabled={saving || !selectedWeekDeletable}
title={selectedWeekDeletable ? '선택한 추가 주차 삭제' : '기본 커리큘럼 주차는 삭제할 수 없습니다'}
className="inline-flex h-9 shrink-0 items-center gap-1.5 rounded-md border border-[#fecdd3] bg-white px-3 text-[12px] font-black text-[#e11d48] transition hover:bg-[#fff1f2] disabled:cursor-not-allowed disabled:border-[#cbd5e1] disabled:text-[#94a3b8] disabled:hover:bg-white"
>
{saving ? <Loader2 className="animate-spin" size={14} aria-hidden="true" /> : <Trash2 size={14} aria-hidden="true" />}
삭제
</button>
) : null}
</div>
</div>
{activeTab === 'apply' ? (
<div className="grid gap-4 p-5">
{!hasGroups ? (
<div className="rounded-md border border-[#dbe3ef] bg-[#f8fafc] px-4 py-3 text-[12px] font-bold text-[#64748b]">
그룹을 생성하면 선택한 학습단계를 수강생에게 적용할 수 있습니다.
</div>
) : null}
<section>
<h3 className="text-[15px] font-black text-[#111827]">현재 진행 학습단계</h3>
<div className="mt-3 grid gap-2">
{selectedTopics.length ? (
selectedTopics.map((topic, index) => {
const selected = topic === activeLearningStep;
return (
<button
key={`${selectedWeek?.week}-apply-${index}`}
type="button"
disabled={selectedWeekCompleted}
className={`flex items-start gap-3 rounded-lg border px-4 py-3 text-left transition disabled:cursor-default ${
selected
? selectedWeekCompleted
? 'border-[#d1d9e6] bg-[#f8fafc]'
: 'border-[#4f46e5] bg-[#f0efff]'
: selectedWeekCompleted
? 'border-[#cbd5e1] bg-white'
: 'border-[#d1d9e6] bg-white hover:bg-[#f8fafc]'
}`}
onClick={() => {
if (selectedWeekCompleted) return;
setActiveLearningStep(topic);
}}
>
<span className={`grid h-7 w-7 shrink-0 place-items-center rounded-md text-[12px] font-black ${selected && !selectedWeekCompleted ? 'bg-[#4f46e5] text-white' : 'bg-[#f1f5f9] text-[#64748b]'}`}>{index + 1}</span>
<span className="min-w-0">
<span className="block text-[14px] font-black text-[#111827]">{topic}</span>
<span className="mt-1 block text-[12px] font-semibold leading-relaxed text-[#64748b]">{selectedTopicDetails[index] ?? ''}</span>
</span>
</button>
);
})
) : (
<p className="rounded-lg border border-dashed border-[#d1d9e6] px-4 py-8 text-center text-[13px] font-bold text-[#64748b]">학습단계가 없습니다.</p>
)}
</div>
</section>
</div>
) : activeTab === 'modules' ? (
<div className="grid gap-4 p-5">
{editableEnvironmentProfiles.length > 0 ? (
<section>
<div>
<h3 className="text-[15px] font-black text-[#111827]">환경별 모듈 구성</h3>
<p className="mt-1 text-[12px] font-bold text-[#64748b]">소스 저장소는 공통으로 유지하고, 로컬과 플랫폼에서 실행할 모듈만 다르게 선택합니다.</p>
</div>
<div className="mt-3 grid gap-3">
{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 (
<div key={profile.id} className="rounded-lg border border-[#d1d9e6] bg-[#f8fafc] p-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<span className="block truncate text-[13px] font-black text-[#111827]">{profile.name}</span>
<span className="mt-0.5 block text-[11px] font-bold text-[#64748b]">{profile.storageKey === 'local' ? '로컬 실행 환경' : '플랫폼 실행 환경'}</span>
</div>
<span className="shrink-0 rounded-md bg-white px-2.5 py-1 text-[11px] font-black text-[#475569]">{selectedCount}/{selectedWeek.modules?.length ?? 0}</span>
</div>
<div className="mt-3 grid gap-1.5 sm:grid-cols-2">
{(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 (
<div
key={module.id}
className={`min-w-0 rounded-md border px-2.5 py-2 text-[12px] font-bold transition ${globallyEnabled ? 'border-[#dbe3ee] bg-white hover:border-[#93c5fd]' : 'cursor-not-allowed border-[#e2e8f0] bg-[#f1f5f9] text-[#94a3b8]'}`}
>
<label className={`flex min-w-0 items-center gap-2 ${globallyEnabled ? 'cursor-pointer' : 'cursor-not-allowed'}`}>
<input
type="checkbox"
className="h-3.5 w-3.5 shrink-0 cursor-pointer rounded border-[#cbd5e1] text-[#2563eb] focus:ring-[#2563eb] disabled:cursor-not-allowed"
checked={checked}
disabled={!globallyEnabled}
onChange={(event) => {
const enabled = event.target.checked;
setEnvironmentModuleIdsByPlatformKey((current) => {
const currentIds = new Set(current[profile.storageKey] ?? (selectedWeek.modules ?? []).map((item) => item.id));
if (enabled) currentIds.add(module.id);
else currentIds.delete(module.id);
return { ...current, [profile.storageKey]: [...currentIds] };
});
if (!enabled) {
setEnvironmentModuleParametersByPlatformKey((current) => {
const profileParameters = { ...(current[profile.storageKey] ?? {}) };
delete profileParameters[module.id];
return { ...current, [profile.storageKey]: profileParameters };
});
setEnvironmentModuleDockerImagesByPlatformKey((current) => {
const profileImages = { ...(current[profile.storageKey] ?? {}) };
delete profileImages[module.id];
return { ...current, [profile.storageKey]: profileImages };
});
}
}}
/>
<span className="min-w-0 truncate">{module.name}</span>
</label>
{checked ? (
<div className="mt-2 border-t border-[#e2e8f0] pt-2">
<div className="mb-2">
<span className="block text-[10px] text-[#64748b]">실행 이미지</span>
<span className="mt-0.5 block truncate font-mono text-[10px] font-medium text-[#475569]" title={dockerImage}>{dockerImage}</span>
</div>
<div className="mb-1 flex items-center justify-between gap-2 text-[10px] text-[#64748b]">
<span>실행 파라미터</span>
<button
type="button"
className="flex h-5 w-5 items-center justify-center rounded text-[#2563eb] hover:bg-[#eff6ff]"
aria-label={`${module.name} 파라미터 추가`}
onClick={() => setEnvironmentModuleParametersByPlatformKey((current) => ({
...current,
[profile.storageKey]: {
...(current[profile.storageKey] ?? {}),
[module.id]: [...(current[profile.storageKey]?.[module.id] ?? []), { key: '', value: '' }],
},
}))}
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
{parameterRows.map((parameter, parameterIndex) => (
<div key={`${module.id}-${parameterIndex}`} className="mb-1 flex items-center gap-1">
<input
value={parameter.key}
placeholder="키"
className="min-w-0 flex-1 rounded border border-[#dbe3ee] px-1.5 py-1 text-[11px] font-medium outline-none focus:border-[#60a5fa]"
onChange={(event) => 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 } };
})}
/>
<input
value={parameter.value}
placeholder="값"
className="min-w-0 flex-1 rounded border border-[#dbe3ee] px-1.5 py-1 text-[11px] font-medium outline-none focus:border-[#60a5fa]"
onChange={(event) => 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 } };
})}
/>
<button
type="button"
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-[#94a3b8] hover:bg-[#f1f5f9] hover:text-[#dc2626]"
aria-label={`${module.name} 파라미터 삭제`}
onClick={() => setEnvironmentModuleParametersByPlatformKey((current) => ({
...current,
[profile.storageKey]: {
...(current[profile.storageKey] ?? {}),
[module.id]: (current[profile.storageKey]?.[module.id] ?? []).filter((_, index) => index !== parameterIndex),
},
}))}
>
<Trash2 className="h-3 w-3" />
</button>
</div>
))}
</div>
) : null}
</div>
);
})}
</div>
</div>
);
})}
</div>
</section>
) : null}
</div>
) : activeTab === 'maps' ? (
<div className="grid gap-4 p-5">
<section>
<div>
<div>
<h3 className="text-[15px] font-black text-[#111827]">학습 맵</h3>
<p className="mt-1 text-[12px] font-bold text-[#64748b]">학생이 맵 선택 화면에서 사용할 NAV2 학습 환경을 선택하세요.</p>
</div>
</div>
<div className="mt-3 grid gap-2">
{mapOptions.map((map) => {
const enabled = Boolean(mapEnabledByKey[map.key]);
return (
<label key={map.key} className={`flex cursor-pointer items-center gap-3 rounded-lg border px-4 py-3 transition ${enabled ? 'border-[#d1d9e6] bg-white hover:bg-[#f8fafc]' : 'border-[#cbd5e1] bg-[#f8fafc] opacity-75 hover:bg-[#f1f5f9]'}`}>
<input
type="checkbox"
className="h-4 w-4 shrink-0 cursor-pointer rounded border-[#cbd5e1] text-[#2563eb] focus:ring-[#2563eb]"
checked={enabled}
onChange={(event) => setMapEnabledByKey((current) => ({ ...current, [map.key]: event.target.checked }))}
/>
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-md bg-[#eff6ff] text-[#2563eb]"><Map size={15} /></span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[14px] font-black text-[#111827]">{map.name}</span>
<span className="mt-1 block truncate text-[12px] font-bold text-[#64748b]">{map.description}</span>
</span>
{map.custom ? <span className="shrink-0 rounded-md bg-[#ede9ff] px-2.5 py-1 text-[11px] font-black text-[#6d28d9]">커스텀</span> : null}
</label>
);
})}
</div>
</section>
</div>
) : activeTab === 'examples' ? (
<div className="grid gap-4 p-5">
<section>
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-[15px] font-black text-[#111827]">예제 코드</h3>
<p className="mt-1 text-[12px] font-bold text-[#64748b]">학생 앱의 예제 코드 화면에 표시할 주차별 파일을 업로드합니다.</p>
</div>
<button
type="button"
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-[#d1d9e6] bg-white px-3 text-[12px] font-black text-[#475569] transition hover:border-[#2563eb] hover:bg-[#eff6ff] hover:text-[#2563eb]"
onClick={() => exampleCodeFileInputRef.current?.click()}
>
<FileText size={14} aria-hidden="true" />
파일 선택
</button>
</div>
<input
ref={exampleCodeFileInputRef}
type="file"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0] ?? null;
setExampleCodeFile(file);
if (file && !exampleCodeTitle.trim()) setExampleCodeTitle(file.name);
}}
/>
<div className="mt-3 grid gap-2 rounded-lg border border-[#d1d9e6] bg-[#f8fafc] p-3">
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
<input
className="h-10 rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-bold text-[#111827] outline-none focus:border-[#2563eb]"
value={exampleCodeTitle}
placeholder="파일 표시명"
onChange={(event) => setExampleCodeTitle(event.target.value)}
/>
<div className="flex h-10 min-w-0 items-center rounded-md border border-[#d1d9e6] bg-white px-3 text-[12px] font-bold text-[#64748b]">
<span className="truncate">{exampleCodeFile?.name ?? '선택된 파일 없음'}</span>
</div>
<button
type="button"
disabled={saving || !exampleCodeFile}
onClick={() => void handleUploadExampleCode()}
className="inline-flex h-10 items-center justify-center gap-1.5 rounded-md bg-[#2563eb] px-4 text-[13px] font-black text-white transition hover:bg-[#1d4ed8] disabled:cursor-not-allowed disabled:opacity-45"
>
{saving ? <Loader2 className="animate-spin" size={14} aria-hidden="true" /> : <Upload size={14} aria-hidden="true" />}
업로드
</button>
</div>
<div className="grid gap-2">
{selectedWeek.detail.exampleCodes?.length ? selectedWeek.detail.exampleCodes.map((example) => (
<div key={example.id} className="flex items-center gap-3 rounded-md border border-[#cbd5e1] bg-white px-3 py-2">
<FileCode2 className="shrink-0 text-[#64748b]" size={16} aria-hidden="true" />
<div className="min-w-0 flex-1">
<p className="truncate text-[13px] font-black text-[#111827]">{example.title}</p>
<p className="mt-0.5 truncate text-[11px] font-bold text-[#64748b]">
{example.fileName}
{example.fileSizeBytes ? ` · ${formatFileSize(Number(example.fileSizeBytes))}` : ''}
</p>
</div>
<button
type="button"
disabled={saving}
onClick={() => void handleDeleteReference(example.id, '예제 코드')}
className="grid h-8 w-8 shrink-0 place-items-center rounded-md text-[#94a3b8] transition hover:bg-[#fff1f2] hover:text-[#e11d48] disabled:cursor-not-allowed disabled:opacity-45"
aria-label={`${example.title} 삭제`}
title="예제 코드 삭제"
>
<Trash2 size={14} aria-hidden="true" />
</button>
</div>
)) : (
<p className="rounded-md border border-dashed border-[#d1d9e6] bg-white px-4 py-10 text-center text-[13px] font-bold text-[#64748b]">등록된 예제 코드가 없습니다.</p>
)}
</div>
</div>
</section>
</div>
) : activeTab === 'ai' ? (
<div className="grid gap-5 p-5">
<section>
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<Bot size={17} className="text-[#2563eb]" aria-hidden="true" />
<h3 className="text-[15px] font-black text-[#111827]">강좌 AI 에이전트</h3>
</div>
<p className="mt-1.5 max-w-[640px] text-[12px] font-semibold leading-6 text-[#64748b]">학생이 현재 모듈의 코드를 AI에게 분석시키고, 검토한 변경안을 코드 에디터에 적용할 수 있습니다.</p>
</div>
<label className="inline-flex cursor-pointer items-center gap-2 text-[12px] font-black text-[#475569]">
<input type="checkbox" checked={aiEnabled} onChange={(event) => setAiEnabled(event.target.checked)} className="h-4 w-4 rounded border-[#cbd5e1] text-[#2563eb] focus:ring-[#2563eb]" />
사용
</label>
</div>
</section>
<section className="border-t border-[#e2e8f0] pt-5">
<div>
<span className="text-[12px] font-black text-[#475569]">AI 공급자</span>
<div className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-4" role="radiogroup" aria-label="AI 공급자">
{(Object.keys(AI_PROVIDER_LABELS) as CourseAiSetting['provider'][]).map((provider) => (
<button
key={provider}
type="button"
role="radio"
aria-checked={aiProvider === provider}
disabled={aiSettingLoading}
onClick={() => {
setAiProvider(provider);
setAiModel(AI_MODEL_DEFAULTS[provider]);
setAiApiKey('');
}}
className={`h-10 rounded-md border text-[13px] font-black transition disabled:cursor-not-allowed disabled:opacity-60 ${
aiProvider === provider
? 'border-[#2563eb] bg-[#eff6ff] text-[#1d4ed8] shadow-[0_0_0_1px_rgba(37,99,235,0.12)]'
: 'border-[#cbd5e1] bg-white text-[#475569] hover:border-[#94a3b8] hover:bg-[#f8fafc]'
}`}
>
{AI_PROVIDER_LABELS[provider]}
</button>
))}
</div>
</div>
<label className="mt-4 block">
<span className="text-[12px] font-black text-[#475569]">모델</span>
<select
value={aiModel}
disabled={aiSettingLoading}
onChange={(event) => setAiModel(event.target.value)}
className="mt-1.5 h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-bold text-[#111827] outline-none focus:border-[#2563eb]"
>
{AI_MODEL_OPTIONS[aiProvider].map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
<label className="mt-4 block">
<span className="flex items-center gap-1.5 text-[12px] font-black text-[#475569]"><KeyRound size={13} aria-hidden="true" />API 키</span>
<input
type="password"
autoComplete="new-password"
value={aiApiKey}
onChange={(event) => setAiApiKey(event.target.value)}
placeholder={aiHasApiKey && aiConfiguredProvider === aiProvider ? '설정됨 · 변경할 때만 새 키 입력' : 'API 키 입력'}
className="mt-1.5 h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-bold text-[#111827] outline-none focus:border-[#2563eb]"
/>
<span className="mt-1.5 block text-[11px] font-semibold text-[#64748b]">키는 서버에서 암호화되어 저장되며 학생 앱으로 전달되지 않습니다.</span>
</label>
</section>
<section className="border-t border-[#e2e8f0] pt-5">
<h3 className="text-[13px] font-black text-[#111827]">학생 적용 절차</h3>
<ol className="mt-3 grid gap-2 text-[12px] font-semibold leading-6 text-[#64748b]">
<li>1. 학생이 에이전트 탭에서 요청을 입력합니다.</li>
<li>2. AI가 현재 모듈 파일을 읽고 파일별 변경안을 만듭니다.</li>
<li>3. 학생이 변경 내용을 확인하고 적용 버튼을 누릅니다.</li>
<li>4. 변경 파일이 코드 에디터에 열리며 빌드와 실행은 학생이 직접 수행합니다.</li>
</ol>
</section>
</div>
) : (
<div className="grid gap-4 p-5">
<section className="grid gap-3 pb-2">
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="text-[12px] font-black text-[#475569]">주차 제목</span>
<input
className="mt-1.5 h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-bold text-[#111827] outline-none focus:border-[#2563eb]"
value={draftTitle}
onChange={(event) => setDraftTitle(event.target.value)}
/>
</label>
<label className="block">
<span className="text-[12px] font-black text-[#475569]">플랫폼 배지</span>
<select
className="mt-1.5 h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-bold text-[#111827] outline-none focus:border-[#2563eb]"
value={selectedTagLabel}
onChange={(event) => setDraftTagLabel(event.target.value)}
>
{tagOptions.length ? tagOptions.map((option) => (
<option key={option.key} value={option.label}>{option.label}</option>
)) : (
<option value="없음">없음</option>
)}
</select>
</label>
</div>
<label className="block h-[100px]">
<span className="text-[12px] font-black text-[#475569]">주차 개요</span>
<textarea
className="mt-1.5 h-[76px] w-full resize-none rounded-md border border-[#d1d9e6] bg-white px-3 pb-0 pt-2 text-[13px] font-semibold leading-relaxed text-[#111827] outline-none focus:border-[#2563eb]"
value={draftOverview}
onChange={(event) => setDraftOverview(event.target.value)}
/>
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="text-[12px] font-black text-[#475569]">예상 학습 시간</span>
<input
className="mt-1.5 h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-bold text-[#111827] outline-none focus:border-[#2563eb]"
value={draftDuration}
placeholder="예: 8-10시간"
onChange={(event) => setDraftDuration(event.target.value)}
/>
</label>
<label className="block">
<span className="text-[12px] font-black text-[#475569]">팀 프로젝트</span>
<select
className="mt-1.5 h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-bold text-[#111827] outline-none focus:border-[#2563eb]"
value={draftIsTeamProject ? 'true' : 'false'}
onChange={(event) => setDraftIsTeamProject(event.target.value === 'true')}
>
<option value="false">미사용</option>
<option value="true">사용</option>
</select>
</label>
</div>
</section>
<section>
<div className="flex items-center justify-between gap-3">
<h3 className="text-[15px] font-black text-[#111827]">학습단계</h3>
<button
type="button"
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-[#d1d9e6] bg-white px-3 text-[12px] font-black text-[#475569] transition hover:border-[#2563eb] hover:bg-[#eff6ff] hover:text-[#2563eb]"
onClick={handleAddLearningStep}
>
<Plus size={14} aria-hidden="true" />
추가
</button>
</div>
<div className="mt-3 grid gap-2">
{selectedTopics.length ? (
selectedTopics.map((topic, index) => (
<div
key={`${selectedWeek?.week}-${index}`}
className="flex items-start gap-3 rounded-lg border border-[#d1d9e6] bg-white px-4 py-3 text-left transition hover:bg-[#f8fafc]"
>
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-md bg-[#f1f5f9] text-[12px] font-black text-[#64748b]">
{index + 1}
</span>
<span className="grid min-w-0 flex-1 gap-2">
<input
className="h-9 rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-black text-[#111827] outline-none focus:border-[#2563eb]"
value={topic}
placeholder="제목"
onChange={(event) => {
const next = [...draftTopics];
next[index] = event.target.value;
setDraftTopics(next);
}}
/>
<textarea
className="min-h-[58px] resize-none rounded-md border border-[#d1d9e6] bg-white px-3 py-2 text-[12px] font-semibold leading-relaxed text-[#64748b] outline-none focus:border-[#2563eb]"
value={draftTopicDetails[index] ?? ''}
placeholder="내용"
onChange={(event) => {
const next = [...draftTopicDetails];
next[index] = event.target.value;
setDraftTopicDetails(next);
}}
/>
<label className="inline-flex w-fit cursor-pointer items-center gap-2 text-[12px] font-black text-[#475569]">
<input
type="checkbox"
className="h-4 w-4 rounded border-[#cbd5e1] text-[#2563eb] focus:ring-[#2563eb]"
checked={draftTopicVideoEnabled[index] || Boolean(draftTopicVideoUrls[index]?.trim())}
onChange={(event) => {
const checked = event.target.checked;
setDraftTopicVideoEnabled((current) => {
const next = [...current];
next[index] = checked;
return next;
});
if (!checked) {
setDraftTopicVideoUrls((current) => {
const next = [...current];
next[index] = '';
return next;
});
}
}}
/>
동영상강의
</label>
{(draftTopicVideoEnabled[index] || Boolean(draftTopicVideoUrls[index]?.trim())) && (
<input
className="h-9 rounded-md border border-[#d1d9e6] bg-white px-3 text-[12px] font-semibold text-[#111827] outline-none focus:border-[#2563eb]"
value={draftTopicVideoUrls[index] ?? ''}
placeholder="YouTube URL"
onChange={(event) => {
const next = [...draftTopicVideoUrls];
next[index] = event.target.value;
setDraftTopicVideoUrls(next);
}}
/>
)}
<label className="grid gap-1.5">
<span className="text-[12px] font-black text-[#475569]">핵심개념</span>
<textarea
className="min-h-[70px] resize-y rounded-md border border-[#d1d9e6] bg-white px-3 py-2 text-[12px] font-semibold leading-relaxed text-[#64748b] outline-none focus:border-[#2563eb]"
value={draftTopicConcepts[index] ?? ''}
placeholder="한 줄에 하나씩 입력"
onChange={(event) => {
const next = [...draftTopicConcepts];
next[index] = event.target.value;
setDraftTopicConcepts(next);
}}
/>
</label>
</span>
<button
type="button"
onClick={() => handleDeleteLearningStep(index)}
className="grid h-8 w-8 shrink-0 place-items-center rounded-md text-[#94a3b8] transition hover:bg-[#fff1f2] hover:text-[#e11d48]"
aria-label={`${index + 1}번 학습단계 삭제`}
title="학습단계 삭제"
>
<Trash2 size={14} aria-hidden="true" />
</button>
</div>
))
) : (
<p className="rounded-lg border border-dashed border-[#d1d9e6] px-4 py-8 text-center text-[13px] font-bold text-[#64748b]">학습단계가 없습니다.</p>
)}
</div>
</section>
<section>
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-[15px] font-black text-[#111827]">실습 내용</h3>
</div>
<button
type="button"
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-[#d1d9e6] bg-white px-3 text-[12px] font-black text-[#475569] transition hover:border-[#2563eb] hover:bg-[#eff6ff] hover:text-[#2563eb]"
onClick={handleAddPracticeContent}
>
<Plus size={14} aria-hidden="true" />
추가
</button>
</div>
<div className="mt-3 grid gap-2">
{draftPracticeContents.length ? draftPracticeContents.map((item, index) => (
<div key={`practice-${index}`} className="flex items-start gap-3 rounded-lg border border-[#d1d9e6] bg-white px-4 py-3">
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-md bg-[#ede9ff] text-[12px] font-black text-[#4f46e5]">{index + 1}</span>
<span className="grid min-w-0 flex-1 gap-2">
<input
className="h-9 rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-black text-[#111827] outline-none focus:border-[#2563eb]"
value={item.title}
placeholder="제목"
onChange={(event) => handleUpdatePracticeContent(index, 'title', event.target.value)}
/>
<textarea
className="min-h-[58px] resize-none rounded-md border border-[#d1d9e6] bg-white px-3 py-2 text-[12px] font-semibold leading-relaxed text-[#64748b] outline-none focus:border-[#2563eb]"
value={item.description}
placeholder="설명"
onChange={(event) => handleUpdatePracticeContent(index, 'description', event.target.value)}
/>
</span>
<button
type="button"
onClick={() => handleDeletePracticeContent(index)}
className="grid h-8 w-8 shrink-0 place-items-center rounded-md text-[#94a3b8] transition hover:bg-[#fff1f2] hover:text-[#e11d48]"
aria-label={`${index + 1}번 실습 내용 삭제`}
title="실습 내용 삭제"
>
<Trash2 size={14} aria-hidden="true" />
</button>
</div>
)) : (
<p className="rounded-lg border border-dashed border-[#d1d9e6] px-4 py-8 text-center text-[13px] font-bold text-[#64748b]">실습 내용이 없습니다.</p>
)}
</div>
</section>
{selectedWeek && <WeekCoverEditor key={`${selectedCourseId}:${selectedWeek.week}`}
accessToken={session.accessToken} courseId={selectedCourseId} week={selectedWeek.week}
cover={coverOverrides[`${selectedCourseId}:${selectedWeek.week}`] !== undefined
? coverOverrides[`${selectedCourseId}:${selectedWeek.week}`] ?? null : selectedWeek.detail.coverImage ?? null}
onChange={(cover) => setCoverOverrides((current) => ({ ...current, [`${selectedCourseId}:${selectedWeek.week}`]: cover }))} />}
<label className="block">
<h3 className="text-[15px] font-black text-[#111827]">제출 기준</h3>
<textarea
className="mt-1.5 min-h-[84px] w-full resize-none rounded-md border border-[#d1d9e6] bg-white px-3 py-2 text-[13px] font-semibold leading-relaxed text-[#111827] outline-none focus:border-[#2563eb]"
value={draftDeliverable}
onChange={(event) => setDraftDeliverable(event.target.value)}
/>
</label>
<section>
<div className="flex items-center justify-between gap-3">
<h3 className="text-[15px] font-black text-[#111827]">참고자료</h3>
<button
type="button"
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-[#d1d9e6] bg-white px-3 text-[12px] font-black text-[#475569] transition hover:border-[#2563eb] hover:bg-[#eff6ff] hover:text-[#2563eb]"
onClick={() => referenceFileInputRef.current?.click()}
>
<FileText size={14} aria-hidden="true" />
파일 선택
</button>
</div>
<input
ref={referenceFileInputRef}
type="file"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0] ?? null;
setReferenceFile(file);
if (file && !referenceTitle.trim()) setReferenceTitle(file.name);
}}
/>
<div className="mt-3 grid gap-2 rounded-lg border border-[#d1d9e6] bg-[#f8fafc] p-3">
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
<input
className="h-10 rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-bold text-[#111827] outline-none focus:border-[#2563eb]"
value={referenceTitle}
placeholder="자료명"
onChange={(event) => setReferenceTitle(event.target.value)}
/>
<div className="flex h-10 min-w-0 items-center rounded-md border border-[#d1d9e6] bg-white px-3 text-[12px] font-bold text-[#64748b]">
<span className="truncate">{referenceFile?.name ?? '선택된 파일 없음'}</span>
</div>
<button
type="button"
disabled={saving || !referenceFile}
onClick={() => void handleUploadReference()}
className="inline-flex h-10 items-center justify-center gap-1.5 rounded-md bg-[#2563eb] px-4 text-[13px] font-black text-white transition hover:bg-[#1d4ed8] disabled:cursor-not-allowed disabled:opacity-45"
>
{saving ? <Loader2 className="animate-spin" size={14} aria-hidden="true" /> : <Upload size={14} aria-hidden="true" />}
업로드
</button>
</div>
<div className="grid gap-2">
{selectedWeek.detail.references?.length ? selectedWeek.detail.references.map((reference) => (
<div key={reference.id} className="flex items-center gap-3 rounded-md border border-[#cbd5e1] bg-white px-3 py-2">
<FileText className="shrink-0 text-[#64748b]" size={16} aria-hidden="true" />
<div className="min-w-0 flex-1">
<p className="truncate text-[13px] font-black text-[#111827]">{reference.title}</p>
<p className="mt-0.5 truncate text-[11px] font-bold text-[#64748b]">
{reference.fileName}
{reference.fileSizeBytes ? ` · ${formatFileSize(Number(reference.fileSizeBytes))}` : ''}
</p>
</div>
<button
type="button"
disabled={saving}
onClick={() => void handleDeleteReference(reference.id)}
className="grid h-8 w-8 shrink-0 place-items-center rounded-md text-[#94a3b8] transition hover:bg-[#fff1f2] hover:text-[#e11d48] disabled:cursor-not-allowed disabled:opacity-45"
aria-label={`${reference.title} 삭제`}
title="참고자료 삭제"
>
<Trash2 size={14} aria-hidden="true" />
</button>
</div>
)) : (
<p className="rounded-md border border-dashed border-[#d1d9e6] bg-white px-4 py-6 text-center text-[13px] font-bold text-[#64748b]">등록된 참고자료가 없습니다.</p>
)}
</div>
</div>
</section>
<section className="grid min-w-0 gap-3">
<h3 className="text-[15px] font-black text-[#111827]">오버뷰</h3>
<label className="block min-w-0">
<span className="text-[12px] font-black text-[#475569]">오버뷰 시청 URL</span>
<input type="url" value={draftCameraUrl} onChange={(event) => setDraftCameraUrl(event.target.value)}
placeholder="시청 링크"
className="mt-1.5 h-10 w-full min-w-0 rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] outline-none focus:border-[#2563eb]" />
</label>
</section>
</div>
)}
</div>
</section>
{mapCreateOpen ? createPortal(
<div className="fixed inset-0 z-50 grid place-items-center bg-slate-950/45 p-5" role="dialog" aria-modal="true" aria-label="학습 맵 추가">
<section className="w-full max-w-[420px] rounded-lg border border-[#cbd5e1] bg-white shadow-2xl">
<header className="border-b border-[#e2e8f0] px-5 py-4">
<h2 className="text-[16px] font-black text-[#111827]">학습 맵 추가</h2>
<p className="mt-1 text-[12px] font-bold text-[#64748b]">등록 후 현재 주차에 선택된 상태로 추가됩니다.</p>
</header>
<div className="grid gap-4 px-5 py-4">
<label className="grid gap-1.5 text-[12px] font-black text-[#334155]">
맵 이름
<input autoFocus value={mapName} onChange={(event) => setMapName(event.target.value)} placeholder="예: 실습 장애물 맵" className="h-10 rounded-md border border-[#cbd5e1] px-3 text-[13px] font-semibold outline-none transition focus:border-[#2563eb] focus:ring-2 focus:ring-[#bfdbfe]" />
</label>
<label className="grid gap-1.5 text-[12px] font-black text-[#334155]">
설명
<input value={mapDescription} onChange={(event) => setMapDescription(event.target.value)} placeholder="학생에게 보여줄 맵 설명" className="h-10 rounded-md border border-[#cbd5e1] px-3 text-[13px] font-semibold outline-none transition focus:border-[#2563eb] focus:ring-2 focus:ring-[#bfdbfe]" />
</label>
</div>
<footer className="flex justify-end gap-2 border-t border-[#e2e8f0] px-5 py-3">
<button type="button" disabled={creatingMap} onClick={() => setMapCreateOpen(false)} className="h-8 rounded-md border border-[#d1d9e6] px-3 text-[12px] font-black text-[#475569] hover:bg-[#f8fafc] disabled:opacity-50">취소</button>
<button type="button" disabled={creatingMap || !mapName.trim()} onClick={() => void handleCreateMap()} className="inline-flex h-8 items-center gap-1.5 rounded-md bg-[#2563eb] px-3 text-[12px] font-black text-white hover:bg-[#1d4ed8] disabled:opacity-50">
{creatingMap ? <Loader2 className="animate-spin" size={13} /> : <Plus size={13} />}
추가
</button>
</footer>
</section>
</div>
, document.body) : null}
<ConfirmDialog
open={deleteConfirmOpen && Boolean(selectedWeek)}
title={selectedWeek ? `${selectedWeek.week}주차를 삭제할까요?` : '주차를 삭제할까요?'}
message="삭제한 주차는 강의 진행 목록과 학생 앱에서 사라집니다. 이 작업은 바로 적용됩니다."
detail={selectedWeek ? (
<div className="rounded-lg border border-[#fee2e2] bg-[#fff1f2] px-4 py-3">
<p className="truncate text-[13px] font-black text-[#111827]">{selectedWeek.title}</p>
<p className="mt-1 text-[12px] font-bold text-[#e11d48]">추가 주차만 삭제할 수 있습니다.</p>
</div>
) : null}
confirmLabel="삭제"
loading={saving}
danger
icon={Trash2}
onCancel={() => setDeleteConfirmOpen(false)}
onConfirm={() => void handleDeleteWeek()}
/>
<ConfirmDialog
open={completeConfirmOpen && Boolean(selectedWeek)}
title={selectedWeek ? `${selectedWeek.week}주차를 완료할까요?` : '주차를 완료할까요?'}
message="확인하면 선택한 그룹의 해당 주차가 완료 처리됩니다."
detail={selectedWeek ? (
<div className="rounded-lg border border-[#dbeafe] bg-[#eff6ff] px-4 py-3">
<p className="truncate text-[13px] font-black text-[#111827]">{selectedWeek.title}</p>
<p className="mt-1 text-[12px] font-bold text-[#2563eb]">현재 단계: {activeLearningStep ?? '미선택'}</p>
</div>
) : null}
confirmLabel="완료처리"
loading={saving}
icon={CheckCircle2}
onCancel={() => setCompleteConfirmOpen(false)}
onConfirm={() => {
setCompleteConfirmOpen(false);
void handleCompleteWeek();
}}
/>
</div>
);
}
function SummaryCard({ label, value, icon: Icon }: { label: string; value: string; icon: LucideIcon }) {
return (
<article className="rounded-lg border border-[#c7d2e0] bg-white px-5 py-4">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-[12px] font-semibold text-[#64748b]">{label}</p>
<p className="mt-2 truncate text-[18px] font-black leading-none text-[#111827]">{value}</p>
</div>
<span className="grid h-11 w-11 shrink-0 place-items-center rounded-md bg-[#eff6ff] text-[#2563eb]">
<Icon size={24} aria-hidden="true" />
</span>
</div>
</article>
);
}
function formatFileSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function environmentStorageKey(profile: Pick<CurriculumEnvironmentProfile, 'config' | 'profileType'>): 'local' | 'platform' {
return profile.profileType.toUpperCase() === 'PLATFORM' || isPlatformEnvironment(profile.config) ? 'platform' : 'local';
}