refactor(admin): 활동 로그 컴포넌트 분리 및 빈 상세정보 처리

Logs.jsx에서 상수/유틸과 다이얼로그를 components/pc/admin/log/로 분리하여
프로젝트 구조 패턴에 맞춤. 빈 객체 {} details가 표시되던 버그 수정.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-03-02 21:49:14 +09:00
parent abf71d97d7
commit aa95f737ba
6 changed files with 227 additions and 76 deletions

View file

@ -156,10 +156,13 @@ fromis_9/
│ │ │ │ │ ├── PhotoPreviewModal.jsx │ │ │ │ │ ├── PhotoPreviewModal.jsx
│ │ │ │ │ ├── PendingFileItem.jsx │ │ │ │ │ ├── PendingFileItem.jsx
│ │ │ │ │ └── BulkEditPanel.jsx │ │ │ │ │ └── BulkEditPanel.jsx
│ │ │ │ └── bot/ │ │ │ │ ├── bot/
│ │ │ │ ├── BotCard.jsx │ │ │ │ │ ├── BotCard.jsx
│ │ │ │ ├── YouTubeBotDialog.jsx │ │ │ │ │ ├── YouTubeBotDialog.jsx
│ │ │ │ └── XBotDialog.jsx │ │ │ │ │ └── XBotDialog.jsx
│ │ │ │ └── log/
│ │ │ │ ├── constants.js
│ │ │ │ └── LogDetailDialog.jsx
│ │ │ │ │ │ │ │
│ │ │ └── mobile/ # 모바일 컴포넌트 │ │ │ └── mobile/ # 모바일 컴포넌트
│ │ │ ├── layout/ │ │ │ ├── layout/

View file

@ -12,3 +12,6 @@ export * from './album';
// 봇 관련 // 봇 관련
export * from './bot'; export * from './bot';
// 로그 관련
export * from './log';

View file

@ -0,0 +1,124 @@
/**
* 로그 상세 다이얼로그
*/
import { motion, AnimatePresence } from 'framer-motion';
import { X, User, Bot } from 'lucide-react';
import { ACTION_STYLES, ACTION_LABELS, CATEGORY_LABELS, parseSummary, formatDateTime, hasDetails } from './constants';
//
function ActorBadge({ 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>
);
}
// summary
function Summary({ summary }) {
const { prefix, detail } = parseSummary(summary);
return (
<>
<span className="text-primary font-medium">[{prefix}]</span>
{detail && <span className="ml-1.5">{detail}</span>}
</>
);
}
export { ActorBadge, Summary };
export default function LogDetailDialog({ log, onClose }) {
return (
<AnimatePresence>
{log && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/40"
onClick={onClose}
/>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.15 }}
className="relative bg-white rounded-2xl shadow-xl max-w-lg w-full mx-4"
>
{/* 헤더 */}
<div className="flex items-center justify-between p-5 border-b border-gray-100">
<div className="flex items-center gap-3">
<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>
<span className="text-sm text-gray-500">
{CATEGORY_LABELS[log.category] || log.category}
</span>
</div>
<button
onClick={onClose}
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors"
>
<X size={18} className="text-gray-400" />
</button>
</div>
{/* 본문 */}
<div className="p-5 space-y-4">
{/* 내용 */}
<div>
<div className="text-sm text-gray-400 mb-1.5">내용</div>
<div className="text-sm text-gray-900 leading-relaxed">
<Summary summary={log.summary} />
</div>
</div>
{/* 행위자 + 시간 */}
<div className="flex gap-6">
<div>
<div className="text-sm text-gray-400 mb-1.5">행위자</div>
<ActorBadge actor={log.actor} />
</div>
<div>
<div className="text-sm text-gray-400 mb-1.5">시간</div>
<span className="text-sm text-gray-700 tabular-nums">{formatDateTime(log.created_at)}</span>
</div>
</div>
{/* 대상 */}
{(log.target_type || log.target_id) && (
<div>
<div className="text-sm text-gray-400 mb-1.5">대상</div>
<span className="text-sm text-gray-700">
{log.target_type && <span>{log.target_type}</span>}
{log.target_id && <span className="ml-1.5 text-gray-400">#{log.target_id}</span>}
</span>
</div>
)}
{/* 상세 정보 */}
{hasDetails(log.details) && (
<div>
<div className="text-sm text-gray-400 mb-1.5">상세 정보</div>
<pre className="text-xs text-gray-600 bg-gray-50 rounded-lg p-3 overflow-auto max-h-40">
{JSON.stringify(log.details, null, 2)}
</pre>
</div>
)}
</div>
</motion.div>
</div>
)}
</AnimatePresence>
);
}

View file

