feat(frontend): 활동 로그 API 연동 및 더미데이터 제거

더미데이터를 실제 API 호출(React Query)로 교체.
서버 사이드 필터링/페이지네이션, 검색 디바운스(300ms),
keepPreviousData로 페이지 전환 시 깜빡임 방지,
페이지 수가 많을 때 생략 부호(...) 페이지네이션 추가.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-03-02 17:06:39 +09:00
parent 1f1d6987d1
commit 414b798914
3 changed files with 101 additions and 81 deletions

View file

@ -8,6 +8,7 @@ export * as adminCategoryApi from './categories';
export * as adminBotApi from './bots'; export * as adminBotApi from './bots';
export * as adminStatsApi from './stats'; export * as adminStatsApi from './stats';
export * as adminSuggestionApi from './suggestions'; export * as adminSuggestionApi from './suggestions';
export * as adminLogApi from './logs';
export * as adminAuthApi from './auth'; export * as adminAuthApi from './auth';
// 개별 함수 export // 개별 함수 export

View file

@ -0,0 +1,27 @@
/**
* 관리자 활동 로그 API
*/
import { fetchAuthApi } from '@/api/client';
/**
* 활동 로그 목록 조회
* @param {object} params - 쿼리 파라미터
* @param {number} [params.page] - 페이지 번호
* @param {number} [params.limit] - 페이지당 개수
* @param {string} [params.category] - 카테고리 필터 (콤마 구분)
* @param {string} [params.actor] - 행위자 필터 (admin 또는 bot)
* @param {string} [params.search] - summary 검색
* @param {string} [params.from] - 시작 날짜 (YYYY-MM-DD)
* @param {string} [params.to] - 종료 날짜 (YYYY-MM-DD)
* @returns {Promise<{logs: Array, total: number, page: number, limit: number, totalPages: number}>}
*/
export async function getLogs(params = {}) {
const query = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null && value !== '') {
query.set(key, value);
}
}
const qs = query.toString();
return fetchAuthApi(`/admin/logs${qs ? `?${qs}` : ''}`);
}

View file

