feat(admin): 활동 로그 페이지 컴포넌트 및 설계 문서 추가
더미데이터로 활동 로그 UI 구현 (필터, 테이블, 페이지네이션) 라우트/메뉴 연결은 다음 단계에서 진행 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9335720fa8
commit
c4cd0dec30
2 changed files with 595 additions and 0 deletions
197
docs/activity-logs.md
Normal file
197
docs/activity-logs.md
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
# 활동 로그 시스템
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
관리자 페이지에서 모든 행동(관리자 수동 작업 + 봇 자동 작업)에 대한 로그를 조회할 수 있는 시스템.
|
||||||
|
앨범 CRUD, 멤버 수정, 일정 추가/수정/삭제, 봇 동기화 등 모든 활동을 DB에 기록하고 관리자 페이지에서 필터링/페이지네이션으로 조회.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DB 테이블
|
||||||
|
|
||||||
|
### `activity_logs`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE activity_logs (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
actor VARCHAR(50) NOT NULL, -- "admin" 또는 봇 ID ("youtube-3", "x-1" 등)
|
||||||
|
action VARCHAR(50) NOT NULL, -- create, update, delete, start, stop, sync_complete, error 등
|
||||||
|
category VARCHAR(30) NOT NULL, -- album, schedule, member, bot, category, dict, concert, sync
|
||||||
|
target_type VARCHAR(50) DEFAULT NULL, -- youtube_schedule, x_schedule, album, photo, member 등
|
||||||
|
target_id INT UNSIGNED DEFAULT NULL,
|
||||||
|
summary VARCHAR(500) NOT NULL, -- 사람이 읽을 수 있는 한 줄 요약
|
||||||
|
details JSON DEFAULT NULL, -- 추가 상세 정보
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_created_at (created_at),
|
||||||
|
INDEX idx_category (category),
|
||||||
|
INDEX idx_actor (actor)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 컬럼 설명
|
||||||
|
|
||||||
|
| 컬럼 | 설명 | 예시 |
|
||||||
|
|------|------|------|
|
||||||
|
| `actor` | 행위자 | `"admin"`, `"youtube-3"`, `"x-1"`, `"meilisearch"` |
|
||||||
|
| `action` | 행동 유형 | `create`, `update`, `delete`, `upload`, `start`, `stop`, `sync_complete`, `error`, `reorder` |
|
||||||
|
| `category` | 대분류 | `album`, `schedule`, `member`, `bot`, `category`, `dict`, `concert`, `sync` |
|
||||||
|
| `target_type` | 대상 타입 | `youtube_schedule`, `x_schedule`, `album`, `photo`, `teaser`, `member`, `youtube_bot`, `x_bot`, `category`, `concert` |
|
||||||
|
| `target_id` | 대상 DB ID | 해당 레코드의 PK |
|
||||||
|
| `summary` | 한 줄 요약 | `"YouTube 일정 생성: fromis_9 영상 제목"` |
|
||||||
|
| `details` | 추가 정보 (JSON) | `{ "videoId": "abc123", "channelName": "채널명" }` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 백엔드 구현
|
||||||
|
|
||||||
|
### 로그 유틸리티
|
||||||
|
|
||||||
|
**파일:** `backend/src/utils/activityLog.js`
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
logActivity(db, { actor, action, category, targetType, targetId, summary, details })
|
||||||
|
```
|
||||||
|
|
||||||
|
- fire-and-forget: 로그 실패가 비즈니스 로직에 영향 주지 않도록 try/catch 감싸기
|
||||||
|
- 트랜잭션 외부에서 호출 (로그 실패가 롤백 유발하지 않도록)
|
||||||
|
|
||||||
|
### API 엔드포인트
|
||||||
|
|
||||||
|
**GET /api/admin/activity-logs** — 로그 목록 조회 (인증 필수)
|
||||||
|
|
||||||
|
| 파라미터 | 타입 | 기본값 | 설명 |
|
||||||
|
|----------|------|--------|------|
|
||||||
|
| `page` | integer | 1 | 페이지 번호 |
|
||||||
|
| `limit` | integer | 50 | 페이지당 개수 (최대 100) |
|
||||||
|
| `category` | string | - | 카테고리 필터 (콤마 구분) |
|
||||||
|
| `actor` | string | - | 행위자 필터 (`"admin"` 또는 `"bot"`) |
|
||||||
|
| `search` | string | - | summary 검색 |
|
||||||
|
| `from` | string | - | 시작 날짜 (YYYY-MM-DD) |
|
||||||
|
| `to` | string | - | 종료 날짜 (YYYY-MM-DD) |
|
||||||
|
|
||||||
|
**응답:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"logs": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"actor": "admin",
|
||||||
|
"action": "create",
|
||||||
|
"category": "schedule",
|
||||||
|
"target_type": "youtube_schedule",
|
||||||
|
"target_id": 456,
|
||||||
|
"summary": "YouTube 일정 생성: fromis_9 영상 제목",
|
||||||
|
"details": { "videoId": "abc123", "channelName": "채널명" },
|
||||||
|
"created_at": "2026-03-01T14:30:00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1234,
|
||||||
|
"page": 1,
|
||||||
|
"limit": 50,
|
||||||
|
"totalPages": 25
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 로그 삽입 대상
|
||||||
|
|
||||||
|
#### 관리자 수동 작업
|
||||||
|
|
||||||
|
| 파일 | 로그 대상 |
|
||||||
|
|------|----------|
|
||||||
|
| `routes/admin/youtube.js` | YouTube 일정 생성/수정 |
|
||||||
|
| `routes/admin/x.js` | X 일정 생성 |
|
||||||
|
| `routes/admin/concert.js` | 콘서트 일정 생성 |
|
||||||
|
| `routes/admin/youtube-bots.js` | YouTube 봇 생성/수정/삭제 |
|
||||||
|
| `routes/admin/x-bots.js` | X 봇 생성/수정/삭제 |
|
||||||
|
| `routes/admin/bots.js` | 봇 시작/정지/전체동기화 |
|
||||||
|
| `routes/albums/index.js` | 앨범 생성/수정/삭제 |
|
||||||
|
| `routes/albums/photos.js` | 사진 업로드/삭제 |
|
||||||
|
| `routes/albums/teasers.js` | 티저 삭제 |
|
||||||
|
| `routes/members/index.js` | 멤버 수정 |
|
||||||
|
| `routes/schedules/index.js` | 일정 삭제, 카테고리 CRUD, 순서변경 |
|
||||||
|
| `routes/schedules/suggestions.js` | 사전 저장 |
|
||||||
|
|
||||||
|
#### 봇 자동 작업
|
||||||
|
|
||||||
|
| 파일 | 로그 대상 |
|
||||||
|
|------|----------|
|
||||||
|
| `plugins/scheduler.js` | 동기화 완료 (addedCount > 0일 때만), 에러 |
|
||||||
|
| `services/youtube/index.js` | 영상 추가 성공 |
|
||||||
|
| `services/x/index.js` | 트윗 추가 성공 |
|
||||||
|
|
||||||
|
> **봇 로그 전략:** 변화 없는 동기화는 로그 안 남김. `addedCount > 0`이거나 에러인 경우만 기록.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 프론트엔드 구현
|
||||||
|
|
||||||
|
### 로그 페이지
|
||||||
|
|
||||||
|
**경로:** `/admin/logs`
|
||||||
|
|
||||||
|
**UI 구성:**
|
||||||
|
- 필터 바: 카테고리 칩, 행위자 드롭다운, 기간 선택, 텍스트 검색
|
||||||
|
- 로그 테이블: 시간, 행위자(아이콘), 액션 뱃지(색상별), summary
|
||||||
|
- 페이지네이션
|
||||||
|
|
||||||
|
**액션 뱃지 색상:**
|
||||||
|
| 액션 | 색상 |
|
||||||
|
|------|------|
|
||||||
|
| create / upload | 초록 |
|
||||||
|
| update / reorder | 파랑 |
|
||||||
|
| delete | 빨강 |
|
||||||
|
| sync_complete | 보라 |
|
||||||
|
| error | 빨강 |
|
||||||
|
| start / stop | 노랑 |
|
||||||
|
|
||||||
|
### 대시보드 메뉴
|
||||||
|
|
||||||
|
대시보드 menuItems에 활동 로그 항목 추가 (ScrollText 아이콘)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 수정 파일 목록
|
||||||
|
|
||||||
|
### 신규 생성 (4개)
|
||||||
|
|
||||||
|
| 파일 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| `backend/src/utils/activityLog.js` | logActivity 유틸리티 함수 |
|
||||||
|
| `backend/src/routes/admin/activity-logs.js` | API 엔드포인트 |
|
||||||
|
| `frontend/src/api/admin/activityLogs.js` | API 클라이언트 |
|
||||||
|
| `frontend/src/pages/pc/admin/logs/ActivityLogs.jsx` | 로그 페이지 컴포넌트 |
|
||||||
|
|
||||||
|
### 수정 (백엔드 15개 + 프론트엔드 3개)
|
||||||
|
|
||||||
|
| 파일 | 변경 |
|
||||||
|
|------|------|
|
||||||
|
| `backend/src/routes/index.js` | 라우트 등록 |
|
||||||
|
| `backend/src/routes/admin/youtube.js` | logActivity 호출 |
|
||||||
|
| `backend/src/routes/admin/x.js` | logActivity 호출 |
|
||||||
|
| `backend/src/routes/admin/concert.js` | logActivity 호출 |
|
||||||
|
| `backend/src/routes/admin/youtube-bots.js` | logActivity 호출 |
|
||||||
|
| `backend/src/routes/admin/x-bots.js` | logActivity 호출 |
|
||||||
|
| `backend/src/routes/admin/bots.js` | logActivity 호출 |
|
||||||
|
| `backend/src/routes/albums/index.js` | logActivity 호출 |
|
||||||
|
| `backend/src/routes/albums/photos.js` | logActivity 호출 |
|
||||||
|
| `backend/src/routes/albums/teasers.js` | logActivity 호출 |
|
||||||
|
| `backend/src/routes/members/index.js` | logActivity 호출 |
|
||||||
|
| `backend/src/routes/schedules/index.js` | logActivity 호출 |
|
||||||
|
| `backend/src/routes/schedules/suggestions.js` | logActivity 호출 |
|
||||||
|
| `backend/src/plugins/scheduler.js` | 동기화 로그 |
|
||||||
|
| `backend/src/services/youtube/index.js` | 영상 추가 로그 |
|
||||||
|
| `backend/src/services/x/index.js` | 트윗 추가 로그 |
|
||||||
|
| `frontend/src/api/admin/index.js` | export 추가 |
|
||||||
|
| `frontend/src/routes/pc/admin/index.jsx` | 라우트 등록 |
|
||||||
|
| `frontend/src/pages/pc/admin/dashboard/Dashboard.jsx` | 메뉴 추가 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 구현 순서
|
||||||
|
|
||||||
|
1. DB 테이블 생성
|
||||||
|
2. 백엔드: logActivity 유틸리티 + API 엔드포인트 + 라우트 등록
|
||||||
|
3. 백엔드: 각 라우트/서비스에 logActivity 호출 추가
|
||||||
|
4. 프론트엔드: API 클라이언트 + 로그 페이지 + 라우트/대시보드 연결
|
||||||
|
5. 서버 재빌드 + 테스트
|
||||||
|
6. 문서 업데이트
|
||||||
398
frontend/src/pages/pc/admin/logs/ActivityLogs.jsx
Normal file
398
frontend/src/pages/pc/admin/logs/ActivityLogs.jsx
Normal file
|
|
@ -0,0 +1,398 @@
|
||||||
|
/**
|
||||||
|
* 관리자 활동 로그 페이지
|
||||||
|
*/
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
Home, ChevronRight, Search, ChevronLeft, ChevronDown,
|
||||||
|
User, Bot, ScrollText, X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { AdminLayout } from '@/components/pc/admin';
|
||||||
|
import { useAdminAuth } from '@/hooks/pc/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: 'reorder', 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 = [
|
||||||
|
{ value: 'album', label: '앨범' },
|
||||||
|
{ value: 'schedule', label: '일정' },
|
||||||
|
{ value: 'member', label: '멤버' },
|
||||||
|
{ value: 'bot', label: '봇' },
|
||||||
|
{ value: 'category', label: '카테고리' },
|
||||||
|
{ value: 'dict', label: '사전' },
|
||||||
|
{ value: 'concert', label: '콘서트' },
|
||||||
|
{ value: 'sync', label: '동기화' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 액션 뱃지 색상
|
||||||
|
const ACTION_STYLES = {
|
||||||
|
create: 'bg-emerald-100 text-emerald-700',
|
||||||
|
upload: 'bg-emerald-100 text-emerald-700',
|
||||||
|
update: 'bg-blue-100 text-blue-700',
|
||||||
|
reorder: 'bg-blue-100 text-blue-700',
|
||||||
|
delete: 'bg-red-100 text-red-700',
|
||||||
|
sync_complete: 'bg-purple-100 text-purple-700',
|
||||||
|
error: 'bg-red-100 text-red-700',
|
||||||
|
start: 'bg-amber-100 text-amber-700',
|
||||||
|
stop: 'bg-amber-100 text-amber-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 액션 한글 라벨
|
||||||
|
const ACTION_LABELS = {
|
||||||
|
create: '생성',
|
||||||
|
upload: '업로드',
|
||||||
|
update: '수정',
|
||||||
|
reorder: '순서변경',
|
||||||
|
delete: '삭제',
|
||||||
|
sync_complete: '동기화',
|
||||||
|
error: '에러',
|
||||||
|
start: '시작',
|
||||||
|
stop: '정지',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 15;
|
||||||
|
|
||||||
|
function ActivityLogs() {
|
||||||
|
const { user } = useAdminAuth();
|
||||||
|
|
||||||
|
// 필터 상태
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [selectedCategories, setSelectedCategories] = useState([]);
|
||||||
|
const [actorFilter, setActorFilter] = useState('all'); // all, admin, bot
|
||||||
|
const [dateFrom, setDateFrom] = useState('');
|
||||||
|
const [dateTo, setDateTo] = useState('');
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [actorDropdownOpen, setActorDropdownOpen] = useState(false);
|
||||||
|
|
||||||
|
// 카테고리 토글
|
||||||
|
const toggleCategory = (cat) => {
|
||||||
|
setSelectedCategories((prev) =>
|
||||||
|
prev.includes(cat) ? prev.filter((c) => c !== cat) : [...prev, cat]
|
||||||
|
);
|
||||||
|
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 date = new Date(dateStr);
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
return `${month}.${day} ${hours}:${minutes}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 행위자 아이콘
|
||||||
|
const renderActorBadge = (actor) => {
|
||||||
|
if (actor === 'admin') {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 text-gray-700 text-xs font-medium rounded-full">
|
||||||
|
<User size={12} />
|
||||||
|
관리자
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-indigo-50 text-indigo-700 text-xs font-medium rounded-full">
|
||||||
|
<Bot size={12} />
|
||||||
|
{actor}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필터 초기화
|
||||||
|
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">
|
||||||
|
{/* 상단: 검색 + 행위자 + 날짜 */}
|
||||||
|
<div className="flex items-center gap-4 mb-4">
|
||||||
|
{/* 검색 */}
|
||||||
|
<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
|
||||||
|
onClick={() => setActorDropdownOpen(!actorDropdownOpen)}
|
||||||
|
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>
|
||||||
|
{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">
|
||||||
|
{[
|
||||||
|
{ 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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 날짜 필터 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateFrom}
|
||||||
|
onChange={(e) => { setDateFrom(e.target.value); 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"
|
||||||
|
/>
|
||||||
|
<span className="text-gray-400 text-sm">~</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateTo}
|
||||||
|
onChange={(e) => { setDateTo(e.target.value); 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필터 초기화 */}
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<button
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
초기화
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 하단: 카테고리 칩 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-gray-400 mr-1">카테고리</span>
|
||||||
|
{CATEGORIES.map((cat) => (
|
||||||
|
<button
|
||||||
|
key={cat.value}
|
||||||
|
onClick={() => toggleCategory(cat.value)}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium rounded-full transition-colors ${
|
||||||
|
selectedCategories.includes(cat.value)
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cat.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 결과 개수 */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
총 <span className="font-medium text-gray-900">{filteredLogs.length}</span>개의 로그
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-100">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-6 py-4 text-sm font-medium text-gray-500 w-28">시간</th>
|
||||||
|
<th className="text-left px-6 py-4 text-sm font-medium text-gray-500 w-28">행위자</th>
|
||||||
|
<th className="text-left px-6 py-4 text-sm font-medium text-gray-500 w-24">액션</th>
|
||||||
|
<th className="text-left px-6 py-4 text-sm font-medium text-gray-500 w-24">카테고리</th>
|
||||||
|
<th className="text-left px-6 py-4 text-sm font-medium text-gray-500">내용</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{paginatedLogs.map((log, index) => (
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<td className="px-6 py-3.5 text-sm text-gray-500 tabular-nums">
|
||||||
|
{formatDateTime(log.created_at)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3.5">
|
||||||
|
{renderActorBadge(log.actor)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3.5">
|
||||||
|
<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>
|
||||||
|
<td className="px-6 py-3.5">
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{CATEGORIES.find((c) => c.value === log.category)?.label || log.category}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3.5 text-sm text-gray-700">
|
||||||
|
{log.summary}
|
||||||
|
</td>
|
||||||
|
</motion.tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{paginatedLogs.length === 0 && (
|
||||||
|
<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 && (
|
||||||
|
<div className="flex items-center justify-center gap-2 mt-6">
|
||||||
|
<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).map((page) => (
|
||||||
|
<button
|
||||||
|
key={page}
|
||||||
|
onClick={() => setCurrentPage(page)}
|
||||||
|
className={`w-9 h-9 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
currentPage === page
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'text-gray-600 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</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>
|
||||||
|
</AdminLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ActivityLogs;
|
||||||
Loading…
Add table
Reference in a new issue