fromis_9/frontend-temp/src/pages/pc/admin/schedules/ScheduleBots.jsx

340 lines
11 KiB
React
Raw Normal View History

import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { motion, AnimatePresence } from 'framer-motion';
import { Home, ChevronRight, Bot, CheckCircle, XCircle, RefreshCw } from 'lucide-react';
import { Toast, Tooltip, AnimatedNumber } from '@/components/common';
import { AdminLayout, BotCard } from '@/components/pc/admin';
import { useAdminAuth } from '@/hooks/pc/admin';
import { useToast } from '@/hooks/common';
import * as botsApi from '@/api/admin/bots';
// 애니메이션 variants
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.1 },
},
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.4, ease: 'easeOut' },
},
};
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}`;
};
// 통계 계산
const runningCount = bots.filter((b) => b.status === 'running').length;
const stoppedCount = bots.filter((b) => b.status === 'stopped').length;
const errorCount = bots.filter((b) => b.status === 'error').length;
return (
<AdminLayout user={user}>
<Toast toast={toast} onClose={() => setToast(null)} />
{/* 메인 콘텐츠 */}
<motion.div
className="max-w-7xl mx-auto px-6 py-8"
variants={containerVariants}
initial="hidden"
animate="visible"
>
{/* 브레드크럼 */}
<motion.div variants={itemVariants} 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>
</motion.div>
{/* 타이틀 */}
<motion.div variants={itemVariants} className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2"> 관리</h1>
<p className="text-gray-500">일정 자동화 봇을 관리합니다</p>
</motion.div>
{/* 봇 통계 */}
<motion.div variants={itemVariants} 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">
<AnimatedNumber value={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">
<AnimatedNumber value={runningCount} />
</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">
<AnimatedNumber value={stoppedCount} />
</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">
<AnimatedNumber value={errorCount} />
</div>
</div>
</motion.div>
{/* API 할당량 경고 배너 */}
<AnimatePresence>
{quotaWarning && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="bg-red-50 border border-red-200 rounded-xl p-4 mb-8 flex items-start justify-between overflow-hidden"
>
<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>
</motion.div>
)}
</AnimatePresence>
{/* 봇 목록 */}
<motion.div variants={itemVariants} 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) => (
<BotCard
key={bot.id}
bot={bot}
index={index}
isInitialLoad={isInitialLoad}
syncing={syncing}
statusInfo={getStatusInfo(bot.status)}
onSync={handleSyncAllVideos}
onToggle={toggleBot}
onAnimationComplete={() =>
isInitialLoad && index === bots.length - 1 && setIsInitialLoad(false)
}
formatTime={formatTime}
formatInterval={formatInterval}
/>
))}
</div>
)}
</motion.div>
</motion.div>
</AdminLayout>
);
}
export default ScheduleBots;