Files
lms-adm-fe/src/pages/dashboard/DashboardPage.tsx
T
2026-07-07 17:23:40 +09:00

349 lines
16 KiB
TypeScript

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}일 전`;
}