Initial import
This commit is contained in:
871
src/features/planBoard/PlanSchedulePage.tsx
Executable file
871
src/features/planBoard/PlanSchedulePage.tsx
Executable file
@@ -0,0 +1,871 @@
|
||||
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 { useTokenAccess } from '../../app/main/tokenAccess';
|
||||
import './planBoard.css';
|
||||
import './planSchedule.css';
|
||||
import { maskNotePreviewByWord } from './noteMasking';
|
||||
import { PlanListDetailLayout } from './PlanListDetailLayout';
|
||||
import type { PlanAutomationType } from './types';
|
||||
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<PlanScheduleRepeatUnit, string> = {
|
||||
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;
|
||||
const PLAN_AUTOMATION_TYPE_OPTIONS: Array<{ label: string; value: PlanAutomationType }> = [
|
||||
{ label: '선택 안함', value: 'none' },
|
||||
{ label: '작업 요청 등록', value: 'plan' },
|
||||
{ label: 'Command 실행', value: 'command_execution' },
|
||||
{ label: '비 소스작업', value: 'non_source_work' },
|
||||
{ label: 'autoWorker', value: 'auto_worker' },
|
||||
];
|
||||
|
||||
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 [messageApi, contextHolder] = message.useMessage();
|
||||
const [items, setItems] = useState<PlanScheduledTask[]>([]);
|
||||
const [draft, setDraft] = useState(() => createEmptyScheduleDraft());
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(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 (
|
||||
<div className="plan-schedule-page">
|
||||
{contextHolder}
|
||||
<Card className="plan-schedule-page__overview" bordered={false}>
|
||||
<Flex justify="space-between" align="center" gap={12} wrap>
|
||||
<div>
|
||||
<Title level={4}>자동화 Schedule</Title>
|
||||
<Paragraph className="plan-schedule-page__intro">
|
||||
반복 작업 내용을 스케줄로 등록하고 활성 상태와 반복 주기를 관리합니다.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<Space wrap>
|
||||
<Button onClick={() => void loadItems()} loading={loading}>
|
||||
조회
|
||||
</Button>
|
||||
<Button onClick={handleCreateNew} disabled={!hasAccess}>
|
||||
새 스케줄
|
||||
</Button>
|
||||
<Button onClick={() => void handleSetup()} disabled={!hasAccess}>
|
||||
테이블 생성
|
||||
</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
</Card>
|
||||
|
||||
{!hasAccess ? (
|
||||
<Alert
|
||||
showIcon
|
||||
type="warning"
|
||||
className="plan-schedule-page__alert"
|
||||
message="권한 토큰이 없어 작업 스케줄은 조회만 사용할 수 있습니다."
|
||||
description="설정 > 토큰 관리에서 권한 토큰을 등록하기 전에는 조회 외 버튼과 입력을 사용할 수 없고, 작업 메모는 단어별로 마스킹됩니다."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{errorMessage ? (
|
||||
<Alert
|
||||
showIcon
|
||||
type="error"
|
||||
className="plan-schedule-page__alert"
|
||||
message="스케줄을 사용할 수 없습니다."
|
||||
description={errorMessage}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<PlanListDetailLayout
|
||||
classNamePrefix="plan-schedule-page"
|
||||
listTitle="스케줄 목록"
|
||||
listExtra={<Text code>{items.length} items</Text>}
|
||||
listContent={
|
||||
<PlanScheduleList
|
||||
activeDraftId={draft.id}
|
||||
editorOpen={editorOpen}
|
||||
hasAccess={hasAccess}
|
||||
items={items}
|
||||
loading={loading}
|
||||
onSelectItem={handleSelectItem}
|
||||
/>
|
||||
}
|
||||
desktopDetailOpen={editorOpen}
|
||||
mobileDetailOpen={editorOpen}
|
||||
detailTitle={draft.id ? `스케줄 #${draft.id}` : '새 스케줄'}
|
||||
detailActions={
|
||||
<>
|
||||
{draft.id ? (
|
||||
<Button danger onClick={() => void handleDelete()} loading={saving} disabled={!hasAccess}>
|
||||
삭제
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="primary" onClick={() => void handleSave()} loading={saving} disabled={!hasAccess}>
|
||||
저장
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
onCloseDetail={closeEditor}
|
||||
showDesktopClose
|
||||
emptyDetailTitle="스케줄 상세"
|
||||
detailContent={
|
||||
<PlanScheduleDetail
|
||||
draft={draft}
|
||||
hasAccess={hasAccess}
|
||||
selectedItem={selectedItem}
|
||||
validationMessages={validationMessages}
|
||||
onChangeDraft={setDraft}
|
||||
onCopyText={handleCopyText}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<List
|
||||
className="plan-schedule-page__list"
|
||||
loading={loading}
|
||||
dataSource={items}
|
||||
locale={{ emptyText: <Empty description="등록된 스케줄이 없습니다." /> }}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
key={item.id}
|
||||
className={`plan-schedule-page__list-item${
|
||||
editorOpen && activeDraftId === item.id ? ' plan-schedule-page__list-item--active' : ''
|
||||
}`}
|
||||
onClick={() => onSelectItem(item)}
|
||||
>
|
||||
<div className="plan-schedule-page__list-body">
|
||||
<Flex justify="space-between" align="start" gap={8}>
|
||||
<Text strong>{item.workId || `스케줄 #${item.id}`}</Text>
|
||||
<Tag color={item.enabled ? 'green' : 'default'}>{item.enabled ? '적용' : '중지'}</Tag>
|
||||
</Flex>
|
||||
<Paragraph ellipsis={{ rows: 2 }} className="plan-schedule-page__list-note">
|
||||
{item.note ? (hasAccess ? item.note : maskNotePreviewByWord(item.note)) : '작업 내용이 없습니다.'}
|
||||
</Paragraph>
|
||||
<Space wrap size={8}>
|
||||
<Tag>{formatScheduleCycle(item)}</Tag>
|
||||
<Tag color="blue">다음 실행 {formatNextPlanScheduleRunAt(item)}</Tag>
|
||||
<Tag>{item.immediateRunEnabled ? '즉시실행' : '주기 후 실행'}</Tag>
|
||||
<Tag>{item.autoDeployToMain ? 'main 자동등록' : 'release만'}</Tag>
|
||||
<Tag>기능동작확인 {item.jangsingProcessingRequired ? '완료' : '오동작'}</Tag>
|
||||
</Space>
|
||||
<Flex justify="space-between" align="center" gap={8} wrap style={{ marginTop: 10 }}>
|
||||
<Text type="secondary">최근 등록 {formatPlanScheduleDateTime(item.lastRegisteredAt)}</Text>
|
||||
<Text type="secondary">수정 {formatPlanScheduleDateTime(item.updatedAt)}</Text>
|
||||
</Flex>
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
function PlanScheduleDetail({
|
||||
draft,
|
||||
hasAccess,
|
||||
selectedItem,
|
||||
validationMessages,
|
||||
onChangeDraft,
|
||||
onCopyText,
|
||||
}: {
|
||||
draft: PlanScheduledTaskDraft;
|
||||
hasAccess: boolean;
|
||||
selectedItem: PlanScheduledTask | null;
|
||||
validationMessages: string[];
|
||||
onChangeDraft: Dispatch<SetStateAction<PlanScheduledTaskDraft>>;
|
||||
onCopyText: (text: string) => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<Space direction="vertical" size={14} style={{ width: '100%' }}>
|
||||
{selectedItem ? (
|
||||
<Alert
|
||||
showIcon
|
||||
type="info"
|
||||
className="plan-schedule-page__alert"
|
||||
message="등록 정보"
|
||||
description={
|
||||
<Space direction="vertical" size={4}>
|
||||
<Text>다음 실행: {formatNextPlanScheduleRunAt(selectedItem)}</Text>
|
||||
<Text>최근 작업 등록: {formatPlanScheduleDateTime(selectedItem.lastRegisteredAt)}</Text>
|
||||
<Text>생성: {formatPlanScheduleDateTime(selectedItem.createdAt)}</Text>
|
||||
<Text>수정: {formatPlanScheduleDateTime(selectedItem.updatedAt)}</Text>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{validationMessages.length ? (
|
||||
<Alert
|
||||
showIcon
|
||||
type={validationMessages.some((message) => message !== '비활성 스케줄은 자동 등록되지 않습니다.') ? 'warning' : 'info'}
|
||||
className="plan-schedule-page__alert"
|
||||
message="스케줄 등록 검증"
|
||||
description={
|
||||
<Space direction="vertical" size={2}>
|
||||
{validationMessages.map((message) => (
|
||||
<Text key={message}>{message}</Text>
|
||||
))}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="plan-schedule-page__form">
|
||||
<div>
|
||||
<Text strong>작업 ID</Text>
|
||||
<Input
|
||||
value={draft.workId}
|
||||
placeholder="예: 반복-정리"
|
||||
disabled={!hasAccess}
|
||||
onChange={(event) => onChangeDraft((previous) => ({ ...previous, workId: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Flex justify="space-between" align="center" gap={8} wrap>
|
||||
<Text strong>작업 메모</Text>
|
||||
<Space size={8}>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => void onCopyText(draft.note)}>
|
||||
복사
|
||||
</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
<div className="plan-schedule-page__notepad-frame">
|
||||
<TextArea
|
||||
value={hasAccess ? draft.note : maskNotePreviewByWord(draft.note)}
|
||||
rows={10}
|
||||
placeholder={hasAccess ? '반복 등록할 작업 내용을 입력하세요.' : '권한 토큰 등록 후 편집할 수 있습니다.'}
|
||||
className="plan-schedule-page__notepad"
|
||||
disabled={!hasAccess}
|
||||
onChange={(event) => onChangeDraft((previous) => ({ ...previous, note: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
{!hasAccess ? <Text type="secondary">조회 화면에서는 작업 메모를 단어별 마스킹으로 표시합니다.</Text> : null}
|
||||
</div>
|
||||
<div>
|
||||
<Text strong>release 브랜치</Text>
|
||||
<Input
|
||||
value={draft.releaseTarget}
|
||||
placeholder="release"
|
||||
disabled={!hasAccess}
|
||||
onChange={(event) =>
|
||||
onChangeDraft((previous) => ({ ...previous, releaseTarget: event.target.value || 'release' }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Text strong>자동화 처리</Text>
|
||||
<Select
|
||||
className="plan-schedule-page__select plan-schedule-page__select--automation"
|
||||
value={draft.automationType}
|
||||
options={PLAN_AUTOMATION_TYPE_OPTIONS}
|
||||
popupClassName="plan-schedule-page__select-popup"
|
||||
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
|
||||
disabled={!hasAccess}
|
||||
onChange={(automationType) => onChangeDraft((previous) => ({ ...previous, automationType }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Checkbox
|
||||
checked={draft.enabled}
|
||||
disabled={!hasAccess}
|
||||
onChange={(event) => onChangeDraft((previous) => ({ ...previous, enabled: event.target.checked }))}
|
||||
>
|
||||
적용
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div>
|
||||
<Text strong>반복 주기</Text>
|
||||
<Tabs
|
||||
size="small"
|
||||
items={SCHEDULE_MODE_TAB_ITEMS.map((item) => ({
|
||||
...item,
|
||||
disabled: !hasAccess,
|
||||
}))}
|
||||
activeKey={draft.scheduleMode}
|
||||
onChange={(key) => onChangeDraft((previous) => ({ ...previous, scheduleMode: key as PlanScheduleMode }))}
|
||||
/>
|
||||
{draft.scheduleMode === 'daily' ? (
|
||||
<Space align="center" wrap>
|
||||
<Text>매일</Text>
|
||||
<Select
|
||||
style={{ width: 96 }}
|
||||
options={HOUR_OPTIONS}
|
||||
value={normalizeDailyRunTime(draft.dailyRunTime).split(':')[0]}
|
||||
popupClassName="plan-schedule-page__select-popup"
|
||||
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
|
||||
disabled={!hasAccess}
|
||||
onChange={(value) =>
|
||||
onChangeDraft((previous) => ({
|
||||
...previous,
|
||||
dailyRunTime: updateDailyRunTime(previous.dailyRunTime, 'hour', value),
|
||||
repeatIntervalValue: 1,
|
||||
repeatIntervalUnit: 'day',
|
||||
repeatIntervalMinutes: getRepeatIntervalMinutes(1, 'day'),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
style={{ width: 96 }}
|
||||
options={MINUTE_OPTIONS}
|
||||
value={normalizeDailyRunTime(draft.dailyRunTime).split(':')[1]}
|
||||
popupClassName="plan-schedule-page__select-popup"
|
||||
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
|
||||
disabled={!hasAccess}
|
||||
onChange={(value) =>
|
||||
onChangeDraft((previous) => ({
|
||||
...previous,
|
||||
dailyRunTime: updateDailyRunTime(previous.dailyRunTime, 'minute', value),
|
||||
repeatIntervalValue: 1,
|
||||
repeatIntervalUnit: 'day',
|
||||
repeatIntervalMinutes: getRepeatIntervalMinutes(1, 'day'),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Text type="secondary">에 작업 메모로 등록</Text>
|
||||
</Space>
|
||||
) : (
|
||||
<>
|
||||
<Space align="center" wrap>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={getRepeatIntervalValueMax(draft.repeatIntervalUnit)}
|
||||
value={draft.repeatIntervalValue}
|
||||
disabled={!hasAccess}
|
||||
onChange={(value) => {
|
||||
const repeatIntervalValue = normalizeRepeatIntervalValue(
|
||||
Number(value) || 1,
|
||||
draft.repeatIntervalUnit,
|
||||
);
|
||||
onChangeDraft((previous) => ({
|
||||
...previous,
|
||||
repeatIntervalValue,
|
||||
repeatIntervalMinutes: getRepeatIntervalMinutes(repeatIntervalValue, previous.repeatIntervalUnit),
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
style={{ width: 96 }}
|
||||
options={REPEAT_UNIT_OPTIONS}
|
||||
value={draft.repeatIntervalUnit}
|
||||
popupClassName="plan-schedule-page__select-popup"
|
||||
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
|
||||
disabled={!hasAccess}
|
||||
onChange={(value) =>
|
||||
onChangeDraft((previous) => ({
|
||||
...previous,
|
||||
repeatIntervalValue: normalizeRepeatIntervalValue(previous.repeatIntervalValue, value),
|
||||
repeatIntervalUnit: value,
|
||||
repeatIntervalMinutes: getRepeatIntervalMinutes(
|
||||
normalizeRepeatIntervalValue(previous.repeatIntervalValue, value),
|
||||
value,
|
||||
),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Text type="secondary">마다 작업 메모로 등록</Text>
|
||||
</Space>
|
||||
<Space size={6} wrap style={{ marginTop: 8 }}>
|
||||
{REPEAT_PRESET_OPTIONS.map((option) => (
|
||||
<Button
|
||||
key={`${option.value}-${option.unit}`}
|
||||
size="small"
|
||||
disabled={!hasAccess}
|
||||
onClick={() =>
|
||||
onChangeDraft((previous) => ({
|
||||
...previous,
|
||||
scheduleMode: option.unit === 'day' && option.value === 1 ? 'daily' : 'interval',
|
||||
repeatIntervalValue: option.value,
|
||||
repeatIntervalUnit: option.unit,
|
||||
repeatIntervalMinutes: getRepeatIntervalMinutes(option.value, option.unit),
|
||||
}))
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Checkbox
|
||||
checked={draft.immediateRunEnabled}
|
||||
disabled={!hasAccess}
|
||||
onChange={(event) =>
|
||||
onChangeDraft((previous) => ({ ...previous, immediateRunEnabled: event.target.checked }))
|
||||
}
|
||||
>
|
||||
즉시실행 여부
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div>
|
||||
<Checkbox
|
||||
checked={draft.autoDeployToMain}
|
||||
disabled={!hasAccess}
|
||||
onChange={(event) => onChangeDraft((previous) => ({ ...previous, autoDeployToMain: event.target.checked }))}
|
||||
>
|
||||
메인까지 자동등록
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div>
|
||||
<Text strong>기능동작확인</Text>
|
||||
<Segmented
|
||||
options={FUNCTION_CHECK_OPTIONS}
|
||||
value={draft.jangsingProcessingRequired ? '완료' : '오동작'}
|
||||
disabled={!hasAccess}
|
||||
onChange={(value) =>
|
||||
onChangeDraft((previous) => ({
|
||||
...previous,
|
||||
jangsingProcessingRequired: value === '완료',
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user