2026-03-02 16:16:59 +09:00
|
|
|
/**
|
|
|
|
|
* 관리자 활동 로그 페이지
|
|
|
|
|
*/
|
2026-03-02 17:06:39 +09:00
|
|
|
import { useState, useEffect } from 'react';
|
2026-03-02 16:16:59 +09:00
|
|
|
import { Link } from 'react-router-dom';
|
2026-03-02 17:06:39 +09:00
|
|
|
import { useQuery, keepPreviousData } from '@tanstack/react-query';
|
2026-03-02 16:40:27 +09:00
|
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
2026-03-02 16:16:59 +09:00
|
|
|
import {
|
|
|
|
|
Home, ChevronRight, Search, ChevronLeft, ChevronDown,
|
2026-03-02 21:49:14 +09:00
|
|
|
X, Loader2, Check, ScrollText,
|
2026-03-02 16:16:59 +09:00
|
|
|
} from 'lucide-react';
|
2026-03-02 21:49:14 +09:00
|
|
|
import {
|
|
|
|
|
AdminLayout, DatePicker,
|
|
|
|
|
CATEGORY_LABELS, ACTION_STYLES, ACTION_LABELS, ITEMS_PER_PAGE, formatDateTime,
|
|
|
|
|
LogDetailDialog, ActorBadge, Summary,
|
|
|
|
|
} from '@/components/pc/admin';
|
2026-03-02 16:16:59 +09:00
|
|
|
import { useAdminAuth } from '@/hooks/pc/admin';
|
2026-03-02 17:06:39 +09:00
|
|
|
import { adminLogApi } from '@/api/admin';
|
2026-03-02 16:16:59 +09:00
|
|
|
|
2026-03-02 16:54:08 +09:00
|
|
|
function Logs() {
|
2026-03-02 16:16:59 +09:00
|
|
|
const { user } = useAdminAuth();
|
|
|
|
|
|
|
|
|
|
// 필터 상태
|
|
|
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
|
|
|
const [selectedCategories, setSelectedCategories] = useState([]);
|
2026-03-02 21:49:14 +09:00
|
|
|
const [actorFilter, setActorFilter] = useState('all');
|
2026-03-02 16:16:59 +09:00
|
|
|
const [dateFrom, setDateFrom] = useState('');
|
|
|
|
|
const [dateTo, setDateTo] = useState('');
|
|
|
|
|
const [currentPage, setCurrentPage] = useState(1);
|
2026-04-22 11:47:59 +09:00
|
|
|
const [pageInput, setPageInput] = useState('1');
|
2026-03-02 16:16:59 +09:00
|
|
|
const [actorDropdownOpen, setActorDropdownOpen] = useState(false);
|
2026-03-02 17:16:52 +09:00
|
|
|
const [categoryDropdownOpen, setCategoryDropdownOpen] = useState(false);
|
2026-03-02 21:49:14 +09:00
|
|
|
const [selectedLog, setSelectedLog] = useState(null);
|
2026-03-02 16:16:59 +09:00
|
|
|
|
2026-03-02 17:06:39 +09:00
|
|
|
// 검색어 디바운스
|
|
|
|
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const timer = setTimeout(() => setDebouncedSearch(searchQuery), 300);
|
|
|
|
|
return () => clearTimeout(timer);
|
|
|
|
|
}, [searchQuery]);
|
|
|
|
|
|
2026-03-02 17:16:52 +09:00
|
|
|
// 카테고리 목록 조회
|
|
|
|
|
const { data: categoryData } = useQuery({
|
|
|
|
|
queryKey: ['admin', 'logs', 'categories'],
|
|
|
|
|
queryFn: () => adminLogApi.getLogCategories(),
|
|
|
|
|
staleTime: 5 * 60 * 1000,
|
|
|
|
|
});
|
|
|
|
|
const categories = categoryData?.categories || [];
|
|
|
|
|
|
|
|
|
|
// 로그 API 호출
|
2026-03-02 17:06:39 +09:00
|
|
|
const { data, isLoading } = useQuery({
|
|
|
|
|
queryKey: ['admin', 'logs', { page: currentPage, category: selectedCategories.join(','), actor: actorFilter === 'all' ? '' : actorFilter, search: debouncedSearch, from: dateFrom, to: dateTo }],
|
|
|
|
|
queryFn: () => adminLogApi.getLogs({
|
|
|
|
|
page: currentPage,
|
|
|
|
|
limit: ITEMS_PER_PAGE,
|
|
|
|
|
category: selectedCategories.join(',') || undefined,
|
|
|
|
|
actor: actorFilter === 'all' ? undefined : actorFilter,
|
|
|
|
|
search: debouncedSearch || undefined,
|
|
|
|
|
from: dateFrom || undefined,
|
|
|
|
|
to: dateTo || undefined,
|
|
|
|
|
}),
|
|
|
|
|
placeholderData: keepPreviousData,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const logs = data?.logs || [];
|
|
|
|
|
const total = data?.total || 0;
|
|
|
|
|
const totalPages = data?.totalPages || 0;
|
|
|
|
|
|
2026-04-22 11:47:59 +09:00
|
|
|
// 페이지 변경 시 입력 필드 동기화
|
|
|
|
|
useEffect(() => { setPageInput(String(currentPage)); }, [currentPage]);
|
|
|
|
|
|
|
|
|
|
const goToPageFromInput = () => {
|
|
|
|
|
const n = parseInt(pageInput, 10);
|
|
|
|
|
if (!Number.isFinite(n) || n < 1) {
|
|
|
|
|
setPageInput(String(currentPage));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const clamped = Math.min(totalPages, n);
|
|
|
|
|
setCurrentPage(clamped);
|
|
|
|
|
setPageInput(String(clamped));
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-02 16:16:59 +09:00
|
|
|
// 카테고리 토글
|
|
|
|
|
const toggleCategory = (cat) => {
|
|
|
|
|
setSelectedCategories((prev) =>
|
|
|
|
|
prev.includes(cat) ? prev.filter((c) => c !== cat) : [...prev, cat]
|
|
|
|
|
);
|
|
|
|
|
setCurrentPage(1);
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-02 17:16:52 +09:00
|
|
|
// 카테고리 드롭다운 버튼 텍스트
|
|
|
|
|
const getCategoryButtonText = () => {
|
|
|
|
|
if (selectedCategories.length === 0) return '전체 카테고리';
|
|
|
|
|
if (selectedCategories.length === 1) return CATEGORY_LABELS[selectedCategories[0]] || selectedCategories[0];
|
|
|
|
|
return `카테고리 (${selectedCategories.length})`;
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-02 16:16:59 +09:00
|
|
|
// 필터 초기화
|
|
|
|
|
const clearFilters = () => {
|
|
|
|
|
setSearchQuery('');
|
|
|
|
|
setSelectedCategories([]);
|
|
|
|
|
setActorFilter('all');
|
|
|
|
|
setDateFrom('');
|
|
|
|
|
setDateTo('');
|
|
|
|
|
setCurrentPage(1);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const hasActiveFilters = searchQuery || selectedCategories.length > 0 || actorFilter !== 'all' || dateFrom || dateTo;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<AdminLayout user={user}>
|
|
|
|
|
<div className="max-w-7xl mx-auto px-6 py-8">
|
|
|
|
|
{/* 브레드크럼 */}
|
|
|
|
|
<div className="flex items-center gap-2 text-sm text-gray-400 mb-8">
|
|
|
|
|
<Link to="/admin/dashboard" className="hover:text-primary transition-colors">
|
|
|
|
|
<Home size={16} />
|
|
|
|
|
</Link>
|
|
|
|
|
<ChevronRight size={14} />
|
|
|
|
|
<span className="text-gray-700">활동 로그</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 타이틀 */}
|
|
|
|
|
<div className="mb-8">
|
|
|
|
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">활동 로그</h1>
|
|
|
|
|
<p className="text-gray-500">모든 관리자 및 봇 활동 기록을 확인합니다</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 필터 영역 */}
|
|
|
|
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5 mb-6">
|
2026-03-02 17:16:52 +09:00
|
|
|
<div className="flex items-center gap-3">
|
2026-03-02 16:16:59 +09:00
|
|
|
{/* 검색 */}
|
|
|
|
|
<div className="relative flex-1 max-w-sm">
|
|
|
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
value={searchQuery}
|
|
|
|
|
onChange={(e) => { setSearchQuery(e.target.value); setCurrentPage(1); }}
|
|
|
|
|
placeholder="로그 검색..."
|
|
|
|
|
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-sm"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 행위자 드롭다운 */}
|
|
|
|
|
<div className="relative">
|
|
|
|
|
<button
|
2026-03-02 17:16:52 +09:00
|
|
|
onClick={() => { setActorDropdownOpen(!actorDropdownOpen); setCategoryDropdownOpen(false); }}
|
2026-03-02 16:16:59 +09:00
|
|
|
className="flex items-center gap-2 px-4 py-2 border border-gray-200 rounded-lg text-sm hover:bg-gray-50 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<span className="text-gray-600">
|
|
|
|
|
{actorFilter === 'all' ? '전체 행위자' : actorFilter === 'admin' ? '관리자' : '봇'}
|
|
|
|
|
</span>
|
|
|
|
|
<ChevronDown size={16} className="text-gray-400" />
|
|
|
|
|
</button>
|
2026-03-02 16:40:27 +09:00
|
|
|
<AnimatePresence>
|
|
|
|
|
{actorDropdownOpen && (
|
|
|
|
|
<>
|
|
|
|
|
<div className="fixed inset-0 z-10" onClick={() => setActorDropdownOpen(false)} />
|
|
|
|
|
<motion.div
|
|
|
|
|
initial={{ opacity: 0, y: -8 }}
|
|
|
|
|
animate={{ opacity: 1, y: 0 }}
|
|
|
|
|
exit={{ opacity: 0, y: -8 }}
|
|
|
|
|
transition={{ duration: 0.15 }}
|
|
|
|
|
className="absolute top-full left-0 mt-1 w-36 bg-white border border-gray-200 rounded-lg shadow-lg z-20 py-1"
|
|
|
|
|
>
|
|
|
|
|
{[
|
|
|
|
|
{ value: 'all', label: '전체 행위자' },
|
|
|
|
|
{ value: 'admin', label: '관리자' },
|
|
|
|
|
{ value: 'bot', label: '봇' },
|
|
|
|
|
].map((opt) => (
|
|
|
|
|
<button
|
|
|
|
|
key={opt.value}
|
|
|
|
|
onClick={() => { setActorFilter(opt.value); setActorDropdownOpen(false); setCurrentPage(1); }}
|
|
|
|
|
className={`w-full text-left px-4 py-2 text-sm hover:bg-gray-50 transition-colors ${
|
|
|
|
|
actorFilter === opt.value ? 'text-primary font-medium' : 'text-gray-700'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{opt.label}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</motion.div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</AnimatePresence>
|
2026-03-02 16:16:59 +09:00
|
|
|
</div>
|
|
|
|
|
|
2026-03-02 17:16:52 +09:00
|
|
|
{/* 카테고리 드롭다운 */}
|
|
|
|
|
<div className="relative">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => categories.length > 0 && (setCategoryDropdownOpen(!categoryDropdownOpen), setActorDropdownOpen(false))}
|
|
|
|
|
disabled={categories.length === 0}
|
|
|
|
|
className={`flex items-center gap-2 px-4 py-2 border rounded-lg text-sm transition-colors ${
|
|
|
|
|
categories.length === 0
|
|
|
|
|
? 'border-gray-200 text-gray-400 cursor-not-allowed'
|
|
|
|
|
: selectedCategories.length > 0
|
|
|
|
|
? 'border-primary text-primary hover:bg-gray-50'
|
|
|
|
|
: 'border-gray-200 text-gray-600 hover:bg-gray-50'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
<span>{getCategoryButtonText()}</span>
|
|
|
|
|
<ChevronDown size={16} className={selectedCategories.length > 0 ? 'text-primary' : 'text-gray-400'} />
|
|
|
|
|
</button>
|
|
|
|
|
<AnimatePresence>
|
|
|
|
|
{categoryDropdownOpen && (
|
|
|
|
|
<>
|
|
|
|
|
<div className="fixed inset-0 z-10" onClick={() => setCategoryDropdownOpen(false)} />
|
|
|
|
|
<motion.div
|
|
|
|
|
initial={{ opacity: 0, y: -8 }}
|
|
|
|
|
animate={{ opacity: 1, y: 0 }}
|
|
|
|
|
exit={{ opacity: 0, y: -8 }}
|
|
|
|
|
transition={{ duration: 0.15 }}
|
|
|
|
|
className="absolute top-full left-0 mt-1 w-44 bg-white border border-gray-200 rounded-lg shadow-lg z-20 py-1"
|
|
|
|
|
>
|
|
|
|
|
{categories.map((cat) => (
|
|
|
|
|
<button
|
|
|
|
|
key={cat}
|
|
|
|
|
onClick={() => toggleCategory(cat)}
|
|
|
|
|
className="w-full flex items-center gap-2.5 px-4 py-2 text-sm hover:bg-gray-50 transition-colors text-gray-700"
|
|
|
|
|
>
|
|
|
|
|
<span className={`w-4 h-4 rounded border flex items-center justify-center flex-shrink-0 ${
|
|
|
|
|
selectedCategories.includes(cat) ? 'bg-primary border-primary' : 'border-gray-300'
|
|
|
|
|
}`}>
|
|
|
|
|
{selectedCategories.includes(cat) && <Check size={12} className="text-white" />}
|
|
|
|
|
</span>
|
|
|
|
|
{CATEGORY_LABELS[cat] || cat}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
{selectedCategories.length > 0 && (
|
|
|
|
|
<>
|
|
|
|
|
<div className="border-t border-gray-100 my-1" />
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => { setSelectedCategories([]); setCurrentPage(1); }}
|
|
|
|
|
className="w-full text-left px-4 py-2 text-sm text-gray-400 hover:bg-gray-50 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
선택 해제
|
|
|
|
|
</button>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</motion.div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</AnimatePresence>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-03-02 16:16:59 +09:00
|
|
|
{/* 날짜 필터 */}
|
|
|
|
|
<div className="flex items-center gap-2">
|
2026-03-02 17:16:52 +09:00
|
|
|
<div className="w-40">
|
2026-03-02 16:40:27 +09:00
|
|
|
<DatePicker
|
|
|
|
|
value={dateFrom}
|
|
|
|
|
onChange={(v) => { setDateFrom(v); setCurrentPage(1); }}
|
|
|
|
|
placeholder="시작일"
|
2026-03-02 17:16:52 +09:00
|
|
|
max={dateTo || undefined}
|
|
|
|
|
compact
|
2026-03-02 16:40:27 +09:00
|
|
|
/>
|
|
|
|
|
</div>
|
2026-03-02 16:16:59 +09:00
|
|
|
<span className="text-gray-400 text-sm">~</span>
|
2026-03-02 17:16:52 +09:00
|
|
|
<div className="w-40">
|
2026-03-02 16:40:27 +09:00
|
|
|
<DatePicker
|
|
|
|
|
value={dateTo}
|
|
|
|
|
onChange={(v) => { setDateTo(v); setCurrentPage(1); }}
|
|
|
|
|
placeholder="종료일"
|
2026-03-02 17:16:52 +09:00
|
|
|
min={dateFrom || undefined}
|
|
|
|
|
compact
|
2026-03-02 16:40:27 +09:00
|
|
|
/>
|
|
|
|
|
</div>
|
2026-03-02 16:16:59 +09:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 필터 초기화 */}
|
|
|
|
|
{hasActiveFilters && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={clearFilters}
|
2026-03-02 17:16:52 +09:00
|
|
|
className="flex items-center gap-1 px-2 py-2 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
2026-03-02 16:16:59 +09:00
|
|
|
>
|
|
|
|
|
<X size={14} />
|
|
|
|
|
초기화
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 결과 개수 */}
|
|
|
|
|
<div className="flex items-center justify-between mb-4">
|
|
|
|
|
<p className="text-sm text-gray-500">
|
2026-03-02 17:06:39 +09:00
|
|
|
총 <span className="font-medium text-gray-900">{total}</span>개의 로그
|
2026-03-02 16:16:59 +09:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 로그 테이블 */}
|
|
|
|
|
<motion.div
|
|
|
|
|
initial={{ opacity: 0, y: 12 }}
|
|
|
|
|
animate={{ opacity: 1, y: 0 }}
|
|
|
|
|
transition={{ duration: 0.4, ease: [0.25, 0.1, 0.25, 1], delay: 0.15 }}
|
|
|
|
|
className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden"
|
|
|
|
|
>
|
2026-03-02 16:40:27 +09:00
|
|
|
<table className="w-full table-fixed">
|
2026-03-02 16:16:59 +09:00
|
|
|
<thead className="bg-gray-50 border-b border-gray-100">
|
|
|
|
|
<tr>
|
2026-03-02 16:40:27 +09:00
|
|
|
<th className="text-left pl-4 pr-2 py-4 text-sm font-medium text-gray-500 whitespace-nowrap w-[15%]">시간</th>
|
2026-03-03 00:28:53 +09:00
|
|
|
<th className="text-left px-3 py-4 text-sm font-medium text-gray-500 whitespace-nowrap w-[15%]">행위자</th>
|
|
|
|
|
<th className="text-left px-3 py-4 text-sm font-medium text-gray-500 whitespace-nowrap w-[10%]">액션</th>
|
|
|
|
|
<th className="text-left px-3 py-4 text-sm font-medium text-gray-500 whitespace-nowrap w-[10%]">카테고리</th>
|
2026-03-02 16:40:27 +09:00
|
|
|
<th className="text-left pl-3 pr-6 py-4 text-sm font-medium text-gray-500">내용</th>
|
2026-03-02 16:16:59 +09:00
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody className="divide-y divide-gray-100">
|
2026-03-02 17:06:39 +09:00
|
|
|
{logs.map((log, index) => (
|
2026-03-02 16:16:59 +09:00
|
|
|
<motion.tr
|
|
|
|
|
key={log.id}
|
|
|
|
|
initial={{ opacity: 0, y: 10 }}
|
|
|
|
|
animate={{ opacity: 1, y: 0 }}
|
|
|
|
|
transition={{ delay: index * 0.03 }}
|
|
|
|
|
className="hover:bg-gray-50 transition-colors"
|
|
|
|
|
>
|
2026-03-02 16:40:27 +09:00
|
|
|
<td className="pl-4 pr-2 py-3.5 text-sm text-gray-500 tabular-nums whitespace-nowrap">
|
2026-03-02 16:16:59 +09:00
|
|
|
{formatDateTime(log.created_at)}
|
|
|
|
|
</td>
|
2026-03-02 16:40:27 +09:00
|
|
|
<td className="px-3 py-3.5 whitespace-nowrap">
|
2026-03-02 21:49:14 +09:00
|
|
|
<ActorBadge actor={log.actor} />
|
2026-03-02 16:16:59 +09:00
|
|
|
</td>
|
2026-03-02 16:40:27 +09:00
|
|
|
<td className="px-3 py-3.5 whitespace-nowrap">
|
2026-03-02 16:16:59 +09:00
|
|
|
<span className={`inline-block px-2.5 py-1 text-xs font-medium rounded-full ${ACTION_STYLES[log.action] || 'bg-gray-100 text-gray-600'}`}>
|
|
|
|
|
{ACTION_LABELS[log.action] || log.action}
|
|
|
|
|
</span>
|
|
|
|
|
</td>
|
2026-03-02 16:40:27 +09:00
|
|
|
<td className="px-3 py-3.5 whitespace-nowrap">
|
2026-03-02 16:16:59 +09:00
|
|
|
<span className="text-xs text-gray-500">
|
2026-03-02 17:16:52 +09:00
|
|
|
{CATEGORY_LABELS[log.category] || log.category}
|
2026-03-02 16:16:59 +09:00
|
|
|
</span>
|
|
|
|
|
</td>
|
2026-03-02 16:40:27 +09:00
|
|
|
<td className="pl-3 pr-6 py-3.5 text-sm text-gray-700">
|
2026-03-02 21:49:14 +09:00
|
|
|
<div
|
|
|
|
|
onClick={() => setSelectedLog(log)}
|
|
|
|
|
className="truncate cursor-pointer hover:text-gray-900 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<Summary summary={log.summary} />
|
|
|
|
|
</div>
|
2026-03-02 16:16:59 +09:00
|
|
|
</td>
|
|
|
|
|
</motion.tr>
|
|
|
|
|
))}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
|
2026-03-02 17:06:39 +09:00
|
|
|
{isLoading && logs.length === 0 && (
|
|
|
|
|
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
|
|
|
|
|
<Loader2 size={32} className="animate-spin mb-4" />
|
|
|
|
|
<p className="text-sm">로그를 불러오는 중...</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{!isLoading && logs.length === 0 && (
|
2026-03-02 16:16:59 +09:00
|
|
|
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
|
|
|
|
|
<ScrollText size={48} strokeWidth={1} className="mb-4" />
|
|
|
|
|
<p className="text-sm">
|
|
|
|
|
{hasActiveFilters ? '검색 결과가 없습니다.' : '활동 로그가 없습니다.'}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</motion.div>
|
|
|
|
|
|
|
|
|
|
{/* 페이지네이션 */}
|
|
|
|
|
{totalPages > 1 && (
|
2026-04-22 11:47:59 +09:00
|
|
|
<div className="grid grid-cols-3 items-center mt-6">
|
|
|
|
|
<div />
|
|
|
|
|
<div className="flex items-center justify-center gap-2">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
|
|
|
|
disabled={currentPage === 1}
|
|
|
|
|
className="p-2 rounded-lg hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<ChevronLeft size={18} />
|
|
|
|
|
</button>
|
|
|
|
|
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
|
|
|
|
.filter((page) => {
|
|
|
|
|
if (totalPages <= 7) return true;
|
|
|
|
|
if (page === 1 || page === totalPages) return true;
|
|
|
|
|
if (Math.abs(page - currentPage) <= 2) return true;
|
|
|
|
|
return false;
|
|
|
|
|
})
|
|
|
|
|
.reduce((acc, page, i, arr) => {
|
|
|
|
|
if (i > 0 && page - arr[i - 1] > 1) {
|
|
|
|
|
acc.push({ type: 'ellipsis', key: `e-${page}` });
|
|
|
|
|
}
|
|
|
|
|
acc.push({ type: 'page', value: page, key: page });
|
|
|
|
|
return acc;
|
|
|
|
|
}, [])
|
|
|
|
|
.map((item) =>
|
|
|
|
|
item.type === 'ellipsis' ? (
|
|
|
|
|
<span key={item.key} className="w-9 h-9 flex items-center justify-center text-sm text-gray-400">...</span>
|
|
|
|
|
) : (
|
|
|
|
|
<button
|
|
|
|
|
key={item.key}
|
|
|
|
|
onClick={() => setCurrentPage(item.value)}
|
|
|
|
|
className={`w-9 h-9 rounded-lg text-sm font-medium transition-colors ${
|
|
|
|
|
currentPage === item.value
|
|
|
|
|
? 'bg-primary text-white'
|
|
|
|
|
: 'text-gray-600 hover:bg-gray-100'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{item.value}
|
|
|
|
|
</button>
|
|
|
|
|
)
|
|
|
|
|
)}
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
|
|
|
|
disabled={currentPage === totalPages}
|
|
|
|
|
className="p-2 rounded-lg hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<ChevronRight size={18} />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center justify-end gap-1.5">
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
inputMode="numeric"
|
|
|
|
|
value={pageInput}
|
|
|
|
|
onChange={(e) => setPageInput(e.target.value.replace(/\D/g, ''))}
|
|
|
|
|
onBlur={goToPageFromInput}
|
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
if (e.key === 'Enter') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
goToPageFromInput();
|
|
|
|
|
e.currentTarget.blur();
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
className="w-12 h-9 text-center tabular-nums border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
|
|
|
|
aria-label="페이지 번호 입력"
|
|
|
|
|
/>
|
|
|
|
|
<span className="text-sm text-gray-400 tabular-nums">/ {totalPages}</span>
|
|
|
|
|
</div>
|
2026-03-02 16:16:59 +09:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-03-02 21:49:14 +09:00
|
|
|
|
|
|
|
|
{/* 로그 상세 다이얼로그 */}
|
|
|
|
|
<LogDetailDialog log={selectedLog} onClose={() => setSelectedLog(null)} />
|
2026-03-02 16:16:59 +09:00
|
|
|
</AdminLayout>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 16:54:08 +09:00
|
|
|
export default Logs;
|