chore: init lms adm fe

This commit is contained in:
2026-07-07 17:23:40 +09:00
commit d682e9a382
57 changed files with 12065 additions and 0 deletions
+471
View File
@@ -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>
);
}