import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { batchApi, type ScheduleResponse } from '../api/batchApi'; import { formatDateTime } from '../utils/formatters'; import { useToastContext } from '../contexts/ToastContext'; import ConfirmModal from '../components/ConfirmModal'; import EmptyState from '../components/EmptyState'; import LoadingSpinner from '../components/LoadingSpinner'; import { getNextExecutions } from '../utils/cronPreview'; type ScheduleMode = 'new' | 'existing'; interface ConfirmAction { type: 'toggle' | 'delete'; schedule: ScheduleResponse; } const CRON_PRESETS = [ { label: '매 분', cron: '0 * * * * ?' }, { label: '매시 정각', cron: '0 0 * * * ?' }, { label: '매 15분', cron: '0 0/15 * * * ?' }, { label: '매일 00:00', cron: '0 0 0 * * ?' }, { label: '매일 12:00', cron: '0 0 12 * * ?' }, { label: '매주 월 00:00', cron: '0 0 0 ? * MON' }, ]; function CronPreview({ cron }: { cron: string }) { const nextDates = useMemo(() => getNextExecutions(cron, 5), [cron]); if (nextDates.length === 0) { return (

미리보기 불가 (복잡한 표현식)

); } const fmt = new Intl.DateTimeFormat('ko-KR', { month: '2-digit', day: '2-digit', weekday: 'short', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }); return (
{nextDates.map((d, i) => ( {fmt.format(d)} ))}
); } function getTriggerStateStyle(state: string | null): string { switch (state) { case 'NORMAL': return 'bg-emerald-100 text-emerald-700'; case 'PAUSED': return 'bg-amber-100 text-amber-700'; case 'BLOCKED': return 'bg-red-100 text-red-700'; case 'ERROR': return 'bg-red-100 text-red-700'; default: return 'bg-wing-card text-wing-muted'; } } export default function Schedules() { const { showToast } = useToastContext(); // Form state const [jobs, setJobs] = useState([]); const [selectedJob, setSelectedJob] = useState(''); const [cronExpression, setCronExpression] = useState(''); const [description, setDescription] = useState(''); const [scheduleMode, setScheduleMode] = useState('new'); const [formLoading, setFormLoading] = useState(false); const [saving, setSaving] = useState(false); // Schedule list state const [schedules, setSchedules] = useState([]); const [listLoading, setListLoading] = useState(true); // Confirm modal state const [confirmAction, setConfirmAction] = useState(null); // 폼 영역 ref (편집 버튼 클릭 시 스크롤) const formRef = useRef(null); const loadSchedules = useCallback(async () => { try { const result = await batchApi.getSchedules(); setSchedules(result.schedules); } catch (err) { showToast('스케줄 목록 조회 실패', 'error'); console.error(err); } finally { setListLoading(false); } }, [showToast]); const loadJobs = useCallback(async () => { try { const result = await batchApi.getJobs(); setJobs(result); } catch (err) { showToast('작업 목록 조회 실패', 'error'); console.error(err); } }, [showToast]); useEffect(() => { loadJobs(); loadSchedules(); }, [loadJobs, loadSchedules]); const handleJobSelect = async (jobName: string) => { setSelectedJob(jobName); setCronExpression(''); setDescription(''); setScheduleMode('new'); if (!jobName) return; setFormLoading(true); try { const schedule = await batchApi.getSchedule(jobName); setCronExpression(schedule.cronExpression); setDescription(schedule.description ?? ''); setScheduleMode('existing'); } catch { // 404 = new schedule setScheduleMode('new'); } finally { setFormLoading(false); } }; const handleSave = async () => { if (!selectedJob) { showToast('작업을 선택해주세요', 'error'); return; } if (!cronExpression.trim()) { showToast('Cron 표현식을 입력해주세요', 'error'); return; } setSaving(true); try { if (scheduleMode === 'existing') { await batchApi.updateSchedule(selectedJob, { cronExpression: cronExpression.trim(), description: description.trim() || undefined, }); showToast('스케줄이 수정되었습니다', 'success'); } else { await batchApi.createSchedule({ jobName: selectedJob, cronExpression: cronExpression.trim(), description: description.trim() || undefined, }); showToast('스케줄이 등록되었습니다', 'success'); } await loadSchedules(); // Reload schedule info for current job await handleJobSelect(selectedJob); } catch (err) { const message = err instanceof Error ? err.message : '저장 실패'; showToast(message, 'error'); } finally { setSaving(false); } }; const handleToggle = async (schedule: ScheduleResponse) => { try { await batchApi.toggleSchedule(schedule.jobName, !schedule.active); showToast( `${schedule.jobName} 스케줄이 ${schedule.active ? '비활성화' : '활성화'}되었습니다`, 'success', ); await loadSchedules(); } catch (err) { const message = err instanceof Error ? err.message : '토글 실패'; showToast(message, 'error'); } setConfirmAction(null); }; const handleDelete = async (schedule: ScheduleResponse) => { try { await batchApi.deleteSchedule(schedule.jobName); showToast(`${schedule.jobName} 스케줄이 삭제되었습니다`, 'success'); await loadSchedules(); // Clear form if deleted schedule was selected if (selectedJob === schedule.jobName) { setSelectedJob(''); setCronExpression(''); setDescription(''); setScheduleMode('new'); } } catch (err) { const message = err instanceof Error ? err.message : '삭제 실패'; showToast(message, 'error'); } setConfirmAction(null); }; const handleEditFromCard = (schedule: ScheduleResponse) => { setSelectedJob(schedule.jobName); setCronExpression(schedule.cronExpression); setDescription(schedule.description ?? ''); setScheduleMode('existing'); formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }; return (
{/* Form Section */}

스케줄 등록 / 수정

{/* Job Select */}
{selectedJob && ( {scheduleMode === 'existing' ? '기존 스케줄' : '새 스케줄'} )} {formLoading && (
)}
{/* Cron Expression */}
setCronExpression(e.target.value)} placeholder="0 0/15 * * * ?" className="w-full rounded-lg border border-wing-border px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-wing-accent focus:border-wing-accent" disabled={!selectedJob || formLoading} />
{/* Cron Presets */}
{CRON_PRESETS.map(({ label, cron }) => ( ))}
{/* Cron Preview */} {cronExpression.trim() && ( )} {/* Description */}
setDescription(e.target.value)} placeholder="스케줄 설명 (선택)" className="w-full rounded-lg border border-wing-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-wing-accent focus:border-wing-accent" disabled={!selectedJob || formLoading} />
{/* Save Button */}
{/* Schedule List */}

등록된 스케줄 {schedules.length > 0 && ( ({schedules.length}개) )}

{listLoading ? ( ) : schedules.length === 0 ? ( ) : (
{schedules.map((schedule) => (
{/* Header */}

{schedule.jobName}

{schedule.active ? '활성' : '비활성'} {schedule.triggerState && ( {schedule.triggerState} )}
{/* Cron Expression */}
{schedule.cronExpression}
{/* Description */} {schedule.description && (

{schedule.description}

)} {/* Time Info */}
다음 실행:{' '} {formatDateTime(schedule.nextFireTime)}
이전 실행:{' '} {formatDateTime(schedule.previousFireTime)}
등록일:{' '} {formatDateTime(schedule.createdAt)}
수정일:{' '} {formatDateTime(schedule.updatedAt)}
{/* Action Buttons */}
))}
)}
{/* Confirm Modal */} {confirmAction?.type === 'toggle' && ( handleToggle(confirmAction.schedule)} onCancel={() => setConfirmAction(null)} /> )} {confirmAction?.type === 'delete' && ( handleDelete(confirmAction.schedule)} onCancel={() => setConfirmAction(null)} /> )}
); }