platform/lms-adm-fe release promotion snapshot
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
import { BarChart3 } from 'lucide-react';
|
||||
import { PlaceholderPage } from '../common/PlaceholderPage';
|
||||
|
||||
export function AnalyticsPage() {
|
||||
return <PlaceholderPage icon={BarChart3} route="analytics" items={['평균 진도', '플랫폼 사용률', '에스컬레이션 추이']} />;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { PageHeader } from '../../components/PageHeader';
|
||||
import { pageMeta } from '../../pageMeta';
|
||||
import type { AppRoute } from '../../routes';
|
||||
|
||||
type PlaceholderPageProps = {
|
||||
icon: LucideIcon;
|
||||
route: AppRoute;
|
||||
items: string[];
|
||||
};
|
||||
|
||||
export function PlaceholderPage({ icon: Icon, route, items }: PlaceholderPageProps) {
|
||||
const title = pageMeta[route].title;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1220px]">
|
||||
<PageHeader route={route} />
|
||||
|
||||
<section className="rounded-lg border border-[#e5e7eb] bg-white p-5">
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<div className="grid h-10 w-10 place-items-center rounded-lg bg-[#eef2ff] text-[#4f46e5]">
|
||||
<Icon size={18} aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-[15px] font-black">{title} 준비 영역</h2>
|
||||
<p className="mt-1 text-[12px] font-bold text-[#64748b]">메뉴별 라우팅과 페이지 경계가 분리되어 있습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{items.map((item) => (
|
||||
<article className="rounded-lg border border-[#e5e7eb] bg-[#fbfcfe] p-4" key={item}>
|
||||
<p className="text-[13px] font-black text-[#111827]">{item}</p>
|
||||
<p className="mt-2 text-[12px] font-bold leading-5 text-[#64748b]">후속 API와 상세 UI를 이 페이지 폴더 안에서 확장합니다.</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,348 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Activity, BarChart3, Car, ChevronRight, CircleAlert, Loader2, Users, Wifi, type LucideIcon } from 'lucide-react';
|
||||
import { PageHeader } from '../../components/PageHeader';
|
||||
import { useToast } from '../../components/ToastProvider';
|
||||
import { listInstructorChatThreads, type InstructorChatThread } from '../../lib/chat';
|
||||
import { listInstructorGroups, type InstructorGroup, type InstructorGroupsSummary } from '../../lib/groups';
|
||||
import { listInstructorPlatforms, type InstructorPlatform, type InstructorPlatformsSummary } from '../../lib/platforms';
|
||||
import type { AppRoute } from '../../routes';
|
||||
import type { AuthSession } from '../../types/auth';
|
||||
|
||||
const emptyPlatformSummary: InstructorPlatformsSummary = {
|
||||
totalPlatforms: 0,
|
||||
activePlatforms: 0,
|
||||
standbyPlatforms: 0,
|
||||
errorPlatforms: 0,
|
||||
};
|
||||
|
||||
const emptyGroupSummary: InstructorGroupsSummary = {
|
||||
totalGroups: 0,
|
||||
assignedPlatforms: 0,
|
||||
totalStudents: 0,
|
||||
averageProgress: 0,
|
||||
};
|
||||
|
||||
export function DashboardPage({ session, selectedCourseId, onNavigate, onOpenChatThread }: { session: AuthSession; selectedCourseId: string; onNavigate?: (route: AppRoute) => void; onOpenChatThread?: (threadId: string) => void }) {
|
||||
const { showToast } = useToast();
|
||||
const [platforms, setPlatforms] = useState<InstructorPlatform[]>([]);
|
||||
const [platformSummary, setPlatformSummary] = useState<InstructorPlatformsSummary>(emptyPlatformSummary);
|
||||
const [groups, setGroups] = useState<InstructorGroup[]>([]);
|
||||
const [groupSummary, setGroupSummary] = useState<InstructorGroupsSummary>(emptyGroupSummary);
|
||||
const [threads, setThreads] = useState<InstructorChatThread[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
Promise.allSettled([
|
||||
listInstructorPlatforms(session.accessToken),
|
||||
listInstructorGroups(session.accessToken, selectedCourseId || null),
|
||||
listInstructorChatThreads(session.accessToken),
|
||||
])
|
||||
.then(([platformResult, groupResult, chatResult]) => {
|
||||
if (cancelled) return;
|
||||
|
||||
if (platformResult.status === 'fulfilled') {
|
||||
setPlatforms(platformResult.value.platforms);
|
||||
setPlatformSummary(platformResult.value.summary);
|
||||
} else {
|
||||
setPlatforms([]);
|
||||
setPlatformSummary(emptyPlatformSummary);
|
||||
}
|
||||
|
||||
if (groupResult.status === 'fulfilled') {
|
||||
setGroups(groupResult.value.groups);
|
||||
setGroupSummary(groupResult.value.summary);
|
||||
} else {
|
||||
setGroups([]);
|
||||
setGroupSummary(emptyGroupSummary);
|
||||
}
|
||||
|
||||
if (chatResult.status === 'fulfilled') {
|
||||
setThreads(chatResult.value);
|
||||
} else {
|
||||
setThreads([]);
|
||||
}
|
||||
|
||||
const failed = [platformResult, groupResult].filter((result) => result.status === 'rejected');
|
||||
setError(failed.length > 0 ? '대시보드 일부 데이터를 불러오지 못했습니다.' : null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [session.accessToken, selectedCourseId]);
|
||||
|
||||
const waitingThreads = useMemo(() => threads.filter((thread) => thread.status === 'WAITING_INSTRUCTOR'), [threads]);
|
||||
const activeUsers = useMemo(() => {
|
||||
const groupActiveUsers = groups.reduce((sum, group) => sum + group.activeUsers, 0);
|
||||
if (groupActiveUsers > 0) return groupActiveUsers;
|
||||
return platforms.filter((platform) => platform.user).length;
|
||||
}, [groups, platforms]);
|
||||
|
||||
const metrics = [
|
||||
{ label: '활성 플랫폼', value: `${platformSummary.activePlatforms}/${platformSummary.totalPlatforms}`, icon: Activity, tone: 'bg-[#f0efff] text-[#4f46e5]' },
|
||||
{ label: '총 수강생', value: `${groupSummary.totalStudents}명`, icon: Users, tone: 'bg-[#eff6ff] text-[#2563eb]' },
|
||||
{ label: '현재 접속', value: `${activeUsers}명`, icon: Wifi, tone: 'bg-[#f8fafc] text-[#64748b]' },
|
||||
{ label: '평균 진도', value: `${groupSummary.averageProgress}%`, icon: BarChart3, tone: 'bg-[#fff7ed] text-[#f97316]' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1220px]">
|
||||
<PageHeader route="dashboard" />
|
||||
|
||||
{error ? <p className="mb-4 rounded-md border border-[#fecdd3] bg-[#fff1f2] px-4 py-3 text-[13px] font-bold text-[#e11d48]">{error}</p> : null}
|
||||
|
||||
<section className="mb-4 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
{metrics.map((metric) => (
|
||||
<MetricCard key={metric.label} {...metric} loading={loading} />
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<div className="rounded-lg border border-[#c7d2e0] bg-white">
|
||||
<div className="flex items-start justify-between gap-3 border-b border-[#cbd5e1] px-4 py-4">
|
||||
<div>
|
||||
<h2 className="text-[15px] font-black text-[#111827]">플랫폼 실시간 상태</h2>
|
||||
<p className="mt-1 text-[12px] font-bold text-[#64748b]">하드웨어 모니터링</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-8 w-8 shrink-0 place-items-center rounded-md border border-[#dbe3ef] bg-white text-[#64748b] transition hover:border-[#bfdbfe] hover:bg-[#eff6ff] hover:text-[#2563eb]"
|
||||
onClick={() => onNavigate?.('platforms')}
|
||||
aria-label="플랫폼 관리로 이동"
|
||||
title="플랫폼 관리로 이동"
|
||||
>
|
||||
<ChevronRight size={17} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[490px] space-y-3 overflow-y-auto p-4">
|
||||
{loading ? (
|
||||
<LoadingState message="플랫폼 상태를 불러오는 중입니다." />
|
||||
) : platforms.length > 0 ? (
|
||||
platforms.map((platform) => (
|
||||
<PlatformStatusCard
|
||||
key={platform.id}
|
||||
platform={platform}
|
||||
onEmergencyStop={() => {
|
||||
showToast({
|
||||
title: '긴급 정지 요청이 전송되었습니다.',
|
||||
message: platform.name,
|
||||
variant: 'error',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<EmptyState message="등록된 플랫폼이 없습니다." />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-[#c7d2e0] bg-white">
|
||||
<div className="flex items-start justify-between gap-3 border-b border-[#cbd5e1] px-4 py-4">
|
||||
<div>
|
||||
<h2 className="text-[15px] font-black text-[#111827]">그룹 진행 요약</h2>
|
||||
<p className="mt-1 text-[12px] font-bold text-[#64748b]">그룹 구성, 플랫폼 배정, 진행률 조회</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-8 w-8 shrink-0 place-items-center rounded-md border border-[#dbe3ef] bg-white text-[#64748b] transition hover:border-[#bfdbfe] hover:bg-[#eff6ff] hover:text-[#2563eb]"
|
||||
onClick={() => onNavigate?.('groups')}
|
||||
aria-label="그룹 관리로 이동"
|
||||
title="그룹 관리로 이동"
|
||||
>
|
||||
<ChevronRight size={17} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[490px] space-y-3 overflow-y-auto p-4">
|
||||
{loading ? (
|
||||
<LoadingState message="그룹 진행 정보를 불러오는 중입니다." />
|
||||
) : groups.length > 0 ? (
|
||||
groups.map((group) => (
|
||||
<GroupSummaryCard key={group.id} group={group} />
|
||||
))
|
||||
) : (
|
||||
<EmptyState message="등록된 그룹이 없습니다." />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-4 rounded-lg border border-[#c7d2e0] bg-white">
|
||||
<div className="flex items-start justify-between gap-4 border-b border-[#cbd5e1] px-4 py-4">
|
||||
<div>
|
||||
<h2 className="text-[15px] font-black text-[#111827]">AI 가이드 에스컬레이션</h2>
|
||||
<p className="mt-1 text-[12px] font-bold text-[#64748b]">강사 지원이 필요한 질문</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-[#fff1f2] px-2.5 py-1 text-[13px] font-bold text-[#e11d48]">{waitingThreads.length} 건</span>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-[#e2e8f0]">
|
||||
{loading ? (
|
||||
<LoadingState message="문의 목록을 불러오는 중입니다." />
|
||||
) : waitingThreads.length > 0 ? (
|
||||
waitingThreads.slice(0, 5).map((thread) => (
|
||||
<EscalationRow key={thread.id} thread={thread} onRespond={() => onOpenChatThread?.(thread.id)} />
|
||||
))
|
||||
) : (
|
||||
<EmptyState message="강사 지원이 필요한 질문이 없습니다." />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlatformStatusCard({ platform, onEmergencyStop }: { platform: InstructorPlatform; onEmergencyStop: () => void }) {
|
||||
return (
|
||||
<article className="flex min-h-[72px] items-center gap-3 rounded-lg border border-[#c7d2e0] bg-white px-3 py-3">
|
||||
<div className={`grid h-10 w-10 shrink-0 place-items-center rounded-md ${platform.status === '오류' ? 'bg-[#fff1f2] text-[#e11d48]' : 'bg-[#f0efff] text-[#4f46e5]'}`}>
|
||||
<Car size={18} aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="truncate text-[14px] font-bold text-[#111827]">{platform.name}</h3>
|
||||
<span className={`inline-flex h-6 items-center rounded-full border border-current/20 px-2 text-[11px] font-bold ${statusTone(platform.status)}`}>{platform.status}</span>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-[12px] font-bold text-[#64748b]">
|
||||
{platform.user ? `사용자: ${platform.user} 배터리: ${platform.battery}% 속도: ${platform.speed}` : `배터리: ${platform.battery}%`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{platform.emergencyStop ? (
|
||||
<button className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md bg-[#e11d48] px-3 text-[12px] font-bold text-white hover:bg-[#be123c]" type="button" onClick={onEmergencyStop}>
|
||||
<CircleAlert size={14} aria-hidden="true" />
|
||||
긴급 정지
|
||||
</button>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupSummaryCard({ group }: { group: InstructorGroup }) {
|
||||
return (
|
||||
<article className="rounded-lg border border-[#c7d2e0] bg-white px-4 py-3">
|
||||
<div className="mb-3 flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-[14px] font-black text-[#111827]">{group.name}</h3>
|
||||
<span className="inline-flex h-6 items-center rounded-full border border-[#c7d2e0] bg-[#f8fafc] px-2 text-[11px] font-bold text-[#64748b]">{group.status}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[12px] font-bold text-[#64748b]">{group.members.length}명 | {group.week}</p>
|
||||
</div>
|
||||
<span className={`inline-flex h-7 shrink-0 items-center rounded-full border px-2.5 text-[11px] font-black ${group.platform === '미배정' ? 'border-[#d1d9e6] bg-[#f8fafc] text-[#94a3b8]' : 'border-[#c7d2fe] bg-[#eef2ff] text-[#4f46e5]'}`}>{group.platform}</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 flex items-center justify-between text-[12px] font-bold text-[#475569]">
|
||||
<span>진행률</span>
|
||||
<span className="text-[#4f46e5]">{group.progress}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-[#dbe3ef]">
|
||||
<div className="h-full rounded-full bg-[#4f46e5]" style={{ width: `${group.progress}%` }} />
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-4 text-[11px] font-bold text-[#94a3b8]">
|
||||
<span>
|
||||
완료 <strong className="text-[#64748b]">{group.completedMissions}</strong>
|
||||
</span>
|
||||
<span>
|
||||
활동 <strong className={group.activeUsers > 0 ? 'text-[#4f46e5]' : 'text-[#94a3b8]'}>{group.activeUsers}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function EscalationRow({ thread, onRespond }: { thread: InstructorChatThread; onRespond?: () => void }) {
|
||||
return (
|
||||
<article className="grid gap-3 px-4 py-4 lg:grid-cols-[minmax(150px,180px)_minmax(0,1fr)_auto] lg:items-center">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-md bg-[#fff1f2] text-[#e11d48]">
|
||||
<CircleAlert size={16} aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="truncate text-[14px] font-bold text-[#111827]">{thread.studentName}</h3>
|
||||
<span className="rounded border border-[#d1d9e6] px-1.5 py-0.5 text-[11px] font-bold text-[#475569]">{thread.groupName ?? (thread.week ? `${thread.week}주차` : '미배정')}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[12px] font-medium text-[#94a3b8]">{formatRelativeTime(thread.lastMessageAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="min-w-0 text-[13px] font-semibold leading-6 text-[#111827]">{thread.lastMessage}</p>
|
||||
|
||||
<button
|
||||
className="h-8 cursor-pointer justify-self-start rounded-md border border-[#c7d2fe] bg-[#eef2ff] px-3 text-[12px] font-bold text-[#4f46e5] hover:bg-[#e0e7ff] lg:justify-self-end"
|
||||
type="button"
|
||||
onClick={onRespond}
|
||||
>
|
||||
응답하기
|
||||
</button>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, icon: Icon, tone, loading = false }: { label: string; value: string; icon: LucideIcon; tone: string; loading?: boolean }) {
|
||||
return (
|
||||
<article className="rounded-lg border border-[#c7d2e0] bg-white px-5 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-[12px] font-semibold text-[#64748b]">{label}</p>
|
||||
<p className="mt-2 text-[20px] font-black leading-none text-[#111827]">{loading ? '-' : value}</p>
|
||||
</div>
|
||||
<span className={`grid h-11 w-11 shrink-0 place-items-center rounded-md ${tone}`}>
|
||||
<Icon size={28} aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex h-[180px] items-center justify-center text-[#64748b]">
|
||||
<Loader2 className="mr-2 animate-spin" size={18} aria-hidden="true" />
|
||||
<span className="text-[13px] font-bold">{message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex h-[180px] items-center justify-center px-4 text-center">
|
||||
<p className="text-[13px] font-bold text-[#64748b]">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function statusTone(status: string) {
|
||||
if (status === '활성') return 'bg-[#f0efff] text-[#4f46e5]';
|
||||
if (status === '오류') return 'bg-[#fff1f2] text-[#e11d48]';
|
||||
return 'bg-[#f1f5f9] text-[#64748b]';
|
||||
}
|
||||
|
||||
function formatRelativeTime(value: string | undefined) {
|
||||
if (!value) return '-';
|
||||
const time = new Date(value).getTime();
|
||||
if (Number.isNaN(time)) return '-';
|
||||
|
||||
const diffMinutes = Math.max(0, Math.floor((Date.now() - time) / 60000));
|
||||
if (diffMinutes < 1) return '방금 전';
|
||||
if (diffMinutes < 60) return `${diffMinutes}분 전`;
|
||||
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
if (diffHours < 24) return `${diffHours}시간 전`;
|
||||
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
return `${diffDays}일 전`;
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Activity, BarChart3, Car, Edit3, Loader2, Plus, Search, Trash2, Users, X, type LucideIcon } from 'lucide-react';
|
||||
import { PageHeader } from '../../components/PageHeader';
|
||||
import { PaginationBar } from '../../components/PaginationBar';
|
||||
import { useToast } from '../../components/ToastProvider';
|
||||
import {
|
||||
createInstructorGroup,
|
||||
deleteInstructorGroup,
|
||||
listInstructorGroups,
|
||||
updateInstructorGroup,
|
||||
type GroupStudentOption,
|
||||
type GroupPlatformOption,
|
||||
type InstructorGroup,
|
||||
type InstructorGroupsResponse,
|
||||
type InstructorGroupsSummary,
|
||||
} from '../../lib/groups';
|
||||
import type { AuthSession } from '../../types/auth';
|
||||
|
||||
const emptySummary: InstructorGroupsSummary = {
|
||||
totalGroups: 0,
|
||||
assignedPlatforms: 0,
|
||||
totalStudents: 0,
|
||||
averageProgress: 0,
|
||||
};
|
||||
|
||||
export function GroupsPage({ session, selectedCourseId }: { session: AuthSession; selectedCourseId: string }) {
|
||||
const { showToast } = useToast();
|
||||
const [groups, setGroups] = useState<InstructorGroup[]>([]);
|
||||
const [summary, setSummary] = useState<InstructorGroupsSummary>(emptySummary);
|
||||
const [unassignedStudents, setUnassignedStudents] = useState<GroupStudentOption[]>([]);
|
||||
const [platformOptions, setPlatformOptions] = useState<GroupPlatformOption[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [modalMode, setModalMode] = useState<'create' | 'edit' | null>(null);
|
||||
const [editingGroup, setEditingGroup] = useState<InstructorGroup | null>(null);
|
||||
const [groupName, setGroupName] = useState('');
|
||||
const [selectedPlatform, setSelectedPlatform] = useState('unassigned');
|
||||
const [selectedMemberIds, setSelectedMemberIds] = useState<string[]>([]);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<InstructorGroup | null>(null);
|
||||
const [deletingGroupId, setDeletingGroupId] = useState<string | null>(null);
|
||||
const filteredGroups = useMemo(() => {
|
||||
const keyword = query.trim().toLowerCase();
|
||||
if (!keyword) return groups;
|
||||
return groups.filter((group) =>
|
||||
[group.name, group.platform, ...group.members]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(keyword),
|
||||
);
|
||||
}, [groups, query]);
|
||||
const pagedGroups = useMemo(() => filteredGroups.slice((page - 1) * pageSize, page * pageSize), [filteredGroups, page, pageSize]);
|
||||
const summaryItems = [
|
||||
{ label: '전체 그룹', value: `${summary.totalGroups}개`, icon: Users, tone: 'bg-[#f0efff] text-[#4f46e5]' },
|
||||
{ label: '배정 플랫폼', value: `${summary.assignedPlatforms}대`, icon: Car, tone: 'bg-[#eff6ff] text-[#2563eb]' },
|
||||
{ label: '수강생', value: `${summary.totalStudents}명`, icon: Activity, tone: 'bg-[#fff7ed] text-[#f97316]' },
|
||||
{ label: '평균 진도', value: `${summary.averageProgress}%`, icon: BarChart3, tone: 'bg-[#f8fafc] text-[#64748b]' }
|
||||
];
|
||||
|
||||
const applyGroupsPayload = (payload: InstructorGroupsResponse) => {
|
||||
setError(null);
|
||||
setGroups(payload.groups);
|
||||
setSummary(payload.summary);
|
||||
setUnassignedStudents(payload.unassignedStudents ?? []);
|
||||
setPlatformOptions(payload.platformOptions ?? []);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
listInstructorGroups(session.accessToken, selectedCourseId || null)
|
||||
.then((payload) => {
|
||||
if (cancelled) return;
|
||||
applyGroupsPayload(payload);
|
||||
})
|
||||
.catch((reason: Error) => {
|
||||
if (cancelled) return;
|
||||
setGroups([]);
|
||||
setSummary(emptySummary);
|
||||
setError(reason.message);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [session.accessToken, selectedCourseId]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
const totalPages = Math.max(1, Math.ceil(filteredGroups.length / pageSize));
|
||||
if (page > totalPages) setPage(totalPages);
|
||||
}, [filteredGroups.length, page, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!modalMode && !deleteTarget) return;
|
||||
|
||||
const originalOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = originalOverflow;
|
||||
};
|
||||
}, [deleteTarget, modalMode]);
|
||||
|
||||
const handlePageSizeChange = (nextPageSize: number) => {
|
||||
setPageSize(nextPageSize);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
setModalMode('create');
|
||||
setEditingGroup(null);
|
||||
setGroupName('');
|
||||
setSelectedPlatform('unassigned');
|
||||
setSelectedMemberIds([]);
|
||||
setFormError(null);
|
||||
};
|
||||
|
||||
const openEditModal = (group: InstructorGroup) => {
|
||||
setModalMode('edit');
|
||||
setEditingGroup(group);
|
||||
setGroupName(group.name);
|
||||
setSelectedPlatform(group.instructorPlatformId ?? 'unassigned');
|
||||
setSelectedMemberIds((group.membersDetailed ?? []).map((member) => member.id));
|
||||
setFormError(null);
|
||||
};
|
||||
|
||||
const closeGroupModal = () => {
|
||||
if (saving) return;
|
||||
setModalMode(null);
|
||||
setEditingGroup(null);
|
||||
};
|
||||
|
||||
const availableStudents = useMemo(() => {
|
||||
const currentMembers = editingGroup?.membersDetailed ?? [];
|
||||
const byId = new Map<string, GroupStudentOption>();
|
||||
[...currentMembers, ...unassignedStudents].forEach((student) => byId.set(student.id, student));
|
||||
return [...byId.values()].sort((left, right) => left.name.localeCompare(right.name, 'ko'));
|
||||
}, [editingGroup, unassignedStudents]);
|
||||
|
||||
const availablePlatforms = useMemo(() => {
|
||||
const byId = new Map<string, GroupPlatformOption>();
|
||||
if (editingGroup?.instructorPlatformId && editingGroup.platform !== '미배정') {
|
||||
byId.set(editingGroup.instructorPlatformId, {
|
||||
id: editingGroup.instructorPlatformId,
|
||||
name: editingGroup.platform,
|
||||
});
|
||||
}
|
||||
platformOptions.forEach((platform) => byId.set(platform.id, platform));
|
||||
return [...byId.values()].sort((left, right) => left.name.localeCompare(right.name, 'ko'));
|
||||
}, [editingGroup, platformOptions]);
|
||||
|
||||
const toggleMember = (studentId: string) => {
|
||||
setSelectedMemberIds((current) =>
|
||||
current.includes(studentId)
|
||||
? current.filter((id) => id !== studentId)
|
||||
: [...current, studentId],
|
||||
);
|
||||
};
|
||||
|
||||
const handleSaveGroup = async () => {
|
||||
const name = groupName.trim();
|
||||
if (!name) {
|
||||
setFormError('그룹명을 입력해 주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setFormError(null);
|
||||
try {
|
||||
const input = {
|
||||
courseId: selectedCourseId || null,
|
||||
name,
|
||||
instructorPlatformId: selectedPlatform === 'unassigned' ? null : selectedPlatform,
|
||||
platformName: availablePlatforms.find((platform) => platform.id === selectedPlatform)?.name ?? null,
|
||||
memberIds: selectedMemberIds,
|
||||
};
|
||||
const payload = modalMode === 'edit' && editingGroup
|
||||
? await updateInstructorGroup(session.accessToken, editingGroup.id, input)
|
||||
: await createInstructorGroup(session.accessToken, input);
|
||||
applyGroupsPayload(payload);
|
||||
showToast({
|
||||
title: modalMode === 'edit' ? '그룹이 수정되었습니다.' : '그룹이 추가되었습니다.',
|
||||
message: name,
|
||||
});
|
||||
setModalMode(null);
|
||||
setEditingGroup(null);
|
||||
setPage(1);
|
||||
} catch (reason) {
|
||||
setFormError(reason instanceof Error ? reason.message : '그룹을 저장하지 못했습니다.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteGroup = async (group: InstructorGroup) => {
|
||||
setDeletingGroupId(group.id);
|
||||
setError(null);
|
||||
try {
|
||||
const payload = await deleteInstructorGroup(session.accessToken, group.id, selectedCourseId || null);
|
||||
applyGroupsPayload(payload);
|
||||
showToast({
|
||||
title: '그룹이 삭제되었습니다.',
|
||||
message: group.name,
|
||||
});
|
||||
setDeleteTarget(null);
|
||||
setPage(1);
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : '그룹을 삭제하지 못했습니다.');
|
||||
} finally {
|
||||
setDeletingGroupId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1220px]">
|
||||
<PageHeader
|
||||
route="groups"
|
||||
action={
|
||||
<button className="inline-flex h-9 items-center gap-2 rounded-md bg-[#2563eb] px-3 text-[13px] font-bold text-white hover:bg-[#1d4ed8]" type="button" onClick={openCreateModal}>
|
||||
<Plus size={15} aria-hidden="true" />
|
||||
그룹 추가
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<section className="mb-4 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
{summaryItems.map((item) => (
|
||||
<SummaryCard key={item.label} {...item} />
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-[#c7d2e0] bg-white">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#cbd5e1] px-4 py-3">
|
||||
<div>
|
||||
<h2 className="text-[15px] font-black text-[#111827]">그룹 리스트</h2>
|
||||
<p className="mt-1 text-[12px] font-bold text-[#64748b]">그룹 구성, 플랫폼 배정, 진행률 조회</p>
|
||||
</div>
|
||||
|
||||
<label className="flex h-9 w-full max-w-[260px] items-center gap-2 rounded-md border border-[#d1d9e6] bg-white px-3 text-[#94a3b8]">
|
||||
<Search size={15} aria-hidden="true" />
|
||||
<input className="min-w-0 flex-1 bg-transparent text-[13px] font-bold text-[#111827] outline-none placeholder:text-[#94a3b8]" placeholder="그룹 검색" value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="hidden gap-4 grid-cols-[44px_190px_minmax(220px,1fr)_170px_190px_74px] items-center border-b border-[#cbd5e1] bg-[#f8fafc] px-4 py-3 text-center text-[11px] font-bold text-[#64748b] xl:grid">
|
||||
<span>No.</span>
|
||||
<span>그룹</span>
|
||||
<span>그룹원</span>
|
||||
<span>플랫폼</span>
|
||||
<span>진행률</span>
|
||||
<span>관리</span>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-[#e2e8f0]">
|
||||
{error ? (
|
||||
<div className="flex h-[220px] items-center justify-center px-4 text-center">
|
||||
<p className="text-[13px] font-bold text-[#64748b]">{error}</p>
|
||||
</div>
|
||||
) : pagedGroups.length > 0 ? (
|
||||
pagedGroups.map((group, index) => (
|
||||
<article className="grid gap-4 px-4 py-4 xl:grid-cols-[44px_190px_minmax(220px,1fr)_170px_190px_74px] xl:items-center" key={group.name}>
|
||||
<div className="text-center">
|
||||
<p className="mb-2 text-[11px] font-bold text-[#94a3b8] xl:hidden">번호</p>
|
||||
<span className="text-[13px] font-bold text-[#64748b]">{filteredGroups.length - ((page - 1) * pageSize + index)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-md bg-[#f0efff] text-[#4f46e5]">
|
||||
<Users size={17} aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-[14px] font-black text-[#111827]">{group.name}</h3>
|
||||
<span className="rounded bg-[#f1f5f9] px-1.5 py-0.5 text-[11px] font-bold text-[#64748b]">{group.status}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[12px] font-bold text-[#64748b]">
|
||||
{group.members.length}명 | {group.week}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<p className="mb-2 text-[11px] font-bold text-[#94a3b8]">그룹원</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{group.members.map((member) => (
|
||||
<span className="rounded bg-[#f8fafc] px-2 py-1 text-[12px] font-bold text-[#334155]" key={member}>
|
||||
{member}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="mb-2 text-[11px] font-bold text-[#94a3b8]">플랫폼</p>
|
||||
<span className="inline-flex h-7 items-center rounded-md bg-[#f1f5f9] px-2.5 text-[12px] font-bold text-[#111827]">{group.platform}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between text-[12px] font-bold text-[#475569]">
|
||||
<span>진행률</span>
|
||||
<span className="text-[#4f46e5]">{group.progress}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-[#dbe3ef]">
|
||||
<div className="h-full rounded-full bg-[#4f46e5]" style={{ width: `${group.progress}%` }} />
|
||||
</div>
|
||||
<p className="mt-2 text-center text-[11px] font-bold text-[#94a3b8]">
|
||||
완료 {group.completedMissions} · 활동 {group.activeUsers}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button className="grid h-8 w-8 place-items-center rounded-md border border-[#c7d2fe] bg-[#eef2ff] text-[#4f46e5] hover:bg-[#e0e7ff]" type="button" aria-label={`${group.name} 수정`} title="수정" onClick={() => openEditModal(group)}>
|
||||
<Edit3 size={15} aria-hidden="true" />
|
||||
</button>
|
||||
<button className="grid h-8 w-8 place-items-center rounded-md border border-[#fecdd3] bg-[#fff1f2] text-[#e11d48] hover:bg-[#ffe4e6] disabled:cursor-not-allowed disabled:opacity-60" type="button" aria-label={`${group.name} 삭제`} title="삭제" onClick={() => setDeleteTarget(group)} disabled={deletingGroupId === group.id}>
|
||||
{deletingGroupId === group.id ? <Loader2 className="animate-spin" size={15} aria-hidden="true" /> : <Trash2 size={15} aria-hidden="true" />}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
) : (
|
||||
<div className="flex h-[220px] items-center justify-center px-4 text-center">
|
||||
<p className="text-[13px] font-bold text-[#64748b]">조회된 그룹이 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PaginationBar
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
totalItems={filteredGroups.length}
|
||||
totalLabel={`전체 ${filteredGroups.length}개`}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{modalMode ? createPortal((
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#0f172a]/25 px-4">
|
||||
<div className="flex max-h-[86vh] w-full max-w-[620px] flex-col rounded-lg border border-[#c7d2e0] bg-white shadow-[0_24px_70px_rgba(15,23,42,0.18)]">
|
||||
<div className="flex items-center justify-between border-b border-[#cbd5e1] px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-[16px] font-black text-[#111827]">{modalMode === 'edit' ? '그룹 수정' : '그룹 추가'}</h2>
|
||||
<p className="mt-1 text-[12px] font-bold text-[#64748b]">그룹 정보와 배정 인원을 설정해 주세요.</p>
|
||||
</div>
|
||||
<button className="grid h-8 w-8 place-items-center rounded-md text-[#64748b] hover:bg-[#f1f5f9] hover:text-[#111827]" type="button" onClick={closeGroupModal} disabled={saving} aria-label="닫기" title="닫기">
|
||||
<X size={17} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-5 py-5">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-[12px] font-black text-[#334155]">그룹명</span>
|
||||
<input
|
||||
className="h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-semibold text-[#111827] outline-none placeholder:text-[#94a3b8] focus:border-[#2563eb] focus:ring-2 focus:ring-[#dbeafe]"
|
||||
placeholder="그룹명 입력"
|
||||
value={groupName}
|
||||
onChange={(event) => setGroupName(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-[12px] font-black text-[#334155]">플랫폼</span>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-semibold text-[#111827] outline-none focus:border-[#2563eb] focus:ring-2 focus:ring-[#dbeafe]"
|
||||
value={selectedPlatform}
|
||||
onChange={(event) => setSelectedPlatform(event.target.value)}
|
||||
>
|
||||
<option value="unassigned">미배정</option>
|
||||
{availablePlatforms.map((platform) => (
|
||||
<option key={platform.id} value={platform.id}>{platform.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-[12px] font-black text-[#334155]">그룹원</span>
|
||||
<span className="text-[12px] font-bold text-[#64748b]">선택 {selectedMemberIds.length}명</span>
|
||||
</div>
|
||||
<div className="max-h-[260px] space-y-2 overflow-y-auto rounded-lg border border-[#d1d9e6] bg-[#f8fafc] p-2">
|
||||
{availableStudents.length > 0 ? availableStudents.map((student) => (
|
||||
<label className="flex cursor-pointer items-center gap-3 rounded-md bg-white px-3 py-2 hover:bg-[#f8faff]" key={student.id}>
|
||||
<input
|
||||
className="h-4 w-4 accent-[#4f46e5]"
|
||||
type="checkbox"
|
||||
checked={selectedMemberIds.includes(student.id)}
|
||||
onChange={() => toggleMember(student.id)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[13px] font-black text-[#111827]">{student.name}</span>
|
||||
<span className="mt-0.5 block truncate text-[12px] font-semibold text-[#64748b]">{student.email}</span>
|
||||
</span>
|
||||
</label>
|
||||
)) : (
|
||||
<div className="flex h-[120px] items-center justify-center text-[13px] font-bold text-[#64748b]">배정 가능한 수강생이 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formError ? <p className="rounded-md bg-[#fff1f2] px-3 py-2 text-[12px] font-bold text-[#e11d48]">{formError}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-[#cbd5e1] px-5 py-4">
|
||||
<button className="h-9 rounded-md border border-[#d1d9e6] bg-white px-4 text-[13px] font-bold text-[#475569] hover:bg-[#f8fafc]" type="button" onClick={closeGroupModal} disabled={saving}>취소</button>
|
||||
<button className="inline-flex h-9 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={handleSaveGroup} disabled={saving}>
|
||||
{saving ? <Loader2 className="animate-spin" size={15} aria-hidden="true" /> : <Plus size={15} aria-hidden="true" />}
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
), document.body) : null}
|
||||
|
||||
{deleteTarget ? createPortal((
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#0f172a]/25 px-4">
|
||||
<div className="w-full max-w-[420px] rounded-lg border border-[#c7d2e0] bg-white shadow-[0_24px_70px_rgba(15,23,42,0.18)]">
|
||||
<div className="flex items-center justify-between border-b border-[#cbd5e1] px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-[16px] font-black text-[#111827]">그룹 삭제</h2>
|
||||
<p className="mt-1 text-[12px] font-bold text-[#64748b]">그룹원은 모두 미배정 상태가 됩니다.</p>
|
||||
</div>
|
||||
<button className="grid h-8 w-8 place-items-center rounded-md text-[#64748b] hover:bg-[#f1f5f9] hover:text-[#111827]" type="button" onClick={() => setDeleteTarget(null)} disabled={deletingGroupId === deleteTarget.id} aria-label="닫기" title="닫기">
|
||||
<X size={17} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-5 py-5">
|
||||
<div className="rounded-lg border border-[#cbd5e1] bg-[#f8fafc] px-4 py-3">
|
||||
<p className="text-[14px] font-black text-[#111827]">{deleteTarget.name}</p>
|
||||
<p className="mt-1 text-[12px] font-semibold text-[#64748b]">{deleteTarget.members.length}명 | {deleteTarget.platform}</p>
|
||||
</div>
|
||||
<p className="mt-4 text-[13px] font-bold leading-relaxed text-[#475569]">그룹만 삭제되고 수강생 계정과 수강 등록은 유지됩니다.</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 border-t border-[#cbd5e1] px-5 py-4">
|
||||
<button className="h-9 rounded-md border border-[#d1d9e6] bg-white px-4 text-[13px] font-bold text-[#475569] hover:bg-[#f8fafc] disabled:opacity-60" type="button" onClick={() => setDeleteTarget(null)} disabled={deletingGroupId === deleteTarget.id}>취소</button>
|
||||
<button className="inline-flex h-9 items-center gap-2 rounded-md border border-[#fecdd3] bg-[#fff1f2] px-4 text-[13px] font-bold text-[#e11d48] hover:bg-[#ffe4e6] disabled:opacity-60" type="button" onClick={() => handleDeleteGroup(deleteTarget)} disabled={deletingGroupId === deleteTarget.id}>
|
||||
{deletingGroupId === deleteTarget.id ? <Loader2 className="animate-spin" size={15} aria-hidden="true" /> : <Trash2 size={15} aria-hidden="true" />}
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
), document.body) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryCard({ label, value, icon: Icon, tone }: { label: string; value: string; icon: LucideIcon; tone: string }) {
|
||||
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 text-[20px] font-black leading-none text-[#111827]">{value}</p>
|
||||
</div>
|
||||
<span className={`grid h-11 w-11 shrink-0 place-items-center rounded-md ${tone}`}>
|
||||
<Icon size={28} aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { ArrowRight, CheckCircle2 } from 'lucide-react';
|
||||
import { BrandMark } from '../../components/BrandMark';
|
||||
import { loginInstructor } from '../../lib/auth';
|
||||
import type { AuthSession } from '../../types/auth';
|
||||
|
||||
type LoginPageProps = {
|
||||
onLogin: (session: AuthSession) => void;
|
||||
};
|
||||
|
||||
const REMEMBERED_EMAIL_STORAGE_KEY = 'atnmy.lms.admin.login.rememberedEmail';
|
||||
const DEFAULT_EMAIL = '';
|
||||
const DEFAULT_PASSWORD = 'qwer1234';
|
||||
|
||||
export function LoginPage({ onLogin }: LoginPageProps) {
|
||||
const rememberedEmail = window.localStorage.getItem(REMEMBERED_EMAIL_STORAGE_KEY);
|
||||
const [email, setEmail] = useState(rememberedEmail ?? DEFAULT_EMAIL);
|
||||
const [password, setPassword] = useState(DEFAULT_PASSWORD);
|
||||
const [rememberEmail, setRememberEmail] = useState(Boolean(rememberedEmail));
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const session = await loginInstructor(email, password);
|
||||
if (rememberEmail) {
|
||||
window.localStorage.setItem(REMEMBERED_EMAIL_STORAGE_KEY, email);
|
||||
} else {
|
||||
window.localStorage.removeItem(REMEMBERED_EMAIL_STORAGE_KEY);
|
||||
}
|
||||
onLogin(session);
|
||||
} catch (loginError) {
|
||||
setError(loginError instanceof Error ? loginError.message : '로그인에 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="grid min-h-screen bg-white text-[#111827] lg:grid-cols-[minmax(420px,58%)_minmax(360px,42%)]">
|
||||
<section className="relative flex min-h-[42vh] overflow-hidden bg-[linear-gradient(145deg,#3b82f6_0%,#7048e8_54%,#4f46e5_100%)] px-7 py-8 text-white sm:px-12 lg:min-h-screen lg:px-[60px]">
|
||||
<div className="absolute inset-0 opacity-25">
|
||||
<div className="absolute left-[14%] top-[18%] h-40 w-40 rounded-full bg-white blur-3xl" />
|
||||
<div className="absolute bottom-[18%] right-[12%] h-56 w-56 rounded-full bg-[#93c5fd] blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 flex w-full max-w-[580px] flex-col">
|
||||
<BrandMark />
|
||||
|
||||
<div className="my-auto py-12 lg:py-0">
|
||||
<p className="mb-4 text-[11px] font-extrabold uppercase tracking-[0.16em] text-white/70">Learning Management System</p>
|
||||
<h1 className="max-w-[520px] text-[34px] font-black leading-[1.12] tracking-[0] sm:text-[44px]">
|
||||
<span className="text-[#67e8f9]">자율주행</span> 교육 플랫폼
|
||||
</h1>
|
||||
<p className="mt-5 max-w-[410px] text-[14px] font-bold leading-6 text-white/82">
|
||||
실제 하드웨어와 시뮬레이션을 활용한 차세대 학습 관리 시스템
|
||||
</p>
|
||||
|
||||
<div className="mt-8 grid max-w-[410px] gap-2.5 rounded-xl bg-white/12 p-4 ring-1 ring-white/18 backdrop-blur">
|
||||
{['실시간 모니터링 및 통합 관리', '하드웨어 플랫폼 상태 추적', 'AI 가이드 에스컬레이션'].map((item) => (
|
||||
<div className="flex items-center gap-3 text-[12px] font-bold text-white/88" key={item}>
|
||||
<CheckCircle2 size={16} aria-hidden="true" />
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[12px] font-bold text-white/55">© 2026. All rights reserved.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex min-h-[58vh] items-center justify-center px-7 py-10 sm:px-12 lg:min-h-screen">
|
||||
<div className="w-full max-w-[420px]">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-[26px] font-black tracking-[0] text-[#4f46e5]">강사 로그인</h2>
|
||||
<p className="mt-2.5 text-[13px] font-bold text-[#6b7280]">이메일과 비밀번호를 입력해주세요.</p>
|
||||
</div>
|
||||
|
||||
<form className="grid gap-4" onSubmit={handleSubmit}>
|
||||
<label className="grid gap-2 text-[13px] font-extrabold text-[#303747]">
|
||||
이메일
|
||||
<input
|
||||
className="h-11 rounded-lg border border-[#d8dde8] bg-white px-4 text-[14px] font-bold text-[#111827] outline-none transition placeholder:text-[#a0a7b7] focus:border-[#4f46e5] focus:ring-4 focus:ring-[#4f46e5]/12"
|
||||
name="email"
|
||||
placeholder="이메일을 입력하세요"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-2 text-[13px] font-extrabold text-[#303747]">
|
||||
비밀번호
|
||||
<input
|
||||
className="h-11 rounded-lg border border-[#d8dde8] bg-white px-4 text-[14px] font-bold text-[#111827] outline-none transition placeholder:text-[#a0a7b7] focus:border-[#4f46e5] focus:ring-4 focus:ring-[#4f46e5]/12"
|
||||
name="password"
|
||||
placeholder="비밀번호를 입력하세요"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label className="flex items-center gap-2 text-[12px] font-bold text-[#64748b]">
|
||||
<input
|
||||
className="h-4 w-4 rounded border-[#d8dde8] accent-[#4f46e5]"
|
||||
type="checkbox"
|
||||
checked={rememberEmail}
|
||||
onChange={(event) => setRememberEmail(event.target.checked)}
|
||||
/>
|
||||
아이디 기억
|
||||
</label>
|
||||
<button className="text-[12px] font-extrabold text-[#4f46e5]" type="button">
|
||||
비밀번호를 잊으셨나요?
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="h-12 rounded-lg bg-[#4f46e5] text-[14px] font-extrabold text-white shadow-[0_10px_24px_rgba(79,70,229,0.22)] transition hover:bg-[#4338ca] focus:outline-none focus:ring-4 focus:ring-[#4f46e5]/20 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '로그인 중...' : '로그인'}
|
||||
</button>
|
||||
|
||||
{error ? <p className="text-center text-[12px] font-bold text-[#dc2626]">{error}</p> : null}
|
||||
</form>
|
||||
|
||||
<div className="my-7 flex items-center gap-4">
|
||||
<span className="h-px flex-1 bg-[#e3e7ef]" />
|
||||
<span className="text-[12px] font-bold text-[#9aa2b1]">또는</span>
|
||||
<span className="h-px flex-1 bg-[#e3e7ef]" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="group flex h-12 w-full items-center justify-center gap-3 rounded-lg border border-[#d8dde8] bg-white px-4 text-[14px] font-extrabold text-[#263141] shadow-sm transition hover:border-[#b8c0d0] hover:bg-[#fafbff] focus:outline-none focus:ring-4 focus:ring-[#4f46e5]/15"
|
||||
type="button"
|
||||
onClick={() => setError('Google 로그인은 공통 인증 서버에 엔드포인트가 준비된 뒤 연결됩니다.')}
|
||||
>
|
||||
<GoogleMark />
|
||||
Google로 계속
|
||||
<ArrowRight className="ml-auto text-[#98a1b2] transition group-hover:translate-x-0.5 group-hover:text-[#4f46e5]" size={16} aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<p className="mt-7 text-center text-[11px] font-bold leading-5 text-[#8b93a3]">접근 권한이 없는 계정은 관리자에게 문의하세요.</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function GoogleMark() {
|
||||
return (
|
||||
<span className="grid h-5 w-5 place-items-center rounded-full border border-[#dbe1eb] bg-[#f8fafc] text-[15px] font-black leading-none text-[#4285f4]" aria-hidden="true">
|
||||
<span>G</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,199 @@
|
||||
import { useRef, useState, type ChangeEvent } from 'react';
|
||||
import { Building2, Camera, Loader2, Mail, ShieldCheck, Trash2, type LucideIcon } from 'lucide-react';
|
||||
import { ConfirmDialog } from '../../components/ConfirmDialog';
|
||||
import { PageHeader } from '../../components/PageHeader';
|
||||
import { useToast } from '../../components/ToastProvider';
|
||||
import { useProfileAvatar } from '../../hooks/useProfileAvatar';
|
||||
import { deleteProfileAvatar, notifyProfileAvatarDeleted, notifyProfileAvatarUpdated, resizeImageToWebp, uploadProfileAvatar } from '../../lib/profileAvatar';
|
||||
import type { AuthSession } from '../../types/auth';
|
||||
|
||||
type ProfilePageProps = {
|
||||
session: AuthSession;
|
||||
};
|
||||
|
||||
export function ProfilePage({ session }: ProfilePageProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const { showToast } = useToast();
|
||||
const { avatarObjectUrl, avatarMissing, avatarLoading, refreshAvatar, setAvatarMissing } = useProfileAvatar(session.accessToken);
|
||||
const [avatarUploading, setAvatarUploading] = useState(false);
|
||||
const [avatarDeleting, setAvatarDeleting] = useState(false);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const displayName = getDisplayName(session);
|
||||
const affiliation = getAffiliation(session);
|
||||
const roleLabel = getRoleLabel(session.user.role);
|
||||
const organizationName = session.user.primaryAffiliation?.organizationName ?? '소속 미지정';
|
||||
const department = session.user.primaryAffiliation?.department ?? '부서 미지정';
|
||||
const position = session.user.primaryAffiliation?.position ?? '직책 미지정';
|
||||
const hasAvatar = !avatarMissing && Boolean(avatarObjectUrl);
|
||||
const avatarBusy = avatarLoading || avatarUploading || avatarDeleting;
|
||||
|
||||
const handleAvatarSelect = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
|
||||
setAvatarUploading(true);
|
||||
|
||||
try {
|
||||
const imageBlob = await resizeImageToWebp(file, 128);
|
||||
await uploadProfileAvatar(session.accessToken, imageBlob);
|
||||
refreshAvatar();
|
||||
notifyProfileAvatarUpdated();
|
||||
showToast({ title: '프로필 이미지가 변경되었습니다.', variant: 'success' });
|
||||
} catch (error) {
|
||||
showToast({
|
||||
title: '프로필 이미지 변경 실패',
|
||||
message: error instanceof Error ? error.message : '이미지를 다시 확인해주세요.',
|
||||
variant: 'error',
|
||||
});
|
||||
} finally {
|
||||
setAvatarUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarDelete = async () => {
|
||||
setAvatarDeleting(true);
|
||||
|
||||
try {
|
||||
await deleteProfileAvatar(session.accessToken);
|
||||
setAvatarMissing(true);
|
||||
notifyProfileAvatarDeleted();
|
||||
setDeleteConfirmOpen(false);
|
||||
showToast({ title: '프로필 이미지가 삭제되었습니다.', variant: 'success' });
|
||||
} catch {
|
||||
showToast({ title: '프로필 이미지 삭제 실패', message: '잠시 후 다시 시도해주세요.', variant: 'error' });
|
||||
} finally {
|
||||
setAvatarDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1220px]">
|
||||
<PageHeader route="profile" />
|
||||
|
||||
<section className="grid gap-4 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||
<div className="rounded-lg border border-[#c7d2e0] bg-white p-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative h-16 w-16 shrink-0">
|
||||
<div className="grid h-full w-full place-items-center overflow-hidden rounded-full border border-[#dbe3ef] bg-[#2563eb] text-[22px] font-black text-white shadow-[0_12px_24px_rgba(37,99,235,0.18)]">
|
||||
{hasAvatar ? (
|
||||
<img
|
||||
src={avatarObjectUrl ?? undefined}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
onError={() => setAvatarMissing(true)}
|
||||
/>
|
||||
) : (
|
||||
displayName.slice(0, 1)
|
||||
)}
|
||||
</div>
|
||||
{avatarBusy ? (
|
||||
<span className="absolute inset-0 grid place-items-center rounded-full bg-white/65 text-[#2563eb] backdrop-blur-[2px]">
|
||||
<Loader2 size={18} className="animate-spin" aria-hidden="true" />
|
||||
</span>
|
||||
) : null}
|
||||
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleAvatarSelect} />
|
||||
<button
|
||||
type="button"
|
||||
className="absolute bottom-0 right-0 z-10 grid h-7 w-7 translate-x-0.5 translate-y-0.5 cursor-pointer place-items-center rounded-full border border-white bg-[#2563eb] text-white shadow-[0_8px_18px_rgba(37,99,235,0.28)] transition hover:bg-[#1d4ed8] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={avatarBusy}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
aria-label="프로필 이미지 등록"
|
||||
>
|
||||
<Camera size={14} aria-hidden="true" />
|
||||
</button>
|
||||
{hasAvatar ? (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-0 top-0 z-10 grid h-7 w-7 translate-x-0.5 -translate-y-0.5 cursor-pointer place-items-center rounded-full border border-white bg-[#fff1f2] text-[#e11d48] shadow-[0_8px_18px_rgba(225,29,72,0.18)] transition hover:bg-[#ffe4e6] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={avatarBusy}
|
||||
onClick={() => setDeleteConfirmOpen(true)}
|
||||
aria-label="프로필 이미지 삭제"
|
||||
>
|
||||
<Trash2 size={13} aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-[18px] font-black text-[#111827]">{displayName}</h2>
|
||||
<p className="mt-1 truncate text-[12px] font-bold text-[#64748b]">{roleLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-3">
|
||||
<ProfileRow icon={Building2} label="소속" value={affiliation} />
|
||||
<ProfileRow icon={Mail} label="이메일" value={session.user.email} />
|
||||
<ProfileRow icon={ShieldCheck} label="권한" value={roleLabel} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-[#c7d2e0] bg-white">
|
||||
<div className="border-b border-[#cbd5e1] px-5 py-4">
|
||||
<h2 className="text-[15px] font-black text-[#111827]">계정 정보</h2>
|
||||
<p className="mt-1 text-[12px] font-bold text-[#64748b]">공통 인증 서버에서 전달된 로그인 정보입니다.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 p-5 md:grid-cols-2">
|
||||
<InfoCard label="소속 기관" value={organizationName} />
|
||||
<InfoCard label="부서" value={department} />
|
||||
<InfoCard label="직책" value={position} />
|
||||
<InfoCard label="계정 상태" value="활성" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteConfirmOpen}
|
||||
title="프로필 이미지 삭제"
|
||||
message="등록된 썸네일 이미지를 삭제하시겠습니까?"
|
||||
confirmLabel="삭제"
|
||||
loading={avatarDeleting}
|
||||
danger
|
||||
icon={Trash2}
|
||||
onCancel={() => setDeleteConfirmOpen(false)}
|
||||
onConfirm={() => void handleAvatarDelete()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileRow({ icon: Icon, label, value }: { icon: LucideIcon; label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-lg bg-[#f8fafc] px-3 py-3">
|
||||
<Icon className="mt-0.5 shrink-0 text-[#64748b]" size={16} aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-bold text-[#94a3b8]">{label}</p>
|
||||
<p className="mt-1 break-words text-[13px] font-bold text-[#111827]">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoCard({ label, value, wide = false }: { label: string; value: string; wide?: boolean }) {
|
||||
return (
|
||||
<article className={`rounded-lg border border-[#c7d2e0] bg-white p-4 ${wide ? 'md:col-span-2' : ''}`}>
|
||||
<p className="text-[12px] font-semibold text-[#64748b]">{label}</p>
|
||||
<p className="mt-2 text-[14px] font-bold text-[#111827]">{value}</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function getDisplayName(session: AuthSession): string {
|
||||
const name = session.user.name?.trim();
|
||||
if (name && name !== 'ATNMY Studio Admin') return name;
|
||||
return session.user.email.split('@')[0] || '강사';
|
||||
}
|
||||
|
||||
function getAffiliation(session: AuthSession): string {
|
||||
const affiliation = session.user.primaryAffiliation;
|
||||
if (!affiliation) return '소속 미지정';
|
||||
|
||||
return [affiliation.organizationName, affiliation.department, affiliation.position].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
function getRoleLabel(role: string): string {
|
||||
const normalized = role.toUpperCase();
|
||||
if (normalized === 'LMS_ADMIN') return '관리자';
|
||||
if (normalized === 'INSTRUCTOR') return '강사';
|
||||
return role;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Settings } from 'lucide-react';
|
||||
import { PlaceholderPage } from '../common/PlaceholderPage';
|
||||
|
||||
export function SettingsPage() {
|
||||
return <PlaceholderPage icon={Settings} route="settings" items={['권한 정책', '알림 설정', '서비스 연결']} />;
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { BarChart3, CircleAlert, GitBranch, Loader2, Plus, Search, Trash2, UserRound, Wifi, X, type LucideIcon } from 'lucide-react';
|
||||
import { PageHeader } from '../../components/PageHeader';
|
||||
import { PaginationBar } from '../../components/PaginationBar';
|
||||
import { useToast } from '../../components/ToastProvider';
|
||||
import { listInstructorGroups, type InstructorGroup } from '../../lib/groups';
|
||||
import { createInstructorStudent, listInstructorStudents, removeInstructorStudent, type InstructorStudent, type InstructorStudentsSummary } from '../../lib/students';
|
||||
import type { AuthSession } from '../../types/auth';
|
||||
|
||||
const emptySummary: InstructorStudentsSummary = {
|
||||
totalStudents: 0,
|
||||
onlineStudents: 0,
|
||||
averageProgress: 0,
|
||||
unresolvedIssues: 0,
|
||||
};
|
||||
|
||||
export function StudentsPage({ session, selectedCourseId }: { session: AuthSession; selectedCourseId: string }) {
|
||||
const { showToast } = useToast();
|
||||
const [students, setStudents] = useState<InstructorStudent[]>([]);
|
||||
const [summary, setSummary] = useState<InstructorStudentsSummary>(emptySummary);
|
||||
const [query, setQuery] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [groups, setGroups] = useState<InstructorGroup[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [studentModalOpen, setStudentModalOpen] = useState(false);
|
||||
const [newStudentEmail, setNewStudentEmail] = useState('');
|
||||
const [newStudentName, setNewStudentName] = useState('');
|
||||
const [newStudentGroupId, setNewStudentGroupId] = useState('unassigned');
|
||||
const [studentSaving, setStudentSaving] = useState(false);
|
||||
const [studentFormError, setStudentFormError] = useState<string | null>(null);
|
||||
const [removingStudentId, setRemovingStudentId] = useState<string | null>(null);
|
||||
const [removeTarget, setRemoveTarget] = useState<InstructorStudent | null>(null);
|
||||
const [issueTarget, setIssueTarget] = useState<InstructorStudent | null>(null);
|
||||
|
||||
const loadStudents = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const payload = await listInstructorStudents(session.accessToken, selectedCourseId || null);
|
||||
setStudents(payload.students);
|
||||
setSummary(payload.summary);
|
||||
} catch (reason) {
|
||||
setStudents([]);
|
||||
setSummary(emptySummary);
|
||||
setError(reason instanceof Error ? reason.message : '수강생 현황을 불러오지 못했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [session.accessToken, selectedCourseId]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
loadStudents();
|
||||
listInstructorGroups(session.accessToken, selectedCourseId || null)
|
||||
.then((payload) => {
|
||||
if (!cancelled) setGroups(payload.groups);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setGroups([]);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [loadStudents, session.accessToken, selectedCourseId]);
|
||||
|
||||
const filteredStudents = useMemo(() => {
|
||||
const keyword = query.trim().toLowerCase();
|
||||
if (!keyword) return students;
|
||||
|
||||
return students.filter((student) =>
|
||||
[student.name, student.email, student.group, student.week, student.courseTitle]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(keyword),
|
||||
);
|
||||
}, [query, students]);
|
||||
|
||||
const pagedStudents = useMemo(() => filteredStudents.slice((page - 1) * pageSize, page * pageSize), [filteredStudents, page, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
const totalPages = Math.max(1, Math.ceil(filteredStudents.length / pageSize));
|
||||
if (page > totalPages) setPage(totalPages);
|
||||
}, [filteredStudents.length, page, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!studentModalOpen && !removeTarget && !issueTarget) return;
|
||||
|
||||
const originalOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = originalOverflow;
|
||||
};
|
||||
}, [issueTarget, removeTarget, studentModalOpen]);
|
||||
|
||||
const handlePageSizeChange = (nextPageSize: number) => {
|
||||
setPageSize(nextPageSize);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const openStudentModal = () => {
|
||||
setNewStudentEmail('');
|
||||
setNewStudentName('');
|
||||
setNewStudentGroupId('unassigned');
|
||||
setStudentFormError(null);
|
||||
setStudentModalOpen(true);
|
||||
};
|
||||
|
||||
const closeStudentModal = () => {
|
||||
if (studentSaving) return;
|
||||
setStudentModalOpen(false);
|
||||
};
|
||||
|
||||
const handleCreateStudent = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const email = newStudentEmail.trim();
|
||||
const name = newStudentName.trim();
|
||||
|
||||
if (!email || !name) {
|
||||
setStudentFormError('이메일과 이름을 입력해 주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setStudentSaving(true);
|
||||
setStudentFormError(null);
|
||||
|
||||
try {
|
||||
const payload = await createInstructorStudent(session.accessToken, {
|
||||
courseId: selectedCourseId || null,
|
||||
email,
|
||||
name,
|
||||
groupId: newStudentGroupId === 'unassigned' ? null : newStudentGroupId,
|
||||
});
|
||||
setStudents(payload.students);
|
||||
setSummary(payload.summary);
|
||||
setStudentModalOpen(false);
|
||||
setPage(1);
|
||||
showToast({
|
||||
title: '수강생이 추가되었습니다.',
|
||||
message: name,
|
||||
});
|
||||
} catch (reason) {
|
||||
const message = reason instanceof Error ? reason.message : '수강생을 등록하지 못했습니다.';
|
||||
setStudentFormError(message);
|
||||
showToast({
|
||||
title: '수강생 추가에 실패했습니다.',
|
||||
message,
|
||||
variant: 'error',
|
||||
});
|
||||
} finally {
|
||||
setStudentSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveStudent = async (student: InstructorStudent) => {
|
||||
setRemovingStudentId(student.id);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const payload = await removeInstructorStudent(session.accessToken, student.id);
|
||||
setStudents(payload.students);
|
||||
setSummary(payload.summary);
|
||||
showToast({
|
||||
title: '수강생이 제외되었습니다.',
|
||||
message: student.name,
|
||||
});
|
||||
} catch (reason) {
|
||||
const message = reason instanceof Error ? reason.message : '수강생을 제외하지 못했습니다.';
|
||||
setError(message);
|
||||
showToast({
|
||||
title: '수강생 제외에 실패했습니다.',
|
||||
message,
|
||||
variant: 'error',
|
||||
});
|
||||
} finally {
|
||||
setRemovingStudentId(null);
|
||||
setRemoveTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
const summaryItems = [
|
||||
{ label: '전체 수강생', value: `${summary.totalStudents}명`, icon: UserRound, tone: 'bg-[#f0efff] text-[#4f46e5]' },
|
||||
{ label: '현재 접속', value: `${summary.onlineStudents}명`, icon: Wifi, tone: 'bg-[#eff6ff] text-[#2563eb]' },
|
||||
{ label: '평균 진도', value: `${summary.averageProgress}%`, icon: BarChart3, tone: 'bg-[#fff7ed] text-[#f97316]' },
|
||||
{ label: '이슈', value: `${summary.unresolvedIssues}건`, icon: CircleAlert, tone: 'bg-[#fff1f2] text-[#e11d48]' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1220px]">
|
||||
<PageHeader
|
||||
route="students"
|
||||
action={
|
||||
<button className="inline-flex h-9 items-center gap-2 rounded-md bg-[#2563eb] px-3 text-[13px] font-bold text-white hover:bg-[#1d4ed8]" type="button" onClick={openStudentModal}>
|
||||
<Plus size={15} aria-hidden="true" />
|
||||
수강생 추가
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<section className="mb-4 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
{summaryItems.map((item) => (
|
||||
<SummaryCard key={item.label} {...item} />
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-[#c7d2e0] bg-white">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#cbd5e1] px-4 py-3">
|
||||
<div>
|
||||
<h2 className="text-[15px] font-black text-[#111827]">수강생 리스트</h2>
|
||||
<p className="mt-1 text-[12px] font-bold text-[#64748b]">그룹, 주차, 진도, 커밋, 이슈, 최근 활동 조회</p>
|
||||
</div>
|
||||
|
||||
<label className="flex h-9 w-full max-w-[260px] items-center gap-2 rounded-md border border-[#d1d9e6] bg-white px-3 text-[#94a3b8]">
|
||||
<Search size={15} aria-hidden="true" />
|
||||
<input
|
||||
className="min-w-0 flex-1 bg-transparent text-[13px] font-bold text-[#111827] outline-none placeholder:text-[#94a3b8]"
|
||||
placeholder="수강생 검색"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div>
|
||||
<div className="hidden gap-4 grid-cols-[44px_108px_minmax(160px,1fr)_minmax(116px,0.7fr)_78px_minmax(150px,1fr)_58px_58px_118px_70px_42px] items-center border-b border-[#cbd5e1] bg-[#f8fafc] px-4 py-3 text-center text-[11px] font-bold text-[#64748b] xl:grid">
|
||||
<span>No.</span>
|
||||
<span>이름</span>
|
||||
<span>이메일</span>
|
||||
<span>그룹</span>
|
||||
<span>주차</span>
|
||||
<span>진행률</span>
|
||||
<span>커밋</span>
|
||||
<span>이슈</span>
|
||||
<span>최근 활동</span>
|
||||
<span>상태</span>
|
||||
<span>제외</span>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-[#e2e8f0]">
|
||||
{loading ? (
|
||||
<div className="flex h-[220px] items-center justify-center text-[#64748b]">
|
||||
<Loader2 className="mr-2 animate-spin" size={18} aria-hidden="true" />
|
||||
<span className="text-[13px] font-bold">수강생 현황을 불러오는 중입니다.</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<EmptyState message={error} />
|
||||
) : filteredStudents.length > 0 ? (
|
||||
pagedStudents.map((student, index) => (
|
||||
<article className="grid gap-4 px-4 py-4 xl:grid-cols-[44px_108px_minmax(160px,1fr)_minmax(116px,0.7fr)_78px_minmax(150px,1fr)_58px_58px_118px_70px_42px] xl:items-center" key={student.id}>
|
||||
<Cell label="번호">
|
||||
<span className="text-[13px] font-bold text-[#64748b]">{filteredStudents.length - ((page - 1) * pageSize + index)}</span>
|
||||
</Cell>
|
||||
|
||||
<div className="min-w-0 text-center">
|
||||
<p className="mb-2 text-[11px] font-bold text-[#94a3b8] xl:hidden">이름</p>
|
||||
<h3 className="truncate text-[14px] font-black text-[#111827]">{student.name}</h3>
|
||||
</div>
|
||||
|
||||
<Cell label="이메일">
|
||||
<span className="block truncate text-[12px] font-semibold text-[#64748b]">{student.email}</span>
|
||||
</Cell>
|
||||
|
||||
<Cell label="그룹">
|
||||
<span className={`inline-flex h-7 items-center rounded-md px-2.5 text-[12px] font-semibold ${
|
||||
student.group === '미배정' ? 'bg-[#f1f5f9] text-[#64748b]' : 'bg-[#f0efff] text-[#4f46e5]'
|
||||
}`}>{student.group}</span>
|
||||
</Cell>
|
||||
|
||||
<Cell label="주차">
|
||||
<span className="text-[13px] font-semibold text-[#111827]">{student.week}</span>
|
||||
</Cell>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between text-[12px] font-bold text-[#475569]">
|
||||
<span className="xl:hidden">진행률</span>
|
||||
<span className="font-semibold text-[#4f46e5]">{student.progress}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-[#dbe3ef]">
|
||||
<div className="h-full rounded-full bg-[#4f46e5]" style={{ width: `${student.progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StudentMetric icon={GitBranch} label="커밋" value={student.commits} />
|
||||
<StudentIssueMetric student={student} onOpen={setIssueTarget} />
|
||||
|
||||
<Cell label="최근 활동">
|
||||
<span className="text-[13px] font-semibold text-[#64748b]">{student.lastActive}</span>
|
||||
</Cell>
|
||||
|
||||
<Cell label="상태">
|
||||
<span className={`inline-flex h-7 items-center justify-center rounded-md px-2.5 text-[12px] font-bold ${student.online ? 'bg-[#eef2ff] text-[#4f46e5]' : 'bg-[#f1f5f9] text-[#64748b]'}`}>
|
||||
{student.online ? '온라인' : '오프라인'}
|
||||
</span>
|
||||
</Cell>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
className="grid h-8 w-8 place-items-center rounded-md border border-[#fecdd3] bg-[#fff1f2] text-[#e11d48] hover:bg-[#ffe4e6]"
|
||||
type="button"
|
||||
aria-label={`${student.name} 수강 제외`}
|
||||
title="수강 제외"
|
||||
onClick={() => setRemoveTarget(student)}
|
||||
disabled={removingStudentId === student.id}
|
||||
>
|
||||
{removingStudentId === student.id ? <Loader2 className="animate-spin" size={15} aria-hidden="true" /> : <Trash2 size={15} aria-hidden="true" />}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
) : (
|
||||
<EmptyState message="조회된 수강생이 없습니다." />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PaginationBar
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
totalItems={filteredStudents.length}
|
||||
totalLabel={`전체 ${filteredStudents.length}명`}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{studentModalOpen ? createPortal((
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#0f172a]/25 px-4">
|
||||
<form
|
||||
className="w-full max-w-[440px] rounded-lg border border-[#c7d2e0] bg-white shadow-[0_24px_70px_rgba(15,23,42,0.18)]"
|
||||
onSubmit={handleCreateStudent}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[#cbd5e1] px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-[16px] font-black text-[#111827]">수강생 추가</h2>
|
||||
<p className="mt-1 text-[12px] font-bold text-[#64748b]">수강생 정보를 입력해 주세요.</p>
|
||||
</div>
|
||||
<button
|
||||
className="grid h-8 w-8 place-items-center rounded-md text-[#64748b] hover:bg-[#f1f5f9] hover:text-[#111827]"
|
||||
type="button"
|
||||
onClick={closeStudentModal}
|
||||
aria-label="닫기"
|
||||
title="닫기"
|
||||
>
|
||||
<X size={17} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 px-5 py-5">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-[12px] font-black text-[#334155]">이메일</span>
|
||||
<input
|
||||
className="h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-semibold text-[#111827] outline-none placeholder:text-[#94a3b8] focus:border-[#2563eb] focus:ring-2 focus:ring-[#dbeafe]"
|
||||
type="email"
|
||||
placeholder="student@futuredrive.net"
|
||||
value={newStudentEmail}
|
||||
onChange={(event) => setNewStudentEmail(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-[12px] font-black text-[#334155]">이름</span>
|
||||
<input
|
||||
className="h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-semibold text-[#111827] outline-none placeholder:text-[#94a3b8] focus:border-[#2563eb] focus:ring-2 focus:ring-[#dbeafe]"
|
||||
placeholder="홍길동"
|
||||
value={newStudentName}
|
||||
onChange={(event) => setNewStudentName(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-[12px] font-black text-[#334155]">그룹</span>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-semibold text-[#111827] outline-none focus:border-[#2563eb] focus:ring-2 focus:ring-[#dbeafe]"
|
||||
value={newStudentGroupId}
|
||||
onChange={(event) => setNewStudentGroupId(event.target.value)}
|
||||
>
|
||||
<option value="unassigned">미배정</option>
|
||||
{groups.map((group) => (
|
||||
<option key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{studentFormError ? (
|
||||
<p className="rounded-md bg-[#fff1f2] px-3 py-2 text-[12px] font-bold text-[#e11d48]">{studentFormError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-[#cbd5e1] px-5 py-4">
|
||||
<button
|
||||
className="h-9 rounded-md border border-[#d1d9e6] bg-white px-4 text-[13px] font-bold text-[#475569] hover:bg-[#f8fafc]"
|
||||
type="button"
|
||||
onClick={closeStudentModal}
|
||||
disabled={studentSaving}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
className="inline-flex h-9 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="submit"
|
||||
disabled={studentSaving}
|
||||
>
|
||||
{studentSaving ? <Loader2 className="animate-spin" size={15} aria-hidden="true" /> : <Plus size={15} aria-hidden="true" />}
|
||||
등록
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
), document.body) : null}
|
||||
|
||||
{removeTarget ? createPortal((
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#0f172a]/25 px-4">
|
||||
<div className="w-full max-w-[420px] rounded-lg border border-[#c7d2e0] bg-white shadow-[0_24px_70px_rgba(15,23,42,0.18)]">
|
||||
<div className="flex items-center justify-between border-b border-[#cbd5e1] px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-[16px] font-black text-[#111827]">수강생 제외</h2>
|
||||
<p className="mt-1 text-[12px] font-bold text-[#64748b]">현재 소속의 수강 목록에서 제외합니다.</p>
|
||||
</div>
|
||||
<button
|
||||
className="grid h-8 w-8 place-items-center rounded-md text-[#64748b] hover:bg-[#f1f5f9] hover:text-[#111827]"
|
||||
type="button"
|
||||
onClick={() => setRemoveTarget(null)}
|
||||
aria-label="닫기"
|
||||
title="닫기"
|
||||
disabled={removingStudentId === removeTarget.id}
|
||||
>
|
||||
<X size={17} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-5">
|
||||
<div className="rounded-lg border border-[#cbd5e1] bg-[#f8fafc] px-4 py-3">
|
||||
<p className="truncate text-[14px] font-black text-[#111827]">{removeTarget.name}</p>
|
||||
<p className="mt-1 truncate text-[12px] font-semibold text-[#64748b]">{removeTarget.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-[#cbd5e1] px-5 py-4">
|
||||
<button
|
||||
className="h-9 rounded-md border border-[#d1d9e6] bg-white px-4 text-[13px] font-bold text-[#475569] hover:bg-[#f8fafc] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
onClick={() => setRemoveTarget(null)}
|
||||
disabled={removingStudentId === removeTarget.id}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
className="inline-flex h-9 items-center gap-2 rounded-md border border-[#fecdd3] bg-[#fff1f2] px-4 text-[13px] font-bold text-[#e11d48] hover:bg-[#ffe4e6] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
onClick={() => handleRemoveStudent(removeTarget)}
|
||||
disabled={removingStudentId === removeTarget.id}
|
||||
>
|
||||
{removingStudentId === removeTarget.id ? <Loader2 className="animate-spin" size={15} aria-hidden="true" /> : <Trash2 size={15} aria-hidden="true" />}
|
||||
제외
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
), document.body) : null}
|
||||
|
||||
{issueTarget ? createPortal((
|
||||
<IssueModal student={issueTarget} onClose={() => setIssueTarget(null)} />
|
||||
), document.body) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex h-[220px] items-center justify-center px-4 text-center">
|
||||
<p className="text-[13px] font-bold text-[#64748b]">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Cell({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-w-0 text-center">
|
||||
<p className="mb-2 text-[11px] font-bold text-[#94a3b8] xl:hidden">{label}</p>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StudentMetric({ icon: Icon, label, value, danger = false }: { icon: typeof GitBranch; label: string; value: number; danger?: boolean }) {
|
||||
return (
|
||||
<div className="min-w-0 text-center">
|
||||
<div className="flex items-center justify-center gap-1.5 text-[11px] font-bold text-[#94a3b8] xl:hidden">
|
||||
<Icon size={13} aria-hidden="true" />
|
||||
{label}
|
||||
</div>
|
||||
<p className={`mt-1 text-[13px] font-semibold xl:mt-0 ${danger ? 'text-[#e11d48]' : 'text-[#111827]'}`}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StudentIssueMetric({ student, onOpen }: { student: InstructorStudent; onOpen: (student: InstructorStudent) => void }) {
|
||||
const hasIssues = student.issues > 0;
|
||||
|
||||
return (
|
||||
<div className="min-w-0 text-center">
|
||||
<div className="flex items-center justify-center gap-1.5 text-[11px] font-bold text-[#94a3b8] xl:hidden">
|
||||
<CircleAlert size={13} aria-hidden="true" />
|
||||
이슈
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (hasIssues) onOpen(student);
|
||||
}}
|
||||
disabled={!hasIssues}
|
||||
className={[
|
||||
'mt-1 inline-flex h-7 min-w-7 items-center justify-center rounded-md px-2 text-[13px] font-black xl:mt-0',
|
||||
hasIssues
|
||||
? 'cursor-pointer bg-[#fff1f2] text-[#e11d48] transition-colors hover:bg-[#ffe4e6]'
|
||||
: 'cursor-default bg-transparent text-[#111827]',
|
||||
].join(' ')}
|
||||
title={hasIssues ? '이슈 상세 보기' : undefined}
|
||||
>
|
||||
{student.issues}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IssueModal({ student, onClose }: { student: InstructorStudent; onClose: () => void }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#0f172a]/25 px-4">
|
||||
<section className="w-full max-w-[520px] overflow-hidden rounded-lg border border-[#c7d2e0] bg-white shadow-[0_24px_70px_rgba(15,23,42,0.18)]">
|
||||
<header className="flex items-center justify-between border-b border-[#cbd5e1] px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-[16px] font-black text-[#111827]">이슈 상세</h2>
|
||||
<p className="mt-1 truncate text-[12px] font-bold text-[#64748b]">{student.name} · {student.email}</p>
|
||||
</div>
|
||||
<button
|
||||
className="grid h-8 w-8 place-items-center rounded-md text-[#64748b] hover:bg-[#f1f5f9] hover:text-[#111827]"
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="닫기"
|
||||
title="닫기"
|
||||
>
|
||||
<X size={17} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="max-h-[520px] overflow-y-auto px-5 py-4">
|
||||
{student.issueItems.length ? (
|
||||
<div className="grid gap-2">
|
||||
{student.issueItems.map((issue, index) => (
|
||||
<article key={`${issue.type}-${issue.week ?? 'score'}-${index}`} className="rounded-lg border border-[#cbd5e1] bg-[#f8fafc] px-4 py-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 grid h-7 w-7 shrink-0 place-items-center rounded-md bg-[#fff1f2] text-[#e11d48]">
|
||||
<CircleAlert size={15} aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13px] font-black text-[#111827]">{issue.title}</p>
|
||||
<p className="mt-1 text-[12px] font-semibold leading-5 text-[#64748b]">{issue.detail}</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid h-[180px] place-items-center text-[13px] font-bold text-[#94a3b8]">표시할 이슈가 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="flex justify-end border-t border-[#cbd5e1] px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="h-9 rounded-md bg-[#2563eb] px-4 text-[13px] font-bold text-white hover:bg-[#1d4ed8]"
|
||||
>
|
||||
확인
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryCard({ label, value, icon: Icon, tone }: { label: string; value: string; icon: LucideIcon; tone: string }) {
|
||||
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 text-[20px] font-black leading-none text-[#111827]">{value}</p>
|
||||
</div>
|
||||
<span className={`grid h-11 w-11 shrink-0 place-items-center rounded-md ${tone}`}>
|
||||
<Icon size={28} aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,935 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { BookOpen, CalendarDays, CheckCircle2, Clock3, FileCode2, FolderOpen, GitCommitHorizontal, Loader2, Save, UsersRound, UserRound, X, XCircle } from 'lucide-react';
|
||||
|
||||
import { useToast } from '../../components/ToastProvider';
|
||||
import { PageHeader } from '../../components/PageHeader';
|
||||
import { listInstructorSubmissions, saveInstructorSubmissionScore, type InstructorSubmissionItem } from '../../lib/submissions';
|
||||
import type { AuthSession } from '../../types/auth';
|
||||
|
||||
type CommitFile = InstructorSubmissionItem['commits'][number]['files'][number];
|
||||
type DiffViewLine = {
|
||||
key: string;
|
||||
type: 'file' | 'hunk' | 'context' | 'add' | 'delete' | 'note';
|
||||
oldLine: number | null;
|
||||
newLine: number | null;
|
||||
content: string;
|
||||
};
|
||||
type PatchModalState = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
files: CommitFile[];
|
||||
activePath: 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]',
|
||||
};
|
||||
|
||||
const parseDiffViewLines = (diff: string): DiffViewLine[] => {
|
||||
const viewLines: DiffViewLine[] = [];
|
||||
let oldLine = 0;
|
||||
let newLine = 0;
|
||||
|
||||
diff.split('\n').forEach((line, index) => {
|
||||
if (line.startsWith('diff --git ')) {
|
||||
const match = line.match(/^diff --git a\/(.+) b\/(.+)$/);
|
||||
viewLines.push({
|
||||
key: `file-${index}`,
|
||||
type: 'file',
|
||||
oldLine: null,
|
||||
newLine: null,
|
||||
content: match?.[2] ?? line.replace(/^diff --git\s+/, ''),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
line.startsWith('index ') ||
|
||||
line.startsWith('--- ') ||
|
||||
line.startsWith('+++ ') ||
|
||||
line.startsWith('new file mode ') ||
|
||||
line.startsWith('deleted file mode ')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.startsWith('\\ No newline')) {
|
||||
viewLines.push({
|
||||
key: `note-${index}`,
|
||||
type: 'note',
|
||||
oldLine: null,
|
||||
newLine: null,
|
||||
content: '파일 끝 줄바꿈 없음',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.startsWith('@@')) {
|
||||
const match = line.match(/@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@(.*)$/);
|
||||
oldLine = Number(match?.[1] ?? 0);
|
||||
newLine = Number(match?.[2] ?? 0);
|
||||
viewLines.push({
|
||||
key: `hunk-${index}`,
|
||||
type: 'hunk',
|
||||
oldLine: null,
|
||||
newLine: null,
|
||||
content: match?.[3]?.trim() || line,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!viewLines.length) return;
|
||||
|
||||
if (line.startsWith('+')) {
|
||||
viewLines.push({
|
||||
key: `add-${index}`,
|
||||
type: 'add',
|
||||
oldLine: null,
|
||||
newLine,
|
||||
content: line.slice(1),
|
||||
});
|
||||
newLine += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.startsWith('-')) {
|
||||
viewLines.push({
|
||||
key: `delete-${index}`,
|
||||
type: 'delete',
|
||||
oldLine,
|
||||
newLine: null,
|
||||
content: line.slice(1),
|
||||
});
|
||||
oldLine += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
viewLines.push({
|
||||
key: `context-${index}`,
|
||||
type: 'context',
|
||||
oldLine,
|
||||
newLine,
|
||||
content: line.startsWith(' ') ? line.slice(1) : line,
|
||||
});
|
||||
oldLine += 1;
|
||||
newLine += 1;
|
||||
});
|
||||
|
||||
return viewLines;
|
||||
};
|
||||
|
||||
export function SubmissionsPage({ session }: { session: AuthSession }) {
|
||||
const [items, setItems] = 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);
|
||||
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([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [session.accessToken]);
|
||||
|
||||
const courseNodes = useMemo(() => items.length
|
||||
? Array.from(new Map(items.map((item) => [item.course.id, item.course])).values())
|
||||
: [], [items]);
|
||||
const selectedCourse = courseNodes.find((course) => course.id === selectedCourseId) ?? null;
|
||||
const courseItems = useMemo(() => selectedCourse ? items.filter((item) => item.course.id === selectedCourse.id) : [], [items, selectedCourse]);
|
||||
const weekNodes = useMemo(() => {
|
||||
const byWeek = new Map<string, {
|
||||
id: string;
|
||||
week: number;
|
||||
title: string;
|
||||
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,
|
||||
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 groupNodes = useMemo(() => {
|
||||
const groupMap = new Map<string, { name: string; total: number; submitted: number; pending: number; notInstalled: number }>();
|
||||
weekItems.forEach((item) => {
|
||||
const group = 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(() => 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, weekItems]);
|
||||
const selectedStudent = studentNodes.find((student) => student.id === selectedStudentId) ?? null;
|
||||
const detailItems = 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 = items.filter((item) => item.course.id === course.id).length;
|
||||
const submittedCount = items.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.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={<UserRound size={15} />} title="수강생" count={studentNodes.length}>
|
||||
{!selectedGroup ? <EmptyState message="그룹을 선택하세요." /> : studentNodes.length ? studentNodes.map((student) => (
|
||||
<button
|
||||
key={student.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedStudentId(student.id);
|
||||
const firstItem = 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]">제출 {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={selectedStudent ? '제출 내역이 없습니다.' : '수강생을 선택하세요.'}
|
||||
onSelect={setSelectedId}
|
||||
onSaveScore={handleSaveScore}
|
||||
onOpenPatch={(title, subtitle, files, activePath) => setPatchModal({ title, subtitle, files, activePath })}
|
||||
/>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
{patchModal ? createPortal(
|
||||
<PatchModal
|
||||
modal={patchModal}
|
||||
onChangeActivePath={(activePath) => setPatchModal((current) => current ? { ...current, activePath } : current)}
|
||||
onClose={() => setPatchModal(null)}
|
||||
/>,
|
||||
document.body,
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubmissionDetail({
|
||||
item,
|
||||
items,
|
||||
emptyMessage,
|
||||
onSelect,
|
||||
onSaveScore,
|
||||
onOpenPatch,
|
||||
}: {
|
||||
item: InstructorSubmissionItem | null;
|
||||
items: InstructorSubmissionItem[];
|
||||
emptyMessage: string;
|
||||
onSelect: (id: string) => void;
|
||||
onSaveScore: (item: InstructorSubmissionItem, score: number) => Promise<number>;
|
||||
onOpenPatch: (title: string, subtitle: string, files: CommitFile[], activePath: string) => 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) 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>;
|
||||
}
|
||||
|
||||
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]">{item.student.name} · {item.module.name}</h2>
|
||||
<p className="mt-1 text-[12px] font-bold text-[#64748b]">{item.week.week}주차 {item.week.title} · {item.student.email}</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 max-h-[760px] 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]">제출 내역</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>
|
||||
<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>
|
||||
</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={() => onOpenPatch(
|
||||
`${item.module.name} · 최종 변경사항`,
|
||||
`${item.student.name} · ${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-mono 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>
|
||||
)
|
||||
) : item.commits.length ? (
|
||||
<div className="grid gap-4">
|
||||
{item.commits.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]">{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={() => onOpenPatch(
|
||||
`${item.module.name} · ${commit.shortHash}`,
|
||||
`${item.student.name} · ${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-mono 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' ? '아직 모듈 저장소가 생성되지 않았습니다.' : '아직 제출된 커밋이 없습니다.'}
|
||||
</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,
|
||||
onChangeActivePath,
|
||||
onClose,
|
||||
}: {
|
||||
modal: NonNullable<PatchModalState>;
|
||||
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);
|
||||
|
||||
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(760px,calc(100vh-48px))] w-full max-w-[1240px] grid-rows-[78px_minmax(0,1fr)] overflow-hidden rounded-[14px] border border-white/70 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-[320px_minmax(0,1fr)]">
|
||||
<aside className="min-h-0 border-r border-[#dfe6f1] bg-[#fbfdff]">
|
||||
<div className="flex h-[52px] items-center justify-between px-5">
|
||||
<span className="text-[13px] font-black text-[#334155]">파일 목록</span>
|
||||
<span className="rounded-full bg-[#eef2f7] px-2.5 py-1 text-[11px] font-black text-[#475569]">{visibleFiles.length}개</span>
|
||||
</div>
|
||||
<div className="h-[calc(100%-52px)] overflow-auto px-4 pb-4">
|
||||
{treeNodes.map((node) => (
|
||||
<FileTreeNode
|
||||
key={node.path}
|
||||
node={node}
|
||||
activePath={visibleActiveFile?.path ?? ''}
|
||||
onSelect={onChangeActivePath}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="grid min-h-0 grid-rows-[60px_minmax(0,1fr)] bg-[#fbfdff]">
|
||||
<div className="flex items-center justify-between gap-3 px-5">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-md border border-[#d1d9e6] bg-white text-[#334155]">
|
||||
<FileCode2 size={16} />
|
||||
</span>
|
||||
<span
|
||||
className="min-w-0 truncate text-[14px] font-black text-[#1f2937]"
|
||||
title={visibleActiveFile?.path ?? undefined}
|
||||
>
|
||||
{visibleActiveFile?.path.split('/').slice(-1)[0] ?? '파일 없음'}
|
||||
</span>
|
||||
</div>
|
||||
{visibleActiveFile ? <span className="rounded-full bg-[#eef2ff] px-2.5 py-1 text-[10px] font-black uppercase text-[#4f46e5]">{visibleActiveFile.status}</span> : null}
|
||||
</div>
|
||||
<div className="min-h-0 overflow-auto px-5 pb-5">
|
||||
{visibleActiveFile?.patch ? (
|
||||
<div className="w-full min-w-0 overflow-hidden rounded-lg border border-[#d5deea] bg-white font-mono text-[11px] shadow-[0_8px_24px_rgba(15,23,42,0.06)]">
|
||||
{parseDiffViewLines(visibleActiveFile.patch).map(renderDiffLine)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid h-full place-items-center text-[12px] font-bold text-[#94a3b8]">변경 내용 없음</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isIgnoredSubmissionFile(path: string) {
|
||||
return path === '.gitignore' || path.endsWith('/.gitignore') || path === '.autonomy-studio-git.json';
|
||||
}
|
||||
|
||||
function renderDiffLine(line: DiffViewLine) {
|
||||
const rowClass = line.type === 'file'
|
||||
? 'bg-[#edf4ff] text-[#4169e1]'
|
||||
: line.type === 'hunk'
|
||||
? 'bg-[#eef5ff] text-[#4169e1]'
|
||||
: line.type === 'note'
|
||||
? 'bg-[#fff7d6] text-[#9a5200]'
|
||||
: line.type === 'add'
|
||||
? 'bg-[#e8fff0] text-[#166534]'
|
||||
: line.type === 'delete'
|
||||
? 'bg-[#ffe3ea] text-[#9f1239]'
|
||||
: 'bg-white text-[#263247]';
|
||||
const marker = line.type === 'file' ? 'file' : line.type === 'add' ? '+' : line.type === 'delete' ? '-' : line.type === 'hunk' ? '@' : line.type === 'note' ? '!' : ' ';
|
||||
|
||||
return (
|
||||
<div key={line.key} className={`grid min-w-0 grid-cols-[48px_48px_34px_minmax(0,1fr)] border-b border-[#dfe6f1] last:border-b-0 ${rowClass}`}>
|
||||
<span className="px-2 py-1.5 text-right font-normal tabular-nums text-[#8b99ad]">{line.oldLine ?? ''}</span>
|
||||
<span className="px-2 py-1.5 text-right font-normal tabular-nums text-[#8b99ad]">{line.newLine ?? ''}</span>
|
||||
<span className="px-2 py-1.5 text-center font-semibold">{marker}</span>
|
||||
<code className={`min-w-0 max-w-full whitespace-pre-wrap break-all px-3 py-1.5 font-normal ${line.type === 'file' || line.type === 'hunk' ? 'font-sans' : ''}`}>
|
||||
{line.content || ' '}
|
||||
</code>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
if (node.kind === 'folder') {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="mb-1 flex h-7 items-center gap-2 rounded-md pr-2 text-[11px] font-black text-[#64748b] transition-colors hover:bg-[#f8faff] hover:text-[#334155]"
|
||||
style={{ paddingLeft: depth * 14 }}
|
||||
title={node.path}
|
||||
>
|
||||
<FolderOpen size={14} className="shrink-0 text-[#94a3b8]" />
|
||||
<span className="truncate">{node.name}</span>
|
||||
</div>
|
||||
{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={[
|
||||
'mb-1.5 grid min-h-[58px] w-full cursor-pointer grid-cols-[24px_minmax(0,1fr)_auto] items-center gap-2 rounded-md border py-2 pr-2.5 text-left transition-[background-color,border-color,box-shadow,color]',
|
||||
active ? 'border-[#c7d2fe] bg-[#eef2ff] text-[#4f46e5] shadow-[inset_4px_0_0_#4f46e5]' : 'border-transparent bg-transparent text-[#334155] hover:border-[#cbd5e1] hover:bg-[#f1f5f9] hover:shadow-[0_8px_18px_rgba(15,23,42,0.08)]',
|
||||
].join(' ')}
|
||||
style={{ paddingLeft: depth * 12 + 10 }}
|
||||
title={node.path}
|
||||
>
|
||||
<FileCode2 size={14} className="shrink-0 text-[#64748b]" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[12px] font-black text-[#111827]">{node.name}</span>
|
||||
<span className="mt-0.5 block truncate font-mono text-[10px] font-semibold text-[#64748b]">{node.path}</span>
|
||||
</span>
|
||||
{node.file ? <span className="shrink-0 rounded bg-[#eef2ff] px-1.5 py-0.5 text-[9px] font-black uppercase text-[#4f46e5]">{node.file.status}</span> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user