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)}
/>
)}
);
}