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

1271 lines
65 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react';
import { CheckCircle2, CirclePlay, Clock3, FileCode2, FileText, Loader2, Plus, RadioTower, Save, Target, Trash2, Upload, type LucideIcon } from 'lucide-react';
import { ConfirmDialog } from '../../components/ConfirmDialog';
import { PageHeader } from '../../components/PageHeader';
import { useToast } from '../../components/ToastProvider';
import { deleteCurriculumReference, deleteInstructorCurriculumWeek, getInstructorCurriculum, updateInstructorCurriculum, uploadCurriculumExampleCode, uploadCurriculumReference, type CurriculumWeek, type InstructorCurriculumResponse } from '../../lib/curriculum';
import { listInstructorGroups, type InstructorGroup } from '../../lib/groups';
import type { AuthSession } from '../../types/auth';
const CURRICULUM_SELECTED_GROUP_STORAGE_PREFIX = 'lms-admin-curriculum-selected-group-id';
export function CurriculumPage({ session, selectedCourseId }: { session: AuthSession; selectedCourseId: string }) {
const { showToast } = useToast();
const [weeks, setWeeks] = useState<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 [draftRequiresSourceSubmission, setDraftRequiresSourceSubmission] = useState(true);
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 [tagOptions, setTagOptions] = useState<NonNullable<InstructorCurriculumResponse['tagOptions']>>([]);
const [visualizationPlatforms, setVisualizationPlatforms] = useState<NonNullable<InstructorCurriculumResponse['visualizationPlatforms']>>([]);
const [visualizationEnabledByKey, setVisualizationEnabledByKey] = useState<Record<string, boolean>>({});
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' | '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 resolveProgressSelection = (nextWeeks: CurriculumWeek[], setting: InstructorCurriculumResponse['setting']) => {
const nextWeek = nextWeeks.find((week) => week.state === 'current')?.week
?? nextWeeks.find((week) => week.state !== 'done')?.week
?? setting?.activeWeek
?? nextWeeks[0]?.week
?? 1;
const nextWeekData = nextWeeks.find((week) => week.week === nextWeek) ?? nextWeeks[0];
return {
week: nextWeek,
learningStep: setting?.activeLearningStep ?? nextWeekData?.topics[0] ?? null,
};
};
const selectProgressWeek = (nextWeeks = weeks, setting?: InstructorCurriculumResponse['setting'] | null) => {
const progress = setting === undefined
? { week: currentProgressWeek, learningStep: currentProgressLearningStep }
: resolveProgressSelection(nextWeeks, setting);
const progressWeek = nextWeeks.find((week) => week.week === progress.week) ?? nextWeeks[0] ?? null;
setActiveWeek(progressWeek?.week ?? progress.week);
setActiveLearningStep(progress.learningStep ?? progressWeek?.topics[0] ?? null);
};
const applyPayload = (payload: InstructorCurriculumResponse, options: { selectProgress?: boolean } = {}) => {
setError(null);
setWeeks(payload.weeks);
setTagOptions(payload.tagOptions ?? []);
setVisualizationPlatforms(payload.visualizationPlatforms ?? []);
const progress = resolveProgressSelection(payload.weeks, payload.setting);
setCurrentProgressWeek(progress.week);
setCurrentProgressLearningStep(progress.learningStep);
if (options.selectProgress) {
selectProgressWeek(payload.weeks, payload.setting);
}
};
useEffect(() => {
let cancelled = false;
listInstructorGroups(session.accessToken, selectedCourseId || null)
.then((payload) => {
if (cancelled) return;
setGroups(payload.groups);
if (!payload.groups.length) {
window.localStorage.removeItem(selectedGroupStorageKey);
}
setSelectedGroupId((current) => {
if (payload.groups.some((group) => group.id === current)) return current;
const storedGroupId = window.localStorage.getItem(selectedGroupStorageKey) ?? '';
if (payload.groups.some((group) => group.id === storedGroupId)) return storedGroupId;
return payload.groups[0]?.id || '';
});
})
.catch((reason: Error) => {
if (cancelled) return;
setGroups([]);
setError(reason.message);
});
return () => {
cancelled = true;
};
}, [session.accessToken, selectedCourseId, selectedGroupStorageKey]);
useEffect(() => {
if (selectedGroupId) {
window.localStorage.setItem(selectedGroupStorageKey, selectedGroupId);
}
}, [selectedGroupId, selectedGroupStorageKey]);
useEffect(() => {
let cancelled = false;
getInstructorCurriculum(session.accessToken, selectedGroupId || null, selectedCourseId || null)
.then((payload) => {
if (!cancelled) applyPayload(payload, { selectProgress: true });
})
.catch((reason: Error) => {
if (cancelled) return;
setWeeks([]);
setError(reason.message);
});
return () => {
cancelled = true;
};
}, [session.accessToken, selectedGroupId, selectedCourseId]);
useEffect(() => {
if (!selectedWeek) return;
setDraftTagLabel(selectedWeek.tags[0] ?? '');
setDraftTitle(selectedWeek.title);
setDraftDuration(selectedWeek.duration);
setDraftOverview(selectedWeek.detail.overview);
setDraftDeliverable(selectedWeek.detail.deliverable);
setDraftRequiresSourceSubmission(selectedWeek.detail.requiresSourceSubmission ?? true);
setDraftTopics(selectedWeek.topics.length ? selectedWeek.topics : ['']);
setDraftTopicDetails(selectedWeek.detail.topicDetails.length ? selectedWeek.detail.topicDetails : ['']);
const topicVideoUrls = selectedWeek.topics.map((_, index) => selectedWeek.detail.topicVideoUrls?.[index] ?? '');
setDraftTopicVideoUrls(topicVideoUrls);
setDraftTopicVideoEnabled(topicVideoUrls.map((url) => Boolean(url.trim())));
setDraftTopicConcepts(selectedWeek.topics.map((_, index) => selectedWeek.detail.learningSteps?.[index]?.concepts?.join('\n') ?? ''));
setDraftPracticeContents(selectedWeek.detail.practiceContents?.length ? selectedWeek.detail.practiceContents : []);
setModuleEnabledById(Object.fromEntries((selectedWeek.modules ?? []).map((module) => [module.id, module.enabled !== false])));
setVisualizationEnabledByKey(Object.fromEntries((selectedWeek.visualizationPlatforms ?? []).map((platform) => [platform.key, true])));
setReferenceTitle('');
setReferenceFile(null);
setExampleCodeTitle('');
setExampleCodeFile(null);
if (referenceFileInputRef.current) referenceFileInputRef.current.value = '';
if (exampleCodeFileInputRef.current) exampleCodeFileInputRef.current.value = '';
}, [selectedWeek?.week]);
useEffect(() => {
if (!selectedWeek) return;
if (!activeLearningStep || !selectedTopics.includes(activeLearningStep)) {
setActiveLearningStep(selectedTopics[0] ?? null);
}
}, [activeLearningStep, selectedTopics, selectedWeek]);
const buildDraftLearningSteps = () => draftTopics
.map((topic, index) => {
const title = topic.trim();
const description = draftTopicDetails[index]?.trim() ?? '';
const videoEnabled = draftTopicVideoEnabled[index] || Boolean(draftTopicVideoUrls[index]?.trim());
const videoUrl = videoEnabled ? (draftTopicVideoUrls[index]?.trim() ?? '') : '';
const concepts = (draftTopicConcepts[index] ?? '')
.split('\n')
.map((concept) => concept.trim())
.filter(Boolean);
return title || description || videoUrl || concepts.length
? { title, description, videoEnabled, videoUrl, concepts }
: null;
})
.filter((step): step is { title: string; description: string; videoEnabled: boolean; videoUrl: string; concepts: string[] } => Boolean(step));
const handleApply = async () => {
if (!selectedWeek || !selectedGroup) return;
setSaving(true);
setError(null);
try {
const payload = await updateInstructorCurriculum(session.accessToken, {
courseId: selectedCourseId || null,
groupId: selectedGroupId || null,
activeWeek: selectedWeek.week,
activeLearningStep,
applyToStudents: true,
tagLabel: selectedTagLabel,
title: draftTitle,
duration: draftDuration,
overview: draftOverview,
deliverable: draftDeliverable,
requiresSourceSubmission: draftRequiresSourceSubmission,
topics: draftTopics.map((topic) => topic.trim()).filter(Boolean),
learningSteps: buildDraftLearningSteps(),
topicDetails: draftTopicDetails,
topicVideoUrls: draftTopicVideoUrls.map((url, index) => draftTopicVideoEnabled[index] ? url.trim() : ''),
practiceContents: draftPracticeContents,
});
applyPayload(payload, { selectProgress: true });
showToast({
title: '커리큘럼이 적용되었습니다.',
message: `${selectedGroup.name} · ${selectedWeek.week}주차 · ${activeLearningStep ?? '학습단계 미선택'}`,
});
} catch (reason) {
setError(reason instanceof Error ? reason.message : '커리큘럼을 적용하지 못했습니다.');
} finally {
setSaving(false);
}
};
const handleChangeGroup = (groupId: string) => {
setSelectedGroupId(groupId);
if (groupId) {
window.localStorage.setItem(selectedGroupStorageKey, groupId);
} else {
window.localStorage.removeItem(selectedGroupStorageKey);
}
};
const handleCompleteWeek = async () => {
if (!selectedWeek || !selectedGroup) return;
const topics = draftTopics.map((topic) => topic.trim()).filter(Boolean);
const finalLearningStep = topics[topics.length - 1] ?? selectedWeek.topics[selectedWeek.topics.length - 1] ?? activeLearningStep;
setSaving(true);
setError(null);
try {
const payload = await updateInstructorCurriculum(session.accessToken, {
courseId: selectedCourseId || null,
groupId: selectedGroupId || null,
activeWeek: selectedWeek.week,
activeLearningStep: finalLearningStep,
applyToStudents: true,
completed: true,
tagLabel: selectedTagLabel,
title: draftTitle,
duration: draftDuration,
overview: draftOverview,
deliverable: draftDeliverable,
requiresSourceSubmission: draftRequiresSourceSubmission,
topics,
learningSteps: buildDraftLearningSteps(),
topicDetails: draftTopicDetails,
topicVideoUrls: draftTopicVideoUrls.map((url, index) => draftTopicVideoEnabled[index] ? url.trim() : ''),
practiceContents: draftPracticeContents,
});
applyPayload(payload, { selectProgress: true });
showToast({
title: '주차가 완료되었습니다.',
message: `${selectedGroup.name} · ${selectedWeek.week}주차`,
});
} catch (reason) {
setError(reason instanceof Error ? reason.message : '주차를 완료하지 못했습니다.');
} finally {
setSaving(false);
}
};
const handleRequestCompleteWeek = () => {
if (!selectedWeek || !selectedGroup) return;
setCompleteConfirmOpen(true);
};
const handleSaveContent = async () => {
if (!selectedWeek) return;
setSaving(true);
setError(null);
try {
const payload = await updateInstructorCurriculum(session.accessToken, {
courseId: selectedCourseId || null,
groupId: selectedGroupId || null,
activeWeek: selectedWeek.week,
activeLearningStep,
applyToStudents: false,
tagLabel: selectedTagLabel,
title: draftTitle,
duration: draftDuration,
overview: draftOverview,
deliverable: draftDeliverable,
requiresSourceSubmission: draftRequiresSourceSubmission,
topics: draftTopics.map((topic) => topic.trim()).filter(Boolean),
learningSteps: buildDraftLearningSteps(),
topicDetails: draftTopicDetails,
topicVideoUrls: draftTopicVideoUrls.map((url, index) => draftTopicVideoEnabled[index] ? url.trim() : ''),
practiceContents: draftPracticeContents,
});
applyPayload(payload);
showToast({
title: '커리큘럼 내용이 저장되었습니다.',
message: `${selectedWeek.week}주차`,
});
} catch (reason) {
setError(reason instanceof Error ? reason.message : '커리큘럼을 저장하지 못했습니다.');
} finally {
setSaving(false);
}
};
const handleSaveModules = async () => {
if (!selectedWeek) return;
setSaving(true);
setError(null);
try {
const disabledModuleIds = (selectedWeek.modules ?? [])
.filter((module) => moduleEnabledById[module.id] === false)
.map((module) => module.id);
const payload = await updateInstructorCurriculum(session.accessToken, {
courseId: selectedCourseId || null,
groupId: selectedGroupId || null,
activeWeek: selectedWeek.week,
activeLearningStep,
applyToStudents: false,
disabledModuleIds,
visualizationPlatformKeys: visualizationPlatforms
.filter((platform) => visualizationEnabledByKey[platform.key])
.map((platform) => platform.key),
});
applyPayload(payload);
setActiveWeek(selectedWeek.week);
showToast({
title: '모듈 설정이 저장되었습니다.',
message: `${selectedWeek.week}주차`,
});
} catch (reason) {
setError(reason instanceof Error ? reason.message : '모듈 설정을 저장하지 못했습니다.');
} finally {
setSaving(false);
}
};
const handleAddWeek = async () => {
const nextWeek = Math.max(0, ...weeks.map((week) => week.week)) + 1;
setSaving(true);
setError(null);
try {
const payload = await updateInstructorCurriculum(session.accessToken, {
courseId: selectedCourseId || null,
groupId: selectedGroupId || null,
activeWeek: nextWeek,
activeLearningStep: '학습단계 1',
applyToStudents: false,
tagLabel: tagOptions[0]?.label ?? '없음',
title: `${nextWeek}주차 커리큘럼`,
duration: '-',
overview: '',
deliverable: '',
requiresSourceSubmission: true,
topics: ['학습단계 1'],
learningSteps: [{ title: '학습단계 1', description: '', videoEnabled: false, videoUrl: '', concepts: [] }],
topicDetails: [''],
topicVideoUrls: [''],
practiceContents: [],
});
applyPayload(payload);
setActiveWeek(nextWeek);
setActiveLearningStep('학습단계 1');
setActiveTab('edit');
showToast({
title: '주차가 추가되었습니다.',
message: `${nextWeek}주차`,
});
} catch (reason) {
setError(reason instanceof Error ? reason.message : '주차를 추가하지 못했습니다.');
} finally {
setSaving(false);
}
};
const handleDeleteWeek = async () => {
if (!selectedWeek || !selectedWeekDeletable) return;
setSaving(true);
setError(null);
try {
const payload = await deleteInstructorCurriculumWeek(session.accessToken, selectedWeek.week);
applyPayload(payload, { selectProgress: true });
showToast({
title: '주차가 삭제되었습니다.',
message: `${selectedWeek.week}주차`,
});
setDeleteConfirmOpen(false);
} catch (reason) {
setError(reason instanceof Error ? reason.message : '주차를 삭제하지 못했습니다.');
} finally {
setSaving(false);
}
};
const handleAddLearningStep = () => {
const nextIndex = draftTopics.length + 1;
const nextTopic = `학습단계 ${nextIndex}`;
setDraftTopics((current) => [...current, nextTopic]);
setDraftTopicDetails((current) => [...current, '']);
setDraftTopicVideoUrls((current) => [...current, '']);
setDraftTopicVideoEnabled((current) => [...current, false]);
setDraftTopicConcepts((current) => [...current, '']);
};
const handleDeleteLearningStep = (index: number) => {
setDraftTopics((currentTopics) => {
const removedTopic = currentTopics[index];
const nextTopics = currentTopics.filter((_, topicIndex) => topicIndex !== index);
setActiveLearningStep((currentStep) => {
if (currentStep && currentStep !== removedTopic && nextTopics.includes(currentStep)) return currentStep;
return nextTopics[Math.min(index, nextTopics.length - 1)] ?? null;
});
return nextTopics;
});
setDraftTopicDetails((currentDetails) => currentDetails.filter((_, detailIndex) => detailIndex !== index));
setDraftTopicVideoUrls((currentUrls) => currentUrls.filter((_, urlIndex) => urlIndex !== index));
setDraftTopicVideoEnabled((currentEnabled) => currentEnabled.filter((_, enabledIndex) => enabledIndex !== index));
setDraftTopicConcepts((currentConcepts) => currentConcepts.filter((_, conceptIndex) => conceptIndex !== index));
};
const handleAddPracticeContent = () => {
setDraftPracticeContents((current) => [...current, { title: '', description: '' }]);
};
const handleUpdatePracticeContent = (index: number, field: 'title' | 'description', value: string) => {
setDraftPracticeContents((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, [field]: value } : item));
};
const handleDeletePracticeContent = (index: number) => {
setDraftPracticeContents((current) => current.filter((_, itemIndex) => itemIndex !== index));
};
const handleUploadReference = async () => {
if (!selectedWeek || !referenceFile) return;
setSaving(true);
setError(null);
try {
const payload = await uploadCurriculumReference(session.accessToken, {
courseId: selectedCourseId || null,
week: selectedWeek.week,
title: referenceTitle,
file: referenceFile,
});
applyPayload(payload);
setActiveWeek(selectedWeek.week);
setReferenceTitle('');
setReferenceFile(null);
if (referenceFileInputRef.current) referenceFileInputRef.current.value = '';
showToast({
title: '참고자료가 업로드되었습니다.',
message: referenceFile.name,
});
} catch (reason) {
setError(reason instanceof Error ? reason.message : '참고자료를 업로드하지 못했습니다.');
} finally {
setSaving(false);
}
};
const handleUploadExampleCode = async () => {
if (!selectedWeek || !exampleCodeFile) return;
setSaving(true);
setError(null);
try {
const payload = await uploadCurriculumExampleCode(session.accessToken, {
courseId: selectedCourseId || null,
week: selectedWeek.week,
title: exampleCodeTitle,
file: exampleCodeFile,
});
applyPayload(payload);
setActiveWeek(selectedWeek.week);
setExampleCodeTitle('');
setExampleCodeFile(null);
if (exampleCodeFileInputRef.current) exampleCodeFileInputRef.current.value = '';
showToast({
title: '예제 코드가 업로드되었습니다.',
message: exampleCodeFile.name,
});
} catch (reason) {
setError(reason instanceof Error ? reason.message : '예제 코드를 업로드하지 못했습니다.');
} finally {
setSaving(false);
}
};
const handleDeleteReference = async (referenceId: string, label = '참고자료') => {
if (!selectedWeek) return;
setSaving(true);
setError(null);
try {
const payload = await deleteCurriculumReference(session.accessToken, referenceId, selectedCourseId || null);
applyPayload(payload);
setActiveWeek(selectedWeek.week);
showToast({
title: `${label}가 삭제되었습니다.`,
message: `${selectedWeek.week}주차`,
});
} catch (reason) {
setError(reason instanceof Error ? reason.message : `${label}를 삭제하지 못했습니다.`);
} finally {
setSaving(false);
}
};
return (
<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={Target} />
<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 === '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' ? 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' ? '모듈 저장' : '내용 저장'}
</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">
<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>
{selectedWeek?.modules?.length ? (
<span className="rounded-md bg-[#eff6ff] px-3 py-1.5 text-[12px] font-black text-[#2563eb]">
{(selectedWeek.modules ?? []).filter((module) => moduleEnabledById[module.id] !== false).length}/{selectedWeek.modules.length}
</span>
) : null}
</div>
<div className="mt-3 grid gap-2">
{selectedWeek?.modules?.length ? selectedWeek.modules.map((module) => {
const enabled = moduleEnabledById[module.id] !== false;
return (
<label
key={module.id}
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-70 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) => {
const checked = event.target.checked;
setModuleEnabledById((current) => ({ ...current, [module.id]: checked }));
}}
/>
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-md bg-[#ede9ff] text-[12px] font-black text-[#4f46e5]">{module.sortOrder}</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[14px] font-black text-[#111827]">{module.name}</span>
<span className="mt-1 block truncate text-[12px] font-bold text-[#64748b]">{module.category} · {module.folderName}</span>
</span>
<span className={`shrink-0 rounded-md px-2.5 py-1 text-[11px] font-black ${enabled ? 'bg-[#dcfce7] text-[#15803d]' : 'bg-[#e2e8f0] text-[#64748b]'}`}>
{enabled ? '사용' : '미사용'}
</span>
</label>
);
}) : (
<p className="rounded-lg border border-dashed border-[#d1d9e6] px-4 py-10 text-center text-[13px] font-bold text-[#64748b]">등록된 모듈이 없습니다.</p>
)}
</div>
</section>
<section className="border-t border-[#cbd5e1] pt-4">
<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>
{visualizationPlatforms.length ? (
<span className="rounded-md bg-[#f1f5f9] px-3 py-1.5 text-[12px] font-black text-[#475569]">
{visualizationPlatforms.filter((platform) => visualizationEnabledByKey[platform.key]).length}/{visualizationPlatforms.length}
</span>
) : null}
</div>
<div className="mt-3 grid gap-2">
{visualizationPlatforms.length ? visualizationPlatforms.map((platform) => {
const enabled = Boolean(visualizationEnabledByKey[platform.key]);
return (
<label
key={platform.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) => {
const checked = event.target.checked;
setVisualizationEnabledByKey((current) => ({ ...current, [platform.key]: checked }));
}}
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-[14px] font-black text-[#111827]">{platform.displayName}</span>
<span className="mt-1 block truncate text-[12px] font-bold text-[#64748b]">{platform.description ?? '실행 설정 정보 없음'}</span>
</span>
<span className={`shrink-0 rounded-md px-2.5 py-1 text-[11px] font-black ${enabled ? 'bg-[#dcfce7] text-[#15803d]' : 'bg-[#e2e8f0] text-[#64748b]'}`}>
{enabled ? '사용' : '미사용'}
</span>
</label>
);
}) : (
<p className="rounded-lg border border-dashed border-[#d1d9e6] px-4 py-10 text-center text-[13px] font-bold text-[#64748b]">등록된 시각화 플랫폼이 없습니다.</p>
)}
</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">
<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>
<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>
<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>
</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">
<div className="flex items-center justify-between gap-3">
<h3 className="text-[15px] font-black text-[#111827]">제출 기준</h3>
<label className="inline-flex cursor-pointer items-center gap-2 text-[12px] font-black text-[#475569]">
<input
type="checkbox"
className="h-4 w-4 cursor-pointer rounded border-[#cbd5e1] text-[#2563eb] focus:ring-[#2563eb]"
checked={draftRequiresSourceSubmission}
onChange={(event) => setDraftRequiresSourceSubmission(event.target.checked)}
/>
소스제출
</label>
</div>
<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>
<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`;
}