@ -0,0 +1,73 @@
/**
* 활동 로그 상수 유틸리티
*/
// 카테고리 한글 라벨 매핑
export const CATEGORY_LABELS = {
album: '앨범',
schedule: '일정',
member: '멤버',
bot: '봇',
category: '카테고리',
dict: '사전',
concert: '콘서트',
sync: '동기화',
};
// 액션 뱃지 색상
export 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',
};
// 액션 한글 라벨
export const ACTION_LABELS = {
create: '생성',
upload: '업로드',
update: '수정',
delete: '삭제',
sync_complete: '동기화',
error: '에러',
start: '시작',
stop: '정지',
};
export const ITEMS_PER_PAGE = 15;
// HTML 엔티티 디코딩
export function decodeHtml(str) {
if (!str) return '';
const el = document.createElement('textarea');
el.innerHTML = str;
return el.value;
}
// summary를 prefix와 detail로 분리
export function parseSummary(summary) {
const decoded = decodeHtml(summary);
const idx = decoded.indexOf(': ');
if (idx === -1) return { prefix: decoded, detail: '' };
return { prefix: decoded.substring(0, idx), detail: decoded.substring(idx + 2) };
}
// 날짜/시간 포맷
export function formatDateTime(dateStr) {
const date = new Date(dateStr);
const y = date.getFullYear();
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 `${y}.${month}.${day} ${hours}:${minutes}`;
}
// details가 유효한 데이터인지 확인
export function hasDetails(details) {
return details && typeof details === 'object' && Object.keys(details).length > 0;
}

View file

@ -0,0 +1,2 @@
export * from './constants';
export { default as LogDetailDialog, ActorBadge, Summary } from './LogDetailDialog';

View file

@ -7,62 +7,29 @@ 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, Loader2, Check, X, Loader2, Check, ScrollText,
} from 'lucide-react'; } from 'lucide-react';
import { AdminLayout, DatePicker } from '@/components/pc/admin'; import {
AdminLayout, DatePicker,
CATEGORY_LABELS, ACTION_STYLES, ACTION_LABELS, ITEMS_PER_PAGE, formatDateTime,
LogDetailDialog, ActorBadge, Summary,
} from '@/components/pc/admin';
import { useAdminAuth } from '@/hooks/pc/admin'; import { useAdminAuth } from '@/hooks/pc/admin';
import { adminLogApi } from '@/api/admin'; import { adminLogApi } from '@/api/admin';
//
const CATEGORY_LABELS = {
album: '앨범',
schedule: '일정',
member: '멤버',
bot: '봇',
category: '카테고리',
dict: '사전',
concert: '콘서트',
sync: '동기화',
};
//
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;
function Logs() { function Logs() {
const { user } = useAdminAuth(); const { user } = useAdminAuth();
// //
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedCategories, setSelectedCategories] = useState([]); const [selectedCategories, setSelectedCategories] = useState([]);
const [actorFilter, setActorFilter] = useState('all'); // all, admin, bot const [actorFilter, setActorFilter] = useState('all');
const [dateFrom, setDateFrom] = useState(''); const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState(''); const [dateTo, setDateTo] = useState('');
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [actorDropdownOpen, setActorDropdownOpen] = useState(false); const [actorDropdownOpen, setActorDropdownOpen] = useState(false);
const [categoryDropdownOpen, setCategoryDropdownOpen] = useState(false); const [categoryDropdownOpen, setCategoryDropdownOpen] = useState(false);
const [selectedLog, setSelectedLog] = useState(null);
// //
const [debouncedSearch, setDebouncedSearch] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState('');
@ -113,35 +80,6 @@ function Logs() {
return `카테고리 (${selectedCategories.length})`; return `카테고리 (${selectedCategories.length})`;
}; };
// /
const formatDateTime = (dateStr) => {
const date = new Date(dateStr);
const y = date.getFullYear();
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 `${y}.${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 = () => { const clearFilters = () => {
setSearchQuery(''); setSearchQuery('');
@ -361,7 +299,7 @@ function Logs() {
{formatDateTime(log.created_at)} {formatDateTime(log.created_at)}
</td> </td>
<td className="px-3 py-3.5 whitespace-nowrap"> <td className="px-3 py-3.5 whitespace-nowrap">
{renderActorBadge(log.actor)} <ActorBadge actor={log.actor} />
</td> </td>
<td className="px-3 py-3.5 whitespace-nowrap"> <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'}`}>
@ -374,7 +312,12 @@ function Logs() {
</span> </span>
</td> </td>
<td className="pl-3 pr-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} <div
onClick={() => setSelectedLog(log)}
className="truncate cursor-pointer hover:text-gray-900 transition-colors"
>
<Summary summary={log.summary} />
</div>
</td> </td>
</motion.tr> </motion.tr>
))} ))}
@ -449,6 +392,9 @@ function Logs() {
</div> </div>
)} )}
</div> </div>
{/* 로그 상세 다이얼로그 */}
<LogDetailDialog log={selectedLog} onClose={() => setSelectedLog(null)} />
</AdminLayout> </AdminLayout>
); );
} }