feat(admin): 활동 로그 라우트/메뉴 연결 및 UI 개선

- /admin/logs 라우트 등록, 대시보드 메뉴에 활동 로그 항목 추가
- 테이블 컬럼 비율 조정 (내용 컬럼 공간 확보)
- 날짜 선택기를 커스텀 DatePicker로 교체
- 행위자 드롭다운에 애니메이션 추가
- reorder 액션 제거

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-03-02 16:40:27 +09:00
parent c4cd0dec30
commit 01cf083da2
3 changed files with 70 additions and 52 deletions

View file

@ -4,7 +4,7 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Disc3, Calendar, Users, Home, ChevronRight } from 'lucide-react'; import { Disc3, Calendar, Users, Home, ChevronRight, ScrollText } from 'lucide-react';
import { AdminLayout } from '@/components/pc/admin'; import { AdminLayout } from '@/components/pc/admin';
import { useAdminAuth } from '@/hooks/pc/admin'; import { useAdminAuth } from '@/hooks/pc/admin';
import { adminStatsApi } from '@/api/admin'; import { adminStatsApi } from '@/api/admin';
@ -88,6 +88,13 @@ function AdminDashboard() {
path: '/admin/schedule', path: '/admin/schedule',
color: 'bg-blue-500', color: 'bg-blue-500',
}, },
{
icon: ScrollText,
label: '활동 로그',
description: '관리자 및 봇 활동 기록 조회',
path: '/admin/logs',
color: 'bg-gray-500',
},
]; ];
return ( return (

View file

@ -3,12 +3,12 @@
*/ */
import { useState, useMemo } from 'react'; import { useState, useMemo } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { motion } 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,
} from 'lucide-react'; } from 'lucide-react';
import { AdminLayout } from '@/components/pc/admin'; import { AdminLayout, DatePicker } from '@/components/pc/admin';
import { useAdminAuth } from '@/hooks/pc/admin'; import { useAdminAuth } from '@/hooks/pc/admin';
// //
@ -22,7 +22,7 @@ const DUMMY_LOGS = [
{ 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: 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: 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: 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: 'reorder', category: 'category', target_type: 'category', target_id: null, summary: '카테고리 순서 변경', details: null, created_at: '2026-03-02T13:45: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: 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: 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: 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' },
@ -57,7 +57,6 @@ const ACTION_STYLES = {
create: 'bg-emerald-100 text-emerald-700', create: 'bg-emerald-100 text-emerald-700',
upload: 'bg-emerald-100 text-emerald-700', upload: 'bg-emerald-100 text-emerald-700',
update: 'bg-blue-100 text-blue-700', update: 'bg-blue-100 text-blue-700',
reorder: 'bg-blue-100 text-blue-700',
delete: 'bg-red-100 text-red-700', delete: 'bg-red-100 text-red-700',
sync_complete: 'bg-purple-100 text-purple-700', sync_complete: 'bg-purple-100 text-purple-700',
error: 'bg-red-100 text-red-700', error: 'bg-red-100 text-red-700',
@ -70,7 +69,6 @@ const ACTION_LABELS = {
create: '생성', create: '생성',
upload: '업로드', upload: '업로드',
update: '수정', update: '수정',
reorder: '순서변경',
delete: '삭제', delete: '삭제',
sync_complete: '동기화', sync_complete: '동기화',
error: '에러', error: '에러',
@ -137,11 +135,12 @@ function ActivityLogs() {
// / // /
const formatDateTime = (dateStr) => { const formatDateTime = (dateStr) => {
const date = new Date(dateStr); const date = new Date(dateStr);
const y = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0'); const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0');
return `${month}.${day} ${hours}:${minutes}`; return `${y}.${month}.${day} ${hours}:${minutes}`;
}; };
// //
@ -219,45 +218,55 @@ function ActivityLogs() {
</span> </span>
<ChevronDown size={16} className="text-gray-400" /> <ChevronDown size={16} className="text-gray-400" />
</button> </button>
{actorDropdownOpen && ( <AnimatePresence>
<> {actorDropdownOpen && (
<div className="fixed inset-0 z-10" onClick={() => setActorDropdownOpen(false)} /> <>
<div className="absolute top-full left-0 mt-1 w-36 bg-white border border-gray-200 rounded-lg shadow-lg z-20 py-1"> <div className="fixed inset-0 z-10" onClick={() => setActorDropdownOpen(false)} />
{[ <motion.div
{ value: 'all', label: '전체 행위자' }, initial={{ opacity: 0, y: -8 }}
{ value: 'admin', label: '관리자' }, animate={{ opacity: 1, y: 0 }}
{ value: 'bot', label: '봇' }, exit={{ opacity: 0, y: -8 }}
].map((opt) => ( transition={{ duration: 0.15 }}
<button className="absolute top-full left-0 mt-1 w-36 bg-white border border-gray-200 rounded-lg shadow-lg z-20 py-1"
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 ${ { value: 'all', label: '전체 행위자' },
actorFilter === opt.value ? 'text-primary font-medium' : 'text-gray-700' { value: 'admin', label: '관리자' },
}`} { value: 'bot', label: '봇' },
> ].map((opt) => (
{opt.label} <button
</button> key={opt.value}
))} onClick={() => { setActorFilter(opt.value); setActorDropdownOpen(false); setCurrentPage(1); }}
</div> 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>
</div> </div>
{/* 날짜 필터 */} {/* 날짜 필터 */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <div className="w-44">
type="date" <DatePicker
value={dateFrom} value={dateFrom}
onChange={(e) => { setDateFrom(e.target.value); setCurrentPage(1); }} onChange={(v) => { setDateFrom(v); setCurrentPage(1); }}
className="px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-gray-600" placeholder="시작일"
/> />
</div>
<span className="text-gray-400 text-sm">~</span> <span className="text-gray-400 text-sm">~</span>
<input <div className="w-44">
type="date" <DatePicker
value={dateTo} value={dateTo}
onChange={(e) => { setDateTo(e.target.value); setCurrentPage(1); }} onChange={(v) => { setDateTo(v); setCurrentPage(1); }}
className="px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-gray-600" placeholder="종료일"
/> />
</div>
</div> </div>
{/* 필터 초기화 */} {/* 필터 초기화 */}
@ -305,14 +314,14 @@ function ActivityLogs() {
transition={{ duration: 0.4, ease: [0.25, 0.1, 0.25, 1], delay: 0.15 }} 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" className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden"
> >
<table className="w-full"> <table className="w-full table-fixed">
<thead className="bg-gray-50 border-b border-gray-100"> <thead className="bg-gray-50 border-b border-gray-100">
<tr> <tr>
<th className="text-left px-6 py-4 text-sm font-medium text-gray-500 w-28">시간</th> <th className="text-left pl-4 pr-2 py-4 text-sm font-medium text-gray-500 whitespace-nowrap w-[15%]">시간</th>
<th className="text-left px-6 py-4 text-sm font-medium text-gray-500 w-28">행위자</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-6 py-4 text-sm font-medium text-gray-500 w-24">액션</th> <th className="text-left px-3 py-4 text-sm font-medium text-gray-500 whitespace-nowrap w-[8%]">액션</th>
<th className="text-left px-6 py-4 text-sm font-medium text-gray-500 w-24">카테고리</th> <th className="text-left px-3 py-4 text-sm font-medium text-gray-500 whitespace-nowrap w-[8%]">카테고리</th>
<th className="text-left px-6 py-4 text-sm font-medium text-gray-500">내용</th> <th className="text-left pl-3 pr-6 py-4 text-sm font-medium text-gray-500">내용</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-100"> <tbody className="divide-y divide-gray-100">
@ -324,23 +333,23 @@ function ActivityLogs() {
transition={{ delay: index * 0.03 }} transition={{ delay: index * 0.03 }}
className="hover:bg-gray-50 transition-colors" className="hover:bg-gray-50 transition-colors"
> >
<td className="px-6 py-3.5 text-sm text-gray-500 tabular-nums"> <td className="pl-4 pr-2 py-3.5 text-sm text-gray-500 tabular-nums whitespace-nowrap">
{formatDateTime(log.created_at)} {formatDateTime(log.created_at)}
</td> </td>
<td className="px-6 py-3.5"> <td className="px-3 py-3.5 whitespace-nowrap">
{renderActorBadge(log.actor)} {renderActorBadge(log.actor)}
</td> </td>
<td className="px-6 py-3.5"> <td className="px-3 py-3.5 whitespace-nowrap">
<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'}`}> <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} {ACTION_LABELS[log.action] || log.action}
</span> </span>
</td> </td>
<td className="px-6 py-3.5"> <td className="px-3 py-3.5 whitespace-nowrap">
<span className="text-xs text-gray-500"> <span className="text-xs text-gray-500">
{CATEGORIES.find((c) => c.value === log.category)?.label || log.category} {CATEGORIES.find((c) => c.value === log.category)?.label || log.category}
</span> </span>
</td> </td>
<td className="px-6 py-3.5 text-sm text-gray-700"> <td className="pl-3 pr-6 py-3.5 text-sm text-gray-700">
{log.summary} {log.summary}
</td> </td>
</motion.tr> </motion.tr>

View file

@ -35,6 +35,7 @@ import AdminYouTubeEditForm from '@/pages/pc/admin/schedules/edit/YouTubeEditFor
import AdminScheduleCategory from '@/pages/pc/admin/schedules/ScheduleCategory'; import AdminScheduleCategory from '@/pages/pc/admin/schedules/ScheduleCategory';
import AdminScheduleDict from '@/pages/pc/admin/schedules/ScheduleDict'; import AdminScheduleDict from '@/pages/pc/admin/schedules/ScheduleDict';
import AdminScheduleBots from '@/pages/pc/admin/schedules/ScheduleBots'; import AdminScheduleBots from '@/pages/pc/admin/schedules/ScheduleBots';
import AdminActivityLogs from '@/pages/pc/admin/logs/ActivityLogs';
import AdminNotFound from '@/pages/pc/admin/common/NotFound'; import AdminNotFound from '@/pages/pc/admin/common/NotFound';
/** /**
@ -59,6 +60,7 @@ export default function AdminRoutes() {
<Route path="/admin/schedule/categories" element={<RequireAuth><AdminScheduleCategory /></RequireAuth>} /> <Route path="/admin/schedule/categories" element={<RequireAuth><AdminScheduleCategory /></RequireAuth>} />
<Route path="/admin/schedule/dict" element={<RequireAuth><AdminScheduleDict /></RequireAuth>} /> <Route path="/admin/schedule/dict" element={<RequireAuth><AdminScheduleDict /></RequireAuth>} />
<Route path="/admin/schedule/bots" element={<RequireAuth><AdminScheduleBots /></RequireAuth>} /> <Route path="/admin/schedule/bots" element={<RequireAuth><AdminScheduleBots /></RequireAuth>} />
<Route path="/admin/logs" element={<RequireAuth><AdminActivityLogs /></RequireAuth>} />
<Route path="/admin/*" element={<AdminNotFound />} /> <Route path="/admin/*" element={<AdminNotFound />} />
</Routes> </Routes>
); );