import { MoreHorizontal, Plus } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { DataTableCard, type DataTableColumn, type DataTableFilter } from '../../components/data-table'; import { Button, Modal } from '../../components/ui'; import { useToast } from '../../components/toast'; import { PageTitle } from '../../layout/AppLayout'; import { cn } from '../../lib/utils'; import { createManagedUser, loadLicensePlans, loadOrganizations, loadUserSessionsPage, lockUser, terminateUserSession, unlockUser, type LicensePlan, type Organization, type UserSession } from './operations-api'; import { formatDateTime, LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils'; const emptyCreateForm = { email: '', name: '', password: '', organizationId: '', department: '', position: '', licenseCode: 'EDU' as 'FREE' | 'EDU' | 'PRO' }; export function UserSessionsPage() { const { toast } = useToast(); const [sessions, setSessions] = useState([]); const [page, setPage] = useState(1); const [total, setTotal] = useState(0); const [keyword, setKeyword] = useState(''); const [filterValues, setFilterValues] = useState>({}); const [organizations, setOrganizations] = useState([]); const [licensePlans, setLicensePlans] = useState([]); const [loading, setLoading] = useState(true); const [failed, setFailed] = useState(false); const [createOpen, setCreateOpen] = useState(false); const [createForm, setCreateForm] = useState(emptyCreateForm); const [createSaving, setCreateSaving] = useState(false); const [menuSession, setMenuSession] = useState<{ id: string; x: number; y: number } | null>(null); const [detailSession, setDetailSession] = useState(null); const loadingVisible = useMinimumLoading(loading); const refresh = () => { setLoading(true); loadUserSessionsPage({ page, limit: 6, keyword, state: filterValues.state }) .then((result) => { setSessions(result.items); setTotal(result.total); setFailed(false); }) .catch(() => { setSessions([]); setFailed(true); }) .finally(() => setLoading(false)); }; useEffect(() => { refresh(); Promise.all([ loadOrganizations().catch(() => []), loadLicensePlans().catch(() => []) ]).then(([nextOrganizations, nextLicensePlans]) => { setOrganizations(nextOrganizations); setLicensePlans(nextLicensePlans); setCreateForm((current) => ({ ...current, organizationId: current.organizationId || '', licenseCode: (current.licenseCode || nextLicensePlans[0]?.code || 'EDU') as 'FREE' | 'EDU' | 'PRO' })); }); }, [page, keyword, filterValues.state]); useEffect(() => { if (!menuSession) return; const close = () => setMenuSession(null); const closeOnEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') setMenuSession(null); }; window.addEventListener('click', close); window.addEventListener('keydown', closeOnEscape); return () => { window.removeEventListener('click', close); window.removeEventListener('keydown', closeOnEscape); }; }, [menuSession]); const rows = sessions; const columns = useMemo>>( () => [ { key: 'no', header: 'No.', className: 'w-14 px-3 py-3 text-center font-medium', cell: (row) => {row.no} }, { key: 'user', header: '사용자', className: 'w-[280px] px-5 py-3 font-medium', cell: (row) => (

{row.user}

{row.email ?

{row.email}

: null}
) }, { key: 'session', header: '세션', className: 'w-28 px-5 py-3', cell: (row) => {row.session} }, { key: 'expiresAt', header: '만료', className: 'w-40 px-5 py-3', cell: (row) => {formatDateTime(row.expiresAt)} }, { key: 'lastSeen', header: '최근 접속', className: 'w-40 px-5 py-3', cell: (row) => {formatDateTime(row.lastSeen)} }, { key: 'state', header: '상태', className: 'w-32 px-5 py-3', cell: (row) => }, { key: 'actions', header: '', className: 'w-20 px-5 py-3 text-right font-medium', cell: (row) => (
) } ], [menuSession?.id] ); const filters = useMemo>>( () => [ { key: 'state', label: '상태', options: ['유효토큰', '세션 없음', '잠김', '만료', '종료'].map((state) => ({ label: state, value: state })), getValue: (row) => row.state } ], [] ); const openCreateModal = () => { setCreateForm({ ...emptyCreateForm, organizationId: '', licenseCode: (licensePlans[0]?.code || 'EDU') as 'FREE' | 'EDU' | 'PRO' }); setCreateOpen(true); }; const submitCreateUser = () => { if (!createForm.email.trim() || !createForm.name.trim() || createForm.password.length < 8) return; setCreateSaving(true); createManagedUser({ email: createForm.email.trim(), name: createForm.name.trim(), password: createForm.password, organizationId: createForm.organizationId || null, department: createForm.department.trim() || null, position: createForm.position.trim() || null, licenseCode: createForm.licenseCode }) .then(() => { toast({ type: 'success', title: '사용자 등록', message: '사용자가 등록되었습니다.' }); setCreateOpen(false); refresh(); }) .catch((error: Error) => { toast({ type: 'error', title: '사용자 등록 실패', message: error.message }); }) .finally(() => setCreateSaving(false)); }; return ( <> 추가 } /> {failed ? (
사용자/세션 데이터를 불러오지 못했습니다.
) : null} `${row.user} ${row.email ?? ''} ${row.session} ${row.state}`} getRowKey={(row) => row.id} pageSize={6} minWidth={900} loading={loading} emptyMessage="표시할 사용자/세션 데이터가 없습니다." serverPagination={{ page, total, onPageChange: setPage, onSearchChange: (value) => { setKeyword(value); setPage(1); }, filterValues, onFilterChange: (key, value) => { setFilterValues((prev) => ({ ...prev, [key]: value })); setPage(1); } }} /> {menuSession ? (() => { const row = rows.find((item) => item.id === menuSession.id); if (!row) return null; const locked = row.state === '잠김'; return (
event.stopPropagation()} className="fixed z-[70] min-w-32 overflow-hidden rounded-md border border-border bg-card py-1 text-left shadow-xl" style={{ left: menuSession.x, top: menuSession.y, transform: 'translateX(-100%)' }}>
); })() : null} setDetailSession(null)} className="!max-w-2xl" footer={} > {detailSession ? (

{detailSession.user}

{detailSession.email ?? '-'}

) : null}
{ if (!createSaving) setCreateOpen(false); }} footer={ <> } >
); } function organizationTypeLabel(type?: string | null) { const labels: Record = { COMPANY: '회사', SCHOOL: '학교', GOVERNMENT: '공공기관', LAB: '연구소', OTHER: '기타' }; return type ? labels[type] ?? type : '-'; } function UserAvatar({ session, size = 'sm' }: { session: UserSession; size?: 'sm' | 'lg' }) { const [failed, setFailed] = useState(false); const initial = (session.user || session.email || '?').trim().charAt(0).toUpperCase() || '?'; const sizeClassName = size === 'lg' ? 'size-14 text-lg' : 'size-9 text-sm'; if (session.avatarUrl && !failed) { return ( setFailed(true)} /> ); } return ( {initial} ); } function DetailItem({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) { return ( ); }