Files
lms-adm-fe/src/pages/students/StudentsPage.tsx
T

612 lines
26 KiB
TypeScript

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 className="overflow-x-auto">
<div className="min-w-[1180px]">
<div className="grid grid-cols-[44px_108px_minmax(160px,1fr)_minmax(116px,0.7fr)_78px_minmax(150px,1fr)_58px_58px_118px_70px_42px] items-center gap-4 border-b border-[#cbd5e1] bg-[#f8fafc] px-4 py-3 text-center text-[11px] font-bold text-[#64748b]">
<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 grid-cols-[44px_108px_minmax(160px,1fr)_minmax(116px,0.7fr)_78px_minmax(150px,1fr)_58px_58px_118px_70px_42px] items-center gap-4 px-4 py-4" 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="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="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="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="hidden">
<Icon size={13} aria-hidden="true" />
{label}
</div>
<p className={`mt-0 text-[13px] font-semibold ${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="hidden">
<CircleAlert size={13} aria-hidden="true" />
이슈
</div>
<button
type="button"
onClick={() => {
if (hasIssues) onOpen(student);
}}
disabled={!hasIssues}
className={[
'mt-0 inline-flex h-7 min-w-7 items-center justify-center rounded-md px-2 text-[13px] font-black',
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>
);
}