Files
lms-adm-fe/src/pages/submissions/SubmissionsPage.tsx
T

1001 lines
45 KiB
TypeScript

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<InstructorSubmissionItem['repository']>;
} | 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<InstructorSubmissionItem['status'], string> = {
SUBMITTED: '제출완료',
PENDING: '미제출',
NOT_INSTALLED: '미설치',
};
const statusTone: Record<InstructorSubmissionItem['status'], string> = {
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<InstructorSubmissionItem[]>([]);
const [teamItems, setTeamItems] = useState<InstructorSubmissionItem[]>([]);
const [students, setStudents] = useState<Array<{ id: string; name: string; email: string }>>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedCourseId, setSelectedCourseId] = useState<string | null>(null);
const [selectedWeekId, setSelectedWeekId] = useState<string | null>(null);
const [selectedGroupName, setSelectedGroupName] = useState<string | null>(null);
const [selectedStudentId, setSelectedStudentId] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [patchModal, setPatchModal] = useState<PatchModalState>(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<string, {
id: string;
week: number;
title: string;
isTeamProject: boolean;
total: number;
submitted: number;
pending: number;
notInstalled: number;
}>();
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<string, { name: string; total: number; submitted: number; pending: number; notInstalled: number }>();
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<SubmissionSelectionMember[]>(() => {
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 (
<div className="mx-auto max-w-[1320px]">
<PageHeader route="submissions" />
<section className="grid gap-4">
<div className="grid gap-3 rounded-lg border border-[#c7d2e0] bg-white p-3 xl:grid-cols-4">
<SelectionPanel icon={<BookOpen size={15} />} title="강좌" count={courseNodes.length}>
{loading ? <PanelLoadingState message="강좌를 불러오는 중입니다." /> : error ? <EmptyState message={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 (
<button
key={course.id}
type="button"
onClick={() => {
setSelectedCourseId(course.id);
setSelectedWeekId(null);
setSelectedGroupName(null);
setSelectedStudentId(null);
setSelectedId(null);
}}
className={selectionButtonClass(selectedCourse?.id === course.id)}
>
<span className="min-w-0">
<span className="block truncate text-[13px] font-black text-[#111827]">{course.title}</span>
<span className="mt-1 block text-[11px] font-bold text-[#64748b]">제출 {submittedCount}/{courseItemCount}</span>
</span>
</button>
);
}) : <EmptyState message="조회된 강좌가 없습니다." />}
</SelectionPanel>
<SelectionPanel icon={<CalendarDays size={15} />} title="주차" count={weekNodes.length}>
{loading ? <PanelLoadingState message="주차를 불러오는 중입니다." /> : !selectedCourse ? <EmptyState message="강좌를 선택하세요." /> : weekNodes.length ? weekNodes.map((week) => (
<button
key={week.id}
type="button"
onClick={() => {
setSelectedWeekId(week.id);
setSelectedGroupName(null);
setSelectedStudentId(null);
setSelectedId(null);
}}
className={selectionButtonClass(selectedWeek?.id === week.id)}
>
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-md bg-[#eef2ff] text-[12px] font-black text-[#4f46e5]">{week.week}</span>
<span className="min-w-0">
<span className="block truncate text-[13px] font-black text-[#111827]">{week.title}</span>
<span className="mt-1 block text-[11px] font-bold text-[#64748b]">
{week.isTeamProject ? '팀 프로젝트 · ' : ''}제출 {week.submitted}/{week.total} · 미제출 {week.pending}
</span>
</span>
</button>
)) : <EmptyState message="조회된 주차가 없습니다." />}
</SelectionPanel>
<SelectionPanel icon={<UsersRound size={15} />} title="그룹" count={groupNodes.length}>
{!selectedWeek ? <EmptyState message="주차를 선택하세요." /> : groupNodes.length ? groupNodes.map((group) => (
<button
key={group.name}
type="button"
onClick={() => {
setSelectedGroupName(group.name);
setSelectedStudentId(null);
setSelectedId(null);
}}
className={selectionButtonClass(selectedGroup?.name === group.name)}
>
<span className="min-w-0">
<span className="block truncate text-[13px] font-black text-[#111827]">{group.name}</span>
<span className="mt-1 block text-[11px] font-bold text-[#64748b]">제출 {group.submitted}/{group.total} · 미제출 {group.pending} · 미설치 {group.notInstalled}</span>
</span>
</button>
)) : <EmptyState message="조회된 그룹이 없습니다." />}
</SelectionPanel>
<SelectionPanel icon={teamProjectWeek ? <UsersRound size={15} /> : <UserRound size={15} />} title={teamProjectWeek ? '팀원' : '수강생'} count={studentNodes.length}>
{!selectedGroup ? <EmptyState message="그룹을 선택하세요." /> : studentNodes.length ? studentNodes.map((student) => (
<button
key={student.id}
type="button"
onClick={() => {
setSelectedStudentId(student.id);
const firstItem = teamProjectWeek
? weekItems.find((item) => item.team?.name === selectedGroup?.name)
: weekItems.find((item) => item.student.id === student.id);
setSelectedId(firstItem?.id ?? null);
}}
className={selectionButtonClass(selectedStudent?.id === student.id)}
>
<span className="min-w-0">
<span className="block truncate text-[13px] font-black text-[#111827]">{student.name}</span>
<span className="mt-1 block truncate text-[11px] font-bold text-[#64748b]">{student.email}</span>
<span className="mt-1 block text-[11px] font-bold text-[#64748b]">
{teamProjectWeek
? `${student.commitCount ?? 0}개 커밋 · ${formatDate(student.lastCommittedAt ?? null)}`
: `제출 ${student.submitted}/${student.total} · 미제출 ${student.pending}`}
</span>
</span>
</button>
)) : <EmptyState message="조회된 수강생이 없습니다." />}
</SelectionPanel>
</div>
<section className="overflow-hidden rounded-lg border border-[#c7d2e0] bg-white">
<SubmissionDetail
item={selectedItem}
items={detailItems}
emptyMessage={teamProjectWeek
? selectedGroup ? '팀 제출 내역이 없습니다.' : '그룹을 선택하세요.'
: selectedStudent ? '제출 내역이 없습니다.' : '수강생을 선택하세요.'}
teamMember={teamProjectWeek && selectedStudent ? {
id: selectedStudent.id,
name: selectedStudent.name,
email: selectedStudent.email,
} : null}
onSelect={setSelectedId}
onSaveScore={handleSaveScore}
onOpenPatch={(title, subtitle, files, activePath, repository) => setPatchModal({ title, subtitle, files, activePath, repository })}
/>
</section>
</section>
{patchModal ? createPortal(
<PatchModal
modal={patchModal}
accessToken={session.accessToken}
onChangeActivePath={(activePath) => setPatchModal((current) => current ? { ...current, activePath } : current)}
onClose={() => setPatchModal(null)}
/>,
document.body,
) : null}
</div>
);
}
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<number>;
onOpenPatch: (
title: string,
subtitle: string,
files: CommitFile[],
activePath: string,
repository: NonNullable<InstructorSubmissionItem['repository']>,
) => 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 <div className="grid min-h-[420px] place-items-center text-[13px] font-bold text-[#94a3b8]">{emptyMessage}</div>;
}
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 (
<div className="min-w-0">
<div className="border-b border-[#cbd5e1] px-5 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-[12px] font-black text-[#4f46e5]">{item.course.title}</p>
<h2 className="mt-1 truncate text-[17px] font-black text-[#111827]">{ownerLabel} · {item.module.name}</h2>
<p className="mt-1 text-[12px] font-bold text-[#64748b]">{item.week.week}주차 {item.week.title} · {ownerDescription}</p>
</div>
<span className={`inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-[12px] font-black ${statusTone[item.status]}`}>
{item.status === 'SUBMITTED' ? <CheckCircle2 size={14} /> : item.status === 'PENDING' ? <Clock3 size={14} /> : <XCircle size={14} />}
{statusLabel[item.status]}
</span>
</div>
</div>
<div className="grid h-[min(760px,calc(100vh-260px))] min-h-[420px] overflow-hidden lg:grid-cols-[280px_minmax(0,1fr)]">
<div className="flex min-h-0 flex-col border-r border-[#cbd5e1] p-3">
<p className="mb-2 px-1 text-[11px] font-black text-[#64748b]">{teamProject ? '팀 모듈' : '제출 내역'}</p>
<div className="min-h-0 flex-1 overflow-y-auto">
{items.map((entry) => (
<button
key={entry.id}
type="button"
onClick={() => onSelect(entry.id)}
className={`${selectionButtonClass(entry.id === item.id)} mb-2 min-h-[62px]`}
>
<span className="min-w-0 flex-1">
<span className="flex items-start justify-between gap-2">
<span className="min-w-0 truncate text-[12px] font-black text-[#111827]">{entry.module.name}</span>
<span className={`shrink-0 rounded px-1.5 py-0.5 text-[10px] font-black ${statusTone[entry.status]}`}>{statusLabel[entry.status]}</span>
</span>
<span className="mt-1 block text-[11px] font-bold text-[#64748b]">{entry.commitCount}개 커밋 · {formatDate(entry.submittedAt)}</span>
</span>
</button>
))}
</div>
{!teamProject ? <section className="mt-3 rounded-lg border border-[#d1d9e6] bg-[#f8fafc] p-3">
<div className="flex items-center justify-between gap-3">
<p className="text-[12px] font-black text-[#334155]">과정 총점</p>
</div>
<div className="mt-2 flex items-center gap-2">
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
maxLength={3}
value={scoreDraft}
onChange={(event) => {
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="총점"
/>
<span className="text-[12px] font-black text-[#64748b]">점</span>
<button
type="button"
onClick={handleScoreSave}
disabled={scoreSaving}
className="inline-flex h-10 cursor-pointer items-center gap-1.5 rounded-md bg-[#4f46e5] px-3 text-[12px] font-black text-white transition-colors hover:bg-[#4338ca] disabled:cursor-wait disabled:bg-[#a5b4fc]"
>
{scoreSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
저장
</button>
</div>
{scoreMessage ? (
<p className={[
'mt-2 text-[11px] font-bold',
scoreMessage.tone === 'success' ? 'text-[#15803d]' : 'text-[#dc2626]',
].join(' ')}
>
{scoreMessage.text}
</p>
) : null}
</section> : null}
</div>
<div className="min-w-0 overflow-y-auto px-5 py-4">
<div className="mb-4 flex items-center justify-between gap-3">
<div className="inline-flex rounded-lg bg-[#eef2f7] p-1">
<button
type="button"
onClick={() => setDetailTab('final')}
className={[
'h-8 cursor-pointer rounded-md px-3 text-[12px] font-black transition-colors',
detailTab === 'final' ? 'bg-white text-[#4f46e5] shadow-sm' : 'text-[#64748b] hover:text-[#111827]',
].join(' ')}
>
최종 변경사항
</button>
<button
type="button"
onClick={() => setDetailTab('history')}
className={[
'h-8 cursor-pointer rounded-md px-3 text-[12px] font-black transition-colors',
detailTab === 'history' ? 'bg-white text-[#4f46e5] shadow-sm' : 'text-[#64748b] hover:text-[#111827]',
].join(' ')}
>
제출 이력
</button>
</div>
<p className="text-[11px] font-bold text-[#94a3b8]">
최종 {item.finalFiles.length}개 파일 · 제출 {item.commitCount}회
</p>
</div>
{detailTab === 'final' ? (
item.finalFiles.length ? (
<article className="min-w-0 overflow-hidden rounded-lg border border-[#d1d9e6] transition-[border-color,box-shadow] hover:border-[#c7d2fe] hover:shadow-[0_10px_26px_rgba(15,23,42,0.10)]">
<div className="border-b border-[#cbd5e1] bg-[#f8fafc] px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-[13px] font-black text-[#111827]">원본 대비 최종 변경사항</p>
<p className="mt-1 text-[12px] font-semibold text-[#64748b]">마지막 제출 기준 · {formatDate(item.submittedAt)}</p>
</div>
<span className="shrink-0 rounded bg-[#eef2ff] px-2.5 py-1 text-[11px] font-black text-[#4f46e5]">{item.finalFiles.length}개 파일</span>
</div>
</div>
<div className="divide-y divide-[#edf2f7]">
{item.finalFiles.map((file) => (
<button
key={`${item.id}-final-${file.path}`}
type="button"
onClick={() => openPatch(
`${item.module.name} · 최종 변경사항`,
`${ownerLabel} · ${formatDate(item.submittedAt)}`,
item.finalFiles,
file.path,
)}
className="group flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-3 text-left transition-[background-color,color] hover:bg-[#e2e8f0]"
>
<span className="inline-flex min-w-0 items-center gap-2">
<FileCode2 size={14} className="shrink-0 text-[#64748b]" />
<span className="truncate font-[NanumSquare] text-[12px] font-bold text-[#334155]">{file.path}</span>
</span>
<span className="shrink-0 rounded bg-[#f1f5f9] px-2 py-1 text-[10px] font-black uppercase text-[#64748b] transition-colors group-hover:bg-white">{file.status}</span>
</button>
))}
</div>
</article>
) : (
<div className="grid h-[360px] place-items-center text-center text-[13px] font-bold text-[#94a3b8]">
{item.status === 'NOT_INSTALLED' ? '아직 모듈 저장소가 생성되지 않았습니다.' : '최종 변경 파일이 없습니다.'}
</div>
)
) : visibleCommits.length ? (
<div className="grid gap-4">
{visibleCommits.map((commit) => (
<article key={commit.hash} className="min-w-0 overflow-hidden rounded-lg border border-[#d1d9e6] transition-[border-color,box-shadow] hover:border-[#c7d2fe] hover:shadow-[0_10px_26px_rgba(15,23,42,0.10)]">
<div className="border-b border-[#cbd5e1] bg-[#f8fafc] px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-[13px] font-black text-[#111827]">{commit.message}</p>
<p className="mt-1 text-[12px] font-semibold text-[#64748b]">{teamProject ? `${commit.author} · ` : ''}{formatDate(commit.date)}</p>
</div>
<span className="inline-flex items-center gap-1.5 rounded-md bg-white px-2.5 py-1 font-mono text-[11px] font-black text-[#4f46e5]">
<GitCommitHorizontal size={13} />
{commit.shortHash}
</span>
</div>
</div>
<div className="divide-y divide-[#edf2f7]">
{commit.files.length ? commit.files.map((file) => (
<button
key={`${commit.hash}-${file.path}`}
type="button"
onClick={() => openPatch(
`${item.module.name} · ${commit.shortHash}`,
`${ownerLabel} · ${formatDate(commit.date)}`,
commit.files,
file.path,
)}
className="group flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-3 text-left transition-[background-color,color] hover:bg-[#e2e8f0]"
>
<span className="inline-flex min-w-0 items-center gap-2">
<FileCode2 size={14} className="shrink-0 text-[#64748b]" />
<span className="truncate font-[NanumSquare] text-[12px] font-bold text-[#334155]">{file.path}</span>
</span>
<span className="shrink-0 rounded bg-[#f1f5f9] px-2 py-1 text-[10px] font-black uppercase text-[#64748b] transition-colors group-hover:bg-white">{file.status}</span>
</button>
)) : (
<p className="px-4 py-4 text-[12px] font-bold text-[#94a3b8]">파일 목록 없음</p>
)}
</div>
</article>
))}
</div>
) : (
<div className="grid h-[360px] place-items-center text-center text-[13px] font-bold text-[#94a3b8]">
{item.status === 'NOT_INSTALLED'
? '아직 모듈 저장소가 생성되지 않았습니다.'
: teamMember ? '선택한 팀원의 반영 커밋이 없습니다.' : '아직 제출된 커밋이 없습니다.'}
</div>
)}
</div>
</div>
</div>
);
}
function SelectionPanel({ icon, title, count, children }: { icon: React.ReactNode; title: string; count: number; children: React.ReactNode }) {
return (
<section className="min-w-0 overflow-hidden rounded-lg border border-[#d1d9e6] bg-[#f8fafc]">
<div className="flex h-10 items-center justify-between border-b border-[#cbd5e1] px-3">
<span className="inline-flex items-center gap-2 text-[12px] font-black text-[#475569]">{icon}{title}</span>
<span className="text-[11px] font-black text-[#94a3b8]">{count}개</span>
</div>
<div className="grid max-h-[340px] min-h-[340px] gap-2 overflow-y-auto p-2 content-start">
{children}
</div>
</section>
);
}
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 (
<div className="grid min-h-[180px] place-items-center px-4 text-center text-[13px] font-bold text-[#64748b]">
<span className="inline-flex items-center gap-2">
<Loader2 className="animate-spin" size={17} aria-hidden="true" />
{message}
</span>
</div>
);
}
function PatchModal({
modal,
accessToken,
onChangeActivePath,
onClose,
}: {
modal: NonNullable<PatchModalState>;
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<string | null>(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 (
<div className="fixed inset-0 z-[80] grid place-items-center bg-[#0f172a]/34 px-6 py-6 backdrop-blur-[2px]">
<section className="grid h-[min(780px,calc(100vh-48px))] w-full max-w-[1320px] grid-rows-[78px_minmax(0,1fr)] overflow-hidden rounded-[10px] border border-[#cfd8e6] bg-[#fbfdff] shadow-[0_34px_100px_rgba(15,23,42,0.30)]">
<header className="flex items-center justify-between border-b border-[#dfe6f1] bg-[#fbfdff] px-5">
<div className="flex min-w-0 items-center gap-3">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-md bg-[#eef2ff] text-[#4f46e5] shadow-[inset_0_0_0_1px_rgba(79,70,229,0.08)]">
<FileCode2 size={18} />
</span>
<div className="min-w-0">
<h2 className="truncate text-[16px] font-black text-[#111827]">{modal.title}</h2>
<p className="mt-1 truncate text-[12px] font-bold text-[#64748b]">{modal.subtitle}</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="grid h-9 w-9 cursor-pointer place-items-center rounded-md border border-[#dbe3ef] bg-white text-[#475569] shadow-sm transition-[background-color,border-color,color] hover:border-[#c7d2fe] hover:bg-[#f8faff] hover:text-[#111827]"
aria-label="닫기"
>
<X size={17} />
</button>
</header>
<div className="grid min-h-0 grid-cols-[300px_minmax(0,1fr)]">
<aside className="grid min-h-0 grid-rows-[38px_minmax(0,1fr)] border-r border-[#d9e0eb] bg-[#f3f3f3]">
<div className="flex items-center justify-between border-b border-[#d9e0eb] px-4">
<span className="text-[11px] font-black tracking-[0.08em] text-[#4b5563]">EXPLORER</span>
<span className="text-[11px] font-bold text-[#7b8797]">{visibleFiles.length}</span>
</div>
<div className="min-h-0 overflow-auto py-2">
{treeNodes.map((node) => (
<FileTreeNode
key={node.path}
node={node}
activePath={visibleActiveFile?.path ?? ''}
onSelect={onChangeActivePath}
/>
))}
</div>
</aside>
<main className="grid min-h-0 grid-rows-[38px_minmax(0,1fr)] bg-white">
<div className="flex items-center justify-between gap-3 border-b border-[#d9e0eb] px-4">
<div className="flex min-w-0 items-center gap-2.5">
<FileCode2 size={15} className="shrink-0 text-[#64748b]" />
<span
className="min-w-0 truncate font-mono text-[12px] font-bold text-[#1f2937]"
title={visibleActiveFile?.path ?? undefined}
>
{visibleActiveFile?.path ?? '파일 없음'}
</span>
</div>
{visibleActiveFile ? <span className="text-[10px] font-black uppercase text-[#4f46e5]">{visibleActiveFile.status}</span> : null}
</div>
<div className="relative min-h-0">
{visibleActiveFile && isActiveFileContentLoaded ? (
<DiffEditor
key={`${visibleActiveFile.path}:${visibleActiveFile.originalRef ?? 'empty'}:${visibleActiveFile.modifiedRef ?? 'empty'}`}
height="100%"
language={resolveMonacoLanguage(visibleActiveFile.path)}
original={content.original}
modified={content.modified}
originalModelPath={`original://${visibleActiveFile.originalRef ?? 'empty'}/${visibleActiveFile.path}`}
modifiedModelPath={`modified://${visibleActiveFile.modifiedRef ?? 'empty'}/${visibleActiveFile.path}`}
keepCurrentOriginalModel
keepCurrentModifiedModel
beforeMount={(instance) => {
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={<div className="grid h-full place-items-center text-[12px] font-bold text-[#94a3b8]">편집기를 불러오는 중입니다.</div>}
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 },
}}
/>
) : <div className="grid h-full place-items-center text-[12px] font-bold text-[#94a3b8]">{visibleActiveFile ? '파일 내용을 불러오는 중입니다.' : '표시할 파일이 없습니다.'}</div>}
<span className="pointer-events-none absolute inset-y-0 left-1/2 z-10 w-px bg-[#aebdce]" aria-hidden="true" />
{contentLoading ? <div className="absolute inset-0 grid place-items-center bg-white/72 text-[12px] font-bold text-[#64748b]">파일 내용을 불러오는 중입니다.</div> : null}
{contentError ? <p className="absolute bottom-3 left-3 rounded bg-[#fff7ed] px-2.5 py-1.5 text-[11px] font-bold text-[#c2410c]">{contentError}</p> : null}
</div>
</main>
</div>
</section>
</div>
);
}
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<string, FileTreeNodeModel>();
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 (
<div>
<button
type="button"
onClick={() => setExpanded((current) => !current)}
className="flex h-7 w-full cursor-pointer items-center gap-1.5 pr-2 text-left text-[12px] font-bold text-[#4b5563] hover:bg-[#e5e5e5]"
style={{ paddingLeft: depth * 13 + 5 }}
title={node.path}
>
{expanded ? <ChevronDown size={14} className="shrink-0" /> : <ChevronRight size={14} className="shrink-0" />}
<FolderOpen size={14} className="shrink-0 text-[#718096]" />
<span className="truncate">{node.name}</span>
</button>
{expanded && node.children.map((child) => (
<FileTreeNode key={child.path} node={child} activePath={activePath} onSelect={onSelect} depth={depth + 1} />
))}
</div>
);
}
return (
<button
type="button"
onClick={() => onSelect(node.path)}
className={[
'grid h-7 w-full cursor-pointer grid-cols-[18px_minmax(0,1fr)_auto] items-center gap-1.5 py-0.5 pr-2 text-left transition-colors',
active ? 'bg-[#dbeafe] text-[#1d4ed8]' : 'text-[#374151] hover:bg-[#e5e5e5]',
].join(' ')}
style={{ paddingLeft: depth * 13 + 21 }}
title={node.path}
>
<FileCode2 size={13} className="shrink-0 text-[#64748b]" />
<span className="min-w-0 truncate font-mono text-[11px] font-medium">{node.name}</span>
{node.file ? <span className="shrink-0 text-[9px] font-bold uppercase text-[#64748b]">{node.file.status}</span> : null}
</button>
);
}
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 <div className="grid min-h-[180px] place-items-center px-4 text-center text-[13px] font-bold text-[#94a3b8]">{message}</div>;
}
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,
});
}