chore: init lms adm fe
This commit is contained in:
@@ -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}일 전`;
|
||||
}
|
||||
Reference in New Issue
Block a user