feat: 관리자 페이지 마이그레이션 완료 (Phase 4-5)
- 관리자 페이지 폴더 구조 재구성 (pages/pc/admin/)
- login/, dashboard/, members/, albums/, schedules/
- 앨범 관리 페이지 마이그레이션 (Albums, AlbumForm, AlbumPhotos, AlbumTeasers)
- 일정 관리 페이지 마이그레이션 (Schedules, ScheduleForm, ScheduleCategory, ScheduleDict, ScheduleBots)
- DatePicker 컴포넌트 버그 수정 (월 이동 및 연도 선택)
- 일정 관리 라우트 경로 수정 (/admin/schedule)
- 마이그레이션 문서 업데이트
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 20:35:05 +09:00
|
|
|
import { useState, useEffect } from 'react';
|
|
|
|
|
import { Link } from 'react-router-dom';
|
|
|
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
|
|
|
import { motion } from 'framer-motion';
|
|
|
|
|
import {
|
|
|
|
|
Home,
|
|
|
|
|
ChevronRight,
|
|
|
|
|
Bot,
|
|
|
|
|
Play,
|
|
|
|
|
Square,
|
|
|
|
|
Youtube,
|
|
|
|
|
CheckCircle,
|
|
|
|
|
XCircle,
|
|
|
|
|
RefreshCw,
|
|
|
|
|
Download,
|
|
|
|
|
} from 'lucide-react';
|
|
|
|
|
import { Toast, Tooltip } from '@/components/common';
|
|
|
|
|
import { AdminLayout } from '@/components/pc/admin';
|
|
|
|
|
import { useAdminAuth } from '@/hooks/pc/admin';
|
|
|
|
|
import { useToast } from '@/hooks/common';
|
2026-01-22 21:39:01 +09:00
|
|
|
import * as botsApi from '@/api/admin/bots';
|
feat: 관리자 페이지 마이그레이션 완료 (Phase 4-5)
- 관리자 페이지 폴더 구조 재구성 (pages/pc/admin/)
- login/, dashboard/, members/, albums/, schedules/
- 앨범 관리 페이지 마이그레이션 (Albums, AlbumForm, AlbumPhotos, AlbumTeasers)
- 일정 관리 페이지 마이그레이션 (Schedules, ScheduleForm, ScheduleCategory, ScheduleDict, ScheduleBots)
- DatePicker 컴포넌트 버그 수정 (월 이동 및 연도 선택)
- 일정 관리 라우트 경로 수정 (/admin/schedule)
- 마이그레이션 문서 업데이트
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 20:35:05 +09:00
|
|
|
|
|
|
|
|
// X 아이콘 컴포넌트
|
|
|
|
|
const XIcon = ({ size = 20, fill = 'currentColor' }) => (
|
|
|
|
|
<svg width={size} height={size} viewBox="0 0 24 24" fill={fill}>
|
|
|
|
|
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
|
|
|
|
</svg>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Meilisearch 아이콘 컴포넌트
|
|
|
|
|
const MeilisearchIcon = ({ size = 20 }) => (
|
|
|
|
|
<svg width={size} height={size} viewBox="0 108.4 512 295.2">
|
|
|
|
|
<defs>
|
|
|
|
|
<linearGradient
|
|
|
|
|
id="meili-a"
|
|
|
|
|
x1="488.157"
|
|
|
|
|
x2="-21.055"
|
|
|
|
|
y1="469.917"
|
|
|
|
|
y2="179.001"
|
|
|
|
|
gradientTransform="matrix(1 0 0 -1 0 514)"
|
|
|
|
|
gradientUnits="userSpaceOnUse"
|
|
|
|
|
>
|
|
|
|
|
<stop offset="0" stopColor="#ff5caa" />
|
|
|
|
|
<stop offset="1" stopColor="#ff4e62" />
|
|
|
|
|
</linearGradient>
|
|
|
|
|
<linearGradient
|
|
|
|
|
id="meili-b"
|
|
|
|
|
x1="522.305"
|
|
|
|
|
x2="13.094"
|
|
|
|
|
y1="410.144"
|
|
|
|
|
y2="119.228"
|
|
|
|
|
gradientTransform="matrix(1 0 0 -1 0 514)"
|
|
|
|
|
gradientUnits="userSpaceOnUse"
|
|
|
|
|
>
|
|
|
|
|
<stop offset="0" stopColor="#ff5caa" />
|
|
|
|
|
<stop offset="1" stopColor="#ff4e62" />
|
|
|
|
|
</linearGradient>
|
|
|
|
|
<linearGradient
|
|
|
|
|
id="meili-c"
|
|
|
|
|
x1="556.456"
|
|
|
|
|
x2="47.244"
|
|
|
|
|
y1="350.368"
|
|
|
|
|
y2="59.452"
|
|
|
|
|
gradientTransform="matrix(1 0 0 -1 0 514)"
|
|
|
|
|
gradientUnits="userSpaceOnUse"
|
|
|
|
|
>
|
|
|
|
|
<stop offset="0" stopColor="#ff5caa" />
|
|
|
|
|
<stop offset="1" stopColor="#ff4e62" />
|
|
|
|
|
</linearGradient>
|
|
|
|
|
</defs>
|
|
|
|
|
<path
|
|
|
|
|
d="m0 403.6 94.6-239.3c13.3-33.7 46.2-55.9 82.8-55.9h57l-94.6 239.3c-13.3 33.7-46.2 55.9-82.8 55.9z"
|
|
|
|
|
fill="url(#meili-a)"
|
|
|
|
|
/>
|
|
|
|
|
<path
|
|
|
|
|
d="m138.8 403.6 94.6-239.3c13.3-33.7 46.2-55.9 82.8-55.9h57l-94.6 239.3c-13.3 33.7-46.2 55.9-82.8 55.9z"
|
|
|
|
|
fill="url(#meili-b)"
|
|
|
|
|
/>
|
|
|
|
|
<path
|
|
|
|
|
d="m277.6 403.6 94.6-239.3c13.3-33.7 46.2-55.9 82.8-55.9h57l-94.6 239.3c-13.3 33.7-46.2 55.9-82.8 55.9z"
|
|
|
|
|
fill="url(#meili-c)"
|
|
|
|
|
/>
|
|
|
|
|
</svg>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
function ScheduleBots() {
|
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
|
const { user, isAuthenticated } = useAdminAuth();
|
|
|
|
|
const { toast, setToast } = useToast();
|
|
|
|
|
const [isInitialLoad, setIsInitialLoad] = useState(true); // 첫 로드 여부 (애니메이션용)
|
|
|
|
|
const [syncing, setSyncing] = useState(null); // 동기화 중인 봇 ID
|
|
|
|
|
const [quotaWarning, setQuotaWarning] = useState(null); // 할당량 경고 상태
|
|
|
|
|
|
|
|
|
|
// 봇 목록 조회
|
|
|
|
|
const {
|
|
|
|
|
data: bots = [],
|
|
|
|
|
isLoading: loading,
|
|
|
|
|
isError,
|
|
|
|
|
refetch: fetchBots,
|
|
|
|
|
} = useQuery({
|
|
|
|
|
queryKey: ['admin', 'bots'],
|
|
|
|
|
queryFn: botsApi.getBots,
|
|
|
|
|
enabled: isAuthenticated,
|
|
|
|
|
staleTime: 30000,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 할당량 경고 상태 조회
|
|
|
|
|
const { data: quotaData } = useQuery({
|
|
|
|
|
queryKey: ['admin', 'bots', 'quota'],
|
|
|
|
|
queryFn: botsApi.getQuotaWarning,
|
|
|
|
|
enabled: isAuthenticated,
|
|
|
|
|
staleTime: 60000,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 에러 처리
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (isError) {
|
|
|
|
|
setToast({ type: 'error', message: '봇 목록을 불러올 수 없습니다.' });
|
|
|
|
|
}
|
|
|
|
|
}, [isError, setToast]);
|
|
|
|
|
|
|
|
|
|
// 할당량 경고 상태 업데이트
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (quotaData?.active) {
|
|
|
|
|
setQuotaWarning(quotaData);
|
|
|
|
|
}
|
|
|
|
|
}, [quotaData]);
|
|
|
|
|
|
|
|
|
|
// 할당량 경고 해제
|
|
|
|
|
const handleDismissQuotaWarning = async () => {
|
|
|
|
|
try {
|
|
|
|
|
await botsApi.dismissQuotaWarning();
|
|
|
|
|
setQuotaWarning(null);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('할당량 경고 해제 오류:', error);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 봇 시작/정지 토글
|
|
|
|
|
const toggleBot = async (botId, currentStatus, botName) => {
|
|
|
|
|
try {
|
|
|
|
|
const action = currentStatus === 'running' ? 'stop' : 'start';
|
|
|
|
|
|
|
|
|
|
if (action === 'start') {
|
|
|
|
|
await botsApi.startBot(botId);
|
|
|
|
|
} else {
|
|
|
|
|
await botsApi.stopBot(botId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 캐시 업데이트 (전체 목록 새로고침 대신)
|
|
|
|
|
queryClient.setQueryData(['admin', 'bots'], (prev) =>
|
|
|
|
|
prev?.map((bot) =>
|
|
|
|
|
bot.id === botId ? { ...bot, status: action === 'start' ? 'running' : 'stopped' } : bot
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
setToast({
|
|
|
|
|
type: 'success',
|
|
|
|
|
message:
|
|
|
|
|
action === 'start' ? `${botName} 봇이 시작되었습니다.` : `${botName} 봇이 정지되었습니다.`,
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('봇 토글 오류:', error);
|
|
|
|
|
setToast({ type: 'error', message: error.message || '작업 중 오류가 발생했습니다.' });
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 전체 동기화
|
|
|
|
|
const handleSyncAllVideos = async (botId) => {
|
|
|
|
|
setSyncing(botId);
|
|
|
|
|
try {
|
|
|
|
|
const data = await botsApi.syncAllVideos(botId);
|
|
|
|
|
setToast({
|
|
|
|
|
type: 'success',
|
|
|
|
|
message: `${data.addedCount}개 일정이 추가되었습니다. (전체 ${data.total}개)`,
|
|
|
|
|
});
|
|
|
|
|
fetchBots();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('전체 동기화 오류:', error);
|
|
|
|
|
setToast({ type: 'error', message: error.message || '동기화 중 오류가 발생했습니다.' });
|
|
|
|
|
fetchBots();
|
|
|
|
|
} finally {
|
|
|
|
|
setSyncing(null);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 상태 아이콘 및 색상
|
|
|
|
|
const getStatusInfo = (status) => {
|
|
|
|
|
switch (status) {
|
|
|
|
|
case 'running':
|
|
|
|
|
return {
|
|
|
|
|
icon: <CheckCircle size={16} />,
|
|
|
|
|
text: '실행 중',
|
|
|
|
|
color: 'text-green-500',
|
|
|
|
|
bg: 'bg-green-50',
|
|
|
|
|
dot: 'bg-green-500',
|
|
|
|
|
};
|
|
|
|
|
case 'stopped':
|
|
|
|
|
return {
|
|
|
|
|
icon: <XCircle size={16} />,
|
|
|
|
|
text: '정지됨',
|
|
|
|
|
color: 'text-gray-400',
|
|
|
|
|
bg: 'bg-gray-50',
|
|
|
|
|
dot: 'bg-gray-400',
|
|
|
|
|
};
|
|
|
|
|
case 'error':
|
|
|
|
|
return {
|
|
|
|
|
icon: <XCircle size={16} />,
|
|
|
|
|
text: '오류',
|
|
|
|
|
color: 'text-red-500',
|
|
|
|
|
bg: 'bg-red-50',
|
|
|
|
|
dot: 'bg-red-500',
|
|
|
|
|
};
|
|
|
|
|
default:
|
|
|
|
|
return {
|
|
|
|
|
icon: null,
|
|
|
|
|
text: '알 수 없음',
|
|
|
|
|
color: 'text-gray-400',
|
|
|
|
|
bg: 'bg-gray-50',
|
|
|
|
|
dot: 'bg-gray-400',
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 시간 포맷 (UTC → KST 변환)
|
|
|
|
|
const formatTime = (dateString) => {
|
|
|
|
|
if (!dateString) return '-';
|
|
|
|
|
const date = new Date(dateString);
|
|
|
|
|
return date.toLocaleString('ko-KR', {
|
|
|
|
|
month: 'short',
|
|
|
|
|
day: 'numeric',
|
|
|
|
|
hour: '2-digit',
|
|
|
|
|
minute: '2-digit',
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 간격 포맷 (분 → 분/시간/일)
|
|
|
|
|
const formatInterval = (minutes) => {
|
|
|
|
|
if (!minutes) return '-';
|
|
|
|
|
if (minutes >= 1440) {
|
|
|
|
|
const days = Math.floor(minutes / 1440);
|
|
|
|
|
return `${days}일`;
|
|
|
|
|
} else if (minutes >= 60) {
|
|
|
|
|
const hours = Math.floor(minutes / 60);
|
|
|
|
|
return `${hours}시간`;
|
|
|
|
|
}
|
|
|
|
|
return `${minutes}분`;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<AdminLayout user={user}>
|
|
|
|
|
<Toast toast={toast} onClose={() => setToast(null)} />
|
|
|
|
|
|
|
|
|
|
{/* 메인 콘텐츠 */}
|
|
|
|
|
<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} />
|
|
|
|
|
<Link to="/admin/schedule" className="hover:text-primary transition-colors">
|
|
|
|
|
일정 관리
|
|
|
|
|
</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="grid grid-cols-4 gap-4 mb-8">
|
|
|
|
|
<div className="bg-white rounded-xl p-5 border border-gray-100">
|
|
|
|
|
<div className="text-sm text-gray-500 mb-1">전체 봇</div>
|
|
|
|
|
<div className="text-2xl font-bold text-gray-900">{bots.length}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="bg-white rounded-xl p-5 border border-gray-100">
|
|
|
|
|
<div className="text-sm text-gray-500 mb-1">실행 중</div>
|
|
|
|
|
<div className="text-2xl font-bold text-green-500">
|
|
|
|
|
{bots.filter((b) => b.status === 'running').length}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="bg-white rounded-xl p-5 border border-gray-100">
|
|
|
|
|
<div className="text-sm text-gray-500 mb-1">정지됨</div>
|
|
|
|
|
<div className="text-2xl font-bold text-gray-400">
|
|
|
|
|
{bots.filter((b) => b.status === 'stopped').length}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="bg-white rounded-xl p-5 border border-gray-100">
|
|
|
|
|
<div className="text-sm text-gray-500 mb-1">오류</div>
|
|
|
|
|
<div className="text-2xl font-bold text-red-500">
|
|
|
|
|
{bots.filter((b) => b.status === 'error').length}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* API 할당량 경고 배너 */}
|
|
|
|
|
{quotaWarning && (
|
|
|
|
|
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-8 flex items-start justify-between">
|
|
|
|
|
<div className="flex items-start gap-3">
|
|
|
|
|
<div className="w-8 h-8 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0">
|
|
|
|
|
<XCircle size={18} className="text-red-500" />
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<h3 className="font-bold text-red-700">YouTube API 할당량 경고</h3>
|
|
|
|
|
<p className="text-sm text-red-600 mt-0.5">{quotaWarning.message}</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleDismissQuotaWarning}
|
|
|
|
|
className="text-red-400 hover:text-red-600 transition-colors text-sm px-2 py-1"
|
|
|
|
|
>
|
|
|
|
|
닫기
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 봇 목록 */}
|
|
|
|
|
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
|
|
|
|
|
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
|
|
|
|
|
<h2 className="font-bold text-gray-900">봇 목록</h2>
|
|
|
|
|
<Tooltip text="새로고침">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setIsInitialLoad(true);
|
|
|
|
|
fetchBots();
|
|
|
|
|
}}
|
|
|
|
|
disabled={loading}
|
|
|
|
|
className="p-2 hover:bg-gray-100 rounded-lg transition-colors text-gray-500 hover:text-gray-700 disabled:opacity-50"
|
|
|
|
|
>
|
|
|
|
|
<RefreshCw size={18} className={loading ? 'animate-spin' : ''} />
|
|
|
|
|
</button>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{loading ? (
|
|
|
|
|
<div className="flex justify-center items-center py-20">
|
|
|
|
|
<div className="animate-spin rounded-full h-10 w-10 border-4 border-primary border-t-transparent"></div>
|
|
|
|
|
</div>
|
|
|
|
|
) : bots.length === 0 ? (
|
|
|
|
|
<div className="text-center py-20 text-gray-400">
|
|
|
|
|
<Bot size={48} className="mx-auto mb-4 opacity-30" />
|
|
|
|
|
<p>등록된 봇이 없습니다</p>
|
|
|
|
|
<p className="text-sm mt-1">위의 버튼을 클릭하여 봇을 추가하세요</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="p-6 grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
|
|
|
{bots.map((bot, index) => {
|
|
|
|
|
const statusInfo = getStatusInfo(bot.status);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<motion.div
|
|
|
|
|
key={bot.id}
|
|
|
|
|
initial={isInitialLoad ? { opacity: 0, scale: 0.95 } : false}
|
|
|
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
|
|
|
transition={isInitialLoad ? { delay: index * 0.05 } : { duration: 0.15 }}
|
|
|
|
|
onAnimationComplete={() =>
|
|
|
|
|
isInitialLoad && index === bots.length - 1 && setIsInitialLoad(false)
|
|
|
|
|
}
|
|
|
|
|
className="relative bg-gradient-to-br from-gray-50 to-white rounded-xl border border-gray-200 overflow-hidden hover:shadow-md transition-all"
|
|
|
|
|
>
|
|
|
|
|
{/* 상단 헤더 */}
|
|
|
|
|
<div className="flex items-center justify-between p-4 border-b border-gray-100">
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<div
|
|
|
|
|
className={`w-10 h-10 rounded-lg flex items-center justify-center ${
|
|
|
|
|
bot.type === 'x'
|
|
|
|
|
? 'bg-black'
|
|
|
|
|
: bot.type === 'meilisearch'
|
|
|
|
|
? 'bg-[#ddf1fd]'
|
|
|
|
|
: 'bg-red-50'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{bot.type === 'x' ? (
|
|
|
|
|
<XIcon size={20} fill="white" />
|
|
|
|
|
) : bot.type === 'meilisearch' ? (
|
|
|
|
|
<MeilisearchIcon size={20} />
|
|
|
|
|
) : (
|
|
|
|
|
<Youtube size={20} className="text-red-500" />
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<h3 className="font-bold text-gray-900">{bot.name}</h3>
|
|
|
|
|
<p className="text-xs text-gray-400">
|
|
|
|
|
{bot.last_check_at
|
|
|
|
|
? `${formatTime(bot.last_check_at)}에 업데이트됨`
|
|
|
|
|
: '아직 업데이트 없음'}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<span
|
|
|
|
|
className={`flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-full ${statusInfo.bg} ${statusInfo.color}`}
|
|
|
|
|
>
|
|
|
|
|
<span
|
|
|
|
|
className={`w-1.5 h-1.5 rounded-full ${statusInfo.dot} ${bot.status === 'running' ? 'animate-pulse' : ''}`}
|
|
|
|
|
></span>
|
|
|
|
|
{statusInfo.text}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 통계 정보 */}
|
|
|
|
|
<div className="grid grid-cols-3 divide-x divide-gray-100 bg-gray-50/50">
|
|
|
|
|
{bot.type === 'meilisearch' ? (
|
|
|
|
|
<>
|
|
|
|
|
<div className="p-3 text-center">
|
|
|
|
|
<div className="text-lg font-bold text-gray-900">
|
|
|
|
|
{bot.schedules_added || 0}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-xs text-gray-400">동기화 수</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="p-3 text-center">
|
|
|
|
|
<div className="text-lg font-bold text-gray-900">
|
|
|
|
|
{bot.last_added_count
|
|
|
|
|
? `${(bot.last_added_count / 1000 || 0).toFixed(1)}초`
|
|
|
|
|
: '-'}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-xs text-gray-400">소요 시간</div>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<div className="p-3 text-center">
|
|
|
|
|
<div className="text-lg font-bold text-gray-900">{bot.schedules_added}</div>
|
|
|
|
|
<div className="text-xs text-gray-400">총 추가</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="p-3 text-center">
|
|
|
|
|
<div
|
|
|
|
|
className={`text-lg font-bold ${bot.last_added_count > 0 ? 'text-green-500' : 'text-gray-400'}`}
|
|
|
|
|
>
|
|
|
|
|
+{bot.last_added_count || 0}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-xs text-gray-400">마지막</div>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
<div className="p-3 text-center">
|
|
|
|
|
<div className="text-lg font-bold text-gray-900">
|
|
|
|
|
{formatInterval(bot.check_interval)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-xs text-gray-400">업데이트 간격</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 오류 메시지 */}
|
|
|
|
|
{bot.status === 'error' && bot.error_message && (
|
|
|
|
|
<div className="px-4 py-2 bg-red-50 text-red-600 text-xs border-t border-red-100">
|
|
|
|
|
{bot.error_message}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 액션 버튼 */}
|
|
|
|
|
<div className="p-4 border-t border-gray-100">
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleSyncAllVideos(bot.id)}
|
|
|
|
|
disabled={syncing === bot.id}
|
|
|
|
|
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 bg-blue-500 text-white rounded-lg font-medium transition-colors hover:bg-blue-600 disabled:opacity-50"
|
|
|
|
|
>
|
|
|
|
|
{syncing === bot.id ? (
|
|
|
|
|
<>
|
|
|
|
|
<RefreshCw size={16} className="animate-spin" />
|
|
|
|
|
<span>동기화 중...</span>
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<Download size={16} />
|
|
|
|
|
<span>전체 동기화</span>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => toggleBot(bot.id, bot.status, bot.name)}
|
|
|
|
|
className={`flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium transition-colors ${
|
|
|
|
|
bot.status === 'running'
|
|
|
|
|
? 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
|
|
|
|
: 'bg-green-500 text-white hover:bg-green-600'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{bot.status === 'running' ? (
|
|
|
|
|
<>
|
|
|
|
|
<Square size={16} />
|
|
|
|
|
<span>정지</span>
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<Play size={16} />
|
|
|
|
|
<span>시작</span>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</motion.div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</AdminLayout>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default ScheduleBots;
|