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