871 lines
29 KiB
TypeScript
Executable File
871 lines
29 KiB
TypeScript
Executable File
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<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;
|
|
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<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
|
|
automationTypeOptions={buildAutomationTypeOptions(automationTypes, draft.automationType)}
|
|
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({
|
|
automationTypeOptions,
|
|
draft,
|
|
hasAccess,
|
|
selectedItem,
|
|
validationMessages,
|
|
onChangeDraft,
|
|
onCopyText,
|
|
}: {
|
|
automationTypeOptions: Array<{ label: string; value: string }>;
|
|
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={automationTypeOptions}
|
|
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>
|
|
);
|
|
}
|