@ -1,44 +1,17 @@
/** /**
* 관리자 활동 로그 페이지 * 관리자 활동 로그 페이지
*/ */
import { useState, useMemo } from 'react'; import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useQuery, keepPreviousData } from '@tanstack/react-query';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { import {
Home, ChevronRight, Search, ChevronLeft, ChevronDown, Home, ChevronRight, Search, ChevronLeft, ChevronDown,
User, Bot, ScrollText, X, User, Bot, ScrollText, X, Loader2,
} from 'lucide-react'; } from 'lucide-react';
import { AdminLayout, DatePicker } from '@/components/pc/admin'; import { AdminLayout, DatePicker } from '@/components/pc/admin';
import { useAdminAuth } from '@/hooks/pc/admin'; import { useAdminAuth } from '@/hooks/pc/admin';
import { adminLogApi } from '@/api/admin';
//
const DUMMY_LOGS = [
{ id: 1, actor: 'admin', action: 'create', category: 'album', target_type: 'album', target_id: 12, summary: '앨범 생성: Unlock My World', details: null, created_at: '2026-03-02T14:30:00' },
{ id: 2, actor: 'admin', action: 'upload', category: 'album', target_type: 'photo', target_id: 45, summary: '사진 업로드: Unlock My World (3장)', details: { count: 3 }, created_at: '2026-03-02T14:25:00' },
{ id: 3, actor: 'youtube-3', action: 'sync_complete', category: 'sync', target_type: 'youtube_bot', target_id: 3, summary: '동기화 완료: 스프 채널 (2개 추가)', details: { addedCount: 2, channelName: '스프' }, created_at: '2026-03-02T14:20:00' },
{ id: 4, actor: 'admin', action: 'create', category: 'schedule', target_type: 'youtube_schedule', target_id: 789, summary: 'YouTube 일정 생성: fromis_9 컴백 티저', details: { videoId: 'abc123' }, created_at: '2026-03-02T14:15:00' },
{ id: 5, actor: 'admin', action: 'update', category: 'member', target_type: 'member', target_id: 1, summary: '멤버 수정: 이서연 프로필 업데이트', details: null, created_at: '2026-03-02T14:10:00' },
{ id: 6, actor: 'x-1', action: 'sync_complete', category: 'sync', target_type: 'x_bot', target_id: 1, summary: '동기화 완료: fromis_9 공식 (1개 추가)', details: { addedCount: 1 }, created_at: '2026-03-02T14:05:00' },
{ id: 7, actor: 'admin', action: 'delete', category: 'schedule', target_type: 'youtube_schedule', target_id: 456, summary: 'YouTube 일정 삭제: 이전 영상', details: null, created_at: '2026-03-02T14:00:00' },
{ id: 8, actor: 'admin', action: 'create', category: 'concert', target_type: 'concert', target_id: 5, summary: '콘서트 일정 생성: fromis_9 팬미팅', details: null, created_at: '2026-03-02T13:55:00' },
{ id: 9, actor: 'admin', action: 'update', category: 'category', target_type: 'category', target_id: 3, summary: '카테고리 수정: 음악방송', details: null, created_at: '2026-03-02T13:50:00' },
{ id: 10, actor: 'admin', action: 'update', category: 'category', target_type: 'category', target_id: null, summary: '카테고리 순서 변경', details: null, created_at: '2026-03-02T13:45:00' },
{ id: 11, actor: 'youtube-1', action: 'error', category: 'sync', target_type: 'youtube_bot', target_id: 1, summary: '동기화 에러: API 할당량 초과', details: { error: 'quotaExceeded' }, created_at: '2026-03-02T13:40:00' },
{ id: 12, actor: 'admin', action: 'start', category: 'bot', target_type: 'youtube_bot', target_id: 3, summary: 'YouTube 봇 시작: 스프', details: null, created_at: '2026-03-02T13:35:00' },
{ id: 13, actor: 'admin', action: 'stop', category: 'bot', target_type: 'youtube_bot', target_id: 2, summary: 'YouTube 봇 정지: 채널 비활성화', details: null, created_at: '2026-03-02T13:30:00' },
{ id: 14, actor: 'admin', action: 'create', category: 'dict', target_type: 'dict', target_id: 10, summary: '사전 저장: fromis_9 → 프로미스나인', details: null, created_at: '2026-03-02T13:25:00' },
{ id: 15, actor: 'admin', action: 'delete', category: 'album', target_type: 'teaser', target_id: 8, summary: '티저 삭제: Unlock My World 티저 1', details: null, created_at: '2026-03-02T13:20:00' },
{ id: 16, actor: 'admin', action: 'create', category: 'schedule', target_type: 'x_schedule', target_id: 100, summary: 'X 일정 생성: fromis_9 공식 트윗', details: null, created_at: '2026-03-02T13:15:00' },
{ id: 17, actor: 'youtube-3', action: 'sync_complete', category: 'sync', target_type: 'youtube_bot', target_id: 3, summary: '동기화 완료: 스프 채널 (1개 추가)', details: { addedCount: 1 }, created_at: '2026-03-02T13:10:00' },
{ id: 18, actor: 'admin', action: 'update', category: 'schedule', target_type: 'youtube_schedule', target_id: 780, summary: 'YouTube 일정 수정: 제목 변경', details: null, created_at: '2026-03-02T13:05:00' },
{ id: 19, actor: 'admin', action: 'create', category: 'bot', target_type: 'youtube_bot', target_id: 4, summary: 'YouTube 봇 생성: 새 채널', details: null, created_at: '2026-03-02T13:00:00' },
{ id: 20, actor: 'admin', action: 'delete', category: 'bot', target_type: 'x_bot', target_id: 2, summary: 'X 봇 삭제: 비활성 계정', details: null, created_at: '2026-03-02T12:55:00' },
{ id: 21, actor: 'x-1', action: 'sync_complete', category: 'sync', target_type: 'x_bot', target_id: 1, summary: '동기화 완료: fromis_9 공식 (3개 추가)', details: { addedCount: 3 }, created_at: '2026-03-02T12:50:00' },
{ id: 22, actor: 'admin', action: 'upload', category: 'album', target_type: 'photo', target_id: 50, summary: '사진 업로드: My Little Society (5장)', details: { count: 5 }, created_at: '2026-03-02T12:45:00' },
{ id: 23, actor: 'admin', action: 'update', category: 'album', target_type: 'album', target_id: 5, summary: '앨범 수정: My Little Society 정보 변경', details: null, created_at: '2026-03-02T12:40:00' },
{ id: 24, actor: 'youtube-1', action: 'sync_complete', category: 'sync', target_type: 'youtube_bot', target_id: 1, summary: '동기화 완료: 공식 채널 (1개 추가)', details: { addedCount: 1 }, created_at: '2026-03-02T12:35:00' },
{ id: 25, actor: 'admin', action: 'create', category: 'schedule', target_type: 'youtube_schedule', target_id: 791, summary: 'YouTube 일정 생성: 연습 영상', details: null, created_at: '2026-03-02T12:30:00' },
];
// //
const CATEGORIES = [ const CATEGORIES = [
@ -90,6 +63,32 @@ function Logs() {
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [actorDropdownOpen, setActorDropdownOpen] = useState(false); const [actorDropdownOpen, setActorDropdownOpen] = useState(false);
//
const [debouncedSearch, setDebouncedSearch] = useState('');
useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(searchQuery), 300);
return () => clearTimeout(timer);
}, [searchQuery]);
// API
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;
// //
const toggleCategory = (cat) => { const toggleCategory = (cat) => {
setSelectedCategories((prev) => setSelectedCategories((prev) =>
@ -98,40 +97,6 @@ function Logs() {
setCurrentPage(1); setCurrentPage(1);
}; };
//
const filteredLogs = useMemo(() => {
return DUMMY_LOGS.filter((log) => {
//
if (selectedCategories.length > 0 && !selectedCategories.includes(log.category)) {
return false;
}
//
if (actorFilter === 'admin' && log.actor !== 'admin') return false;
if (actorFilter === 'bot' && log.actor === 'admin') return false;
//
if (searchQuery && !log.summary.toLowerCase().includes(searchQuery.toLowerCase())) {
return false;
}
//
if (dateFrom) {
const logDate = log.created_at.split('T')[0];
if (logDate < dateFrom) return false;
}
if (dateTo) {
const logDate = log.created_at.split('T')[0];
if (logDate > dateTo) return false;
}
return true;
});
}, [searchQuery, selectedCategories, actorFilter, dateFrom, dateTo]);
//
const totalPages = Math.ceil(filteredLogs.length / ITEMS_PER_PAGE);
const paginatedLogs = filteredLogs.slice(
(currentPage - 1) * ITEMS_PER_PAGE,
currentPage * ITEMS_PER_PAGE
);
// / // /
const formatDateTime = (dateStr) => { const formatDateTime = (dateStr) => {
const date = new Date(dateStr); const date = new Date(dateStr);
@ -303,7 +268,7 @@ function Logs() {
{/* 결과 개수 */} {/* 결과 개수 */}
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
<span className="font-medium text-gray-900">{filteredLogs.length}</span>개의 로그 <span className="font-medium text-gray-900">{total}</span>개의 로그
</p> </p>
</div> </div>
@ -325,7 +290,7 @@ function Logs() {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-100"> <tbody className="divide-y divide-gray-100">
{paginatedLogs.map((log, index) => ( {logs.map((log, index) => (
<motion.tr <motion.tr
key={log.id} key={log.id}
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 10 }}
@ -357,7 +322,14 @@ function Logs() {
</tbody> </tbody>
</table> </table>
{paginatedLogs.length === 0 && ( {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 && (
<div className="flex flex-col items-center justify-center py-16 text-gray-400"> <div className="flex flex-col items-center justify-center py-16 text-gray-400">
<ScrollText size={48} strokeWidth={1} className="mb-4" /> <ScrollText size={48} strokeWidth={1} className="mb-4" />
<p className="text-sm"> <p className="text-sm">
@ -377,19 +349,39 @@ function Logs() {
> >
<ChevronLeft size={18} /> <ChevronLeft size={18} />
</button> </button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => ( {Array.from({ length: totalPages }, (_, i) => i + 1)
<button .filter((page) => {
key={page} //
onClick={() => setCurrentPage(page)} if (totalPages <= 7) return true;
className={`w-9 h-9 rounded-lg text-sm font-medium transition-colors ${ if (page === 1 || page === totalPages) return true;
currentPage === page if (Math.abs(page - currentPage) <= 2) return true;
? 'bg-primary text-white' return false;
: 'text-gray-600 hover:bg-gray-100' })
}`} .reduce((acc, page, i, arr) => {
> // (...)
{page} if (i > 0 && page - arr[i - 1] > 1) {
</button> 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 <button
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))} onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages} disabled={currentPage === totalPages}