1587 lines
84 KiB
TypeScript
1587 lines
84 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { CheckCircle2, CirclePlay, Clock3, FileCode2, FileText, Loader2, Map, Plus, RadioTower, Save, Target, Trash2, Upload, UsersRound, type LucideIcon } from 'lucide-react';
|
|
import { ConfirmDialog } from '../../components/ConfirmDialog';
|
|
import { PageHeader } from '../../components/PageHeader';
|
|
import { useToast } from '../../components/ToastProvider';
|
|
import { createInstructorCurriculumMap, deleteCurriculumReference, deleteInstructorCurriculumWeek, getInstructorCurriculum, updateInstructorCurriculum, uploadCurriculumExampleCode, uploadCurriculumReference, type CurriculumEnvironmentProfile, type CurriculumWeek, type InstructorCurriculumResponse } from '../../lib/curriculum';
|
|
import { listInstructorGroups, type InstructorGroup } from '../../lib/groups';
|
|
import type { AuthSession } from '../../types/auth';
|
|
|
|
const CURRICULUM_SELECTED_GROUP_STORAGE_PREFIX = 'lms-admin-curriculum-selected-group-id';
|
|
|
|
type ModuleParameterDraft = {
|
|
key: string;
|
|
value: string;
|
|
};
|
|
|
|
type EditableEnvironmentProfile = CurriculumEnvironmentProfile & {
|
|
storageKey: 'local' | 'platform';
|
|
platformKeys: string[];
|
|
};
|
|
|
|
function isPlatformEnvironment(config: unknown): boolean {
|
|
if (!config || typeof config !== 'object' || Array.isArray(config)) return false;
|
|
const target = (config as Record<string, unknown>).target;
|
|
return target === 'remote-jetson' || target === 'platform';
|
|
}
|
|
|
|
function environmentStorageKey(profile: Pick<CurriculumEnvironmentProfile, 'config' | 'profileType'>): 'local' | 'platform' {
|
|
return profile.profileType.toUpperCase() === 'PLATFORM' || isPlatformEnvironment(profile.config) ? 'platform' : 'local';
|
|
}
|
|
|
|
function parseModuleParameter(parameter: string): ModuleParameterDraft {
|
|
const separatorIndex = parameter.indexOf(':=');
|
|
if (separatorIndex < 0) return { key: parameter.trim(), value: '' };
|
|
return {
|
|
key: parameter.slice(0, separatorIndex).trim(),
|
|
value: parameter.slice(separatorIndex + 2).trim(),
|
|
};
|
|
}
|
|
|
|
function readEnvironmentModuleIds(profile: CurriculumEnvironmentProfile, modules: NonNullable<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 [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 [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'>('apply');
|
|
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 applyTabDisabled = !hasGroups;
|
|
const selectedWeekCompleted = selectedWeek?.state === 'done';
|
|
const primaryActionDisabled = saving || !selectedWeek || (activeTab === 'apply' && !selectedGroup);
|
|
const selectedTopics = activeTab === 'edit' ? draftTopics : selectedWeek?.topics ?? [];
|
|
const selectedTopicDetails = activeTab === 'edit' ? draftTopicDetails : selectedWeek?.detail.topicDetails ?? [];
|
|
const selectedWeekDeletable = Boolean(selectedWeek?.tags.includes('추가'));
|
|
const tagOptionLabels = useMemo(() => new Set(tagOptions.map((option) => option.label)), [tagOptions]);
|
|
const selectedTagLabel = tagOptionLabels.has(draftTagLabel)
|
|
? draftTagLabel
|
|
: tagOptions[0]?.label ?? '없음';
|
|
const selectedGroupStorageKey = useMemo(
|
|
() => `${CURRICULUM_SELECTED_GROUP_STORAGE_PREFIX}:${session.user.id}:${selectedCourseId || 'default'}`,
|
|
[selectedCourseId, session.user.id],
|
|
);
|
|
const editableEnvironmentProfiles = useMemo<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 (!selectedWeek) return;
|
|
setDraftTagLabel(selectedWeek.tags[0] ?? '');
|
|
setDraftTitle(selectedWeek.title);
|
|
setDraftDuration(selectedWeek.duration);
|
|
setDraftOverview(selectedWeek.detail.overview);
|
|
setDraftDeliverable(selectedWeek.detail.deliverable);
|
|
setDraftIsTeamProject(Boolean(selectedWeek.isTeamProject));
|
|
setDraftTopics(selectedWeek.topics.length ? selectedWeek.topics : ['']);
|
|
setDraftTopicDetails(selectedWeek.detail.topicDetails.length ? selectedWeek.detail.topicDetails : ['']);
|
|
const topicVideoUrls = selectedWeek.topics.map((_, index) => selectedWeek.detail.topicVideoUrls?.[index] ?? '');
|
|
setDraftTopicVideoUrls(topicVideoUrls);
|
|
setDraftTopicVideoEnabled(topicVideoUrls.map((url) => Boolean(url.trim())));
|
|
setDraftTopicConcepts(selectedWeek.topics.map((_, index) => selectedWeek.detail.learningSteps?.[index]?.concepts?.join('\n') ?? ''));
|
|
setDraftPracticeContents(selectedWeek.detail.practiceContents?.length ? selectedWeek.detail.practiceContents : []);
|
|
setModuleEnabledById(Object.fromEntries((selectedWeek.modules ?? []).map((module) => [module.id, module.enabled !== false])));
|
|
setModuleDefaultParametersById(Object.fromEntries((selectedWeek.modules ?? []).map((module) => [module.id, (module.defaultParameters ?? []).map(parseModuleParameter)])));
|
|
const profilesByEnvironment = (selectedWeek.environmentProfiles ?? []).reduce<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,
|
|
deliverable: draftDeliverable,
|
|
isTeamProject: draftIsTeamProject,
|
|
topics: draftTopics.map((topic) => topic.trim()).filter(Boolean),
|
|
learningSteps: buildDraftLearningSteps(),
|
|
topicDetails: draftTopicDetails,
|
|
topicVideoUrls: draftTopicVideoUrls.map((url, index) => draftTopicVideoEnabled[index] ? url.trim() : ''),
|
|
practiceContents: draftPracticeContents,
|
|
});
|
|
applyPayload(payload, { selectProgress: true });
|
|
showToast({
|
|
title: '커리큘럼이 적용되었습니다.',
|
|
message: `${selectedGroup.name} · ${selectedWeek.week}주차 · ${activeLearningStep ?? '학습단계 미선택'}`,
|
|
});
|
|
} catch (reason) {
|
|
setError(reason instanceof Error ? reason.message : '커리큘럼을 적용하지 못했습니다.');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleChangeGroup = (groupId: string) => {
|
|
setSelectedGroupId(groupId);
|
|
if (groupId) {
|
|
window.localStorage.setItem(selectedGroupStorageKey, groupId);
|
|
} else {
|
|
window.localStorage.removeItem(selectedGroupStorageKey);
|
|
}
|
|
};
|
|
|
|
const handleCompleteWeek = async () => {
|
|
if (!selectedWeek || !selectedGroup) return;
|
|
const topics = draftTopics.map((topic) => topic.trim()).filter(Boolean);
|
|
const finalLearningStep = topics[topics.length - 1] ?? selectedWeek.topics[selectedWeek.topics.length - 1] ?? activeLearningStep;
|
|
setSaving(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const payload = await updateInstructorCurriculum(session.accessToken, {
|
|
courseId: selectedCourseId || null,
|
|
groupId: selectedGroupId || null,
|
|
activeWeek: selectedWeek.week,
|
|
activeLearningStep: finalLearningStep,
|
|
applyToStudents: true,
|
|
completed: true,
|
|
tagLabel: selectedTagLabel,
|
|
title: draftTitle,
|
|
duration: draftDuration,
|
|
overview: draftOverview,
|
|
deliverable: draftDeliverable,
|
|
isTeamProject: draftIsTeamProject,
|
|
topics,
|
|
learningSteps: buildDraftLearningSteps(),
|
|
topicDetails: draftTopicDetails,
|
|
topicVideoUrls: draftTopicVideoUrls.map((url, index) => draftTopicVideoEnabled[index] ? url.trim() : ''),
|
|
practiceContents: draftPracticeContents,
|
|
});
|
|
applyPayload(payload, { selectProgress: true });
|
|
showToast({
|
|
title: '주차가 완료되었습니다.',
|
|
message: `${selectedGroup.name} · ${selectedWeek.week}주차`,
|
|
});
|
|
} catch (reason) {
|
|
setError(reason instanceof Error ? reason.message : '주차를 완료하지 못했습니다.');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleRequestCompleteWeek = () => {
|
|
if (!selectedWeek || !selectedGroup) return;
|
|
setCompleteConfirmOpen(true);
|
|
};
|
|
|
|
const handleSaveContent = async () => {
|
|
if (!selectedWeek) return;
|
|
setSaving(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const payload = await updateInstructorCurriculum(session.accessToken, {
|
|
courseId: selectedCourseId || null,
|
|
groupId: selectedGroupId || null,
|
|
activeWeek: selectedWeek.week,
|
|
activeLearningStep,
|
|
applyToStudents: false,
|
|
tagLabel: selectedTagLabel,
|
|
title: draftTitle,
|
|
duration: draftDuration,
|
|
overview: draftOverview,
|
|
deliverable: draftDeliverable,
|
|
isTeamProject: draftIsTeamProject,
|
|
topics: draftTopics.map((topic) => topic.trim()).filter(Boolean),
|
|
learningSteps: buildDraftLearningSteps(),
|
|
topicDetails: draftTopicDetails,
|
|
topicVideoUrls: draftTopicVideoUrls.map((url, index) => draftTopicVideoEnabled[index] ? url.trim() : ''),
|
|
practiceContents: draftPracticeContents,
|
|
});
|
|
applyPayload(payload);
|
|
showToast({
|
|
title: '커리큘럼 내용이 저장되었습니다.',
|
|
message: `${selectedWeek.week}주차`,
|
|
});
|
|
} catch (reason) {
|
|
setError(reason instanceof Error ? reason.message : '커리큘럼을 저장하지 못했습니다.');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleSaveModules = async () => {
|
|
if (!selectedWeek) return;
|
|
setSaving(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const disabledModuleIds = (selectedWeek.modules ?? [])
|
|
.filter((module) => moduleEnabledById[module.id] === false)
|
|
.map((module) => module.id);
|
|
const moduleDefaultParameters = (selectedWeek.modules ?? []).map((module) => ({
|
|
moduleId: module.id,
|
|
parameters: (moduleDefaultParametersById[module.id] ?? [])
|
|
.filter((parameter) => parameter.key.trim())
|
|
.map((parameter) => `${parameter.key.trim()}:=${parameter.value.trim()}`),
|
|
}));
|
|
const environmentModuleSets = editableEnvironmentProfiles.flatMap((profile) => {
|
|
const moduleIds = environmentModuleIdsByPlatformKey[profile.storageKey] ?? (selectedWeek.modules ?? []).map((module) => module.id);
|
|
return profile.platformKeys.map((platformKey) => ({
|
|
platformKey,
|
|
moduleIds,
|
|
modules: moduleIds.map((id) => ({
|
|
id,
|
|
defaultParameters: (environmentModuleParametersByPlatformKey[profile.storageKey]?.[id] ?? [])
|
|
.filter((parameter) => parameter.key.trim())
|
|
.map((parameter) => `${parameter.key.trim()}:=${parameter.value.trim()}`),
|
|
dockerImage: environmentModuleDockerImagesByPlatformKey[profile.storageKey]?.[id]?.trim() || undefined,
|
|
})),
|
|
}));
|
|
});
|
|
const payload = await updateInstructorCurriculum(session.accessToken, {
|
|
courseId: selectedCourseId || null,
|
|
groupId: selectedGroupId || null,
|
|
activeWeek: selectedWeek.week,
|
|
activeLearningStep,
|
|
applyToStudents: false,
|
|
disabledModuleIds,
|
|
moduleDefaultParameters,
|
|
environmentModuleSets,
|
|
mapKeys: mapOptions.filter((map) => mapEnabledByKey[map.key]).map((map) => map.key),
|
|
});
|
|
applyPayload(payload);
|
|
setActiveWeek(selectedWeek.week);
|
|
showToast({
|
|
title: '모듈 설정이 저장되었습니다.',
|
|
message: `${selectedWeek.week}주차`,
|
|
});
|
|
} catch (reason) {
|
|
setError(reason instanceof Error ? reason.message : '모듈 설정을 저장하지 못했습니다.');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleCreateMap = async () => {
|
|
const name = mapName.trim();
|
|
if (!name) return;
|
|
setCreatingMap(true);
|
|
try {
|
|
const map = await createInstructorCurriculumMap(session.accessToken, {
|
|
courseId: selectedCourseId || null,
|
|
name,
|
|
description: mapDescription.trim(),
|
|
});
|
|
setMapOptions((current) => [...current, map]);
|
|
setMapEnabledByKey((current) => ({ ...current, [map.key]: true }));
|
|
setMapCreateOpen(false);
|
|
setMapName('');
|
|
setMapDescription('');
|
|
showToast({ title: '학습 맵이 추가되었습니다.', message: '현재 주차에 선택된 상태입니다.' });
|
|
} catch (reason) {
|
|
setError(reason instanceof Error ? reason.message : '학습 맵을 등록하지 못했습니다.');
|
|
} finally {
|
|
setCreatingMap(false);
|
|
}
|
|
};
|
|
|
|
const handleAddWeek = async () => {
|
|
const nextWeek = Math.max(0, ...weeks.map((week) => week.week)) + 1;
|
|
setSaving(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const payload = await updateInstructorCurriculum(session.accessToken, {
|
|
courseId: selectedCourseId || null,
|
|
groupId: selectedGroupId || null,
|
|
activeWeek: nextWeek,
|
|
activeLearningStep: '학습단계 1',
|
|
applyToStudents: false,
|
|
tagLabel: tagOptions[0]?.label ?? '없음',
|
|
title: `${nextWeek}주차 커리큘럼`,
|
|
duration: '-',
|
|
overview: '',
|
|
deliverable: '',
|
|
topics: ['학습단계 1'],
|
|
learningSteps: [{ title: '학습단계 1', description: '', videoEnabled: false, videoUrl: '', concepts: [] }],
|
|
topicDetails: [''],
|
|
topicVideoUrls: [''],
|
|
practiceContents: [],
|
|
});
|
|
applyPayload(payload);
|
|
setActiveWeek(nextWeek);
|
|
setActiveLearningStep('학습단계 1');
|
|
setActiveTab('edit');
|
|
showToast({
|
|
title: '주차가 추가되었습니다.',
|
|
message: `${nextWeek}주차`,
|
|
});
|
|
} catch (reason) {
|
|
setError(reason instanceof Error ? reason.message : '주차를 추가하지 못했습니다.');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleDeleteWeek = async () => {
|
|
if (!selectedWeek || !selectedWeekDeletable) return;
|
|
|
|
setSaving(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const payload = await deleteInstructorCurriculumWeek(session.accessToken, selectedWeek.week);
|
|
applyPayload(payload, { selectProgress: true });
|
|
showToast({
|
|
title: '주차가 삭제되었습니다.',
|
|
message: `${selectedWeek.week}주차`,
|
|
});
|
|
setDeleteConfirmOpen(false);
|
|
} catch (reason) {
|
|
setError(reason instanceof Error ? reason.message : '주차를 삭제하지 못했습니다.');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleAddLearningStep = () => {
|
|
const nextIndex = draftTopics.length + 1;
|
|
const nextTopic = `학습단계 ${nextIndex}`;
|
|
setDraftTopics((current) => [...current, nextTopic]);
|
|
setDraftTopicDetails((current) => [...current, '']);
|
|
setDraftTopicVideoUrls((current) => [...current, '']);
|
|
setDraftTopicVideoEnabled((current) => [...current, false]);
|
|
setDraftTopicConcepts((current) => [...current, '']);
|
|
};
|
|
|
|
const handleDeleteLearningStep = (index: number) => {
|
|
setDraftTopics((currentTopics) => {
|
|
const removedTopic = currentTopics[index];
|
|
const nextTopics = currentTopics.filter((_, topicIndex) => topicIndex !== index);
|
|
setActiveLearningStep((currentStep) => {
|
|
if (currentStep && currentStep !== removedTopic && nextTopics.includes(currentStep)) return currentStep;
|
|
return nextTopics[Math.min(index, nextTopics.length - 1)] ?? null;
|
|
});
|
|
return nextTopics;
|
|
});
|
|
setDraftTopicDetails((currentDetails) => currentDetails.filter((_, detailIndex) => detailIndex !== index));
|
|
setDraftTopicVideoUrls((currentUrls) => currentUrls.filter((_, urlIndex) => urlIndex !== index));
|
|
setDraftTopicVideoEnabled((currentEnabled) => currentEnabled.filter((_, enabledIndex) => enabledIndex !== index));
|
|
setDraftTopicConcepts((currentConcepts) => currentConcepts.filter((_, conceptIndex) => conceptIndex !== index));
|
|
};
|
|
|
|
const handleAddPracticeContent = () => {
|
|
setDraftPracticeContents((current) => [...current, { title: '', description: '' }]);
|
|
};
|
|
|
|
const handleUpdatePracticeContent = (index: number, field: 'title' | 'description', value: string) => {
|
|
setDraftPracticeContents((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, [field]: value } : item));
|
|
};
|
|
|
|
const handleDeletePracticeContent = (index: number) => {
|
|
setDraftPracticeContents((current) => current.filter((_, itemIndex) => itemIndex !== index));
|
|
};
|
|
|
|
const handleUploadReference = async () => {
|
|
if (!selectedWeek || !referenceFile) return;
|
|
setSaving(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const payload = await uploadCurriculumReference(session.accessToken, {
|
|
courseId: selectedCourseId || null,
|
|
week: selectedWeek.week,
|
|
title: referenceTitle,
|
|
file: referenceFile,
|
|
});
|
|
applyPayload(payload);
|
|
setActiveWeek(selectedWeek.week);
|
|
setReferenceTitle('');
|
|
setReferenceFile(null);
|
|
if (referenceFileInputRef.current) referenceFileInputRef.current.value = '';
|
|
showToast({
|
|
title: '참고자료가 업로드되었습니다.',
|
|
message: referenceFile.name,
|
|
});
|
|
} catch (reason) {
|
|
const message = reason instanceof Error ? reason.message : '참고자료를 업로드하지 못했습니다.';
|
|
setError(message);
|
|
showToast({
|
|
title: '참고자료 업로드 실패',
|
|
message,
|
|
variant: 'error',
|
|
});
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleUploadExampleCode = async () => {
|
|
if (!selectedWeek || !exampleCodeFile) return;
|
|
setSaving(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const payload = await uploadCurriculumExampleCode(session.accessToken, {
|
|
courseId: selectedCourseId || null,
|
|
week: selectedWeek.week,
|
|
title: exampleCodeTitle,
|
|
file: exampleCodeFile,
|
|
});
|
|
applyPayload(payload);
|
|
setActiveWeek(selectedWeek.week);
|
|
setExampleCodeTitle('');
|
|
setExampleCodeFile(null);
|
|
if (exampleCodeFileInputRef.current) exampleCodeFileInputRef.current.value = '';
|
|
showToast({
|
|
title: '예제 코드가 업로드되었습니다.',
|
|
message: exampleCodeFile.name,
|
|
});
|
|
} catch (reason) {
|
|
const message = reason instanceof Error ? reason.message : '예제 코드를 업로드하지 못했습니다.';
|
|
setError(message);
|
|
showToast({
|
|
title: '예제 코드 업로드 실패',
|
|
message,
|
|
variant: 'error',
|
|
});
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleDeleteReference = async (referenceId: string, label = '참고자료') => {
|
|
if (!selectedWeek) return;
|
|
setSaving(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const payload = await deleteCurriculumReference(session.accessToken, referenceId, selectedCourseId || null);
|
|
applyPayload(payload);
|
|
setActiveWeek(selectedWeek.week);
|
|
showToast({
|
|
title: `${label}가 삭제되었습니다.`,
|
|
message: `${selectedWeek.week}주차`,
|
|
});
|
|
} catch (reason) {
|
|
setError(reason instanceof Error ? reason.message : `${label}를 삭제하지 못했습니다.`);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<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"
|
|
disabled={applyTabDisabled}
|
|
className={`h-8 rounded-md px-4 text-[13px] font-black transition disabled:cursor-not-allowed disabled:opacity-45 ${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={() => {
|
|
if (applyTabDisabled) return;
|
|
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>
|
|
</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 === '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' ? '맵 저장' : '내용 저장'}
|
|
</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' ? (
|
|
!hasGroups ? (
|
|
<div className="grid min-h-[360px] place-items-center p-5 text-center">
|
|
<div>
|
|
<h3 className="text-[16px] font-black text-[#111827]">등록된 그룹이 없습니다</h3>
|
|
<p className="mt-2 text-[13px] font-bold leading-relaxed text-[#64748b]">
|
|
강의 진행을 적용하려면 먼저 그룹을 생성해 주세요.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="grid gap-4 p-5">
|
|
<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>
|
|
) : (
|
|
<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>
|
|
<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>
|
|
</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`;
|
|
}
|