612 lines
26 KiB
TypeScript
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>
|
|
);
|
|
}
|