import { useEffect, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; import { DiffEditor, loader } from '@monaco-editor/react'; import * as monaco from 'monaco-editor'; import EditorWorker from '../../../node_modules/monaco-editor/esm/vs/editor/editor.worker.js?worker'; import { BookOpen, CalendarDays, CheckCircle2, ChevronDown, ChevronRight, Clock3, FileCode2, FolderOpen, GitCommitHorizontal, Loader2, Save, UsersRound, UserRound, X, XCircle } from 'lucide-react'; import { useToast } from '../../components/ToastProvider'; import { PageHeader } from '../../components/PageHeader'; import { getInstructorSubmissionFile, listInstructorSubmissions, saveInstructorSubmissionScore, type InstructorSubmissionItem } from '../../lib/submissions'; import type { AuthSession } from '../../types/auth'; type CommitFile = InstructorSubmissionItem['commits'][number]['files'][number]; type PatchModalState = { title: string; subtitle: string; files: CommitFile[]; activePath: string; repository: NonNullable; } | null; type SubmissionSelectionMember = { id: string; name: string; email: string; total: number; submitted: number; pending: number; notInstalled: number; commitCount?: number; lastCommittedAt?: string | null; }; const statusLabel: Record = { SUBMITTED: '제출완료', PENDING: '미제출', NOT_INSTALLED: '미설치', }; const statusTone: Record = { SUBMITTED: 'bg-[#ecfdf5] text-[#15803d]', PENDING: 'bg-[#fff7ed] text-[#c2410c]', NOT_INSTALLED: 'bg-[#f1f5f9] text-[#64748b]', }; loader.config({ monaco }); // Vite serves this app below /lms-adm/. Monaco's default worker URL ignores // that base path, so provide the worker explicitly for editor and diff tasks. (globalThis as typeof globalThis & { MonacoEnvironment?: { getWorker: () => Worker }; }).MonacoEnvironment = { getWorker: () => new EditorWorker(), }; export function SubmissionsPage({ session }: { session: AuthSession }) { const [items, setItems] = useState([]); const [teamItems, setTeamItems] = useState([]); const [students, setStudents] = useState>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [selectedCourseId, setSelectedCourseId] = useState(null); const [selectedWeekId, setSelectedWeekId] = useState(null); const [selectedGroupName, setSelectedGroupName] = useState(null); const [selectedStudentId, setSelectedStudentId] = useState(null); const [selectedId, setSelectedId] = useState(null); const [patchModal, setPatchModal] = useState(null); useEffect(() => { let cancelled = false; setLoading(true); setError(null); listInstructorSubmissions(session.accessToken) .then((payload) => { if (cancelled) return; setItems(payload.items); setTeamItems(payload.teamItems); setStudents(payload.students); setSelectedCourseId((current) => current && payload.courses.some((course) => course.id === current) ? current : payload.courses[0]?.id ?? null); setSelectedWeekId(null); setSelectedGroupName(null); setSelectedStudentId(null); setSelectedId(null); }) .catch((reason) => { if (cancelled) return; setError(reason instanceof Error ? reason.message : '제출 현황을 불러오지 못했습니다.'); setItems([]); setTeamItems([]); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [session.accessToken]); const allItems = useMemo(() => [...items, ...teamItems], [items, teamItems]); const courseNodes = useMemo(() => allItems.length ? Array.from(new Map(allItems.map((item) => [item.course.id, item.course])).values()) : [], [allItems]); const selectedCourse = courseNodes.find((course) => course.id === selectedCourseId) ?? null; const courseItems = useMemo(() => selectedCourse ? allItems.filter((item) => item.course.id === selectedCourse.id) : [], [allItems, selectedCourse]); const weekNodes = useMemo(() => { const byWeek = new Map(); courseItems.forEach((item) => { const current = byWeek.get(item.week.id) ?? { id: item.week.id, week: item.week.week, title: item.week.title, isTeamProject: item.week.isTeamProject, total: 0, submitted: 0, pending: 0, notInstalled: 0, }; current.total += 1; if (item.status === 'SUBMITTED') current.submitted += 1; if (item.status === 'PENDING') current.pending += 1; if (item.status === 'NOT_INSTALLED') current.notInstalled += 1; byWeek.set(item.week.id, current); }); return Array.from(byWeek.values()).sort((a, b) => a.week - b.week); }, [courseItems]); const selectedWeek = weekNodes.find((week) => week.id === selectedWeekId) ?? null; const weekItems = useMemo(() => selectedWeek ? courseItems.filter((item) => item.week.id === selectedWeek.id) : [], [courseItems, selectedWeek]); const teamProjectWeek = Boolean(selectedWeek?.isTeamProject); const groupNodes = useMemo(() => { const groupMap = new Map(); weekItems.forEach((item) => { const group = item.team?.name ?? item.student.group ?? '미배정'; const current = groupMap.get(group) ?? { name: group, total: 0, submitted: 0, pending: 0, notInstalled: 0 }; current.total += 1; if (item.status === 'SUBMITTED') current.submitted += 1; if (item.status === 'PENDING') current.pending += 1; if (item.status === 'NOT_INSTALLED') current.notInstalled += 1; groupMap.set(group, current); }); return Array.from(groupMap.values()).sort((a, b) => a.name.localeCompare(b.name, 'ko')); }, [weekItems]); const selectedGroup = groupNodes.find((group) => group.name === selectedGroupName) ?? null; const studentNodes = useMemo(() => { if (teamProjectWeek) { const team = weekItems.find((item) => item.team?.name === selectedGroup?.name)?.team; return (team?.members ?? []).map((member) => ({ ...member, total: 1, submitted: member.commitCount > 0 ? 1 : 0, pending: member.commitCount > 0 ? 0 : 1, notInstalled: 0, })); } return students.map((student) => { const studentItems = weekItems.filter((item) => item.student.group === selectedGroup?.name && item.student.id === student.id); return { ...student, total: studentItems.length, submitted: studentItems.filter((item) => item.status === 'SUBMITTED').length, pending: studentItems.filter((item) => item.status === 'PENDING').length, notInstalled: studentItems.filter((item) => item.status === 'NOT_INSTALLED').length, }; }).filter((student) => student.total > 0); }, [selectedGroup?.name, students, teamProjectWeek, weekItems]); const selectedStudent = studentNodes.find((student) => student.id === selectedStudentId) ?? null; const detailItems = teamProjectWeek ? weekItems.filter((item) => item.team?.name === selectedGroup?.name) : weekItems.filter((item) => item.student.id === selectedStudent?.id); const selectedItem = detailItems.find((item) => item.id === selectedId) ?? null; useEffect(() => { if (selectedCourseId && courseNodes.some((course) => course.id === selectedCourseId)) return; setSelectedCourseId(courseNodes[0]?.id ?? null); setSelectedWeekId(null); setSelectedGroupName(null); setSelectedStudentId(null); setSelectedId(null); }, [courseNodes, selectedCourseId]); useEffect(() => { if (selectedWeekId && weekNodes.some((week) => week.id === selectedWeekId)) return; setSelectedWeekId(null); setSelectedGroupName(null); setSelectedId(null); setSelectedStudentId(null); }, [selectedWeekId, weekNodes]); useEffect(() => { if (selectedGroupName && groupNodes.some((group) => group.name === selectedGroupName)) return; setSelectedGroupName(null); setSelectedStudentId(null); setSelectedId(null); }, [groupNodes, selectedGroupName]); useEffect(() => { if (selectedStudentId && studentNodes.some((student) => student.id === selectedStudentId)) return; setSelectedStudentId(null); setSelectedId(null); }, [selectedStudentId, studentNodes, weekItems]); useEffect(() => { if (selectedId && detailItems.some((item) => item.id === selectedId)) return; setSelectedId(null); }, [detailItems, selectedId]); const handleSaveScore = async (item: InstructorSubmissionItem, score: number) => { const result = await saveInstructorSubmissionScore(session.accessToken, { studentId: item.student.id, weekId: item.week.id, score, }); setItems((current) => current.map((entry) => ( entry.student.id === item.student.id && entry.week.id === item.week.id ? { ...entry, score: result.score } : entry ))); return result.score; }; return (
} title="강좌" count={courseNodes.length}> {loading ? : error ? : courseNodes.length ? courseNodes.map((course) => { const courseItemCount = allItems.filter((item) => item.course.id === course.id).length; const submittedCount = allItems.filter((item) => item.course.id === course.id && item.status === 'SUBMITTED').length; return ( ); }) : } } title="주차" count={weekNodes.length}> {loading ? : !selectedCourse ? : weekNodes.length ? weekNodes.map((week) => ( )) : } } title="그룹" count={groupNodes.length}> {!selectedWeek ? : groupNodes.length ? groupNodes.map((group) => ( )) : } : } title={teamProjectWeek ? '팀원' : '수강생'} count={studentNodes.length}> {!selectedGroup ? : studentNodes.length ? studentNodes.map((student) => ( )) : }
setPatchModal({ title, subtitle, files, activePath, repository })} />
{patchModal ? createPortal( setPatchModal((current) => current ? { ...current, activePath } : current)} onClose={() => setPatchModal(null)} />, document.body, ) : null}
); } function SubmissionDetail({ item, items, emptyMessage, teamMember, onSelect, onSaveScore, onOpenPatch, }: { item: InstructorSubmissionItem | null; items: InstructorSubmissionItem[]; emptyMessage: string; teamMember: { id: string; name: string; email: string } | null; onSelect: (id: string) => void; onSaveScore: (item: InstructorSubmissionItem, score: number) => Promise; onOpenPatch: ( title: string, subtitle: string, files: CommitFile[], activePath: string, repository: NonNullable, ) => void; }) { const [detailTab, setDetailTab] = useState<'final' | 'history'>('final'); const [scoreDraft, setScoreDraft] = useState('0'); const [scoreSaving, setScoreSaving] = useState(false); const [scoreMessage, setScoreMessage] = useState<{ tone: 'success' | 'error'; text: string } | null>(null); const { showToast } = useToast(); useEffect(() => { setScoreDraft(String(item?.score ?? 0)); setScoreMessage(null); }, [item?.id, item?.score]); const handleScoreSave = async () => { if (!item || item.team) return; const score = Number(scoreDraft); if (!Number.isInteger(score) || score < 1 || score > 100) { setScoreMessage({ tone: 'error', text: '1~100점으로 입력해 주세요.' }); showToast({ title: '점수 저장 실패', message: '총점은 1~100점으로 입력해 주세요.', variant: 'error', }); return; } setScoreSaving(true); setScoreMessage(null); try { const savedScore = await onSaveScore(item, score); setScoreDraft(String(savedScore)); setScoreMessage({ tone: 'success', text: '점수가 저장되었습니다.' }); showToast({ title: '점수가 저장되었습니다.', message: `${item.student.name} · ${item.week.week}주차 과정 총점 ${savedScore}점`, variant: 'success', }); } catch (error) { const message = error instanceof Error ? error.message : '점수를 저장하지 못했습니다.'; setScoreMessage({ tone: 'error', text: message, }); showToast({ title: '점수 저장 실패', message, variant: 'error', }); } finally { setScoreSaving(false); } }; if (!item) { return
{emptyMessage}
; } const teamProject = Boolean(item.team); const visibleCommits = teamProject && teamMember ? item.commits.filter((commit) => commit.authorEmail.trim().toLowerCase() === teamMember.email.trim().toLowerCase()) : item.commits; const ownerLabel = item.team?.name ?? item.student.name; const ownerDescription = item.team ? `${item.team.members.length}명 · ${teamMember ? `${teamMember.name} 기여 내역` : '공동 저장소'}` : item.student.email; const openPatch = (title: string, subtitle: string, files: CommitFile[], activePath: string) => { if (!item.repository) return; onOpenPatch(title, subtitle, files, activePath, item.repository); }; return (

{item.course.title}

{ownerLabel} · {item.module.name}

{item.week.week}주차 {item.week.title} · {ownerDescription}

{item.status === 'SUBMITTED' ? : item.status === 'PENDING' ? : } {statusLabel[item.status]}

{teamProject ? '팀 모듈' : '제출 내역'}

{items.map((entry) => ( ))}
{!teamProject ?

과정 총점

{ const digits = event.target.value.replace(/\D/g, ''); if (!digits) { setScoreDraft(''); return; } setScoreDraft(String(Math.min(100, Number(digits)))); }} onBlur={() => { if (!scoreDraft) setScoreDraft('0'); }} className="h-10 min-w-0 flex-1 rounded-md border border-[#cbd5e1] bg-white px-3 text-right text-[14px] font-black text-[#111827] outline-none transition-colors focus:border-[#4f46e5]" aria-label="총점" /> 점
{scoreMessage ? (

{scoreMessage.text}

) : null}
: null}

최종 {item.finalFiles.length}개 파일 · 제출 {item.commitCount}회

{detailTab === 'final' ? ( item.finalFiles.length ? (

원본 대비 최종 변경사항

마지막 제출 기준 · {formatDate(item.submittedAt)}

{item.finalFiles.length}개 파일
{item.finalFiles.map((file) => ( ))}
) : (
{item.status === 'NOT_INSTALLED' ? '아직 모듈 저장소가 생성되지 않았습니다.' : '최종 변경 파일이 없습니다.'}
) ) : visibleCommits.length ? (
{visibleCommits.map((commit) => (

{commit.message}

{teamProject ? `${commit.author} · ` : ''}{formatDate(commit.date)}

{commit.shortHash}
{commit.files.length ? commit.files.map((file) => ( )) : (

파일 목록 없음

)}
))}
) : (
{item.status === 'NOT_INSTALLED' ? '아직 모듈 저장소가 생성되지 않았습니다.' : teamMember ? '선택한 팀원의 반영 커밋이 없습니다.' : '아직 제출된 커밋이 없습니다.'}
)}
); } function SelectionPanel({ icon, title, count, children }: { icon: React.ReactNode; title: string; count: number; children: React.ReactNode }) { return (
{icon}{title} {count}개
{children}
); } function selectionButtonClass(active: boolean) { return [ 'relative flex min-h-[66px] w-full cursor-pointer items-center gap-3 overflow-hidden rounded-lg border py-2 pl-4 pr-3 text-left transition-[background-color,border-color,box-shadow] before:absolute before:left-0 before:top-0 before:h-full before:w-1.5 before:content-[""]', active ? 'border-[#c7d2fe] bg-[#eef2ff] shadow-[0_8px_20px_rgba(79,70,229,0.10)] before:bg-[#2563eb]' : 'border-[#cbd5e1] bg-white before:bg-transparent hover:border-[#c7d2fe] hover:bg-[#f8faff] hover:shadow-[0_8px_20px_rgba(79,70,229,0.10)] hover:before:bg-[#bfdbfe]', ].join(' '); } function PanelLoadingState({ message }: { message: string }) { return (
); } function PatchModal({ modal, accessToken, onChangeActivePath, onClose, }: { modal: NonNullable; accessToken: string; onChangeActivePath: (path: string) => void; onClose: () => void; }) { const activeFile = modal.files.find((file) => file.path === modal.activePath) ?? modal.files[0] ?? null; const visibleFiles = modal.files.filter((file) => !isIgnoredSubmissionFile(file.path)); const visibleActiveFile = visibleFiles.find((file) => file.path === activeFile?.path) ?? visibleFiles[0] ?? null; const treeNodes = buildFileTree(visibleFiles); const [content, setContent] = useState({ path: '', original: '', modified: '', originalAvailable: false, modifiedAvailable: false, }); const [contentLoading, setContentLoading] = useState(false); const [contentError, setContentError] = useState(null); useEffect(() => { if (!visibleActiveFile) return; let cancelled = false; setContentLoading(true); setContentError(null); getInstructorSubmissionFile(accessToken, { owner: modal.repository.owner, repoName: modal.repository.name, path: visibleActiveFile.path, baseRef: visibleActiveFile.originalRef, headRef: visibleActiveFile.modifiedRef, }) .then((result) => { if (!cancelled) setContent({ ...result, path: visibleActiveFile.path }); }) .catch((reason: Error) => { if (cancelled) return; setContent({ path: visibleActiveFile.path, original: '', modified: visibleActiveFile.patch, originalAvailable: false, modifiedAvailable: Boolean(visibleActiveFile.patch), }); setContentError(reason.message); }) .finally(() => { if (!cancelled) setContentLoading(false); }); return () => { cancelled = true; }; }, [accessToken, modal.repository.name, modal.repository.owner, visibleActiveFile]); const isActiveFileContentLoaded = content.path === visibleActiveFile?.path; return (

{modal.title}

{modal.subtitle}

{visibleActiveFile?.path ?? '파일 없음'}
{visibleActiveFile ? {visibleActiveFile.status} : null}
{visibleActiveFile && isActiveFileContentLoaded ? ( { instance.editor.defineTheme('autonomy-diff-light', { base: 'vs', inherit: true, rules: [], colors: { 'diffEditor.insertedTextBackground': '#86efac', 'diffEditor.insertedLineBackground': '#dcfce7', 'diffEditor.removedTextBackground': '#fda4af', 'diffEditor.removedLineBackground': '#ffe4e6', 'diffEditorGutter.insertedLineBackground': '#4ade80', 'diffEditorGutter.removedLineBackground': '#fb7185', }, }); }} theme="autonomy-diff-light" loading={
편집기를 불러오는 중입니다.
} options={{ readOnly: true, originalEditable: false, renderSideBySide: true, diffAlgorithm: 'advanced', ignoreTrimWhitespace: false, renderIndicators: true, minimap: { enabled: false }, scrollBeyondLastLine: false, fontSize: 12, lineHeight: 19, wordWrap: 'off', diffWordWrap: 'off', renderOverviewRuler: false, hideUnchangedRegions: { enabled: false }, }} /> ) :
{visibleActiveFile ? '파일 내용을 불러오는 중입니다.' : '표시할 파일이 없습니다.'}
}
); } function isIgnoredSubmissionFile(path: string) { return path === '.gitignore' || path.endsWith('/.gitignore') || path === '.autonomy-studio-git.json'; } type FileTreeNodeModel = { name: string; path: string; kind: 'folder' | 'file'; file?: CommitFile; children: FileTreeNodeModel[]; }; function buildFileTree(files: CommitFile[]): FileTreeNodeModel[] { const roots: FileTreeNodeModel[] = []; const nodeMap = new Map(); files.forEach((file) => { const parts = file.path.split('/').filter(Boolean); parts.forEach((part, index) => { const path = parts.slice(0, index + 1).join('/'); const isFile = index === parts.length - 1; if (!nodeMap.has(path)) { const node: FileTreeNodeModel = { name: part, path, kind: isFile ? 'file' : 'folder', file: isFile ? file : undefined, children: [], }; nodeMap.set(path, node); const parentPath = parts.slice(0, index).join('/'); const parent = parentPath ? nodeMap.get(parentPath) : null; if (parent) parent.children.push(node); else roots.push(node); } }); }); return roots; } function FileTreeNode({ node, activePath, onSelect, depth = 0, }: { node: FileTreeNodeModel; activePath: string; onSelect: (path: string) => void; depth?: number; }) { const active = node.kind === 'file' && node.path === activePath; const [expanded, setExpanded] = useState(true); if (node.kind === 'folder') { return (
{expanded && node.children.map((child) => ( ))}
); } return ( ); } function resolveMonacoLanguage(path: string) { const extension = path.split('.').pop()?.toLowerCase(); if (extension === 'py') return 'python'; if (['ts', 'tsx'].includes(extension ?? '')) return 'typescript'; if (['js', 'jsx', 'mjs', 'cjs'].includes(extension ?? '')) return 'javascript'; if (['cpp', 'cc', 'cxx', 'hpp', 'hh', 'h'].includes(extension ?? '')) return 'cpp'; if (['yaml', 'yml'].includes(extension ?? '')) return 'yaml'; if (extension === 'json') return 'json'; if (['xml', 'urdf', 'sdf', 'xacro'].includes(extension ?? '')) return 'xml'; if (extension === 'md') return 'markdown'; if (extension === 'sh') return 'shell'; return 'plaintext'; } function EmptyState({ message }: { message: string }) { return
{message}
; } function formatDate(value: string | null) { if (!value) return '-'; const date = new Date(value); if (Number.isNaN(date.getTime())) return value; return date.toLocaleString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false, }); }