2026-03-02 16:16:59 +09:00
/ * *
* 관리자 활동 로그 페이지
* /
import { useState , useMemo } from 'react' ;
import { Link } from 'react-router-dom' ;
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 ,
User , Bot , ScrollText , X ,
} from 'lucide-react' ;
2026-03-02 16:40:27 +09:00
import { AdminLayout , DatePicker } from '@/components/pc/admin' ;
2026-03-02 16:16:59 +09:00
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' } ,
2026-03-02 16:40:27 +09: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' } ,
2026-03-02 16:16:59 +09: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' ,
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 : '수정' ,
delete : '삭제' ,
sync _complete : '동기화' ,
error : '에러' ,
start : '시작' ,
stop : '정지' ,
} ;
const ITEMS _PER _PAGE = 15 ;
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 ( [ ] ) ;
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 ) ;
2026-03-02 16:40:27 +09:00
const y = date . getFullYear ( ) ;
2026-03-02 16:16:59 +09:00
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' ) ;
2026-03-02 16:40:27 +09:00
return ` ${ y } . ${ month } . ${ day } ${ hours } : ${ minutes } ` ;
2026-03-02 16:16:59 +09:00
} ;
// 행위자 아이콘
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 >
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 >
{ /* 날짜 필터 */ }
< div className = "flex items-center gap-2" >
2026-03-02 16:40:27 +09:00
< div className = "w-44" >
< DatePicker
value = { dateFrom }
onChange = { ( v ) => { setDateFrom ( v ) ; setCurrentPage ( 1 ) ; } }
placeholder = "시작일"
/ >
< / div >
2026-03-02 16:16:59 +09:00
< span className = "text-gray-400 text-sm" > ~ < / span >
2026-03-02 16:40:27 +09:00
< div className = "w-44" >
< DatePicker
value = { dateTo }
onChange = { ( v ) => { setDateTo ( v ) ; setCurrentPage ( 1 ) ; } }
placeholder = "종료일"
/ >
< / div >
2026-03-02 16:16:59 +09:00
< / 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"
>
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 >
< 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-[8%]" > 액션 < / 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 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" >
{ 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"
>
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 16:16:59 +09:00
{ renderActorBadge ( log . actor ) }
< / 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" >
{ CATEGORIES . find ( ( c ) => c . value === log . category ) ? . label || log . category }
< / 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 16:16:59 +09:00
{ 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 >
) ;
}
2026-03-02 16:54:08 +09:00
export default Logs ;