feat(admin): 활동 로그 라우트/메뉴 연결 및 UI 개선
- /admin/logs 라우트 등록, 대시보드 메뉴에 활동 로그 항목 추가 - 테이블 컬럼 비율 조정 (내용 컬럼 공간 확보) - 날짜 선택기를 커스텀 DatePicker로 교체 - 행위자 드롭다운에 애니메이션 추가 - reorder 액션 제거 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c4cd0dec30
commit
01cf083da2
3 changed files with 70 additions and 52 deletions
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue