import { CopyOutlined } from '@ant-design/icons'; import { Alert, Button, Card, Checkbox, Empty, Flex, Input, InputNumber, List, Segmented, Select, Space, Tabs, Tag, Typography, message, } from 'antd'; import { memo, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react'; import { buildAutomationTypeOptions, useAutomationTypeRegistry, } from '../../app/main/automationTypeAccess'; import { useTokenAccess } from '../../app/main/tokenAccess'; import './planBoard.css'; import './planSchedule.css'; import { maskNotePreviewByWord } from './noteMasking'; import { PlanListDetailLayout } from './PlanListDetailLayout'; import { createPlanScheduledTask, deletePlanScheduledTask, fetchPlanScheduledTasks, setupPlanBoard, updatePlanScheduledTask, type PlanScheduleMode, type PlanScheduleRepeatUnit, type PlanScheduledTask, type PlanScheduledTaskDraft, } from './api'; const { Paragraph, Text, Title } = Typography; const { TextArea } = Input; const FUNCTION_CHECK_OPTIONS = ['완료', '오동작']; const SCHEDULE_MODE_TAB_ITEMS: { key: PlanScheduleMode; label: string }[] = [ { key: 'interval', label: '반복 주기' }, { key: 'daily', label: '매일 시간' }, ]; const REPEAT_UNIT_OPTIONS: { label: string; value: PlanScheduleRepeatUnit }[] = [ { label: '분', value: 'minute' }, { label: '시간', value: 'hour' }, { label: '일', value: 'day' }, { label: '주', value: 'week' }, { label: '월', value: 'month' }, ]; const REPEAT_UNIT_LABELS: Record = { minute: '분', hour: '시간', day: '일', week: '주', month: '개월', }; const REPEAT_PRESET_OPTIONS: { label: string; value: number; unit: PlanScheduleRepeatUnit }[] = [ { label: '10분', value: 10, unit: 'minute' }, { label: '30분', value: 30, unit: 'minute' }, { label: '1시간', value: 1, unit: 'hour' }, { label: '6시간', value: 6, unit: 'hour' }, { label: '매일 1회', value: 1, unit: 'day' }, { label: '매주 1회', value: 1, unit: 'week' }, { label: '매월 1회', value: 1, unit: 'month' }, ]; const HOUR_OPTIONS = Array.from({ length: 24 }, (_, hour) => ({ label: `${String(hour).padStart(2, '0')}시`, value: String(hour).padStart(2, '0'), })); const MINUTE_OPTIONS = Array.from({ length: 60 }, (_, minute) => ({ label: `${String(minute).padStart(2, '0')}분`, value: String(minute).padStart(2, '0'), })); const DEFAULT_DAILY_RUN_TIME = '09:00'; const KST_TIME_ZONE = 'Asia/Seoul'; const DAY_MS = 24 * 60 * 60 * 1000; function getRepeatIntervalMinutes(value: number, unit: PlanScheduleRepeatUnit) { const normalizedValue = Math.max(1, Math.round(Number(value) || 1)); if (unit === 'day') { return normalizedValue * 24 * 60; } if (unit === 'week') { return normalizedValue * 7 * 24 * 60; } if (unit === 'month') { return normalizedValue * 30 * 24 * 60; } if (unit === 'hour') { return normalizedValue * 60; } return normalizedValue; } function getRepeatIntervalValueMax(unit: PlanScheduleRepeatUnit) { if (unit === 'month') { return 12; } if (unit === 'week') { return 52; } if (unit === 'day') { return 365; } if (unit === 'hour') { return 8760; } return 525600; } function normalizeRepeatIntervalValue(value: number, unit: PlanScheduleRepeatUnit) { const roundedValue = Math.max(1, Math.round(Number(value) || 1)); return Math.min(getRepeatIntervalValueMax(unit), roundedValue); } function formatRepeatInterval(value: number, unit: PlanScheduleRepeatUnit, fallbackMinutes: number) { if (!value || !unit) { return `${fallbackMinutes}분마다`; } return `${value}${REPEAT_UNIT_LABELS[unit]}마다`; } function normalizeScheduleMode(value: PlanScheduleMode | null | undefined): PlanScheduleMode { return value === 'daily' ? 'daily' : 'interval'; } function normalizeDailyRunTime(value: string | null | undefined) { return typeof value === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(value) ? value : DEFAULT_DAILY_RUN_TIME; } function updateDailyRunTime(value: string, part: 'hour' | 'minute', nextPartValue: string) { const [hour, minute] = normalizeDailyRunTime(value).split(':'); return part === 'hour' ? `${nextPartValue}:${minute}` : `${hour}:${nextPartValue}`; } function formatScheduleCycle(item: PlanScheduledTask) { const scheduleMode = normalizeScheduleMode(item.scheduleMode); if (scheduleMode === 'daily') { return `매일 ${normalizeDailyRunTime(item.dailyRunTime)} 실행`; } return formatRepeatInterval(item.repeatIntervalValue, item.repeatIntervalUnit, item.repeatIntervalMinutes); } function getValidDate(value: string | null | undefined) { if (!value) { return null; } const date = new Date(value); return Number.isNaN(date.getTime()) ? null : date; } function getKstDateTimeParts(value: Date) { const parts = new Intl.DateTimeFormat('en-US', { timeZone: KST_TIME_ZONE, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false, hourCycle: 'h23', }).formatToParts(value); const partMap = Object.fromEntries(parts.map((part) => [part.type, part.value])); return { year: Number(partMap.year), month: Number(partMap.month), day: Number(partMap.day), hour: Number(partMap.hour), minute: Number(partMap.minute), }; } function getKstDateKey(value: Date) { const parts = getKstDateTimeParts(value); return `${parts.year}-${String(parts.month).padStart(2, '0')}-${String(parts.day).padStart(2, '0')}`; } function createDateFromKstParts(year: number, month: number, day: number, hour: number, minute: number) { return new Date(Date.UTC(year, month - 1, day, hour - 9, minute, 0, 0)); } function resolveNextPlanScheduleRunAt(item: PlanScheduledTask, now = new Date()) { if (!item.enabled) { return null; } const lastRegisteredAt = getValidDate(item.lastRegisteredAt); if (!lastRegisteredAt && item.immediateRunEnabled) { return now; } if (normalizeScheduleMode(item.scheduleMode) === 'daily') { const [hour, minute] = normalizeDailyRunTime(item.dailyRunTime).split(':').map((value) => Number(value)); const nowParts = getKstDateTimeParts(now); const scheduledToday = createDateFromKstParts(nowParts.year, nowParts.month, nowParts.day, hour, minute); const lastRegisteredToday = lastRegisteredAt ? getKstDateKey(lastRegisteredAt) === getKstDateKey(now) : false; if (!lastRegisteredToday && scheduledToday.getTime() <= now.getTime()) { return now; } if (!lastRegisteredToday) { return scheduledToday; } return new Date(scheduledToday.getTime() + DAY_MS); } const baseAt = lastRegisteredAt ?? getValidDate(item.createdAt) ?? now; const nextRunAt = new Date(baseAt.getTime() + item.repeatIntervalMinutes * 60 * 1000); return nextRunAt.getTime() <= now.getTime() ? now : nextRunAt; } function createEmptyScheduleDraft(defaultReleaseTarget = 'release'): PlanScheduledTaskDraft { return { id: null, workId: '', note: '', automationType: 'none', releaseTarget: defaultReleaseTarget, jangsingProcessingRequired: true, autoDeployToMain: true, enabled: true, immediateRunEnabled: true, scheduleMode: 'interval', repeatIntervalValue: 60, repeatIntervalUnit: 'minute', repeatIntervalMinutes: 60, dailyRunTime: DEFAULT_DAILY_RUN_TIME, }; } function toDraft(item: PlanScheduledTask): PlanScheduledTaskDraft { const repeatIntervalUnit = item.repeatIntervalUnit ?? 'minute'; const repeatIntervalValue = normalizeRepeatIntervalValue( item.repeatIntervalValue ?? item.repeatIntervalMinutes, repeatIntervalUnit, ); return { id: item.id, workId: item.workId, note: item.note, automationType: item.automationType, releaseTarget: item.releaseTarget, jangsingProcessingRequired: item.jangsingProcessingRequired, autoDeployToMain: item.autoDeployToMain, enabled: item.enabled, immediateRunEnabled: item.immediateRunEnabled, scheduleMode: normalizeScheduleMode(item.scheduleMode), repeatIntervalValue, repeatIntervalUnit, repeatIntervalMinutes: item.repeatIntervalMinutes ?? getRepeatIntervalMinutes(repeatIntervalValue, repeatIntervalUnit), dailyRunTime: normalizeDailyRunTime(item.dailyRunTime), }; } function formatPlanScheduleDateTime(value: string | Date | null | undefined) { if (!value) { return '미등록'; } return new Date(value).toLocaleString('ko-KR', { timeZone: KST_TIME_ZONE, }); } function formatNextPlanScheduleRunAt(item: PlanScheduledTask) { if (!item.enabled) { return '중지'; } return formatPlanScheduleDateTime(resolveNextPlanScheduleRunAt(item)); } function validateScheduleDraft(draft: PlanScheduledTaskDraft, items: PlanScheduledTask[]) { const messages: string[] = []; const workId = draft.workId.trim(); const note = draft.note.trim(); if (!workId) { messages.push('작업 ID를 입력하세요.'); } else if (items.some((item) => item.id !== draft.id && item.workId.trim() === workId)) { messages.push('같은 작업 ID의 스케줄이 이미 있습니다.'); } if (!note) { messages.push('반복 등록할 작업 메모를 입력하세요.'); } if (draft.scheduleMode === 'interval' && getRepeatIntervalMinutes(draft.repeatIntervalValue, draft.repeatIntervalUnit) < 10) { messages.push('반복 주기는 최소 10분 이상으로 설정하세요.'); } if (!draft.enabled) { messages.push('비활성 스케줄은 자동 등록되지 않습니다.'); } return messages; } export function PlanSchedulePage() { const { hasAccess } = useTokenAccess(); const { automationTypes } = useAutomationTypeRegistry(); const [messageApi, contextHolder] = message.useMessage(); const [items, setItems] = useState([]); const [draft, setDraft] = useState(() => createEmptyScheduleDraft()); const [editorOpen, setEditorOpen] = useState(false); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const selectedItem = useMemo( () => items.find((item) => item.id === draft.id) ?? null, [draft.id, items], ); const validationMessages = useMemo(() => validateScheduleDraft(draft, items), [draft, items]); async function loadItems() { setLoading(true); setErrorMessage(null); try { setItems(await fetchPlanScheduledTasks()); } catch (error) { setErrorMessage(error instanceof Error ? error.message : '스케줄 목록을 불러오지 못했습니다.'); } finally { setLoading(false); } } useEffect(() => { void loadItems(); }, [hasAccess]); async function handleSetup() { if (!hasAccess) { messageApi.error('설정 > 토큰 관리에서 권한 토큰을 등록한 사용자만 사용할 수 있습니다.'); return; } try { await setupPlanBoard(); messageApi.success('작업 스케줄 테이블을 준비했습니다.'); await loadItems(); } catch (error) { messageApi.error(error instanceof Error ? error.message : '테이블 생성에 실패했습니다.'); } } async function handleSave() { if (!hasAccess) { messageApi.error('설정 > 토큰 관리에서 권한 토큰을 등록한 사용자만 사용할 수 있습니다.'); return; } const blockingMessages = validationMessages.filter((message) => message !== '비활성 스케줄은 자동 등록되지 않습니다.'); if (blockingMessages.length) { messageApi.warning(blockingMessages[0]); return; } setSaving(true); try { const repeatIntervalValue = normalizeRepeatIntervalValue(draft.repeatIntervalValue, draft.repeatIntervalUnit); const draftToSave = { ...draft, repeatIntervalValue, repeatIntervalMinutes: getRepeatIntervalMinutes(repeatIntervalValue, draft.repeatIntervalUnit), }; const savedItem = draft.id ? await updatePlanScheduledTask(draftToSave) : await createPlanScheduledTask(draftToSave); messageApi.success(draft.id ? '스케줄을 수정했습니다.' : '스케줄을 등록했습니다.'); setDraft(toDraft(savedItem)); await loadItems(); } catch (error) { messageApi.error(error instanceof Error ? error.message : '스케줄 저장에 실패했습니다.'); } finally { setSaving(false); } } async function handleDelete() { if (!draft.id) { return; } if (!hasAccess) { messageApi.error('설정 > 토큰 관리에서 권한 토큰을 등록한 사용자만 사용할 수 있습니다.'); return; } if (!window.confirm('선택한 스케줄을 삭제할까요?')) { return; } setSaving(true); try { await deletePlanScheduledTask(draft.id); messageApi.success('스케줄을 삭제했습니다.'); setDraft(createEmptyScheduleDraft(draft.releaseTarget)); setEditorOpen(false); await loadItems(); } catch (error) { messageApi.error(error instanceof Error ? error.message : '스케줄 삭제에 실패했습니다.'); } finally { setSaving(false); } } async function handleCopyText(text: string) { try { await navigator.clipboard.writeText(text); messageApi.success('복사했습니다.'); } catch { messageApi.error('복사에 실패했습니다.'); } } function handleCreateNew() { setDraft(createEmptyScheduleDraft(draft.releaseTarget)); setEditorOpen(true); } function handleSelectItem(item: PlanScheduledTask) { setDraft(toDraft(item)); setEditorOpen(true); } function closeEditor() { setEditorOpen(false); } return (
{contextHolder}
자동화 Schedule 반복 작업 내용을 스케줄로 등록하고 활성 상태와 반복 주기를 관리합니다.
{!hasAccess ? ( ) : null} {errorMessage ? ( ) : null} {items.length} items} listContent={ } desktopDetailOpen={editorOpen} mobileDetailOpen={editorOpen} detailTitle={draft.id ? `스케줄 #${draft.id}` : '새 스케줄'} detailActions={ <> {draft.id ? ( ) : null} } onCloseDetail={closeEditor} showDesktopClose emptyDetailTitle="스케줄 상세" detailContent={ } />
); } const PlanScheduleList = memo(function PlanScheduleList({ activeDraftId, editorOpen, hasAccess, items, loading, onSelectItem, }: { activeDraftId: number | null; editorOpen: boolean; hasAccess: boolean; items: PlanScheduledTask[]; loading: boolean; onSelectItem: (item: PlanScheduledTask) => void; }) { return ( }} renderItem={(item) => ( onSelectItem(item)} >
{item.workId || `스케줄 #${item.id}`} {item.enabled ? '적용' : '중지'} {item.note ? (hasAccess ? item.note : maskNotePreviewByWord(item.note)) : '작업 내용이 없습니다.'} {formatScheduleCycle(item)} 다음 실행 {formatNextPlanScheduleRunAt(item)} {item.immediateRunEnabled ? '즉시실행' : '주기 후 실행'} {item.autoDeployToMain ? 'main 자동등록' : 'release만'} 기능동작확인 {item.jangsingProcessingRequired ? '완료' : '오동작'} 최근 등록 {formatPlanScheduleDateTime(item.lastRegisteredAt)} 수정 {formatPlanScheduleDateTime(item.updatedAt)}
)} /> ); }); function PlanScheduleDetail({ automationTypeOptions, draft, hasAccess, selectedItem, validationMessages, onChangeDraft, onCopyText, }: { automationTypeOptions: Array<{ label: string; value: string }>; draft: PlanScheduledTaskDraft; hasAccess: boolean; selectedItem: PlanScheduledTask | null; validationMessages: string[]; onChangeDraft: Dispatch>; onCopyText: (text: string) => Promise; }) { return ( {selectedItem ? ( 다음 실행: {formatNextPlanScheduleRunAt(selectedItem)} 최근 작업 등록: {formatPlanScheduleDateTime(selectedItem.lastRegisteredAt)} 생성: {formatPlanScheduleDateTime(selectedItem.createdAt)} 수정: {formatPlanScheduleDateTime(selectedItem.updatedAt)} } /> ) : null} {validationMessages.length ? ( message !== '비활성 스케줄은 자동 등록되지 않습니다.') ? 'warning' : 'info'} className="plan-schedule-page__alert" message="스케줄 등록 검증" description={ {validationMessages.map((message) => ( {message} ))} } /> ) : null}
작업 ID onChangeDraft((previous) => ({ ...previous, workId: event.target.value }))} />
작업 메모