4180 lines
139 KiB
TypeScript
Executable File
4180 lines
139 KiB
TypeScript
Executable File
import {
|
|
ArrowLeftOutlined,
|
|
CloseOutlined,
|
|
CopyOutlined,
|
|
DownloadOutlined,
|
|
DownOutlined,
|
|
FullscreenExitOutlined,
|
|
FullscreenOutlined,
|
|
PaperClipOutlined,
|
|
UpOutlined,
|
|
} from '@ant-design/icons';
|
|
import {
|
|
Alert,
|
|
Button,
|
|
Card,
|
|
Checkbox,
|
|
Empty,
|
|
Flex,
|
|
Grid,
|
|
Input,
|
|
List,
|
|
Modal,
|
|
Segmented,
|
|
Select,
|
|
Space,
|
|
Spin,
|
|
Tag,
|
|
Tabs,
|
|
Typography,
|
|
message,
|
|
} from 'antd';
|
|
import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent, type ReactNode } from 'react';
|
|
import { useAppConfig, type AppConfig } from '../../app/main/appConfig';
|
|
import {
|
|
buildAutomationTypeOptions,
|
|
resolveAutomationTypeLabel,
|
|
useAutomationTypeRegistry,
|
|
} from '../../app/main/automationTypeAccess';
|
|
import { uploadChatComposerFile } from '../../app/main/mainChatPanel';
|
|
import { normalizeChatResourceUrl } from '../../app/main/mainChatPanel/chatResourceUrl';
|
|
import type { ChatComposerAttachment } from '../../app/main/mainChatPanel/types';
|
|
import { useTokenAccess } from '../../app/main/tokenAccess';
|
|
import './planBoard.css';
|
|
import {
|
|
type EvidenceAttachmentItem,
|
|
EvidenceAttachmentPreviewBody,
|
|
EvidenceAttachmentStrip,
|
|
} from '../../components/evidenceAttachmentStrip';
|
|
import { CodexDiffPreviewer, PreviewerUI } from '../../components/previewer';
|
|
import {
|
|
appendPlanActionHistory,
|
|
appendPlanIssueAction,
|
|
createPlanItem,
|
|
deletePlanItem,
|
|
type PlanApiRequestMeta,
|
|
fetchPlanActionHistories,
|
|
fetchPlanIssueHistories,
|
|
fetchPlanItemsWithLatestSourceWorks,
|
|
fetchReleaseReviewBoardItems,
|
|
fetchPlanSourceWorkHistories,
|
|
fetchPlanSourceWorkHistory,
|
|
runPlanAction,
|
|
setupPlanBoard,
|
|
updatePlanItemJangsingProcessingRequired,
|
|
updatePlanItem,
|
|
} from './api';
|
|
import {
|
|
getPlanQuickFilterLabel,
|
|
isAutomationFailedItem,
|
|
isReleasePendingMainItem,
|
|
isWorkingPlanItem,
|
|
type PlanQuickFilter,
|
|
} from './quickFilters';
|
|
import { PlanListDetailLayout } from './PlanListDetailLayout';
|
|
import { maskNotePreviewByWord } from './noteMasking';
|
|
import type {
|
|
PlanActionHistory,
|
|
PlanActionType,
|
|
PlanAutomationUsageSnapshot,
|
|
PlanDraft,
|
|
PlanFilterStatus,
|
|
PlanIssueHistory,
|
|
PlanItem,
|
|
PlanReleaseReviewBoardItem,
|
|
PlanSourceWorkHistory,
|
|
} from './types';
|
|
|
|
const { Paragraph, Text, Title } = Typography;
|
|
const { TextArea } = Input;
|
|
|
|
const statusTagColorMap: Record<PlanDraft['status'], string> = {
|
|
등록: 'default',
|
|
작업중: 'processing',
|
|
작업완료: 'cyan',
|
|
릴리즈완료: 'geekblue',
|
|
완료: 'success',
|
|
};
|
|
const ACTIVE_WORKER_STATUSES = new Set([
|
|
'브랜치생성중',
|
|
'자동작업중',
|
|
'release반영중',
|
|
'main반영중',
|
|
]);
|
|
const AUTO_REFRESH_CANDIDATE_WORKER_STATUSES = new Set([
|
|
...ACTIVE_WORKER_STATUSES,
|
|
'브랜치준비',
|
|
'release반영대기',
|
|
'main반영대기',
|
|
]);
|
|
const AUTO_REFRESH_LONG_PRESS_MS = 700;
|
|
|
|
const COLLAPSIBLE_DETAIL_MAX_LINES = 4;
|
|
const COLLAPSIBLE_DETAIL_MAX_CHARS = 180;
|
|
const PLAN_LIST_PAGE_SIZE = 10;
|
|
type WorkerStateFilter = 'all' | 'active' | 'waiting' | 'failed' | 'done' | 'idle';
|
|
type ReleaseStateFilter = 'all' | 'pending' | 'merged' | 'failed';
|
|
type MainStateFilter = 'all' | 'pending' | 'merged' | 'failed' | 'not-targeted';
|
|
type IssueStateFilter = 'all' | 'open' | 'none';
|
|
type CostStateFilter = 'all' | 'recorded' | 'none' | 'stable' | 'attention' | 'warning' | 'high';
|
|
const WORKER_STATE_FILTER_OPTIONS: Array<{ label: string; value: WorkerStateFilter }> = [
|
|
{ label: '작업자 전체', value: 'all' },
|
|
{ label: '진행중', value: 'active' },
|
|
{ label: '대기', value: 'waiting' },
|
|
{ label: '실패', value: 'failed' },
|
|
{ label: '완료', value: 'done' },
|
|
{ label: '미시작', value: 'idle' },
|
|
];
|
|
const RELEASE_STATE_FILTER_OPTIONS: Array<{ label: string; value: ReleaseStateFilter }> = [
|
|
{ label: 'release 전체', value: 'all' },
|
|
{ label: 'release 대기/진행', value: 'pending' },
|
|
{ label: 'release 완료', value: 'merged' },
|
|
{ label: 'release 실패', value: 'failed' },
|
|
];
|
|
const MAIN_STATE_FILTER_OPTIONS: Array<{ label: string; value: MainStateFilter }> = [
|
|
{ label: 'main 전체', value: 'all' },
|
|
{ label: 'main 대기/진행', value: 'pending' },
|
|
{ label: 'main 완료', value: 'merged' },
|
|
{ label: 'main 실패', value: 'failed' },
|
|
{ label: 'main 미대상', value: 'not-targeted' },
|
|
];
|
|
const ISSUE_STATE_FILTER_OPTIONS: Array<{ label: string; value: IssueStateFilter }> = [
|
|
{ label: '이슈 전체', value: 'all' },
|
|
{ label: '열린 이슈', value: 'open' },
|
|
{ label: '이슈 없음', value: 'none' },
|
|
];
|
|
const COST_STATE_FILTER_OPTIONS: Array<{ label: string; value: CostStateFilter }> = [
|
|
{ label: '비용상태 전체', value: 'all' },
|
|
{ label: '비용 기록 있음', value: 'recorded' },
|
|
{ label: '비용 기록 없음', value: 'none' },
|
|
{ label: '안정', value: 'stable' },
|
|
{ label: '관심', value: 'attention' },
|
|
{ label: '주의', value: 'warning' },
|
|
{ label: '높음', value: 'high' },
|
|
];
|
|
type ReviewListIndicator = {
|
|
status: PlanReleaseReviewBoardItem['review']['status'];
|
|
checkedCount: number;
|
|
totalCount: number;
|
|
};
|
|
|
|
type PlanNoteResource = {
|
|
id: string;
|
|
label: string;
|
|
sourcePath: string;
|
|
publicUrl: string;
|
|
previewType: 'image' | 'document' | 'link';
|
|
};
|
|
|
|
const PLAN_NOTE_RESOURCE_LINE_PATTERN =
|
|
/^\s*-\s+(.+?):\s+((?:\/api\/chat\/resources\/[^\s)`]+)|(?:\/?(?:public\/)?\.codex_chat\/[^\s)`]+\/resource\/[^\s)`]+)|(?:public\/\.codex_chat\/[^\s)`]+\/resource\/[^\s)`]+))\s*$/;
|
|
const PLAN_NOTE_RESOURCE_GLOBAL_PATTERN =
|
|
/(?:\/api\/chat\/resources\/[^\s)`]+)|(?:\/?(?:public\/)?\.codex_chat\/[^\s)`]+\/resource\/[^\s)`]+)|(?:public\/\.codex_chat\/[^\s)`]+\/resource\/[^\s)`]+)/g;
|
|
const PLAN_NOTE_IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp']);
|
|
const PLAN_NOTE_DOCUMENT_EXTENSIONS = new Set([
|
|
'pdf',
|
|
'txt',
|
|
'md',
|
|
'json',
|
|
'ts',
|
|
'tsx',
|
|
'js',
|
|
'jsx',
|
|
'mjs',
|
|
'cjs',
|
|
'css',
|
|
'html',
|
|
'diff',
|
|
'log',
|
|
]);
|
|
|
|
function createPlanNoteAttachmentSessionId() {
|
|
return `plan-note-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
}
|
|
|
|
function normalizePlanNoteResourceSourcePath(value: string) {
|
|
return String(value ?? '')
|
|
.trim()
|
|
.replace(/[)>.,]+$/, '')
|
|
.replace(/^\/+/, '/')
|
|
.replace(/^public\/(?=\.codex_chat\/)/, '');
|
|
}
|
|
|
|
function normalizePlanNoteResourceUrl(value: string) {
|
|
const normalizedSourcePath = normalizePlanNoteResourceSourcePath(value);
|
|
if (!normalizedSourcePath) {
|
|
return '';
|
|
}
|
|
|
|
if (normalizedSourcePath.startsWith('/api/chat/resources/')) {
|
|
return normalizeChatResourceUrl(normalizedSourcePath);
|
|
}
|
|
|
|
if (normalizedSourcePath.startsWith('/.codex_chat/')) {
|
|
return normalizeChatResourceUrl(normalizedSourcePath);
|
|
}
|
|
|
|
if (normalizedSourcePath.startsWith('.codex_chat/')) {
|
|
return normalizeChatResourceUrl(`/${normalizedSourcePath}`);
|
|
}
|
|
|
|
return normalizeChatResourceUrl(normalizedSourcePath);
|
|
}
|
|
|
|
function getPlanNoteResourceBaseName(sourcePath: string) {
|
|
const normalized = normalizePlanNoteResourceSourcePath(sourcePath).replace(/^\/+/, '');
|
|
const segments = normalized.split('/').filter(Boolean);
|
|
return segments.at(-1) ?? normalized;
|
|
}
|
|
|
|
function resolvePlanNoteResourcePreviewType(sourcePath: string): PlanNoteResource['previewType'] {
|
|
const baseName = getPlanNoteResourceBaseName(sourcePath);
|
|
const extension = baseName.includes('.') ? baseName.split('.').at(-1)?.toLowerCase() ?? '' : '';
|
|
|
|
if (PLAN_NOTE_IMAGE_EXTENSIONS.has(extension)) {
|
|
return 'image';
|
|
}
|
|
|
|
if (PLAN_NOTE_DOCUMENT_EXTENSIONS.has(extension)) {
|
|
return 'document';
|
|
}
|
|
|
|
return 'link';
|
|
}
|
|
|
|
function extractPlanNoteResources(note: string) {
|
|
const normalizedNote = String(note ?? '');
|
|
const lineEntries = normalizedNote
|
|
.split(/\r?\n/)
|
|
.map((line) => {
|
|
const matched = line.match(PLAN_NOTE_RESOURCE_LINE_PATTERN);
|
|
|
|
if (!matched) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
label: matched[1]?.trim() || getPlanNoteResourceBaseName(matched[2] ?? ''),
|
|
sourcePath: normalizePlanNoteResourceSourcePath(matched[2] ?? ''),
|
|
};
|
|
})
|
|
.filter((item): item is { label: string; sourcePath: string } => Boolean(item?.sourcePath));
|
|
|
|
const seen = new Set(lineEntries.map((item) => item.sourcePath));
|
|
const genericEntries = Array.from(normalizedNote.matchAll(PLAN_NOTE_RESOURCE_GLOBAL_PATTERN))
|
|
.map((matched) => normalizePlanNoteResourceSourcePath(matched[0] ?? ''))
|
|
.filter(Boolean)
|
|
.filter((sourcePath) => {
|
|
if (seen.has(sourcePath)) {
|
|
return false;
|
|
}
|
|
|
|
seen.add(sourcePath);
|
|
return true;
|
|
})
|
|
.map((sourcePath) => ({
|
|
label: getPlanNoteResourceBaseName(sourcePath),
|
|
sourcePath,
|
|
}));
|
|
|
|
return [...lineEntries, ...genericEntries].map((item, index) => ({
|
|
id: `${index}-${item.sourcePath}`,
|
|
label: item.label,
|
|
sourcePath: item.sourcePath,
|
|
publicUrl: normalizePlanNoteResourceUrl(item.sourcePath),
|
|
previewType: resolvePlanNoteResourcePreviewType(item.sourcePath),
|
|
}));
|
|
}
|
|
|
|
function appendPlanNoteAttachments(note: string, attachments: ChatComposerAttachment[]) {
|
|
if (attachments.length === 0) {
|
|
return note;
|
|
}
|
|
|
|
const existingSourcePaths = new Set(extractPlanNoteResources(note).map((item) => item.sourcePath));
|
|
const nextLines = attachments
|
|
.map((attachment) => {
|
|
const sourcePath = normalizePlanNoteResourceSourcePath(attachment.path);
|
|
return {
|
|
label: attachment.name.trim() || getPlanNoteResourceBaseName(sourcePath),
|
|
sourcePath,
|
|
};
|
|
})
|
|
.filter((item) => item.sourcePath)
|
|
.filter((item) => {
|
|
if (existingSourcePaths.has(item.sourcePath)) {
|
|
return false;
|
|
}
|
|
|
|
existingSourcePaths.add(item.sourcePath);
|
|
return true;
|
|
})
|
|
.map((item) => `- ${item.label}: ${item.sourcePath}`);
|
|
|
|
if (nextLines.length === 0) {
|
|
return note;
|
|
}
|
|
|
|
const currentNote = note.trimEnd();
|
|
const attachmentSectionPattern = /(^|\n)첨부 파일:\n(?:- .+\n?)*/;
|
|
const matchedSection = currentNote.match(attachmentSectionPattern);
|
|
|
|
if (!matchedSection || matchedSection.index === undefined) {
|
|
return `${currentNote}${currentNote ? '\n\n' : ''}첨부 파일:\n${nextLines.join('\n')}`;
|
|
}
|
|
|
|
const startIndex = matchedSection.index;
|
|
const matchedText = matchedSection[0];
|
|
const insertIndex = startIndex + matchedText.length;
|
|
const prefix = currentNote.slice(0, insertIndex).replace(/\n*$/, '\n');
|
|
const suffix = currentNote.slice(insertIndex).replace(/^\n+/, '\n');
|
|
|
|
return `${prefix}${nextLines.join('\n')}${suffix}`;
|
|
}
|
|
|
|
function resolvePlanNoteAttachmentSessionId(
|
|
draftId: number | null,
|
|
fallbackSessionId: string,
|
|
) {
|
|
if (draftId) {
|
|
return `plan-note-${draftId}`;
|
|
}
|
|
|
|
return fallbackSessionId;
|
|
}
|
|
|
|
function isPlanItemRequestLocked(item: Pick<PlanItem, 'startedAt'> | null | undefined) {
|
|
return Boolean(item?.startedAt);
|
|
}
|
|
|
|
const NON_RETRY_NOTIFICATION_PATTERNS = [
|
|
'다시 실행하지 않았습니다.',
|
|
'재처리는 요청하지 않았습니다.',
|
|
'자동 재처리 대상은 없습니다.',
|
|
] as const;
|
|
const TOKEN_ACCESS_REQUIRED_MESSAGE = '설정 > 토큰 관리에서 권한 토큰을 등록한 사용자만 자동화 조회 외 기능을 사용할 수 있습니다.';
|
|
|
|
function formatPlanNotificationLabel(item: Pick<PlanItem, 'id' | 'workId'>) {
|
|
const normalizedWorkId = item.workId.trim().replace(/^\[|\]$/g, '').replace(/\s+/g, '').toLowerCase();
|
|
|
|
if (!normalizedWorkId || normalizedWorkId === '작업id' || normalizedWorkId === 'workid') {
|
|
return `#${item.id}`;
|
|
}
|
|
|
|
return item.workId;
|
|
}
|
|
|
|
function formatPlanListLabel(item: Pick<PlanItem, 'id' | 'workId'>) {
|
|
const normalizedWorkId = item.workId.trim().replace(/^\[|\]$/g, '').replace(/\s+/g, '').toLowerCase();
|
|
|
|
if (!normalizedWorkId || normalizedWorkId === '작업id' || normalizedWorkId === 'workid') {
|
|
return `#${item.id}`;
|
|
}
|
|
|
|
return `#${item.id} ${item.workId}`;
|
|
}
|
|
|
|
function formatPlanDateTime(value: string | null | undefined) {
|
|
if (!value) {
|
|
return '미기록';
|
|
}
|
|
|
|
return new Date(value).toLocaleString('ko-KR', {
|
|
timeZone: 'Asia/Seoul',
|
|
});
|
|
}
|
|
|
|
function formatResponseBytes(value: number | null | undefined) {
|
|
const bytes = Number(value ?? 0);
|
|
|
|
if (!Number.isFinite(bytes) || bytes <= 0) {
|
|
return '0 B';
|
|
}
|
|
|
|
if (bytes >= 1024 * 1024) {
|
|
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
|
}
|
|
|
|
if (bytes >= 1024) {
|
|
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
}
|
|
|
|
return `${Math.round(bytes)} B`;
|
|
}
|
|
|
|
function getReleaseReviewListTagColor(status: PlanReleaseReviewBoardItem['review']['status']) {
|
|
if (status === 'approved') {
|
|
return 'success';
|
|
}
|
|
|
|
if (status === 'changes-requested') {
|
|
return 'error';
|
|
}
|
|
|
|
if (status === 'reviewing') {
|
|
return 'processing';
|
|
}
|
|
|
|
return 'default';
|
|
}
|
|
|
|
function getReleaseReviewListTagLabel(review: ReviewListIndicator) {
|
|
const progressLabel = review.totalCount > 0 ? ` ${review.checkedCount}/${review.totalCount}` : '';
|
|
|
|
if (review.status === 'approved') {
|
|
return `검수완료${progressLabel}`;
|
|
}
|
|
|
|
if (review.status === 'changes-requested') {
|
|
return `수정필요${progressLabel}`;
|
|
}
|
|
|
|
if (review.status === 'reviewing') {
|
|
return `검수중${progressLabel}`;
|
|
}
|
|
|
|
return review.totalCount > 0 ? `검수대기 ${review.checkedCount}/${review.totalCount}` : '검수대기';
|
|
}
|
|
|
|
function resolvePlanHistorySuccessMessage(message: string | undefined, fallback: string) {
|
|
if (!message) {
|
|
return fallback;
|
|
}
|
|
|
|
if (NON_RETRY_NOTIFICATION_PATTERNS.some((pattern) => message.includes(pattern))) {
|
|
return fallback;
|
|
}
|
|
|
|
return message;
|
|
}
|
|
|
|
function resolveProtectedText(text: string | null | undefined, masked: boolean) {
|
|
if (!masked) {
|
|
return text?.trim() ? text : '내용이 없습니다.';
|
|
}
|
|
|
|
return maskNotePreviewByWord(text ?? '');
|
|
}
|
|
|
|
function isCollapsibleDetailText(value: string) {
|
|
const trimmedValue = value.trim();
|
|
|
|
if (!trimmedValue) {
|
|
return false;
|
|
}
|
|
|
|
return trimmedValue.length > COLLAPSIBLE_DETAIL_MAX_CHARS || trimmedValue.split(/\r?\n/).length > COLLAPSIBLE_DETAIL_MAX_LINES;
|
|
}
|
|
|
|
function ExpandableDetailText({
|
|
text,
|
|
type,
|
|
}: {
|
|
text: string;
|
|
type?: 'secondary' | 'success' | 'warning' | 'danger';
|
|
}) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
const collapsible = isCollapsibleDetailText(text);
|
|
|
|
return (
|
|
<div className="plan-board-page__detail-text">
|
|
<Paragraph
|
|
className={`plan-board-page__detail-text-body${!expanded && collapsible ? ' plan-board-page__detail-text-body--collapsed' : ''}`}
|
|
type={type}
|
|
>
|
|
{text}
|
|
</Paragraph>
|
|
{collapsible ? (
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
className="plan-board-page__detail-text-toggle"
|
|
aria-label={expanded ? '접기' : '펼치기'}
|
|
icon={expanded ? <UpOutlined /> : <DownOutlined />}
|
|
onClick={() => {
|
|
setExpanded((previous) => !previous);
|
|
}}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PlanNoteResourcePanel({ resources }: { resources: PlanNoteResource[] }) {
|
|
return (
|
|
<div className="plan-board-page__note-resources">
|
|
<Flex justify="space-between" align="center" gap={8} wrap>
|
|
<Text strong>첨부 리소스</Text>
|
|
<Text type="secondary">{resources.length}건</Text>
|
|
</Flex>
|
|
<div className="plan-board-page__note-resource-list">
|
|
{resources.map((resource) => (
|
|
<div key={resource.id} className="plan-board-page__note-resource-card">
|
|
<Flex justify="space-between" align="start" gap={12} wrap>
|
|
<Space direction="vertical" size={2} style={{ minWidth: 0, flex: 1 }}>
|
|
<Text strong ellipsis={{ tooltip: resource.label }}>
|
|
{resource.label}
|
|
</Text>
|
|
<Text type="secondary" className="plan-board-page__note-resource-path">
|
|
{resource.sourcePath}
|
|
</Text>
|
|
</Space>
|
|
<Button
|
|
size="small"
|
|
type="primary"
|
|
href={resource.publicUrl}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
열기
|
|
</Button>
|
|
</Flex>
|
|
{resource.previewType === 'image' ? (
|
|
<img
|
|
className="plan-board-page__note-resource-image"
|
|
src={resource.publicUrl}
|
|
alt={resource.label}
|
|
loading="lazy"
|
|
/>
|
|
) : null}
|
|
{resource.previewType === 'document' ? (
|
|
<iframe
|
|
className="plan-board-page__note-resource-frame"
|
|
src={resource.publicUrl}
|
|
title={resource.label}
|
|
loading="lazy"
|
|
referrerPolicy="no-referrer"
|
|
/>
|
|
) : null}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type ActionButton = {
|
|
key: PlanActionType;
|
|
label: string;
|
|
};
|
|
|
|
function createEmptyDraft(appConfig: AppConfig): PlanDraft {
|
|
return {
|
|
id: null,
|
|
workId: '',
|
|
note: '',
|
|
automationType: 'none',
|
|
status: '등록',
|
|
jangsingProcessingRequired: appConfig.planDefaults.jangsingProcessingRequired,
|
|
autoDeployToMain: appConfig.planDefaults.autoDeployToMain,
|
|
repeatRequestEnabled: false,
|
|
repeatIntervalMinutes: 60,
|
|
};
|
|
}
|
|
|
|
export type PlanBoardPageProps = {
|
|
statusFilter: PlanFilterStatus;
|
|
quickFilter?: PlanQuickFilter | null;
|
|
quickFilterRequestKey?: number;
|
|
initialSelectedPlanId?: number | null;
|
|
initialSelectedWorkId?: string | null;
|
|
};
|
|
|
|
export function PlanBoardPage({
|
|
statusFilter,
|
|
quickFilter = null,
|
|
quickFilterRequestKey = 0,
|
|
initialSelectedPlanId = null,
|
|
initialSelectedWorkId = null,
|
|
}: PlanBoardPageProps) {
|
|
const { hasAccess } = useTokenAccess();
|
|
const { automationTypes } = useAutomationTypeRegistry();
|
|
const appConfig = useAppConfig();
|
|
const autoRefreshIntervalMs = appConfig.automation.autoRefreshIntervalSeconds * 1000;
|
|
const [messageApi, contextHolder] = message.useMessage();
|
|
const screens = Grid.useBreakpoint();
|
|
const [items, setItems] = useState<PlanItem[]>([]);
|
|
const [reviewIndicatorsByPlanId, setReviewIndicatorsByPlanId] = useState<Record<number, ReviewListIndicator>>({});
|
|
const [listRequestMeta, setListRequestMeta] = useState<PlanApiRequestMeta | null>(null);
|
|
const [issueHistories, setIssueHistories] = useState<PlanIssueHistory[]>([]);
|
|
const [actionHistories, setActionHistories] = useState<PlanActionHistory[]>([]);
|
|
const [sourceWorkHistories, setSourceWorkHistories] = useState<PlanSourceWorkHistory[]>([]);
|
|
const [selectedSourceWork, setSelectedSourceWork] = useState<PlanSourceWorkHistory | null>(null);
|
|
const [draft, setDraft] = useState<PlanDraft>(() => createEmptyDraft(appConfig));
|
|
const [noteInputValue, setNoteInputValue] = useState('');
|
|
const [noteAttachmentUploading, setNoteAttachmentUploading] = useState(false);
|
|
const [actionNote, setActionNote] = useState('');
|
|
const [issueActionNote, setIssueActionNote] = useState('');
|
|
const [resolveLatestIssue, setResolveLatestIssue] = useState(false);
|
|
const [retryLatestIssue, setRetryLatestIssue] = useState(false);
|
|
const [searchKeyword, setSearchKeyword] = useState('');
|
|
const [workerStateFilter, setWorkerStateFilter] = useState<WorkerStateFilter>('all');
|
|
const [releaseStateFilter, setReleaseStateFilter] = useState<ReleaseStateFilter>('all');
|
|
const [mainStateFilter, setMainStateFilter] = useState<MainStateFilter>('all');
|
|
const [issueStateFilter, setIssueStateFilter] = useState<IssueStateFilter>('all');
|
|
const [costStateFilter, setCostStateFilter] = useState<CostStateFilter>('all');
|
|
const [currentListPage, setCurrentListPage] = useState(1);
|
|
const [loading, setLoading] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [jangsingProcessingSavingId, setJangsingProcessingSavingId] = useState<number | null>(null);
|
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
const [editorOpen, setEditorOpen] = useState(false);
|
|
const [suppressAutoOpen, setSuppressAutoOpen] = useState(false);
|
|
const [sourceViewerOpen, setSourceViewerOpen] = useState(false);
|
|
const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(appConfig.automation.autoRefreshEnabled);
|
|
const [autoRefreshRemainingMs, setAutoRefreshRemainingMs] = useState(autoRefreshIntervalMs);
|
|
const [pendingSelection, setPendingSelection] = useState<{
|
|
planId: number | null;
|
|
workId: string | null;
|
|
}>({
|
|
planId: initialSelectedPlanId,
|
|
workId: initialSelectedWorkId,
|
|
});
|
|
const draftRef = useRef(draft);
|
|
const noteAttachmentInputRef = useRef<HTMLInputElement | null>(null);
|
|
const noteAttachmentSessionIdRef = useRef(createPlanNoteAttachmentSessionId());
|
|
const savingRef = useRef(saving);
|
|
const previousWorkerStatusMapRef = useRef<Map<number, string | null>>(new Map());
|
|
const notifiedAutomationStartKeysRef = useRef<Set<string>>(new Set());
|
|
const notifiedAutomationFailureKeysRef = useRef<Set<string>>(new Set());
|
|
const hasLoadedItemsRef = useRef(false);
|
|
const hasPendingAutomation = useMemo(
|
|
() => items.some((item) => shouldAutoRefreshAutomation(item.workerStatus)),
|
|
[items],
|
|
);
|
|
const isAutoRefreshRunning = hasPendingAutomation && autoRefreshEnabled;
|
|
const autoRefreshCountdownSeconds = Math.max(1, Math.ceil(autoRefreshRemainingMs / 1000));
|
|
const noteResources = useMemo(
|
|
() => (hasAccess ? extractPlanNoteResources(noteInputValue) : []),
|
|
[hasAccess, noteInputValue],
|
|
);
|
|
|
|
draftRef.current = draft;
|
|
savingRef.current = saving;
|
|
|
|
useEffect(() => {
|
|
setNoteInputValue(draft.note);
|
|
}, [draft.id, draft.note]);
|
|
|
|
function updateDraft(nextDraft: PlanDraft | ((previous: PlanDraft) => PlanDraft)) {
|
|
setDraft((previous) => {
|
|
const resolvedDraft = typeof nextDraft === 'function' ? nextDraft(previous) : nextDraft;
|
|
draftRef.current = resolvedDraft;
|
|
return resolvedDraft;
|
|
});
|
|
}
|
|
|
|
async function notifyAutomationStarted(item: PlanItem) {
|
|
if (!appConfig.automation.notifyOnAutomationStart) {
|
|
return;
|
|
}
|
|
|
|
const presentation = getAutomationStatusPresentation(item);
|
|
const body = `${formatPlanNotificationLabel(item)} 자동화 시작: ${presentation.description ?? presentation.label}`;
|
|
|
|
messageApi.info(body);
|
|
}
|
|
|
|
async function notifyAutomationFailed(item: PlanItem) {
|
|
if (!appConfig.automation.notifyOnAutomationFailure) {
|
|
return;
|
|
}
|
|
|
|
messageApi.error(`${formatPlanNotificationLabel(item)} 자동화 실패`);
|
|
}
|
|
|
|
function isAutomationFailureStatus(status: PlanItem['workerStatus']) {
|
|
return (
|
|
status === '브랜치실패' ||
|
|
status === '자동작업실패' ||
|
|
status === 'release반영실패' ||
|
|
status === 'main반영실패'
|
|
);
|
|
}
|
|
|
|
function syncAutomationStartNotifications(nextItems: PlanItem[]) {
|
|
const nextWorkerStatusMap = new Map<number, string | null>();
|
|
|
|
nextItems.forEach((item) => {
|
|
nextWorkerStatusMap.set(item.id, item.workerStatus);
|
|
});
|
|
|
|
if (!hasLoadedItemsRef.current) {
|
|
previousWorkerStatusMapRef.current = nextWorkerStatusMap;
|
|
hasLoadedItemsRef.current = true;
|
|
return;
|
|
}
|
|
|
|
nextItems.forEach((item) => {
|
|
const previousWorkerStatus = previousWorkerStatusMapRef.current.get(item.id) ?? null;
|
|
const automationBecameActive =
|
|
isAutomationInProgress(item.workerStatus) && !isAutomationInProgress(previousWorkerStatus);
|
|
const notificationKey = `${item.id}:${item.startedAt ?? item.updatedAt}:${item.workerStatus ?? 'idle'}`;
|
|
const failureBecameActive =
|
|
isAutomationFailureStatus(item.workerStatus) && !isAutomationFailureStatus(previousWorkerStatus);
|
|
const failureNotificationKey = `fail:${item.id}:${item.updatedAt}:${item.workerStatus ?? 'idle'}`;
|
|
|
|
if (!automationBecameActive || notifiedAutomationStartKeysRef.current.has(notificationKey)) {
|
|
if (!failureBecameActive || notifiedAutomationFailureKeysRef.current.has(failureNotificationKey)) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (automationBecameActive && !notifiedAutomationStartKeysRef.current.has(notificationKey)) {
|
|
notifiedAutomationStartKeysRef.current.add(notificationKey);
|
|
void notifyAutomationStarted(item);
|
|
}
|
|
|
|
if (failureBecameActive && !notifiedAutomationFailureKeysRef.current.has(failureNotificationKey)) {
|
|
notifiedAutomationFailureKeysRef.current.add(failureNotificationKey);
|
|
void notifyAutomationFailed(item);
|
|
}
|
|
});
|
|
|
|
previousWorkerStatusMapRef.current = nextWorkerStatusMap;
|
|
}
|
|
|
|
const filteredItems = useMemo(() => {
|
|
const keyword = searchKeyword.trim().toLocaleLowerCase('ko-KR');
|
|
|
|
return items
|
|
.filter((item) => {
|
|
if (quickFilter === 'release-pending-main') {
|
|
return isReleasePendingMainItem(item);
|
|
}
|
|
|
|
if (quickFilter === 'automation-failed') {
|
|
return isAutomationFailedItem(item);
|
|
}
|
|
|
|
if (quickFilter === 'working') {
|
|
return isWorkingPlanItem(item);
|
|
}
|
|
|
|
if (statusFilter === 'all') {
|
|
return true;
|
|
}
|
|
|
|
if (statusFilter === 'in-progress') {
|
|
return !isReleaseCompletedPlanItem(item);
|
|
}
|
|
|
|
if (statusFilter === 'done') {
|
|
return isReleaseCompletedPlanItem(item);
|
|
}
|
|
|
|
return item.hasOpenIssues && !isReleaseCompletedPlanItem(item);
|
|
})
|
|
.filter((item) => matchesWorkerStateFilter(item, workerStateFilter))
|
|
.filter((item) => matchesReleaseStateFilter(item, releaseStateFilter))
|
|
.filter((item) => matchesMainStateFilter(item, mainStateFilter))
|
|
.filter((item) => matchesIssueStateFilter(item, issueStateFilter))
|
|
.filter((item) =>
|
|
matchesCostStateFilter(item, costStateFilter, buildAutomationUsageSummaryFromSnapshot(item.usageSnapshot, appConfig)),
|
|
)
|
|
.filter((item) => {
|
|
if (!keyword) {
|
|
return true;
|
|
}
|
|
|
|
const searchableText = [
|
|
item.workId,
|
|
item.note,
|
|
item.status,
|
|
item.assignedBranch,
|
|
item.releaseTarget,
|
|
item.workerStatus,
|
|
...(item.issueTags ?? []),
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')
|
|
.toLocaleLowerCase('ko-KR');
|
|
|
|
return searchableText.includes(keyword);
|
|
})
|
|
.sort((left, right) => {
|
|
if (statusFilter === 'in-progress') {
|
|
const automationPriorityDiff = comparePlanItemsByAutomationPriority(left, right);
|
|
|
|
if (automationPriorityDiff !== 0) {
|
|
return automationPriorityDiff;
|
|
}
|
|
}
|
|
|
|
return comparePlanItemsByRecent(left, right);
|
|
});
|
|
}, [
|
|
items,
|
|
costStateFilter,
|
|
issueStateFilter,
|
|
mainStateFilter,
|
|
quickFilter,
|
|
releaseStateFilter,
|
|
searchKeyword,
|
|
statusFilter,
|
|
workerStateFilter,
|
|
]);
|
|
|
|
const selectedItem = useMemo(
|
|
() => items.find((item) => item.id === draft.id) ?? null,
|
|
[draft.id, items],
|
|
);
|
|
const selectedReleaseReviewNote = typeof selectedItem?.releaseReviewNote === 'string' ? selectedItem.releaseReviewNote : '';
|
|
|
|
const actionButtons = useMemo<ActionButton[]>(() => {
|
|
if (!selectedItem) {
|
|
return [];
|
|
}
|
|
|
|
const needsWorkRetryBecauseBranchMissing =
|
|
(selectedItem.status === '작업완료' || selectedItem.status === '릴리즈완료') &&
|
|
(selectedItem.workerStatus === 'release반영실패' || selectedItem.workerStatus === 'main반영실패') &&
|
|
Boolean(selectedItem.lastError?.includes('브랜치를 찾을 수 없습니다'));
|
|
const canRetryWork =
|
|
(selectedItem.status === '작업중' &&
|
|
(selectedItem.workerStatus === '자동작업실패' || Boolean(selectedItem.lastError))) ||
|
|
needsWorkRetryBecauseBranchMissing;
|
|
const canRetryReleaseMerge =
|
|
selectedItem.status === '작업완료' &&
|
|
selectedItem.workerStatus === 'release반영실패' &&
|
|
!needsWorkRetryBecauseBranchMissing;
|
|
|
|
const buttons: ActionButton[] = [];
|
|
|
|
if (selectedItem.workerStatus === '브랜치실패') {
|
|
buttons.push({ key: 'retry-branch', label: '브랜치 재시도' });
|
|
}
|
|
|
|
if (selectedItem.status === '등록' && selectedItem.workerStatus !== '브랜치생성중') {
|
|
buttons.push({ key: 'start-work', label: '작업시작' });
|
|
}
|
|
|
|
if (canRetryWork) {
|
|
buttons.push({ key: 'retry-work', label: '작업 재처리' });
|
|
}
|
|
|
|
if (selectedItem.status === '작업중') {
|
|
buttons.push({ key: 'complete-development', label: '작업완료 처리' });
|
|
}
|
|
|
|
if (canRetryReleaseMerge) {
|
|
buttons.push({ key: 'retry-merge', label: 'release 반영 재시도' });
|
|
}
|
|
|
|
if (
|
|
selectedItem.status === '릴리즈완료' ||
|
|
(selectedItem.status === '작업완료' && selectedItem.workerStatus === 'release반영실패')
|
|
) {
|
|
buttons.push({ key: 'cancel-release', label: '작업취소' });
|
|
}
|
|
|
|
buttons.push({
|
|
key: 'request-main-merge',
|
|
label:
|
|
selectedItem.workerStatus === 'main반영실패'
|
|
? 'main 일괄 반영 재시도'
|
|
: 'main 일괄 반영 요청',
|
|
});
|
|
|
|
return buttons;
|
|
}, [selectedItem]);
|
|
|
|
const combinedHistories = useMemo(() => {
|
|
return [
|
|
...actionHistories.map((history) => ({
|
|
id: `action-${history.id}`,
|
|
createdAt: history.createdAt,
|
|
kind: 'action' as const,
|
|
action: history,
|
|
})),
|
|
...issueHistories.map((history) => ({
|
|
id: `issue-${history.id}`,
|
|
createdAt: history.createdAt,
|
|
kind: 'issue' as const,
|
|
issue: history,
|
|
})),
|
|
].sort((left, right) => {
|
|
const timeDiff = new Date(left.createdAt).getTime() - new Date(right.createdAt).getTime();
|
|
|
|
if (timeDiff !== 0) {
|
|
return timeDiff;
|
|
}
|
|
|
|
return left.id.localeCompare(right.id);
|
|
});
|
|
}, [actionHistories, issueHistories]);
|
|
|
|
useEffect(() => {
|
|
void loadItems(statusFilter);
|
|
}, [hasAccess, statusFilter]);
|
|
|
|
useEffect(() => {
|
|
if (!quickFilter) {
|
|
return;
|
|
}
|
|
|
|
setSearchKeyword('');
|
|
setCurrentListPage(1);
|
|
}, [quickFilter, quickFilterRequestKey]);
|
|
|
|
useEffect(() => {
|
|
setCurrentListPage(1);
|
|
}, [costStateFilter, issueStateFilter, mainStateFilter, releaseStateFilter, searchKeyword, statusFilter, workerStateFilter]);
|
|
|
|
useEffect(() => {
|
|
const totalPages = Math.max(1, Math.ceil(filteredItems.length / PLAN_LIST_PAGE_SIZE));
|
|
|
|
setCurrentListPage((previous) => Math.min(previous, totalPages));
|
|
}, [filteredItems.length]);
|
|
|
|
useEffect(() => {
|
|
if (!hasAccess) {
|
|
setEditorOpen(false);
|
|
setSourceViewerOpen(false);
|
|
setSelectedSourceWork(null);
|
|
}
|
|
}, [hasAccess]);
|
|
|
|
useEffect(() => {
|
|
setPendingSelection({
|
|
planId: initialSelectedPlanId,
|
|
workId: initialSelectedWorkId,
|
|
});
|
|
}, [initialSelectedPlanId, initialSelectedWorkId]);
|
|
|
|
useEffect(() => {
|
|
setAutoRefreshEnabled(appConfig.automation.autoRefreshEnabled);
|
|
setAutoRefreshRemainingMs(autoRefreshIntervalMs);
|
|
}, [appConfig.automation.autoRefreshEnabled, autoRefreshIntervalMs]);
|
|
|
|
useEffect(() => {
|
|
if (!hasPendingAutomation || !autoRefreshEnabled) {
|
|
setAutoRefreshRemainingMs(autoRefreshIntervalMs);
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
const startedAt = Date.now();
|
|
|
|
setAutoRefreshRemainingMs(autoRefreshIntervalMs);
|
|
|
|
const countdownTimer = window.setInterval(() => {
|
|
const elapsed = Date.now() - startedAt;
|
|
const remaining = Math.max(autoRefreshIntervalMs - elapsed, 0);
|
|
setAutoRefreshRemainingMs(remaining);
|
|
}, 1000);
|
|
|
|
const refreshTimer = window.setTimeout(() => {
|
|
if (cancelled || savingRef.current) {
|
|
return;
|
|
}
|
|
|
|
setAutoRefreshRemainingMs(autoRefreshIntervalMs);
|
|
void loadItems(statusFilter);
|
|
}, autoRefreshIntervalMs);
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
window.clearInterval(countdownTimer);
|
|
window.clearTimeout(refreshTimer);
|
|
};
|
|
}, [autoRefreshEnabled, autoRefreshIntervalMs, hasPendingAutomation, items, statusFilter]);
|
|
|
|
useEffect(() => {
|
|
if (!draft.id) {
|
|
setIssueHistories([]);
|
|
setActionHistories([]);
|
|
setSourceWorkHistories([]);
|
|
setActionNote('');
|
|
setIssueActionNote('');
|
|
setResolveLatestIssue(false);
|
|
setRetryLatestIssue(false);
|
|
return;
|
|
}
|
|
|
|
if (!hasAccess) {
|
|
setIssueHistories([]);
|
|
setActionHistories([]);
|
|
setSourceWorkHistories([]);
|
|
setActionNote('');
|
|
setIssueActionNote('');
|
|
setResolveLatestIssue(false);
|
|
setRetryLatestIssue(false);
|
|
return;
|
|
}
|
|
|
|
void Promise.all([
|
|
loadIssueHistories(draft.id),
|
|
loadActionHistories(draft.id),
|
|
loadSourceWorkHistories(draft.id),
|
|
]);
|
|
}, [draft.id, hasAccess]);
|
|
|
|
useEffect(() => {
|
|
if (pendingSelection.planId || pendingSelection.workId) {
|
|
const matchedItem = items.find((item) => {
|
|
if (pendingSelection.planId && item.id === pendingSelection.planId) {
|
|
return true;
|
|
}
|
|
|
|
if (pendingSelection.workId && item.workId === pendingSelection.workId) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
});
|
|
|
|
if (matchedItem) {
|
|
if (draft.id !== matchedItem.id || !editorOpen) {
|
|
handleSelectItem(matchedItem);
|
|
}
|
|
|
|
setPendingSelection({ planId: null, workId: null });
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (suppressAutoOpen || searchKeyword.trim() || filteredItems.length === 0 || editorOpen) {
|
|
return;
|
|
}
|
|
}, [
|
|
draft.id,
|
|
editorOpen,
|
|
filteredItems,
|
|
items,
|
|
pendingSelection.planId,
|
|
pendingSelection.workId,
|
|
searchKeyword,
|
|
suppressAutoOpen,
|
|
hasAccess,
|
|
]);
|
|
|
|
async function loadItems(filter: PlanFilterStatus) {
|
|
setLoading(true);
|
|
setErrorMessage(null);
|
|
|
|
try {
|
|
const {
|
|
items: nextItems,
|
|
meta,
|
|
} = await fetchPlanItemsWithLatestSourceWorks(filter);
|
|
let nextReleaseReviews: PlanReleaseReviewBoardItem[] = [];
|
|
|
|
try {
|
|
nextReleaseReviews = await fetchReleaseReviewBoardItems();
|
|
} catch {
|
|
nextReleaseReviews = [];
|
|
}
|
|
|
|
syncAutomationStartNotifications(nextItems);
|
|
setItems(nextItems);
|
|
setListRequestMeta(meta);
|
|
setReviewIndicatorsByPlanId(
|
|
Object.fromEntries(
|
|
nextReleaseReviews.map((item) => {
|
|
const targetIds = item.review.metadata.pageSelectionIds ?? [];
|
|
const checkedIds = item.review.metadata.checkedPageSelectionIds ?? [];
|
|
|
|
return [
|
|
item.planItem.id,
|
|
{
|
|
status: item.review.status,
|
|
checkedCount: checkedIds.filter((selectionId) => targetIds.includes(selectionId)).length,
|
|
totalCount: targetIds.length,
|
|
} satisfies ReviewListIndicator,
|
|
];
|
|
}),
|
|
),
|
|
);
|
|
if (draft.id) {
|
|
const nextSelectedItem = nextItems.find((item) => item.id === draft.id);
|
|
const currentSelectedItem = items.find((item) => item.id === draft.id);
|
|
const shouldPreserveEditableDraft = editorOpen && !isPlanItemRequestLocked(currentSelectedItem);
|
|
|
|
if (nextSelectedItem) {
|
|
if (!shouldPreserveEditableDraft) {
|
|
setDraft(toDraft(nextSelectedItem));
|
|
}
|
|
} else {
|
|
setDraft(createEmptyDraft(appConfig));
|
|
setEditorOpen(false);
|
|
setSourceViewerOpen(false);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
setErrorMessage(error instanceof Error ? error.message : '작업 목록을 불러오지 못했습니다.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function loadIssueHistories(planItemId: number) {
|
|
try {
|
|
setIssueHistories(await fetchPlanIssueHistories(planItemId));
|
|
} catch (error) {
|
|
messageApi.error(error instanceof Error ? error.message : '이슈 이력을 불러오지 못했습니다.');
|
|
setIssueHistories([]);
|
|
}
|
|
}
|
|
|
|
async function loadActionHistories(planItemId: number) {
|
|
try {
|
|
setActionHistories(await fetchPlanActionHistories(planItemId));
|
|
} catch (error) {
|
|
messageApi.error(error instanceof Error ? error.message : '조치 이력을 불러오지 못했습니다.');
|
|
setActionHistories([]);
|
|
}
|
|
}
|
|
|
|
async function loadSourceWorkHistories(planItemId: number) {
|
|
try {
|
|
setSourceWorkHistories(await fetchPlanSourceWorkHistories(planItemId));
|
|
} catch (error) {
|
|
messageApi.error(error instanceof Error ? error.message : '소스 작업 이력을 불러오지 못했습니다.');
|
|
setSourceWorkHistories([]);
|
|
}
|
|
}
|
|
|
|
function handleCreateNew() {
|
|
if (!hasAccess) {
|
|
messageApi.error(TOKEN_ACCESS_REQUIRED_MESSAGE);
|
|
return;
|
|
}
|
|
|
|
noteAttachmentSessionIdRef.current = createPlanNoteAttachmentSessionId();
|
|
setDraft(createEmptyDraft(appConfig));
|
|
setResolveLatestIssue(false);
|
|
setRetryLatestIssue(true);
|
|
setEditorOpen(true);
|
|
setSuppressAutoOpen(false);
|
|
setSourceViewerOpen(false);
|
|
}
|
|
|
|
function handleSelectItem(item: PlanItem) {
|
|
noteAttachmentSessionIdRef.current = resolvePlanNoteAttachmentSessionId(item.id, noteAttachmentSessionIdRef.current);
|
|
setDraft(toDraft(item));
|
|
setResolveLatestIssue(false);
|
|
setRetryLatestIssue(true);
|
|
setEditorOpen(true);
|
|
setSuppressAutoOpen(false);
|
|
setSourceViewerOpen(false);
|
|
}
|
|
|
|
function clearNotificationSelectionParams() {
|
|
if (typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
const params = new URLSearchParams(window.location.search);
|
|
let changed = false;
|
|
|
|
if (params.has('planId')) {
|
|
params.delete('planId');
|
|
changed = true;
|
|
}
|
|
|
|
if (params.has('workId')) {
|
|
params.delete('workId');
|
|
changed = true;
|
|
}
|
|
|
|
if (changed) {
|
|
const nextSearch = params.toString();
|
|
const nextUrl = `${window.location.pathname}${nextSearch ? `?${nextSearch}` : ''}`;
|
|
window.history.replaceState(window.history.state, '', nextUrl);
|
|
}
|
|
}
|
|
|
|
function closeEditor() {
|
|
setEditorOpen(false);
|
|
setSourceViewerOpen(false);
|
|
setSelectedSourceWork(null);
|
|
setSuppressAutoOpen(true);
|
|
clearNotificationSelectionParams();
|
|
if (!draft.id) {
|
|
setDraft(createEmptyDraft(appConfig));
|
|
}
|
|
}
|
|
|
|
async function handleOpenSourceWork(sourceWorkId: number) {
|
|
if (!hasAccess) {
|
|
messageApi.error(TOKEN_ACCESS_REQUIRED_MESSAGE);
|
|
return;
|
|
}
|
|
|
|
if (!draft.id) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const item = await fetchPlanSourceWorkHistory(draft.id, sourceWorkId);
|
|
setSelectedSourceWork(item);
|
|
setSourceViewerOpen(true);
|
|
} catch (error) {
|
|
messageApi.error(error instanceof Error ? error.message : '소스 작업 상세를 불러오지 못했습니다.');
|
|
}
|
|
}
|
|
|
|
async function handleSetup() {
|
|
if (!hasAccess) {
|
|
messageApi.error(TOKEN_ACCESS_REQUIRED_MESSAGE);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await setupPlanBoard();
|
|
messageApi.success('작업 테이블을 생성했습니다.');
|
|
await loadItems(statusFilter);
|
|
} catch (error) {
|
|
messageApi.error(error instanceof Error ? error.message : '테이블 생성에 실패했습니다.');
|
|
}
|
|
}
|
|
|
|
async function handleSave() {
|
|
if (savingRef.current) {
|
|
return;
|
|
}
|
|
|
|
const nextDraft = {
|
|
...draftRef.current,
|
|
note: noteInputValue,
|
|
};
|
|
|
|
if (!hasAccess) {
|
|
messageApi.error(TOKEN_ACCESS_REQUIRED_MESSAGE);
|
|
return;
|
|
}
|
|
|
|
if (isPlanItemRequestLocked(selectedItem)) {
|
|
messageApi.warning('자동화 접수된 항목은 수정할 수 없습니다.');
|
|
return;
|
|
}
|
|
|
|
savingRef.current = true;
|
|
setSaving(true);
|
|
|
|
try {
|
|
const isNewItem = !nextDraft.id;
|
|
const savedItem =
|
|
nextDraft.id && selectedItem && isFunctionCheckEditableStatus(selectedItem.status)
|
|
? await updatePlanItemJangsingProcessingRequired(nextDraft.id, nextDraft.jangsingProcessingRequired)
|
|
: nextDraft.id
|
|
? await updatePlanItem(nextDraft)
|
|
: await createPlanItem(nextDraft);
|
|
messageApi.success(
|
|
nextDraft.id
|
|
? isFunctionCheckEditableStatus(savedItem.status)
|
|
? '기능동작확인을 수정했습니다.'
|
|
: '작업 항목을 수정했습니다.'
|
|
: '작업 항목을 등록했습니다.',
|
|
);
|
|
await loadItems(statusFilter);
|
|
updateDraft(toDraft(savedItem));
|
|
setEditorOpen(!isNewItem || appConfig.planDefaults.openEditorAfterCreate);
|
|
} catch (error) {
|
|
messageApi.error(error instanceof Error ? error.message : '저장에 실패했습니다.');
|
|
} finally {
|
|
savingRef.current = false;
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
function handleNoteChange(note: string) {
|
|
setNoteInputValue(note);
|
|
draftRef.current = {
|
|
...draftRef.current,
|
|
note,
|
|
};
|
|
}
|
|
|
|
async function handleNoteAttachmentFilesPicked(files: File[]) {
|
|
if (files.length === 0 || noteAttachmentUploading) {
|
|
return;
|
|
}
|
|
|
|
if (!hasAccess) {
|
|
messageApi.error(TOKEN_ACCESS_REQUIRED_MESSAGE);
|
|
return;
|
|
}
|
|
|
|
if (isPlanItemRequestLocked(selectedItem)) {
|
|
messageApi.warning('자동화 접수된 항목은 첨부 파일을 추가할 수 없습니다.');
|
|
return;
|
|
}
|
|
|
|
setNoteAttachmentUploading(true);
|
|
|
|
try {
|
|
const sessionId = resolvePlanNoteAttachmentSessionId(draftRef.current.id, noteAttachmentSessionIdRef.current);
|
|
const uploadResults = await Promise.allSettled(files.map((file) => uploadChatComposerFile(sessionId, file)));
|
|
const uploadedItems: ChatComposerAttachment[] = [];
|
|
const failedFileNames: string[] = [];
|
|
|
|
uploadResults.forEach((result, index) => {
|
|
if (result.status === 'fulfilled') {
|
|
uploadedItems.push(result.value);
|
|
return;
|
|
}
|
|
|
|
failedFileNames.push(files[index]?.name || `파일 ${index + 1}`);
|
|
});
|
|
|
|
if (uploadedItems.length > 0) {
|
|
const nextNote = appendPlanNoteAttachments(noteInputValue, uploadedItems);
|
|
handleNoteChange(nextNote);
|
|
messageApi.success(`첨부 파일 ${uploadedItems.length}건을 메모에 추가했습니다.`);
|
|
}
|
|
|
|
if (failedFileNames.length > 0) {
|
|
messageApi.error(`업로드 실패: ${failedFileNames.join(', ')}`);
|
|
}
|
|
} finally {
|
|
setNoteAttachmentUploading(false);
|
|
}
|
|
}
|
|
|
|
function handleNoteAttachmentInputChange(event: ChangeEvent<HTMLInputElement>) {
|
|
const files = Array.from(event.target.files ?? []);
|
|
event.target.value = '';
|
|
void handleNoteAttachmentFilesPicked(files);
|
|
}
|
|
|
|
async function handleDelete() {
|
|
if (savingRef.current || !draftRef.current.id) {
|
|
return;
|
|
}
|
|
|
|
if (!hasAccess) {
|
|
messageApi.error(TOKEN_ACCESS_REQUIRED_MESSAGE);
|
|
return;
|
|
}
|
|
|
|
if (isPlanItemRequestLocked(selectedItem)) {
|
|
messageApi.warning('자동화 접수된 항목은 삭제할 수 없습니다.');
|
|
return;
|
|
}
|
|
|
|
if (!window.confirm('선택한 작업 메모를 삭제할까요?')) {
|
|
return;
|
|
}
|
|
|
|
setSaving(true);
|
|
|
|
try {
|
|
await deletePlanItem(draftRef.current.id);
|
|
messageApi.success('작업 항목을 삭제했습니다.');
|
|
updateDraft(createEmptyDraft(appConfig));
|
|
setEditorOpen(false);
|
|
await loadItems(statusFilter);
|
|
} catch (error) {
|
|
messageApi.error(error instanceof Error ? error.message : '삭제에 실패했습니다.');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleAction(action: PlanActionType) {
|
|
if (savingRef.current || !draftRef.current.id) {
|
|
return;
|
|
}
|
|
|
|
if (!hasAccess) {
|
|
messageApi.error(TOKEN_ACCESS_REQUIRED_MESSAGE);
|
|
return;
|
|
}
|
|
|
|
setSaving(true);
|
|
|
|
try {
|
|
const response = await runPlanAction(draftRef.current.id, action);
|
|
messageApi.success(response.message ?? getActionSuccessMessage(action));
|
|
await loadItems(statusFilter);
|
|
updateDraft(toDraft(response.item));
|
|
await Promise.all([
|
|
loadActionHistories(response.item.id),
|
|
loadIssueHistories(response.item.id),
|
|
loadSourceWorkHistories(response.item.id),
|
|
]);
|
|
} catch (error) {
|
|
messageApi.error(error instanceof Error ? error.message : '작업 요청에 실패했습니다.');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleIssueAction() {
|
|
if (!hasAccess) {
|
|
messageApi.error(TOKEN_ACCESS_REQUIRED_MESSAGE);
|
|
return;
|
|
}
|
|
|
|
if (savingRef.current || !draftRef.current.id || !issueActionNote.trim()) {
|
|
messageApi.warning('조치 내용을 입력해 주세요.');
|
|
return;
|
|
}
|
|
|
|
setSaving(true);
|
|
|
|
try {
|
|
const response = await appendPlanIssueAction(
|
|
draftRef.current.id,
|
|
issueActionNote,
|
|
resolveLatestIssue,
|
|
retryLatestIssue,
|
|
);
|
|
messageApi.success(resolvePlanHistorySuccessMessage(
|
|
response.message,
|
|
resolveLatestIssue ? '이슈 조치 및 해결 기록을 남겼습니다.' : '이슈 조치 기록을 남겼습니다.',
|
|
));
|
|
setIssueActionNote('');
|
|
setResolveLatestIssue(false);
|
|
setRetryLatestIssue(false);
|
|
await Promise.all([
|
|
loadIssueHistories(draftRef.current.id),
|
|
loadActionHistories(draftRef.current.id),
|
|
loadItems(statusFilter),
|
|
]);
|
|
if (response.planItem) {
|
|
updateDraft(toDraft(response.planItem));
|
|
}
|
|
} catch (error) {
|
|
messageApi.error(error instanceof Error ? error.message : '이슈 조치 기록 저장에 실패했습니다.');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleActionNote() {
|
|
if (!hasAccess) {
|
|
messageApi.error(TOKEN_ACCESS_REQUIRED_MESSAGE);
|
|
return;
|
|
}
|
|
|
|
if (savingRef.current || !draftRef.current.id || !actionNote.trim()) {
|
|
messageApi.warning('추가 조치 내용을 입력해 주세요.');
|
|
return;
|
|
}
|
|
|
|
setSaving(true);
|
|
|
|
try {
|
|
const response = await appendPlanActionHistory(draftRef.current.id, actionNote);
|
|
messageApi.success(resolvePlanHistorySuccessMessage(response.message, '조치 이력을 추가했습니다.'));
|
|
setActionNote('');
|
|
await Promise.all([
|
|
loadItems(statusFilter),
|
|
loadActionHistories(draftRef.current.id),
|
|
loadIssueHistories(draftRef.current.id),
|
|
]);
|
|
if (response.planItem) {
|
|
updateDraft(toDraft(response.planItem));
|
|
}
|
|
} 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('복사에 실패했습니다.');
|
|
}
|
|
}
|
|
|
|
async function handleJangsingProcessingChange(id: number, jangsingProcessingRequired: boolean) {
|
|
if (jangsingProcessingSavingId === id) {
|
|
return;
|
|
}
|
|
|
|
if (!hasAccess) {
|
|
messageApi.error(TOKEN_ACCESS_REQUIRED_MESSAGE);
|
|
return;
|
|
}
|
|
|
|
const currentItem = items.find((item) => item.id === id);
|
|
|
|
if (!currentItem || currentItem.jangsingProcessingRequired === jangsingProcessingRequired) {
|
|
return;
|
|
}
|
|
|
|
if (isPlanItemRequestLocked(currentItem)) {
|
|
messageApi.warning('자동화 접수된 항목은 기능동작확인을 수정할 수 없습니다.');
|
|
return;
|
|
}
|
|
|
|
if (!isFunctionCheckEditableStatus(currentItem.status)) {
|
|
messageApi.warning('기능동작확인은 작업완료 건만 수정할 수 있습니다.');
|
|
return;
|
|
}
|
|
|
|
setJangsingProcessingSavingId(id);
|
|
|
|
try {
|
|
const updatedItem = await updatePlanItemJangsingProcessingRequired(id, jangsingProcessingRequired);
|
|
setItems((previous) =>
|
|
previous.map((item) => (item.id === id ? updatedItem : item)).sort(comparePlanItemsByRecent),
|
|
);
|
|
|
|
if (draftRef.current.id === id) {
|
|
updateDraft(toDraft(updatedItem));
|
|
}
|
|
|
|
messageApi.success(`기능동작확인을 ${getFunctionCheckLabel(jangsingProcessingRequired)}로 변경했습니다.`);
|
|
} catch (error) {
|
|
messageApi.error(error instanceof Error ? error.message : '기능동작확인 저장에 실패했습니다.');
|
|
} finally {
|
|
setJangsingProcessingSavingId(null);
|
|
}
|
|
}
|
|
|
|
function handleAutoRefreshToggle() {
|
|
setAutoRefreshEnabled((previous) => {
|
|
const next = !previous;
|
|
messageApi.success(next ? '자동조회를 다시 시작합니다.' : '자동조회를 중지했습니다.');
|
|
return next;
|
|
});
|
|
setAutoRefreshRemainingMs(autoRefreshIntervalMs);
|
|
}
|
|
|
|
const hasIssueHistory = issueHistories.length > 0;
|
|
const currentDraftItem = draft.id ? items.find((item) => item.id === draft.id) ?? selectedItem : selectedItem;
|
|
const requestReceived = isPlanItemRequestLocked(currentDraftItem);
|
|
const isRequestLocked = !hasAccess || requestReceived;
|
|
const isRestrictedClient = !hasAccess;
|
|
const isCreating = !draft.id;
|
|
const canEditFunctionCheck = Boolean(
|
|
hasAccess && selectedItem && !isRequestLocked && isFunctionCheckEditableStatus(selectedItem.status),
|
|
);
|
|
const canSave = hasAccess && !isRequestLocked;
|
|
const automationTypeOptions = useMemo(
|
|
() => buildAutomationTypeOptions(automationTypes, draft.automationType),
|
|
[automationTypes, draft.automationType],
|
|
);
|
|
const automationTypeLabel = useMemo(
|
|
() => resolveAutomationTypeLabel(automationTypes, draft.automationType),
|
|
[automationTypes, draft.automationType],
|
|
);
|
|
const latestActionHistory = actionHistories[0] ?? null;
|
|
const latestIssueHistory = issueHistories[0] ?? null;
|
|
const releaseCompletedTimestamps = useMemo(
|
|
() =>
|
|
actionHistories
|
|
.filter((history) => history.actionType === 'release반영완료')
|
|
.slice()
|
|
.sort((left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime())
|
|
.map((history) => history.createdAt),
|
|
[actionHistories],
|
|
);
|
|
const latestReleaseCompletedAt = releaseCompletedTimestamps[0] ?? null;
|
|
const previousReleaseCompletedAt = releaseCompletedTimestamps[1] ?? null;
|
|
const sourceWorkHistoriesAfterLatestRelease = useMemo(
|
|
() => sourceWorkHistories.filter((history) => isHistoryAfterReference(history.createdAt, latestReleaseCompletedAt)),
|
|
[latestReleaseCompletedAt, sourceWorkHistories],
|
|
);
|
|
const sourceWorkHistoriesForLatestReleasedCycle = useMemo(() => {
|
|
if (!latestReleaseCompletedAt) {
|
|
return [];
|
|
}
|
|
|
|
return sourceWorkHistories.filter((history) => {
|
|
if (!history.createdAt) {
|
|
return false;
|
|
}
|
|
|
|
const createdAt = new Date(history.createdAt).getTime();
|
|
const upperBound = new Date(latestReleaseCompletedAt).getTime();
|
|
|
|
if (Number.isNaN(createdAt) || Number.isNaN(upperBound) || createdAt > upperBound) {
|
|
return false;
|
|
}
|
|
|
|
if (!previousReleaseCompletedAt) {
|
|
return true;
|
|
}
|
|
|
|
return createdAt > new Date(previousReleaseCompletedAt).getTime();
|
|
});
|
|
}, [latestReleaseCompletedAt, previousReleaseCompletedAt, sourceWorkHistories]);
|
|
const shouldShowCompletedCycleUsage =
|
|
isMainCompletedPlanItem(selectedItem) &&
|
|
sourceWorkHistoriesAfterLatestRelease.length === 0 &&
|
|
sourceWorkHistoriesForLatestReleasedCycle.length > 0;
|
|
const currentReleaseSourceWorkHistories = shouldShowCompletedCycleUsage
|
|
? sourceWorkHistoriesForLatestReleasedCycle
|
|
: sourceWorkHistoriesAfterLatestRelease;
|
|
const currentReleaseUsagePrefix = shouldShowCompletedCycleUsage
|
|
? '최근 완료 배포 기준'
|
|
: latestReleaseCompletedAt
|
|
? '이번 반영 배포 이후'
|
|
: '현재 누적 기준';
|
|
const currentReleaseUsageSummary = useMemo(
|
|
() => buildAutomationUsageSummary(currentReleaseSourceWorkHistories, appConfig),
|
|
[appConfig, currentReleaseSourceWorkHistories],
|
|
);
|
|
const currentReleaseUsageSummaryByHistoryId = useMemo(
|
|
() => buildAutomationUsageSummaryByHistoryId(currentReleaseSourceWorkHistories, appConfig),
|
|
[appConfig, currentReleaseSourceWorkHistories],
|
|
);
|
|
const usageSummaryByPlanId = useMemo(
|
|
() =>
|
|
Object.fromEntries(
|
|
items.flatMap((item) => {
|
|
const summary = buildAutomationUsageSummaryFromSnapshot(item.usageSnapshot, appConfig);
|
|
return summary ? [[item.id, summary]] : [];
|
|
}),
|
|
) as Record<number, AutomationUsageSummary>,
|
|
[appConfig, items],
|
|
);
|
|
const currentReleaseSourceWorkHistoryIdSet = useMemo(
|
|
() => new Set(currentReleaseSourceWorkHistories.map((history) => history.id)),
|
|
[currentReleaseSourceWorkHistories],
|
|
);
|
|
const selectedSourceWorkUsageSummary =
|
|
selectedSourceWork && currentReleaseSourceWorkHistoryIdSet.has(selectedSourceWork.id)
|
|
? currentReleaseUsageSummaryByHistoryId.get(selectedSourceWork.id) ?? null
|
|
: null;
|
|
const memoRows = screens.md ? 18 : 9;
|
|
return (
|
|
<div className="plan-board-page">
|
|
{contextHolder}
|
|
|
|
<Card className="plan-board-page__overview" bordered={false}>
|
|
<Flex justify="space-between" align="center" gap={12} wrap>
|
|
<div>
|
|
<Title level={4}>자동화</Title>
|
|
<Paragraph className="plan-board-page__intro">
|
|
작업 메모와 이력, 증적을 확인하고 필요한 내용을 수동으로 정리합니다.
|
|
</Paragraph>
|
|
</div>
|
|
|
|
<Space wrap>
|
|
<Space size={8} className="plan-board-page__auto-refresh-control">
|
|
<LongPressButton
|
|
onClick={() => void loadItems(statusFilter)}
|
|
onLongPress={() => {
|
|
if (!hasAccess) {
|
|
messageApi.error(TOKEN_ACCESS_REQUIRED_MESSAGE);
|
|
return;
|
|
}
|
|
|
|
handleAutoRefreshToggle();
|
|
}}
|
|
longPressMs={AUTO_REFRESH_LONG_PRESS_MS}
|
|
loading={loading}
|
|
title="길게 눌러 자동조회 On/Off"
|
|
className={`plan-board-page__auto-refresh-button${
|
|
isAutoRefreshRunning ? ' plan-board-page__auto-refresh-button--active' : ''
|
|
}`}
|
|
>
|
|
조회
|
|
</LongPressButton>
|
|
{isAutoRefreshRunning ? (
|
|
<Text className="plan-board-page__auto-refresh-countdown">
|
|
자동 조회까지 {autoRefreshCountdownSeconds}초
|
|
</Text>
|
|
) : null}
|
|
</Space>
|
|
{listRequestMeta ? (
|
|
<Text type="secondary">
|
|
최근 조회 {formatResponseBytes(listRequestMeta.responseBytes)} · {listRequestMeta.durationMs}ms
|
|
</Text>
|
|
) : null}
|
|
<Button onClick={handleCreateNew} disabled={isRestrictedClient}>
|
|
새 메모
|
|
</Button>
|
|
<Button onClick={() => void handleSetup()} disabled={isRestrictedClient}>
|
|
테이블 생성
|
|
</Button>
|
|
</Space>
|
|
</Flex>
|
|
</Card>
|
|
|
|
{isRestrictedClient ? (
|
|
<Alert
|
|
showIcon
|
|
type="warning"
|
|
className="plan-board-page__alert"
|
|
message="권한 토큰이 없어 자동화 조회만 사용할 수 있습니다."
|
|
description={TOKEN_ACCESS_REQUIRED_MESSAGE}
|
|
/>
|
|
) : null}
|
|
|
|
{loading ? (
|
|
<Alert
|
|
showIcon
|
|
type="info"
|
|
className="plan-board-page__alert"
|
|
message="자동화 현황 데이터를 불러오는 중입니다."
|
|
description={
|
|
<Space size={8}>
|
|
<Spin size="small" />
|
|
<span>서버 설정과 연결 상태를 확인하고 있습니다.</span>
|
|
</Space>
|
|
}
|
|
/>
|
|
) : null}
|
|
|
|
{errorMessage ? (
|
|
<Alert
|
|
showIcon
|
|
type="warning"
|
|
className="plan-board-page__alert"
|
|
message="작업 요청 메뉴를 아직 사용할 수 없습니다."
|
|
description={<ExpandableDetailText text={errorMessage} />}
|
|
action={
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
aria-label="오류 메시지 복사"
|
|
icon={<CopyOutlined />}
|
|
disabled={!hasAccess}
|
|
onClick={() => void handleCopyText(errorMessage)}
|
|
/>
|
|
}
|
|
/>
|
|
) : null}
|
|
|
|
<PlanListDetailLayout
|
|
listTitle={`작업 목록 / ${getPlanQuickFilterLabel(quickFilter) ?? getPlanFilterLabel(statusFilter)}`}
|
|
listExtra={<Text code>{filteredItems.length} items</Text>}
|
|
listContent={
|
|
<>
|
|
{quickFilter ? (
|
|
<Alert
|
|
showIcon
|
|
type={quickFilter === 'automation-failed' ? 'error' : 'info'}
|
|
className="plan-board-page__alert"
|
|
message={`${getPlanQuickFilterLabel(quickFilter)} 목록을 표시합니다.`}
|
|
description={
|
|
quickFilter === 'automation-failed'
|
|
? '브랜치 생성, 작업, release/main 반영 단계에서 실패한 항목만 추렸습니다.'
|
|
: quickFilter === 'working'
|
|
? '현재 상태가 작업중인 항목만 추렸습니다.'
|
|
: 'release 반영은 끝났지만 main 반영이 남아 있거나 실패한 항목만 추렸습니다.'
|
|
}
|
|
/>
|
|
) : null}
|
|
{selectedItem?.lastError ? (
|
|
<Alert
|
|
showIcon
|
|
type="error"
|
|
className="plan-board-page__alert"
|
|
message="현재 선택된 작업에 오류가 있습니다."
|
|
description={<ExpandableDetailText text={resolveProtectedText(selectedItem.lastError, !hasAccess)} type="danger" />}
|
|
action={
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
aria-label="오류 메시지 복사"
|
|
icon={<CopyOutlined />}
|
|
disabled={!hasAccess}
|
|
onClick={() => void handleCopyText(selectedItem.lastError ?? '')}
|
|
/>
|
|
}
|
|
/>
|
|
) : null}
|
|
|
|
<Input.Search
|
|
allowClear
|
|
value={searchKeyword}
|
|
placeholder="작업 ID, 메모 내용, 브랜치, 이슈 태그 검색"
|
|
disabled={isRestrictedClient}
|
|
onChange={(event) => {
|
|
setSearchKeyword(event.target.value);
|
|
}}
|
|
/>
|
|
<Flex gap={8} wrap className="plan-board-page__list-filter-bar">
|
|
<Select
|
|
size="small"
|
|
value={workerStateFilter}
|
|
options={WORKER_STATE_FILTER_OPTIONS}
|
|
disabled={isRestrictedClient}
|
|
onChange={setWorkerStateFilter}
|
|
/>
|
|
<Select
|
|
size="small"
|
|
value={releaseStateFilter}
|
|
options={RELEASE_STATE_FILTER_OPTIONS}
|
|
disabled={isRestrictedClient}
|
|
onChange={setReleaseStateFilter}
|
|
/>
|
|
<Select
|
|
size="small"
|
|
value={mainStateFilter}
|
|
options={MAIN_STATE_FILTER_OPTIONS}
|
|
disabled={isRestrictedClient}
|
|
onChange={setMainStateFilter}
|
|
/>
|
|
<Select
|
|
size="small"
|
|
value={issueStateFilter}
|
|
options={ISSUE_STATE_FILTER_OPTIONS}
|
|
disabled={isRestrictedClient}
|
|
onChange={setIssueStateFilter}
|
|
/>
|
|
<Select
|
|
size="small"
|
|
value={costStateFilter}
|
|
options={COST_STATE_FILTER_OPTIONS}
|
|
disabled={isRestrictedClient}
|
|
onChange={setCostStateFilter}
|
|
/>
|
|
</Flex>
|
|
|
|
<PlanItemList
|
|
activeDraftId={draft.id}
|
|
currentPage={currentListPage}
|
|
editorOpen={editorOpen}
|
|
hasAccess={hasAccess}
|
|
items={filteredItems}
|
|
jangsingProcessingSavingId={jangsingProcessingSavingId}
|
|
reviewIndicatorsByPlanId={reviewIndicatorsByPlanId}
|
|
searchKeyword={searchKeyword}
|
|
usageSummaryByPlanId={usageSummaryByPlanId}
|
|
onChangePage={setCurrentListPage}
|
|
onChangeJangsingProcessing={handleJangsingProcessingChange}
|
|
onSelectItem={handleSelectItem}
|
|
/>
|
|
</>
|
|
}
|
|
desktopDetailOpen={editorOpen}
|
|
mobileDetailOpen={editorOpen}
|
|
mobileLayoutMode="detail-only"
|
|
detailTitle={draft.id ? `상세 #${draft.id}` : '새 메모'}
|
|
detailActions={
|
|
<>
|
|
{hasAccess && draft.id ? (
|
|
<Button danger onClick={() => void handleDelete()} loading={saving} disabled={!canSave}>
|
|
삭제
|
|
</Button>
|
|
) : null}
|
|
{hasAccess && !sourceViewerOpen ? (
|
|
<Button
|
|
type="primary"
|
|
onClick={() => void handleSave()}
|
|
loading={saving}
|
|
disabled={Boolean(draft.id && !canSave)}
|
|
>
|
|
저장
|
|
</Button>
|
|
) : null}
|
|
</>
|
|
}
|
|
onCloseDetail={closeEditor}
|
|
showDesktopClose
|
|
emptyDetailTitle="상세 보기"
|
|
detailContent={
|
|
!sourceViewerOpen ? (
|
|
<>
|
|
{selectedItem ? (
|
|
<Alert
|
|
type={selectedItem.hasOpenIssues ? 'warning' : 'info'}
|
|
showIcon
|
|
message="작업 정보"
|
|
description={
|
|
<Space direction="vertical" size={4}>
|
|
<Text>상태: {selectedItem.status}</Text>
|
|
<Text>등록일: {formatPlanDateTime(selectedItem.createdAt)}</Text>
|
|
<Text>접수 시각: {formatPlanDateTime(selectedItem.startedAt)}</Text>
|
|
<Text>작업완료 시각: {formatPlanDateTime(selectedItem.completedAt)}</Text>
|
|
<Text>최종 반영 시각: {formatPlanDateTime(selectedItem.mergedAt)}</Text>
|
|
<Text>최근 수정: {formatPlanDateTime(selectedItem.updatedAt)}</Text>
|
|
<Text>브랜치: {selectedItem.assignedBranch ?? '-'}</Text>
|
|
<AutomationStatusBar item={selectedItem} usageSummary={currentReleaseUsageSummary} />
|
|
{currentReleaseUsageSummary ? (
|
|
<Space size={[8, 4]} wrap>
|
|
<Text>
|
|
{currentReleaseUsagePrefix} 소스 작업: {currentReleaseSourceWorkHistories.length}건
|
|
</Text>
|
|
<Text>{currentReleaseUsageSummary.costLabel}</Text>
|
|
<Text>{currentReleaseUsageSummary.tokenLabel}</Text>
|
|
{currentReleaseUsageSummary?.tierColor && currentReleaseUsageSummary.tierLabel ? (
|
|
<Tag color={currentReleaseUsageSummary.tierColor}>{currentReleaseUsageSummary.tierLabel}</Tag>
|
|
) : null}
|
|
{currentReleaseUsageSummary.description ? (
|
|
<Text type="secondary">{currentReleaseUsageSummary.description}</Text>
|
|
) : null}
|
|
</Space>
|
|
) : null}
|
|
<Text>릴리즈 브랜치: {selectedItem.releaseTarget ?? 'release'}</Text>
|
|
<Text>메인 자동등록: {selectedItem.autoDeployToMain ? '예' : '아니오'}</Text>
|
|
<Text>원본 요청 수정 가능: {isPlanItemRequestLocked(selectedItem) ? '아니오' : '예'}</Text>
|
|
{latestActionHistory ? (
|
|
<Flex align="start" gap={8} wrap>
|
|
<Text style={{ flex: 1 }}>
|
|
최근 조치: [{latestActionHistory.actionType}] {resolveProtectedText(latestActionHistory.note, !hasAccess)}
|
|
</Text>
|
|
<Button
|
|
size="small"
|
|
icon={<CopyOutlined />}
|
|
disabled={!hasAccess}
|
|
onClick={() =>
|
|
void handleCopyText(`[${latestActionHistory.actionType}] ${latestActionHistory.note}`)
|
|
}
|
|
/>
|
|
</Flex>
|
|
) : null}
|
|
{selectedItem.lastError ? (
|
|
<Flex align="start" gap={8} wrap>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<Text type="danger">최근 오류:</Text>
|
|
<ExpandableDetailText text={resolveProtectedText(selectedItem.lastError, !hasAccess)} type="danger" />
|
|
</div>
|
|
<Button
|
|
size="small"
|
|
icon={<CopyOutlined />}
|
|
disabled={!hasAccess}
|
|
onClick={() => void handleCopyText(selectedItem.lastError ?? '')}
|
|
/>
|
|
</Flex>
|
|
) : null}
|
|
{latestIssueHistory ? (
|
|
<Flex align="start" gap={8} wrap>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<Text>최근 이슈: {latestIssueHistory.issueTag}</Text>
|
|
<ExpandableDetailText text={resolveProtectedText(latestIssueHistory.message, !hasAccess)} />
|
|
</div>
|
|
<Button
|
|
size="small"
|
|
icon={<CopyOutlined />}
|
|
disabled={!hasAccess}
|
|
onClick={() =>
|
|
void handleCopyText(`${latestIssueHistory.issueTag} ${latestIssueHistory.message}`)
|
|
}
|
|
/>
|
|
</Flex>
|
|
) : null}
|
|
</Space>
|
|
}
|
|
/>
|
|
) : null}
|
|
|
|
{selectedItem ? (
|
|
<PlanDetailSection title="작업 체크리스트" defaultOpen>
|
|
<PlanWorkChecklist
|
|
item={selectedItem}
|
|
sourceWorkCount={sourceWorkHistories.length}
|
|
hasOpenIssues={selectedItem.hasOpenIssues}
|
|
/>
|
|
</PlanDetailSection>
|
|
) : null}
|
|
|
|
{selectedItem ? (
|
|
<PlanDetailSection title="릴리즈 준비 요약" defaultOpen>
|
|
<ReleaseReadySummary
|
|
hasAccess={hasAccess}
|
|
item={selectedItem}
|
|
sourceWorkHistories={sourceWorkHistories}
|
|
issueHistories={issueHistories}
|
|
/>
|
|
</PlanDetailSection>
|
|
) : null}
|
|
|
|
<Space wrap className="plan-board-page__action-bar">
|
|
{actionButtons.map((action) => (
|
|
<Button
|
|
key={action.key}
|
|
onClick={() => void handleAction(action.key)}
|
|
loading={saving}
|
|
disabled={isRestrictedClient}
|
|
>
|
|
{action.label}
|
|
</Button>
|
|
))}
|
|
</Space>
|
|
|
|
<div className="plan-board-page__form">
|
|
<div>
|
|
<Text strong>작업 상태</Text>
|
|
<div>
|
|
<Tag color={statusTagColorMap[draft.status]}>{draft.status}</Tag>
|
|
</div>
|
|
</div>
|
|
|
|
{!isCreating ? (
|
|
<div>
|
|
<Text strong>기능동작확인</Text>
|
|
<div>
|
|
{canEditFunctionCheck ? (
|
|
<Segmented
|
|
options={FUNCTION_CHECK_OPTIONS}
|
|
value={getFunctionCheckLabel(draft.jangsingProcessingRequired)}
|
|
onChange={(value) => {
|
|
updateDraft((previous) => ({
|
|
...previous,
|
|
jangsingProcessingRequired: value === '완료',
|
|
}));
|
|
}}
|
|
/>
|
|
) : (
|
|
<Text type="secondary">작업완료 이후에만 선택할 수 있습니다.</Text>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<div>
|
|
<Text strong>작업 ID</Text>
|
|
<Input
|
|
value={draft.workId}
|
|
placeholder="예: 기능확인-기본"
|
|
readOnly={isRequestLocked}
|
|
onChange={(event) => {
|
|
const workId = event.target.value;
|
|
updateDraft((previous) => ({
|
|
...previous,
|
|
workId,
|
|
}));
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Text strong>자동화 처리</Text>
|
|
{requestReceived ? (
|
|
<div className="plan-board-page__readonly-field" aria-readonly="true">
|
|
<Text>{automationTypeLabel}</Text>
|
|
<Tag color="processing">접수 후 읽기전용</Tag>
|
|
</div>
|
|
) : (
|
|
<Select
|
|
className="plan-board-page__select plan-board-page__select--automation"
|
|
value={draft.automationType}
|
|
options={automationTypeOptions}
|
|
popupClassName="plan-board-page__select-popup"
|
|
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
|
|
disabled={!hasAccess}
|
|
onChange={(automationType) => {
|
|
updateDraft((previous) => ({
|
|
...previous,
|
|
automationType,
|
|
}));
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<Flex justify="space-between" align="center" gap={8} wrap>
|
|
<Text strong>메모</Text>
|
|
<Space size={8} wrap>
|
|
<Button
|
|
size="small"
|
|
icon={<PaperClipOutlined />}
|
|
disabled={!hasAccess || isRequestLocked}
|
|
loading={noteAttachmentUploading}
|
|
onClick={() => {
|
|
noteAttachmentInputRef.current?.click();
|
|
}}
|
|
>
|
|
첨부
|
|
</Button>
|
|
<Button
|
|
size="small"
|
|
icon={<CopyOutlined />}
|
|
disabled={!hasAccess}
|
|
onClick={() => void handleCopyText(noteInputValue)}
|
|
>
|
|
복사
|
|
</Button>
|
|
</Space>
|
|
</Flex>
|
|
<div className="plan-board-page__notepad-frame">
|
|
<TextArea
|
|
value={hasAccess ? noteInputValue : maskNotePreviewByWord(noteInputValue)}
|
|
rows={memoRows}
|
|
placeholder={hasAccess ? '작업 내용을 자유롭게 입력하세요.' : '권한 토큰 등록 후 편집할 수 있습니다.'}
|
|
className={`plan-board-page__notepad${isRequestLocked ? ' plan-board-page__notepad--readonly' : ''}`}
|
|
readOnly={isRequestLocked}
|
|
onChange={(event) => {
|
|
handleNoteChange(event.target.value);
|
|
}}
|
|
/>
|
|
</div>
|
|
<input
|
|
ref={noteAttachmentInputRef}
|
|
type="file"
|
|
multiple
|
|
className="plan-board-page__hidden-file-input"
|
|
onChange={handleNoteAttachmentInputChange}
|
|
/>
|
|
{isRequestLocked ? (
|
|
<Text type="secondary">
|
|
{hasAccess ? '자동화 접수된 항목은 본문을 수정할 수 없습니다.' : '조회 화면에서는 작업 메모를 40% 마스킹해 표시합니다.'}
|
|
</Text>
|
|
) : null}
|
|
{noteResources.length ? <PlanNoteResourcePanel resources={noteResources} /> : null}
|
|
</div>
|
|
|
|
{selectedReleaseReviewNote.trim() ? (
|
|
<div>
|
|
<Flex justify="space-between" align="center" gap={8} wrap>
|
|
<Text strong>release 검수 메모</Text>
|
|
<Button
|
|
size="small"
|
|
type="text"
|
|
icon={<CopyOutlined />}
|
|
aria-label="release 검수 메모 복사"
|
|
disabled={!hasAccess}
|
|
onClick={() => void handleCopyText(selectedReleaseReviewNote)}
|
|
/>
|
|
</Flex>
|
|
<div className="plan-board-page__notepad-frame">
|
|
<TextArea
|
|
value={hasAccess ? selectedReleaseReviewNote : maskNotePreviewByWord(selectedReleaseReviewNote)}
|
|
rows={4}
|
|
className="plan-board-page__notepad plan-board-page__notepad--readonly"
|
|
readOnly
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<div>
|
|
<Checkbox
|
|
checked={draft.autoDeployToMain ?? true}
|
|
disabled={isRequestLocked}
|
|
onChange={(event) => {
|
|
const autoDeployToMain = event.target.checked;
|
|
updateDraft((previous) => ({
|
|
...previous,
|
|
autoDeployToMain,
|
|
}));
|
|
}}
|
|
>
|
|
메인까지 자동등록
|
|
</Checkbox>
|
|
{isCreating ? (
|
|
<div style={{ marginTop: 8 }}>
|
|
<Text type="secondary">
|
|
앱 기본값: {appConfig.planDefaults.autoDeployToMain ? '메인까지 자동등록' : 'release만 반영'}
|
|
</Text>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div>
|
|
<Text strong>접수 / 처리 시각</Text>
|
|
<Space direction="vertical" size={4} style={{ display: 'flex' }}>
|
|
<Text>등록일: {formatPlanDateTime(selectedItem?.createdAt ?? null)}</Text>
|
|
<Text>접수 시각: {formatPlanDateTime(selectedItem?.startedAt ?? null)}</Text>
|
|
<Text>작업완료 시각: {formatPlanDateTime(selectedItem?.completedAt ?? null)}</Text>
|
|
<Text>최종 반영 시각: {formatPlanDateTime(selectedItem?.mergedAt ?? null)}</Text>
|
|
<Text>최근 수정: {formatPlanDateTime(selectedItem?.updatedAt ?? null)}</Text>
|
|
{currentReleaseUsageSummary ? (
|
|
<Space size={[8, 4]} wrap>
|
|
<Text>
|
|
{currentReleaseUsagePrefix}: 소스 작업 {currentReleaseSourceWorkHistories.length}건
|
|
</Text>
|
|
<Text>{currentReleaseUsageSummary.costLabel}</Text>
|
|
<Text>{currentReleaseUsageSummary.tokenLabel}</Text>
|
|
{currentReleaseUsageSummary?.tierColor && currentReleaseUsageSummary.tierLabel ? (
|
|
<Tag color={currentReleaseUsageSummary.tierColor}>{currentReleaseUsageSummary.tierLabel}</Tag>
|
|
) : null}
|
|
{currentReleaseUsageSummary.description ? (
|
|
<Text type="secondary">{currentReleaseUsageSummary.description}</Text>
|
|
) : null}
|
|
</Space>
|
|
) : null}
|
|
</Space>
|
|
</div>
|
|
|
|
<PlanDetailSection title="소스 작업 내역">
|
|
{sourceWorkHistories.length ? (
|
|
<List
|
|
dataSource={sourceWorkHistories}
|
|
renderItem={(history) => {
|
|
const isCurrentReleaseHistory = currentReleaseSourceWorkHistoryIdSet.has(history.id);
|
|
const usageSummary = isCurrentReleaseHistory
|
|
? currentReleaseUsageSummaryByHistoryId.get(history.id) ?? null
|
|
: null;
|
|
|
|
return (
|
|
<List.Item key={`source-action-${history.id}`}>
|
|
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
|
<Flex justify="space-between" align="start" gap={8}>
|
|
<Space direction="vertical" size={2}>
|
|
<Text strong>{resolveProtectedText(history.summary, !hasAccess)}</Text>
|
|
<Text type="secondary">{history.branchName}</Text>
|
|
{usageSummary ? (
|
|
<Space size={[8, 4]} wrap>
|
|
<Text type="secondary">
|
|
{currentReleaseUsagePrefix} {usageSummary.costLabel}
|
|
</Text>
|
|
<Text type="secondary">{usageSummary.tokenLabel}</Text>
|
|
{usageSummary.tierColor && usageSummary.tierLabel ? (
|
|
<Tag color={usageSummary.tierColor}>{usageSummary.tierLabel}</Tag>
|
|
) : null}
|
|
{usageSummary.description ? (
|
|
<Text type="secondary">{usageSummary.description}</Text>
|
|
) : null}
|
|
</Space>
|
|
) : null}
|
|
</Space>
|
|
<Text type="secondary">
|
|
{new Date(history.createdAt).toLocaleString('ko-KR')}
|
|
</Text>
|
|
</Flex>
|
|
<Flex justify="end">
|
|
<Button disabled={!hasAccess} onClick={() => void handleOpenSourceWork(history.id)}>
|
|
미리보기
|
|
</Button>
|
|
</Flex>
|
|
</Space>
|
|
</List.Item>
|
|
);
|
|
}}
|
|
/>
|
|
) : (
|
|
<Empty description="기록된 소스 작업 이력이 없습니다." />
|
|
)}
|
|
</PlanDetailSection>
|
|
|
|
<PlanDetailSection title="조치 / 이슈 이력">
|
|
{combinedHistories.length ? (
|
|
<List
|
|
dataSource={combinedHistories}
|
|
renderItem={(history) => (
|
|
<List.Item key={history.id}>
|
|
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
|
<Flex justify="space-between" align="start" gap={8}>
|
|
{history.kind === 'action' ? (
|
|
<Tag color="blue">{history.action.actionType}</Tag>
|
|
) : (
|
|
<Tag color={history.issue.resolved ? 'default' : 'error'}>
|
|
이슈 {history.issue.issueTag}
|
|
</Tag>
|
|
)}
|
|
<Text type="secondary">
|
|
{new Date(history.createdAt).toLocaleString('ko-KR')}
|
|
</Text>
|
|
</Flex>
|
|
{history.kind === 'action' ? (
|
|
<Text>{resolveProtectedText(history.action.note, !hasAccess)}</Text>
|
|
) : (
|
|
<>
|
|
<ExpandableDetailText text={resolveProtectedText(history.issue.message, !hasAccess)} />
|
|
<Flex justify="end">
|
|
<Button
|
|
size="small"
|
|
icon={<CopyOutlined />}
|
|
disabled={!hasAccess}
|
|
onClick={() => void handleCopyText(history.issue.message)}
|
|
>
|
|
에러 복사
|
|
</Button>
|
|
</Flex>
|
|
{history.issue.actionNote ? (
|
|
<Alert
|
|
type={history.issue.resolved ? 'success' : 'info'}
|
|
showIcon
|
|
message={history.issue.resolved ? '조치 완료' : '조치 기록'}
|
|
description={
|
|
<pre className="plan-board-page__viewer-pre">
|
|
{resolveProtectedText(history.issue.actionNote, !hasAccess)}
|
|
</pre>
|
|
}
|
|
/>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</Space>
|
|
</List.Item>
|
|
)}
|
|
/>
|
|
) : (
|
|
<Empty description="조치 및 이력 기록이 없습니다." />
|
|
)}
|
|
</PlanDetailSection>
|
|
|
|
{isRequestLocked ? (
|
|
<Card size="small" title="추가 조치 기록">
|
|
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
|
<TextArea
|
|
value={actionNote}
|
|
rows={4}
|
|
placeholder="작업시작 이후의 추가 조치사항을 기록하세요."
|
|
disabled={!hasAccess}
|
|
onChange={(event) => {
|
|
setActionNote(event.target.value);
|
|
}}
|
|
/>
|
|
<Flex justify="end">
|
|
<Button onClick={() => void handleActionNote()} loading={saving} disabled={!hasAccess}>
|
|
조치 이력 추가
|
|
</Button>
|
|
</Flex>
|
|
</Space>
|
|
</Card>
|
|
) : null}
|
|
|
|
<div>
|
|
<Text strong>이슈 태그</Text>
|
|
<div>
|
|
{selectedItem?.issueTags.length ? (
|
|
<Space wrap size={[4, 4]}>
|
|
{selectedItem.issueTags.map((issueTag) => (
|
|
<Tag key={`detail-${issueTag}`} color={selectedItem.hasOpenIssues ? 'error' : 'default'}>
|
|
{issueTag}
|
|
</Tag>
|
|
))}
|
|
</Space>
|
|
) : (
|
|
<Text type="secondary">현재 기록된 이슈가 없습니다.</Text>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{hasIssueHistory ? (
|
|
<Card size="small" title="이슈 조치 기록">
|
|
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
|
<TextArea
|
|
value={issueActionNote}
|
|
rows={4}
|
|
placeholder="오류 원인 분석, 조치 내용, 재시도 결과를 기록하세요."
|
|
disabled={!hasAccess}
|
|
onChange={(event) => {
|
|
setIssueActionNote(event.target.value);
|
|
}}
|
|
/>
|
|
<Checkbox
|
|
checked={resolveLatestIssue}
|
|
disabled={!hasAccess}
|
|
onChange={(event) => {
|
|
setResolveLatestIssue(event.target.checked);
|
|
}}
|
|
>
|
|
최신 이슈를 해결 처리
|
|
</Checkbox>
|
|
<Checkbox
|
|
checked={retryLatestIssue}
|
|
disabled={!hasAccess}
|
|
onChange={(event) => {
|
|
setRetryLatestIssue(event.target.checked);
|
|
}}
|
|
>
|
|
재처리 요청
|
|
</Checkbox>
|
|
<Flex justify="end">
|
|
<Button onClick={() => void handleIssueAction()} loading={saving} disabled={!hasAccess}>
|
|
조치 기록 저장
|
|
</Button>
|
|
</Flex>
|
|
</Space>
|
|
</Card>
|
|
) : null}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="plan-board-page__overlay-body">
|
|
<Flex justify="space-between" align="start" gap={12} wrap>
|
|
<div>
|
|
<Title level={5} className="plan-board-page__viewer-heading">
|
|
{resolveProtectedText(selectedSourceWork?.summary, !hasAccess)}
|
|
</Title>
|
|
<Text type="secondary">{selectedSourceWork?.branchName}</Text>
|
|
{selectedSourceWorkUsageSummary ? (
|
|
<Space size={[8, 4]} wrap>
|
|
<Text type="secondary">
|
|
{currentReleaseUsagePrefix} {selectedSourceWorkUsageSummary.costLabel}
|
|
</Text>
|
|
<Text type="secondary">{selectedSourceWorkUsageSummary.tokenLabel}</Text>
|
|
{selectedSourceWorkUsageSummary.tierColor && selectedSourceWorkUsageSummary.tierLabel ? (
|
|
<Tag color={selectedSourceWorkUsageSummary.tierColor}>
|
|
{selectedSourceWorkUsageSummary.tierLabel}
|
|
</Tag>
|
|
) : null}
|
|
{selectedSourceWorkUsageSummary.description ? (
|
|
<Text type="secondary">{selectedSourceWorkUsageSummary.description}</Text>
|
|
) : null}
|
|
</Space>
|
|
) : null}
|
|
</div>
|
|
<Space>
|
|
<Button
|
|
icon={<ArrowLeftOutlined />}
|
|
aria-label="상세로 돌아가기"
|
|
onClick={() => setSourceViewerOpen(false)}
|
|
/>
|
|
<Button onClick={closeEditor}>닫기</Button>
|
|
</Space>
|
|
</Flex>
|
|
|
|
<Tabs
|
|
className="plan-board-page__viewer-tabs"
|
|
items={buildSourceViewerTabs(selectedSourceWork, {
|
|
hasAccess,
|
|
note: selectedItem?.note ?? '',
|
|
noteMasked: Boolean(selectedItem?.noteMasked || !hasAccess),
|
|
})}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
/>
|
|
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const PlanItemList = memo(
|
|
function PlanItemList({
|
|
activeDraftId,
|
|
currentPage,
|
|
editorOpen,
|
|
hasAccess,
|
|
items,
|
|
jangsingProcessingSavingId,
|
|
reviewIndicatorsByPlanId,
|
|
searchKeyword,
|
|
usageSummaryByPlanId,
|
|
onChangePage,
|
|
onChangeJangsingProcessing,
|
|
onSelectItem,
|
|
}: {
|
|
activeDraftId: number | null;
|
|
currentPage: number;
|
|
editorOpen: boolean;
|
|
hasAccess: boolean;
|
|
items: PlanItem[];
|
|
jangsingProcessingSavingId: number | null;
|
|
reviewIndicatorsByPlanId: Record<number, ReviewListIndicator>;
|
|
searchKeyword: string;
|
|
usageSummaryByPlanId: Record<number, AutomationUsageSummary>;
|
|
onChangePage: (page: number) => void;
|
|
onChangeJangsingProcessing: (id: number, jangsingProcessingRequired: boolean) => void;
|
|
onSelectItem: (item: PlanItem) => void;
|
|
}) {
|
|
if (items.length === 0) {
|
|
return <Empty description={searchKeyword.trim() ? '검색 결과가 없습니다.' : '등록된 작업 항목이 없습니다.'} />;
|
|
}
|
|
|
|
return (
|
|
<List
|
|
className="plan-board-page__list"
|
|
dataSource={items}
|
|
pagination={{
|
|
align: 'center',
|
|
current: currentPage,
|
|
pageSize: PLAN_LIST_PAGE_SIZE,
|
|
showSizeChanger: false,
|
|
onChange: hasAccess ? onChangePage : undefined,
|
|
}}
|
|
renderItem={(item) => (
|
|
(() => {
|
|
const reviewIndicator = reviewIndicatorsByPlanId[item.id];
|
|
const usageSummary = usageSummaryByPlanId[item.id] ?? null;
|
|
|
|
return (
|
|
<List.Item
|
|
key={item.id}
|
|
className={`plan-board-page__list-item${
|
|
activeDraftId === item.id && editorOpen ? ' plan-board-page__list-item--active' : ''
|
|
}`}
|
|
onClick={() => {
|
|
onSelectItem(item);
|
|
}}
|
|
>
|
|
<div className="plan-board-page__list-body">
|
|
<Flex justify="space-between" align="start" gap={8}>
|
|
<Text strong>{formatPlanListLabel(item)}</Text>
|
|
<Space size={[6, 6]} wrap className="plan-board-page__list-tags">
|
|
{reviewIndicator ? (
|
|
<Tag color={getReleaseReviewListTagColor(reviewIndicator.status)}>
|
|
{getReleaseReviewListTagLabel(reviewIndicator)}
|
|
</Tag>
|
|
) : null}
|
|
<Tag color={statusTagColorMap[item.status]}>{item.status}</Tag>
|
|
</Space>
|
|
</Flex>
|
|
<Flex justify="space-between" align="center" gap={8} wrap>
|
|
<Text type="secondary">기능동작확인</Text>
|
|
{isFunctionCheckEditableStatus(item.status) ? (
|
|
<Space size={6} wrap>
|
|
<Segmented
|
|
size="small"
|
|
options={FUNCTION_CHECK_OPTIONS}
|
|
value={getFunctionCheckLabel(item.jangsingProcessingRequired)}
|
|
disabled={!hasAccess || jangsingProcessingSavingId === item.id || isPlanItemRequestLocked(item)}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
}}
|
|
onChange={(value) => {
|
|
onChangeJangsingProcessing(item.id, value === '완료');
|
|
}}
|
|
/>
|
|
</Space>
|
|
) : (
|
|
<Text type="secondary">작업완료 후 선택</Text>
|
|
)}
|
|
</Flex>
|
|
<AutomationStatusBar item={item} usageSummary={usageSummary} compact />
|
|
{item.assignedBranch ? <Text type="secondary">브랜치: {item.assignedBranch}</Text> : null}
|
|
{item.issueTags.length > 0 ? (
|
|
<Space wrap size={[4, 4]}>
|
|
{item.issueTags.map((issueTag) => (
|
|
<Tag key={`${item.id}-${issueTag}`} color={item.hasOpenIssues ? 'error' : 'default'}>
|
|
{issueTag}
|
|
</Tag>
|
|
))}
|
|
</Space>
|
|
) : null}
|
|
<Paragraph ellipsis={{ rows: 2 }} className="plan-board-page__list-note">
|
|
{item.noteMasked || !hasAccess ? maskNotePreviewByWord(item.note) : item.note}
|
|
</Paragraph>
|
|
<Flex justify="space-between" align="center" gap={8} wrap>
|
|
<Space size={8} wrap>
|
|
{item.noteMasked ? <Tag bordered={false}>요청내용 마스킹</Tag> : null}
|
|
<Text type="secondary">등록 {formatPlanDateTime(item.createdAt)}</Text>
|
|
</Space>
|
|
<Text type="secondary">수정 {formatPlanDateTime(item.updatedAt)}</Text>
|
|
</Flex>
|
|
</div>
|
|
</List.Item>
|
|
);
|
|
})()
|
|
)}
|
|
/>
|
|
);
|
|
},
|
|
(previous, next) =>
|
|
previous.items === next.items &&
|
|
previous.jangsingProcessingSavingId === next.jangsingProcessingSavingId &&
|
|
previous.reviewIndicatorsByPlanId === next.reviewIndicatorsByPlanId &&
|
|
previous.searchKeyword === next.searchKeyword &&
|
|
previous.currentPage === next.currentPage &&
|
|
previous.activeDraftId === next.activeDraftId &&
|
|
previous.editorOpen === next.editorOpen &&
|
|
previous.hasAccess === next.hasAccess,
|
|
);
|
|
|
|
function LongPressButton({
|
|
children,
|
|
className,
|
|
loading,
|
|
longPressMs,
|
|
onClick,
|
|
onLongPress,
|
|
title,
|
|
}: {
|
|
children: ReactNode;
|
|
className?: string;
|
|
loading?: boolean;
|
|
longPressMs: number;
|
|
onClick: () => void;
|
|
onLongPress: () => void;
|
|
title?: string;
|
|
}) {
|
|
const [longPressTriggered, setLongPressTriggered] = useState(false);
|
|
const timerRef = useRef<number | null>(null);
|
|
|
|
const clearPressTimer = () => {
|
|
if (timerRef.current !== null) {
|
|
window.clearTimeout(timerRef.current);
|
|
timerRef.current = null;
|
|
}
|
|
};
|
|
|
|
useEffect(() => clearPressTimer, []);
|
|
|
|
return (
|
|
<Button
|
|
className={className}
|
|
loading={loading}
|
|
title={title}
|
|
onClick={() => {
|
|
if (longPressTriggered) {
|
|
setLongPressTriggered(false);
|
|
return;
|
|
}
|
|
|
|
onClick();
|
|
}}
|
|
onPointerDown={(event) => {
|
|
if (event.pointerType === 'mouse' && event.button !== 0) {
|
|
return;
|
|
}
|
|
|
|
timerRef.current = window.setTimeout(() => {
|
|
setLongPressTriggered(true);
|
|
onLongPress();
|
|
}, longPressMs);
|
|
}}
|
|
onPointerUp={() => {
|
|
clearPressTimer();
|
|
window.setTimeout(() => {
|
|
setLongPressTriggered(false);
|
|
}, 0);
|
|
}}
|
|
onPointerLeave={() => {
|
|
clearPressTimer();
|
|
}}
|
|
onPointerCancel={() => {
|
|
clearPressTimer();
|
|
setLongPressTriggered(false);
|
|
}}
|
|
>
|
|
{children}
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
function getActionSuccessMessage(action: PlanActionType) {
|
|
if (action === 'start-work') {
|
|
return '작업을 시작했습니다.';
|
|
}
|
|
|
|
if (action === 'complete-development') {
|
|
return '작업완료로 전환했습니다. release 반영 자동화를 대기합니다.';
|
|
}
|
|
|
|
if (action === 'retry-branch') {
|
|
return '브랜치 재시도를 요청했습니다.';
|
|
}
|
|
|
|
if (action === 'retry-work') {
|
|
return '자동 작업 재처리를 요청했습니다.';
|
|
}
|
|
|
|
if (action === 'retry-merge') {
|
|
return 'release 반영 재시도를 요청했습니다.';
|
|
}
|
|
|
|
if (action === 'cancel-release') {
|
|
return 'release 배포 내역을 롤백하고 작업취소로 완료 처리했습니다.';
|
|
}
|
|
|
|
return '이슈 브랜치 기준으로 main 반영을 요청했습니다.';
|
|
}
|
|
|
|
const FUNCTION_CHECK_OPTIONS = ['완료', '오동작'];
|
|
|
|
function buildSourceViewerTabs(
|
|
sourceWork: PlanSourceWorkHistory | null,
|
|
planContext?: {
|
|
hasAccess?: boolean;
|
|
note: string;
|
|
noteMasked: boolean;
|
|
},
|
|
) {
|
|
if (!sourceWork) {
|
|
return [];
|
|
}
|
|
|
|
const normalizedPreviewUrl = normalizeReleasePreviewUrl(sourceWork.previewUrl);
|
|
const hasAccess = Boolean(planContext?.hasAccess);
|
|
|
|
const summaryItems = [
|
|
`요청 요약: ${resolveProtectedText(sourceWork.summary, !hasAccess)}`,
|
|
`브랜치: ${sourceWork.branchName}`,
|
|
`Preview: ${normalizedPreviewUrl ?? '없음'}`,
|
|
`기록 시각: ${new Date(sourceWork.createdAt).toLocaleString('ko-KR')}`,
|
|
];
|
|
|
|
return [
|
|
{
|
|
key: 'summary',
|
|
label: '작업란',
|
|
children: (
|
|
<div className="plan-board-page__viewer-stack">
|
|
<ol className="plan-board-page__summary-list">
|
|
{summaryItems.map((item) => (
|
|
<li key={item}>{item}</li>
|
|
))}
|
|
</ol>
|
|
<div>
|
|
<Text strong>요청 메모</Text>
|
|
<Paragraph className="plan-board-page__memo-pre">
|
|
{resolvePlanNoteDetail(planContext?.note ?? '', Boolean(planContext?.noteMasked))}
|
|
</Paragraph>
|
|
</div>
|
|
{sourceWork.changedFiles.length ? (
|
|
<div className="plan-board-page__file-tags">
|
|
{sourceWork.changedFiles.map((file) => (
|
|
<Tag key={`viewer-summary-${file}`}>{file}</Tag>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
{normalizedPreviewUrl ? (
|
|
<Button
|
|
type="link"
|
|
href={hasAccess ? normalizedPreviewUrl : undefined}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
disabled={!hasAccess}
|
|
style={{ paddingInline: 0 }}
|
|
>
|
|
Preview 링크 열기
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'source',
|
|
label: '전체소스',
|
|
children: (
|
|
<CodexDiffPreviewer
|
|
files={sourceWork.sourceFiles}
|
|
diffText={sourceWork.diffText}
|
|
height="auto"
|
|
mode="source"
|
|
showModeSwitch={false}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
key: 'diff',
|
|
label: 'diff',
|
|
children: (
|
|
<CodexDiffPreviewer
|
|
files={sourceWork.sourceFiles}
|
|
diffText={sourceWork.diffText}
|
|
height="auto"
|
|
mode="diff"
|
|
showModeSwitch={false}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
key: 'commands',
|
|
label: '커맨드',
|
|
children: (
|
|
<PreviewerUI
|
|
type="code"
|
|
title="Command Log"
|
|
description="자동화 실행 커맨드"
|
|
value={sourceWork.commandLog || '기록된 실행 커맨드가 없습니다.'}
|
|
language="bash"
|
|
height="auto"
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
key: 'evidence',
|
|
label: '증적',
|
|
children: <WorklogEvidenceTab sourceWork={sourceWork} />,
|
|
},
|
|
{
|
|
key: 'files',
|
|
label: '파일',
|
|
children: sourceWork.changedFiles.length ? (
|
|
<List
|
|
dataSource={sourceWork.changedFiles}
|
|
renderItem={(file) => (
|
|
<List.Item key={`viewer-file-row-${file}`}>
|
|
<Text code>{file}</Text>
|
|
</List.Item>
|
|
)}
|
|
/>
|
|
) : (
|
|
<Empty description="기록된 파일이 없습니다." />
|
|
),
|
|
},
|
|
];
|
|
}
|
|
|
|
function resolvePlanNoteDetail(note: string, masked: boolean) {
|
|
if (!masked) {
|
|
return note || '메모가 없습니다.';
|
|
}
|
|
|
|
return maskNotePreviewByWord(note);
|
|
}
|
|
|
|
export function WorklogEvidenceTab({
|
|
sourceWork,
|
|
}: {
|
|
sourceWork: PlanSourceWorkHistory;
|
|
}) {
|
|
const normalizedPreviewUrl = normalizeReleasePreviewUrl(sourceWork.previewUrl);
|
|
const worklogPaths = useMemo(() => {
|
|
const explicitWorklogPaths = sourceWork.changedFiles.filter((file) => /^docs\/worklogs\/.+\.md$/i.test(file));
|
|
const inferredWorklogPaths = sourceWork.changedFiles
|
|
.map((file) => file.match(/^docs\/assets\/worklogs\/(\d{4}-\d{2}-\d{2})\//i)?.[1] ?? null)
|
|
.filter((value): value is string => Boolean(value))
|
|
.map((captureDate) => `docs/worklogs/${captureDate}.md`);
|
|
|
|
return Array.from(new Set([...explicitWorklogPaths, ...inferredWorklogPaths]));
|
|
}, [sourceWork.changedFiles]);
|
|
const screenshotPaths = useMemo(
|
|
() =>
|
|
Array.from(
|
|
new Set(
|
|
sourceWork.changedFiles.filter((file) =>
|
|
/^docs\/assets\/worklogs\/.+\.(png|jpe?g|gif|webp|svg)$/i.test(file),
|
|
),
|
|
),
|
|
),
|
|
[sourceWork.changedFiles],
|
|
);
|
|
const artifactPaths = useMemo(
|
|
() =>
|
|
Array.from(
|
|
new Set(
|
|
sourceWork.changedFiles.filter(
|
|
(file) =>
|
|
/^docs\/assets\/worklogs\/.+/i.test(file) &&
|
|
!/^docs\/assets\/worklogs\/.+\.(png|jpe?g|gif|webp|svg)$/i.test(file),
|
|
),
|
|
),
|
|
),
|
|
[sourceWork.changedFiles],
|
|
);
|
|
const fetchableTextPaths = useMemo(
|
|
() =>
|
|
Array.from(
|
|
new Set(
|
|
[...worklogPaths, ...artifactPaths].filter((path) => isFetchableTextArtifactPath(path)),
|
|
),
|
|
),
|
|
[artifactPaths, worklogPaths],
|
|
);
|
|
const fetchableTextPathsKey = fetchableTextPaths.join('\n');
|
|
const [worklogContents, setWorklogContents] = useState<Record<string, string>>({});
|
|
const [loading, setLoading] = useState(false);
|
|
const [previewModalItem, setPreviewModalItem] = useState<EvidenceAttachmentItem | null>(null);
|
|
const [isPreviewModalExpanded, setIsPreviewModalExpanded] = useState(false);
|
|
|
|
async function handleCopyText(text: string) {
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
message.success('복사했습니다.');
|
|
} catch {
|
|
message.error('복사에 실패했습니다.');
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
if (!fetchableTextPaths.length) {
|
|
setWorklogContents({});
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
|
|
void Promise.all(
|
|
fetchableTextPaths.map(async (path) => {
|
|
try {
|
|
const response = await fetch(toPublicAssetPath(path), { cache: 'no-store' });
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`작업일지 로드 실패: ${path}`);
|
|
}
|
|
|
|
return [path, await response.text()] as const;
|
|
} catch {
|
|
return [path, '작업일지 내용을 불러오지 못했습니다.'] as const;
|
|
}
|
|
}),
|
|
)
|
|
.then((entries) => {
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
|
|
setWorklogContents(Object.fromEntries(entries));
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) {
|
|
setLoading(false);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [fetchableTextPathsKey]);
|
|
|
|
const previewItems: EvidenceAttachmentItem[] = [
|
|
...worklogPaths.map((path) => ({
|
|
key: `worklog:${path}`,
|
|
kind: 'markdown' as const,
|
|
title: path.split('/').pop() ?? path,
|
|
description: path,
|
|
linkUrl: toPublicAssetPath(path),
|
|
value: worklogContents[path] ?? (loading ? '작업일지 내용을 불러오는 중입니다.' : '작업일지 내용을 불러오지 못했습니다.'),
|
|
})),
|
|
...screenshotPaths.map((path) => ({
|
|
key: `screenshot:${path}`,
|
|
kind: 'image' as const,
|
|
title: path.split('/').pop() ?? path,
|
|
description: path,
|
|
linkUrl: toPublicAssetPath(path),
|
|
value: toPublicAssetPath(path),
|
|
})),
|
|
...artifactPaths.map((path) =>
|
|
buildArtifactPreviewItem(path, worklogContents[path], loading),
|
|
),
|
|
...(normalizedPreviewUrl
|
|
? [
|
|
{
|
|
key: `preview:${sourceWork.id}`,
|
|
kind: 'preview' as const,
|
|
title: 'Preview 링크',
|
|
description: normalizedPreviewUrl,
|
|
linkUrl: normalizedPreviewUrl,
|
|
value: normalizedPreviewUrl,
|
|
},
|
|
]
|
|
: []),
|
|
...(sourceWork.commandLog
|
|
? [
|
|
{
|
|
key: `commands:${sourceWork.id}`,
|
|
kind: 'code' as const,
|
|
title: '실행 커맨드',
|
|
description: '자동화 실행 로그',
|
|
value: sourceWork.commandLog,
|
|
language: 'bash',
|
|
},
|
|
]
|
|
: []),
|
|
];
|
|
|
|
if (previewItems.length === 0 && !sourceWork.sourceFiles.length && !sourceWork.diffText) {
|
|
return <Empty description="연결된 작업일지 산출물이 없습니다." />;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
|
{sourceWork.sourceFiles.length || sourceWork.diffText ? (
|
|
<CodexDiffPreviewer
|
|
title="작업 소스"
|
|
description="변경 파일 기준 전체 소스와 raw diff를 Codex preview 스타일로 전환해 표시합니다."
|
|
files={sourceWork.sourceFiles}
|
|
diffText={sourceWork.diffText}
|
|
height="auto"
|
|
/>
|
|
) : null}
|
|
{previewItems.length ? (
|
|
<EvidenceAttachmentStrip
|
|
title="산출물 Preview"
|
|
attachments={previewItems}
|
|
compact
|
|
onPreview={(attachment) => {
|
|
setPreviewModalItem(attachment);
|
|
}}
|
|
onCopy={(attachment) => handleCopyText(attachment.copyValue ?? attachment.value)}
|
|
/>
|
|
) : null}
|
|
</Space>
|
|
|
|
<Modal
|
|
open={Boolean(previewModalItem)}
|
|
title={previewModalItem?.title ?? 'Preview'}
|
|
footer={null}
|
|
width={isPreviewModalExpanded ? 'calc(100vw - 32px)' : 1120}
|
|
rootClassName={`plan-board-page__evidence-modal${
|
|
isPreviewModalExpanded ? ' plan-board-page__evidence-modal--expanded' : ''
|
|
}`}
|
|
onCancel={() => {
|
|
setPreviewModalItem(null);
|
|
setIsPreviewModalExpanded(false);
|
|
}}
|
|
closeIcon={<CloseOutlined />}
|
|
>
|
|
{previewModalItem ? (
|
|
<div className="plan-board-page__evidence-modal-shell">
|
|
<Flex justify="space-between" align="center" gap={12} wrap className="plan-board-page__evidence-modal-toolbar">
|
|
<Text type="secondary">{previewModalItem.description}</Text>
|
|
<Space size={8}>
|
|
<Button
|
|
aria-label="복사"
|
|
icon={<CopyOutlined />}
|
|
onClick={() => void handleCopyText(previewModalItem.value)}
|
|
/>
|
|
{previewModalItem.linkUrl ? (
|
|
<Button
|
|
aria-label="다운로드"
|
|
icon={<DownloadOutlined />}
|
|
href={previewModalItem.linkUrl}
|
|
target={previewModalItem.kind === 'preview' ? '_blank' : undefined}
|
|
rel={previewModalItem.kind === 'preview' ? 'noreferrer' : undefined}
|
|
download={previewModalItem.kind === 'preview' ? undefined : true}
|
|
/>
|
|
) : null}
|
|
<Button
|
|
aria-label={isPreviewModalExpanded ? '최대화 해제' : '최대화'}
|
|
icon={isPreviewModalExpanded ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
|
|
onClick={() => {
|
|
setIsPreviewModalExpanded((previous) => !previous);
|
|
}}
|
|
/>
|
|
<Button
|
|
aria-label="닫기"
|
|
icon={<CloseOutlined />}
|
|
onClick={() => {
|
|
setPreviewModalItem(null);
|
|
setIsPreviewModalExpanded(false);
|
|
}}
|
|
/>
|
|
</Space>
|
|
</Flex>
|
|
<div className="plan-board-page__evidence-modal-body">
|
|
<EvidenceAttachmentPreviewBody attachment={previewModalItem} />
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</Modal>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function toPublicAssetPath(path: string) {
|
|
const normalizedPath = path.trim().replace(/^\.?\//, '');
|
|
|
|
return normalizedPath.startsWith('/') ? normalizedPath : `/${normalizedPath}`;
|
|
}
|
|
|
|
function normalizeReleasePreviewUrl(url: string | null) {
|
|
return url;
|
|
}
|
|
|
|
const IMAGE_ARTIFACT_PATTERN = /\.(png|jpe?g|gif|webp|svg)$/i;
|
|
const VIDEO_ARTIFACT_PATTERN = /\.(mp4|webm|mov|m4v|ogv)$/i;
|
|
const AUDIO_ARTIFACT_PATTERN = /\.(mp3|wav|m4a|aac|flac|oga|ogg)$/i;
|
|
const PDF_ARTIFACT_PATTERN = /\.pdf$/i;
|
|
const HTML_ARTIFACT_PATTERN = /\.(html?)$/i;
|
|
const MARKDOWN_ARTIFACT_PATTERN = /\.(md|mdx)$/i;
|
|
const JSON_ARTIFACT_PATTERN = /\.(json|jsonl)$/i;
|
|
const TEXT_ARTIFACT_PATTERN =
|
|
/\.(txt|log|csv|yaml|yml|xml|diff|patch|sh|bash|zsh|ini|cfg|conf|sql|js|jsx|ts|tsx|css|scss|less|java|kt|py|rb|go|rs)$/i;
|
|
|
|
function buildArtifactPreviewItem(path: string, content: string | undefined, loading: boolean): EvidenceAttachmentItem {
|
|
const publicPath = toPublicAssetPath(path);
|
|
const fileName = path.split('/').pop() ?? path;
|
|
|
|
if (IMAGE_ARTIFACT_PATTERN.test(path)) {
|
|
return {
|
|
key: `artifact:${path}`,
|
|
kind: 'image',
|
|
title: fileName,
|
|
description: path,
|
|
linkUrl: publicPath,
|
|
value: publicPath,
|
|
};
|
|
}
|
|
|
|
if (VIDEO_ARTIFACT_PATTERN.test(path)) {
|
|
return {
|
|
key: `artifact:${path}`,
|
|
kind: 'video',
|
|
title: fileName,
|
|
description: path,
|
|
linkUrl: publicPath,
|
|
value: publicPath,
|
|
};
|
|
}
|
|
|
|
if (AUDIO_ARTIFACT_PATTERN.test(path)) {
|
|
return {
|
|
key: `artifact:${path}`,
|
|
kind: 'audio',
|
|
title: fileName,
|
|
description: path,
|
|
linkUrl: publicPath,
|
|
value: publicPath,
|
|
};
|
|
}
|
|
|
|
if (PDF_ARTIFACT_PATTERN.test(path) || HTML_ARTIFACT_PATTERN.test(path)) {
|
|
return {
|
|
key: `artifact:${path}`,
|
|
kind: 'pdf',
|
|
title: fileName,
|
|
description: path,
|
|
linkUrl: publicPath,
|
|
value: publicPath,
|
|
};
|
|
}
|
|
|
|
if (MARKDOWN_ARTIFACT_PATTERN.test(path)) {
|
|
return {
|
|
key: `artifact:${path}`,
|
|
kind: 'markdown',
|
|
title: fileName,
|
|
description: path,
|
|
linkUrl: publicPath,
|
|
value: content ?? (loading ? '파일 내용을 불러오는 중입니다.' : '파일 내용을 불러오지 못했습니다.'),
|
|
};
|
|
}
|
|
|
|
if (JSON_ARTIFACT_PATTERN.test(path)) {
|
|
return {
|
|
key: `artifact:${path}`,
|
|
kind: 'json',
|
|
title: fileName,
|
|
description: path,
|
|
linkUrl: publicPath,
|
|
value: content ?? (loading ? '파일 내용을 불러오는 중입니다.' : '파일 내용을 불러오지 못했습니다.'),
|
|
};
|
|
}
|
|
|
|
if (TEXT_ARTIFACT_PATTERN.test(path)) {
|
|
return {
|
|
key: `artifact:${path}`,
|
|
kind: 'code',
|
|
title: fileName,
|
|
description: path,
|
|
linkUrl: publicPath,
|
|
value: content ?? (loading ? '파일 내용을 불러오는 중입니다.' : '파일 내용을 불러오지 못했습니다.'),
|
|
language: inferArtifactLanguage(path),
|
|
};
|
|
}
|
|
|
|
return {
|
|
key: `artifact:${path}`,
|
|
kind: 'empty',
|
|
title: fileName,
|
|
description: path,
|
|
linkUrl: publicPath,
|
|
value: '미리보기를 지원하지 않는 형식입니다. 링크로 원본 파일을 열어 확인하세요.',
|
|
};
|
|
}
|
|
|
|
function isFetchableTextArtifactPath(path: string) {
|
|
return MARKDOWN_ARTIFACT_PATTERN.test(path) || JSON_ARTIFACT_PATTERN.test(path) || TEXT_ARTIFACT_PATTERN.test(path);
|
|
}
|
|
|
|
function inferArtifactLanguage(path: string) {
|
|
const normalizedPath = path.toLowerCase();
|
|
|
|
if (normalizedPath.endsWith('.tsx')) {
|
|
return 'tsx';
|
|
}
|
|
|
|
if (normalizedPath.endsWith('.ts')) {
|
|
return 'typescript';
|
|
}
|
|
|
|
if (normalizedPath.endsWith('.jsx')) {
|
|
return 'jsx';
|
|
}
|
|
|
|
if (normalizedPath.endsWith('.js')) {
|
|
return 'javascript';
|
|
}
|
|
|
|
if (normalizedPath.endsWith('.json') || normalizedPath.endsWith('.jsonl')) {
|
|
return 'json';
|
|
}
|
|
|
|
if (normalizedPath.endsWith('.yml') || normalizedPath.endsWith('.yaml')) {
|
|
return 'yaml';
|
|
}
|
|
|
|
if (normalizedPath.endsWith('.html') || normalizedPath.endsWith('.htm')) {
|
|
return 'html';
|
|
}
|
|
|
|
if (normalizedPath.endsWith('.css') || normalizedPath.endsWith('.scss') || normalizedPath.endsWith('.less')) {
|
|
return 'css';
|
|
}
|
|
|
|
if (normalizedPath.endsWith('.diff') || normalizedPath.endsWith('.patch')) {
|
|
return 'diff';
|
|
}
|
|
|
|
if (normalizedPath.endsWith('.sh') || normalizedPath.endsWith('.bash') || normalizedPath.endsWith('.zsh')) {
|
|
return 'bash';
|
|
}
|
|
|
|
if (normalizedPath.endsWith('.py')) {
|
|
return 'python';
|
|
}
|
|
|
|
if (normalizedPath.endsWith('.md') || normalizedPath.endsWith('.mdx')) {
|
|
return 'markdown';
|
|
}
|
|
|
|
return 'text';
|
|
}
|
|
|
|
function toDraft(item: PlanItem): PlanDraft {
|
|
return {
|
|
id: item.id,
|
|
workId: item.workId,
|
|
note: item.note,
|
|
automationType: item.automationType,
|
|
status: item.status,
|
|
jangsingProcessingRequired: item.jangsingProcessingRequired,
|
|
autoDeployToMain: item.autoDeployToMain,
|
|
repeatRequestEnabled: item.repeatRequestEnabled,
|
|
repeatIntervalMinutes: item.repeatIntervalMinutes,
|
|
};
|
|
}
|
|
|
|
function isFunctionCheckEditableStatus(status: PlanItem['status'] | PlanDraft['status']) {
|
|
return isCompletedPlanStatus(status);
|
|
}
|
|
|
|
function isCompletedPlanStatus(status: PlanItem['status'] | PlanDraft['status']) {
|
|
return status === '작업완료' || status === '릴리즈완료' || status === '완료';
|
|
}
|
|
|
|
function isReleaseCompletedPlanItem(item: Pick<PlanItem, 'status' | 'mergedAt'>) {
|
|
return item.status === '릴리즈완료' || item.status === '완료' || Boolean(item.mergedAt);
|
|
}
|
|
|
|
function isMainCompletedPlanItem(item: Pick<PlanItem, 'status' | 'workerStatus' | 'autoDeployToMain'> | null | undefined) {
|
|
if (!item || !item.autoDeployToMain) {
|
|
return false;
|
|
}
|
|
|
|
return item.status === '완료' || item.workerStatus?.replace(/\s+/g, '') === 'main반영완료';
|
|
}
|
|
|
|
function PlanDetailSection({
|
|
children,
|
|
defaultOpen = false,
|
|
title,
|
|
}: {
|
|
children: ReactNode;
|
|
defaultOpen?: boolean;
|
|
title: string;
|
|
}) {
|
|
return (
|
|
<details className="plan-board-page__detail-section" open={defaultOpen}>
|
|
<summary className="plan-board-page__detail-section-summary">
|
|
<Text strong>{title}</Text>
|
|
<Text type="secondary">접기/펼치기</Text>
|
|
</summary>
|
|
<div className="plan-board-page__detail-section-body">{children}</div>
|
|
</details>
|
|
);
|
|
}
|
|
|
|
function PlanWorkChecklist({
|
|
hasOpenIssues,
|
|
item,
|
|
sourceWorkCount,
|
|
}: {
|
|
hasOpenIssues: boolean;
|
|
item: PlanItem;
|
|
sourceWorkCount: number;
|
|
}) {
|
|
const workerStatus = item.workerStatus?.replace(/\s+/g, '') ?? '';
|
|
const checklist = [
|
|
{
|
|
key: 'implementation',
|
|
label: '구현 변경 기록',
|
|
checked: sourceWorkCount > 0 || workerStatus === '자동완료' || item.status !== '등록',
|
|
note: sourceWorkCount > 0 ? `${sourceWorkCount}개 소스 작업 증적` : '소스 작업 증적 대기',
|
|
},
|
|
{
|
|
key: 'verification',
|
|
label: '기능동작확인',
|
|
checked: item.status === '작업완료' || item.status === '릴리즈완료' || item.status === '완료',
|
|
note: getFunctionCheckLabel(item.jangsingProcessingRequired),
|
|
},
|
|
{
|
|
key: 'release',
|
|
label: 'release 반영',
|
|
checked: item.status === '릴리즈완료' || item.status === '완료' || Boolean(item.mergedAt),
|
|
note: workerStatus === 'release반영실패' ? 'release 반영 실패' : item.releaseTarget ?? 'release',
|
|
},
|
|
{
|
|
key: 'main',
|
|
label: 'main 동기화',
|
|
checked: item.status === '완료' || workerStatus === 'main반영완료' || !item.autoDeployToMain,
|
|
note: getMainChecklistNote(item),
|
|
},
|
|
{
|
|
key: 'issues',
|
|
label: '열린 이슈 없음',
|
|
checked: !hasOpenIssues,
|
|
note: hasOpenIssues ? '조치 필요' : '정상',
|
|
},
|
|
];
|
|
|
|
return (
|
|
<Space direction="vertical" size={8} className="plan-board-page__checklist">
|
|
{checklist.map((entry) => (
|
|
<Flex key={entry.key} justify="space-between" align="center" gap={8} wrap>
|
|
<Checkbox checked={entry.checked} disabled>
|
|
{entry.label}
|
|
</Checkbox>
|
|
<Text type={entry.checked ? 'secondary' : 'warning'}>{entry.note}</Text>
|
|
</Flex>
|
|
))}
|
|
</Space>
|
|
);
|
|
}
|
|
|
|
function ReleaseReadySummary({
|
|
hasAccess,
|
|
issueHistories,
|
|
item,
|
|
sourceWorkHistories,
|
|
}: {
|
|
hasAccess: boolean;
|
|
issueHistories: PlanIssueHistory[];
|
|
item: PlanItem;
|
|
sourceWorkHistories: PlanSourceWorkHistory[];
|
|
}) {
|
|
const changedFiles = Array.from(new Set(sourceWorkHistories.flatMap((history) => history.changedFiles)));
|
|
const unresolvedIssueCount = issueHistories.filter((history) => !history.resolved).length;
|
|
const workerStatus = item.workerStatus?.replace(/\s+/g, '') ?? '';
|
|
const releaseBlocked =
|
|
Boolean(item.lastError) ||
|
|
item.hasOpenIssues ||
|
|
workerStatus === 'release반영실패' ||
|
|
workerStatus === 'main반영실패' ||
|
|
(item.status === '작업중' && sourceWorkHistories.length === 0);
|
|
|
|
return (
|
|
<Space direction="vertical" size={12} className="plan-board-page__release-summary">
|
|
<Flex gap={8} wrap>
|
|
<Tag color={releaseBlocked ? 'error' : 'success'}>
|
|
{releaseBlocked ? '추가 확인 필요' : 'release 반영 가능'}
|
|
</Tag>
|
|
<Tag>{`변경 파일 ${changedFiles.length}개`}</Tag>
|
|
<Tag color={unresolvedIssueCount ? 'warning' : 'default'}>{`미해결 이슈 ${unresolvedIssueCount}개`}</Tag>
|
|
</Flex>
|
|
<Space direction="vertical" size={4}>
|
|
<Text>테스트/검증: {getFunctionCheckLabel(item.jangsingProcessingRequired)}</Text>
|
|
<Text>release 상태: {getReleaseSummaryNote(item)}</Text>
|
|
<Text>main 상태: {getMainChecklistNote(item)}</Text>
|
|
</Space>
|
|
{changedFiles.length ? (
|
|
<div className="plan-board-page__file-tags">
|
|
{changedFiles.slice(0, 20).map((file) => (
|
|
<Tag key={`release-summary-${file}`}>{file}</Tag>
|
|
))}
|
|
{changedFiles.length > 20 ? <Tag>{`+${changedFiles.length - 20}`}</Tag> : null}
|
|
</div>
|
|
) : (
|
|
<Text type="secondary">기록된 변경 파일이 없습니다.</Text>
|
|
)}
|
|
{item.lastError ? <ExpandableDetailText text={resolveProtectedText(item.lastError, !hasAccess)} type="danger" /> : null}
|
|
</Space>
|
|
);
|
|
}
|
|
|
|
function getReleaseSummaryNote(item: PlanItem) {
|
|
const workerStatus = item.workerStatus?.replace(/\s+/g, '') ?? '';
|
|
|
|
if (workerStatus === 'release반영실패') {
|
|
return 'release 반영 실패';
|
|
}
|
|
|
|
if (item.status === '릴리즈완료' || item.status === '완료' || item.mergedAt) {
|
|
return `반영 완료 (${formatPlanDateTime(item.mergedAt)})`;
|
|
}
|
|
|
|
if (workerStatus === 'release반영중') {
|
|
return 'release 반영 중';
|
|
}
|
|
|
|
if (workerStatus === 'release반영대기' || item.status === '작업완료') {
|
|
return 'release 반영 대기';
|
|
}
|
|
|
|
return 'release 반영 전';
|
|
}
|
|
|
|
function getMainChecklistNote(item: PlanItem) {
|
|
const workerStatus = item.workerStatus?.replace(/\s+/g, '') ?? '';
|
|
|
|
if (!item.autoDeployToMain) {
|
|
return 'main 자동등록 미대상';
|
|
}
|
|
|
|
if (workerStatus === 'main반영실패') {
|
|
return 'main 반영 실패';
|
|
}
|
|
|
|
if (item.status === '완료' || workerStatus === 'main반영완료') {
|
|
return `main 반영 완료 (${formatPlanDateTime(item.mergedAt)})`;
|
|
}
|
|
|
|
if (workerStatus === 'main반영중') {
|
|
return 'main 반영 중';
|
|
}
|
|
|
|
if (workerStatus === 'main반영대기' || item.status === '릴리즈완료') {
|
|
return 'main 반영 대기';
|
|
}
|
|
|
|
return 'main 반영 전';
|
|
}
|
|
|
|
function matchesWorkerStateFilter(item: PlanItem, filter: WorkerStateFilter) {
|
|
if (filter === 'all') {
|
|
return true;
|
|
}
|
|
|
|
const normalizedWorkerStatus = item.workerStatus?.replace(/\s+/g, '') ?? '';
|
|
|
|
if (filter === 'active') {
|
|
return isAutomationInProgress(item.workerStatus);
|
|
}
|
|
|
|
if (filter === 'waiting') {
|
|
return normalizedWorkerStatus.endsWith('대기') || normalizedWorkerStatus === '브랜치준비';
|
|
}
|
|
|
|
if (filter === 'failed') {
|
|
return isAutomationFailedItem(item) || Boolean(item.lastError);
|
|
}
|
|
|
|
if (filter === 'done') {
|
|
return normalizedWorkerStatus === '자동완료' || normalizedWorkerStatus.endsWith('완료');
|
|
}
|
|
|
|
return !normalizedWorkerStatus;
|
|
}
|
|
|
|
function matchesReleaseStateFilter(item: PlanItem, filter: ReleaseStateFilter) {
|
|
if (filter === 'all') {
|
|
return true;
|
|
}
|
|
|
|
const normalizedWorkerStatus = item.workerStatus?.replace(/\s+/g, '') ?? '';
|
|
|
|
if (filter === 'pending') {
|
|
return item.status === '작업완료' || normalizedWorkerStatus === 'release반영대기' || normalizedWorkerStatus === 'release반영중';
|
|
}
|
|
|
|
if (filter === 'merged') {
|
|
return item.status === '릴리즈완료' || item.status === '완료' || Boolean(item.mergedAt);
|
|
}
|
|
|
|
return normalizedWorkerStatus === 'release반영실패';
|
|
}
|
|
|
|
function matchesMainStateFilter(item: PlanItem, filter: MainStateFilter) {
|
|
if (filter === 'all') {
|
|
return true;
|
|
}
|
|
|
|
const normalizedWorkerStatus = item.workerStatus?.replace(/\s+/g, '') ?? '';
|
|
|
|
if (filter === 'pending') {
|
|
return item.status === '릴리즈완료' || normalizedWorkerStatus === 'main반영대기' || normalizedWorkerStatus === 'main반영중';
|
|
}
|
|
|
|
if (filter === 'merged') {
|
|
return item.status === '완료' || normalizedWorkerStatus === 'main반영완료';
|
|
}
|
|
|
|
if (filter === 'failed') {
|
|
return normalizedWorkerStatus === 'main반영실패';
|
|
}
|
|
|
|
return !item.autoDeployToMain && item.status !== '완료';
|
|
}
|
|
|
|
function matchesIssueStateFilter(item: PlanItem, filter: IssueStateFilter) {
|
|
if (filter === 'all') {
|
|
return true;
|
|
}
|
|
|
|
if (filter === 'open') {
|
|
return item.hasOpenIssues;
|
|
}
|
|
|
|
return !item.hasOpenIssues;
|
|
}
|
|
|
|
function matchesCostStateFilter(item: PlanItem, filter: CostStateFilter, usageSummary: AutomationUsageSummary | null) {
|
|
void item;
|
|
|
|
if (filter === 'all') {
|
|
return true;
|
|
}
|
|
|
|
if (filter === 'recorded') {
|
|
return Boolean(usageSummary);
|
|
}
|
|
|
|
if (filter === 'none') {
|
|
return !usageSummary;
|
|
}
|
|
|
|
if (!usageSummary?.tierLabel) {
|
|
return false;
|
|
}
|
|
|
|
if (filter === 'stable') {
|
|
return usageSummary.tierLabel === '안정';
|
|
}
|
|
|
|
if (filter === 'attention') {
|
|
return usageSummary.tierLabel === '관심';
|
|
}
|
|
|
|
if (filter === 'warning') {
|
|
return usageSummary.tierLabel === '주의';
|
|
}
|
|
|
|
return usageSummary.tierLabel === '높음';
|
|
}
|
|
|
|
function getFunctionCheckLabel(jangsingProcessingRequired: boolean) {
|
|
return jangsingProcessingRequired ? '완료' : '오동작';
|
|
}
|
|
|
|
function comparePlanItemsByRecent(left: PlanItem, right: PlanItem) {
|
|
return right.id - left.id;
|
|
}
|
|
|
|
function comparePlanItemsByAutomationPriority(left: PlanItem, right: PlanItem) {
|
|
const leftPriority = isAutomationInProgress(left.workerStatus) ? 0 : 1;
|
|
const rightPriority = isAutomationInProgress(right.workerStatus) ? 0 : 1;
|
|
|
|
return leftPriority - rightPriority;
|
|
}
|
|
|
|
function getPlanFilterLabel(statusFilter: PlanFilterStatus) {
|
|
if (statusFilter === 'all') {
|
|
return '전체';
|
|
}
|
|
|
|
if (statusFilter === 'in-progress') {
|
|
return '작업중';
|
|
}
|
|
|
|
if (statusFilter === 'done') {
|
|
return '완료';
|
|
}
|
|
|
|
return '오류';
|
|
}
|
|
|
|
function isAutomationInProgress(workerStatus: string | null) {
|
|
return workerStatus ? ACTIVE_WORKER_STATUSES.has(workerStatus) : false;
|
|
}
|
|
|
|
function shouldAutoRefreshAutomation(workerStatus: string | null) {
|
|
return workerStatus ? AUTO_REFRESH_CANDIDATE_WORKER_STATUSES.has(workerStatus) : false;
|
|
}
|
|
|
|
function formatElapsedDuration(timestamp: string | null) {
|
|
if (!timestamp) {
|
|
return null;
|
|
}
|
|
|
|
const elapsedMs = Date.now() - new Date(timestamp).getTime();
|
|
|
|
if (!Number.isFinite(elapsedMs) || elapsedMs < 0) {
|
|
return null;
|
|
}
|
|
|
|
const totalMinutes = Math.floor(elapsedMs / 60000);
|
|
|
|
if (totalMinutes < 1) {
|
|
return '1분 미만 경과';
|
|
}
|
|
|
|
if (totalMinutes < 60) {
|
|
return `${totalMinutes}분 경과`;
|
|
}
|
|
|
|
const hours = Math.floor(totalMinutes / 60);
|
|
const minutes = totalMinutes % 60;
|
|
|
|
if (minutes === 0) {
|
|
return `${hours}시간 경과`;
|
|
}
|
|
|
|
return `${hours}시간 ${minutes}분 경과`;
|
|
}
|
|
|
|
function describeAutomationWork(workerStatus: string | null) {
|
|
if (!workerStatus) {
|
|
return null;
|
|
}
|
|
|
|
const normalizedStatus = workerStatus.replace(/\s+/g, '');
|
|
|
|
if (normalizedStatus === '브랜치생성중') {
|
|
return '작업 브랜치를 만들고 자동 작업 준비를 진행하고 있습니다.';
|
|
}
|
|
|
|
if (normalizedStatus === '자동작업중') {
|
|
return '요청 내용을 반영해 코드와 관련 파일을 수정하고 결과를 확인하고 있습니다.';
|
|
}
|
|
|
|
if (normalizedStatus === 'release반영중') {
|
|
return '작업 결과를 release 브랜치에 병합하고 반영 상태를 확인하고 있습니다.';
|
|
}
|
|
|
|
if (normalizedStatus === 'main반영중') {
|
|
return 'release 브랜치에 쌓인 반영 결과를 main 브랜치에 일괄 적용하고 있습니다.';
|
|
}
|
|
|
|
if (normalizedStatus === '브랜치준비') {
|
|
return '브랜치 생성 작업이 시작되기 전 대기 중입니다.';
|
|
}
|
|
|
|
if (normalizedStatus === 'release반영대기') {
|
|
return '개발 작업이 끝나서 release 반영 순서를 기다리고 있습니다.';
|
|
}
|
|
|
|
if (normalizedStatus === 'main반영대기') {
|
|
return 'release 브랜치 기준 main 일괄 반영 순서를 기다리고 있습니다.';
|
|
}
|
|
|
|
if (normalizedStatus === '브랜치실패') {
|
|
return '브랜치 생성 단계에서 문제가 발생해 재시도가 필요합니다.';
|
|
}
|
|
|
|
if (normalizedStatus === '자동작업실패') {
|
|
return '코드 수정 또는 검증 단계에서 문제가 발생해 확인이 필요합니다.';
|
|
}
|
|
|
|
if (normalizedStatus === 'release반영실패') {
|
|
return 'release 브랜치 반영 단계에서 문제가 발생했습니다.';
|
|
}
|
|
|
|
if (normalizedStatus === 'main반영실패') {
|
|
return 'main 브랜치 일괄 반영 단계에서 문제가 발생했습니다.';
|
|
}
|
|
|
|
if (normalizedStatus === '자동완료' || normalizedStatus.endsWith('완료')) {
|
|
return '자동화 단계 처리가 완료되었습니다.';
|
|
}
|
|
|
|
if (workerStatus.includes('검증')) {
|
|
return `${workerStatus} 단계로 결과를 점검하고 필요한 후속 수정을 확인하고 있습니다.`;
|
|
}
|
|
|
|
if (workerStatus.includes('수정')) {
|
|
return `${workerStatus} 단계로 요청 내용을 반영하는 작업을 진행하고 있습니다.`;
|
|
}
|
|
|
|
return `${workerStatus} 단계 작업을 진행하고 있습니다.`;
|
|
}
|
|
|
|
type AutomationTone = 'idle' | 'active' | 'success' | 'error';
|
|
type AutomationPresentation = {
|
|
label: string;
|
|
description: string | null;
|
|
tone: AutomationTone;
|
|
active: boolean;
|
|
};
|
|
|
|
function getAutomationStatusPresentation(item: Pick<PlanItem, 'status' | 'workerStatus' | 'lastError'>) {
|
|
const workerStatus = item.workerStatus;
|
|
const description = describeAutomationWork(workerStatus);
|
|
|
|
if (!workerStatus) {
|
|
return {
|
|
label: '자동화 대기',
|
|
description: item.status === '완료' ? '모든 자동화 단계가 끝난 상태입니다.' : '자동화 작업이 아직 시작되지 않았습니다.',
|
|
tone: item.status === '완료' ? 'success' : 'idle',
|
|
active: false,
|
|
} satisfies AutomationPresentation;
|
|
}
|
|
|
|
if (workerStatus.includes('실패') || item.lastError) {
|
|
return {
|
|
label: `자동화 상태: ${workerStatus}`,
|
|
description,
|
|
tone: 'error',
|
|
active: false,
|
|
} satisfies AutomationPresentation;
|
|
}
|
|
|
|
if (isAutomationInProgress(workerStatus)) {
|
|
return {
|
|
label: `자동화 진행중: ${workerStatus}`,
|
|
description,
|
|
tone: 'active',
|
|
active: true,
|
|
} satisfies AutomationPresentation;
|
|
}
|
|
|
|
if (workerStatus === '자동완료' || workerStatus.endsWith('완료') || item.status === '완료') {
|
|
return {
|
|
label: `자동화 상태: ${workerStatus}`,
|
|
description,
|
|
tone: 'success',
|
|
active: false,
|
|
} satisfies AutomationPresentation;
|
|
}
|
|
|
|
return {
|
|
label: `자동화 상태: ${workerStatus}`,
|
|
description,
|
|
tone: 'idle',
|
|
active: false,
|
|
} satisfies AutomationPresentation;
|
|
}
|
|
|
|
function extractAutomationTokenUsageText(sourceWork: Pick<PlanSourceWorkHistory, 'summary' | 'commandLog'> | null | undefined) {
|
|
const candidates = [sourceWork?.commandLog, sourceWork?.summary];
|
|
|
|
for (const candidate of candidates) {
|
|
if (!candidate) {
|
|
continue;
|
|
}
|
|
|
|
const line = candidate
|
|
.split('\n')
|
|
.map((entry) => entry.trim())
|
|
.find((entry) => /^토큰 사용량:\s*/.test(entry));
|
|
|
|
if (line) {
|
|
return line.replace(/^토큰 사용량:\s*/u, '').trim();
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function isHistoryAfterReference(createdAt: string | null | undefined, referenceAt: string | null | undefined) {
|
|
if (!createdAt) {
|
|
return false;
|
|
}
|
|
|
|
if (!referenceAt) {
|
|
return true;
|
|
}
|
|
|
|
return new Date(createdAt).getTime() > new Date(referenceAt).getTime();
|
|
}
|
|
|
|
function parseTokenMetricValue(valueText: string, unitText: string | undefined) {
|
|
const normalized = Number(valueText.replace(/,/g, ''));
|
|
|
|
if (!Number.isFinite(normalized)) {
|
|
return null;
|
|
}
|
|
|
|
const unit = unitText?.toLowerCase() ?? '';
|
|
|
|
if (unit === 'k') {
|
|
return Math.round(normalized * 1_000);
|
|
}
|
|
|
|
if (unit === 'm') {
|
|
return Math.round(normalized * 1_000_000);
|
|
}
|
|
|
|
return Math.round(normalized);
|
|
}
|
|
|
|
function parseAutomationTokenUsageMetrics(tokenUsageText: string) {
|
|
const normalizedText = tokenUsageText
|
|
.replace(/^tokens?\s+used\s*:?\s*/iu, '')
|
|
.replace(/\(([^)]+)\)/g, ', $1')
|
|
.trim();
|
|
const metrics = new Map<string, number>();
|
|
|
|
for (const match of normalizedText.matchAll(/(\d[\d,]*(?:\.\d+)?)\s*([km]?)\s*(input|output|total|cached|reasoning)\b/giu)) {
|
|
const label = match[3]?.toLowerCase() ?? '';
|
|
const value = parseTokenMetricValue(match[1] ?? '', match[2]);
|
|
|
|
if (!label || value === null) {
|
|
continue;
|
|
}
|
|
|
|
metrics.set(label, value);
|
|
}
|
|
|
|
for (const match of normalizedText.matchAll(/\b(input|output|total|cached|reasoning)\s*[:=]?\s*(\d[\d,]*(?:\.\d+)?)\s*([km]?)/giu)) {
|
|
const label = match[1]?.toLowerCase() ?? '';
|
|
const value = parseTokenMetricValue(match[2] ?? '', match[3]);
|
|
|
|
if (!label || value === null) {
|
|
continue;
|
|
}
|
|
|
|
metrics.set(label, value);
|
|
}
|
|
|
|
if (metrics.size > 0) {
|
|
return metrics;
|
|
}
|
|
|
|
const fallbackMatch = normalizedText.match(/(\d[\d,]*(?:\.\d+)?)\s*([km]?)/i);
|
|
|
|
if (!fallbackMatch) {
|
|
return null;
|
|
}
|
|
|
|
const fallbackValue = parseTokenMetricValue(fallbackMatch[1], fallbackMatch[2]);
|
|
|
|
if (fallbackValue === null) {
|
|
return null;
|
|
}
|
|
|
|
return new Map([['total', fallbackValue]]);
|
|
}
|
|
|
|
function formatTokenMetricValue(value: number) {
|
|
return new Intl.NumberFormat('ko-KR').format(value);
|
|
}
|
|
|
|
function formatCostMetricValue(value: number) {
|
|
return `${new Intl.NumberFormat('ko-KR').format(Math.round(value))}원`;
|
|
}
|
|
|
|
function summarizeAutomationTokenUsage(
|
|
sourceWorks: Array<Pick<PlanSourceWorkHistory, 'summary' | 'commandLog'>>,
|
|
) {
|
|
const totals = new Map<string, number>();
|
|
|
|
sourceWorks.forEach((sourceWork) => {
|
|
const tokenUsageText = extractAutomationTokenUsageText(sourceWork);
|
|
|
|
if (!tokenUsageText) {
|
|
return;
|
|
}
|
|
|
|
const metrics = parseAutomationTokenUsageMetrics(tokenUsageText);
|
|
|
|
if (!metrics) {
|
|
return;
|
|
}
|
|
|
|
metrics.forEach((value, key) => {
|
|
totals.set(key, (totals.get(key) ?? 0) + value);
|
|
});
|
|
});
|
|
|
|
if (totals.size === 0) {
|
|
return null;
|
|
}
|
|
|
|
const entries: Array<[string, string]> = [
|
|
['total', '총'],
|
|
['input', '입력'],
|
|
['output', '출력'],
|
|
['cached', '캐시'],
|
|
['reasoning', '추론'],
|
|
];
|
|
|
|
return entries
|
|
.filter(([key]) => totals.has(key))
|
|
.map(([key, label]) => `${label} ${formatTokenMetricValue(totals.get(key) ?? 0)}`)
|
|
.join(' · ');
|
|
}
|
|
|
|
type AutomationUsageSummary = {
|
|
costLabel: string;
|
|
tokenLabel: string;
|
|
description: string | null;
|
|
tierColor: 'success' | 'processing' | 'warning' | 'error' | null;
|
|
tierLabel: string | null;
|
|
};
|
|
|
|
function formatProcessingDurationLabel(durationSeconds: number | null | undefined) {
|
|
const normalized = Number(durationSeconds ?? 0);
|
|
|
|
if (!Number.isFinite(normalized) || normalized <= 0) {
|
|
return null;
|
|
}
|
|
|
|
if (normalized < 60) {
|
|
return `처리 ${Math.round(normalized)}초`;
|
|
}
|
|
|
|
if (normalized < 3600) {
|
|
return `처리 ${Math.round(normalized / 60)}분`;
|
|
}
|
|
|
|
return `처리 ${(normalized / 3600).toFixed(1)}시간`;
|
|
}
|
|
|
|
function summarizeAutomationUsageSnapshotTokens(snapshot: PlanAutomationUsageSnapshot) {
|
|
const tokenTotals = snapshot.tokenTotals ?? {
|
|
total: snapshot.totalTokens,
|
|
input: 0,
|
|
output: 0,
|
|
cached: 0,
|
|
reasoning: 0,
|
|
};
|
|
const entries = [
|
|
[Number(tokenTotals.total ?? snapshot.totalTokens ?? 0), '총'],
|
|
[Number(tokenTotals.input ?? 0), '입력'],
|
|
[Number(tokenTotals.output ?? 0), '출력'],
|
|
[Number(tokenTotals.cached ?? 0), '캐시'],
|
|
[Number(tokenTotals.reasoning ?? 0), '추론'],
|
|
] as Array<[number, string]>;
|
|
const validEntries = entries.filter(([value]) => Number.isFinite(value) && value > 0);
|
|
|
|
if (validEntries.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return validEntries
|
|
.map(([value, label]) => `${label} ${formatTokenMetricValue(value)}`)
|
|
.join(' · ');
|
|
}
|
|
|
|
function buildAutomationUsageSummaryFromSnapshot(
|
|
snapshot: PlanAutomationUsageSnapshot | null | undefined,
|
|
appConfig: Pick<AppConfig, 'planCost'>,
|
|
): AutomationUsageSummary | null {
|
|
if (!snapshot) {
|
|
return null;
|
|
}
|
|
|
|
const tokenSummaryText = summarizeAutomationUsageSnapshotTokens(snapshot);
|
|
const estimatedCost = estimateAutomationCost(
|
|
Math.max(0, Number(snapshot.totalTokens ?? 0)),
|
|
Math.max(0, Number(snapshot.retryCount ?? 0)),
|
|
snapshot.processingDurationSeconds,
|
|
appConfig.planCost.baseCostPerMillionTokens,
|
|
appConfig.planCost.retryCostMultiplierPercent,
|
|
appConfig.planCost.hourlyCostMultiplierPercent,
|
|
appConfig.planCost.timeCostUnit,
|
|
);
|
|
const tier = getAutomationCostTier(
|
|
estimatedCost,
|
|
appConfig.planCost.baseCostPerMillionTokens,
|
|
appConfig.planCost.attentionCostThresholdMultiplier,
|
|
appConfig.planCost.warningCostThresholdMultiplier,
|
|
appConfig.planCost.highCostThresholdMultiplier,
|
|
);
|
|
const descriptionParts = [
|
|
snapshot.retryCount > 0 ? `재처리 ${snapshot.retryCount}회 포함` : null,
|
|
snapshot.sourceWorkCount > 0 ? `증적 ${snapshot.sourceWorkCount}건` : null,
|
|
formatProcessingDurationLabel(snapshot.processingDurationSeconds),
|
|
].filter(Boolean);
|
|
|
|
if (!tokenSummaryText && snapshot.sourceWorkCount === 0 && !snapshot.processingDurationSeconds) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
costLabel: `예상 비용 ${formatCostMetricValue(estimatedCost)}`,
|
|
tokenLabel: tokenSummaryText ? `누적 토큰 사용량 ${tokenSummaryText}` : '누적 토큰 사용량 기록 없음',
|
|
description: descriptionParts.length > 0 ? descriptionParts.join(' · ') : null,
|
|
tierColor: tier.color,
|
|
tierLabel: tier.label,
|
|
};
|
|
}
|
|
|
|
function getAutomationTotalTokenCount(metrics: Map<string, number>) {
|
|
const total = metrics.get('total');
|
|
|
|
if (typeof total === 'number' && Number.isFinite(total)) {
|
|
return total;
|
|
}
|
|
|
|
return ['input', 'output', 'cached', 'reasoning'].reduce((sum, key) => sum + (metrics.get(key) ?? 0), 0);
|
|
}
|
|
|
|
function estimateAutomationCost(
|
|
totalTokens: number,
|
|
retryCount: number,
|
|
processingDurationSeconds: number | null | undefined,
|
|
baseCostPerMillionTokens: number,
|
|
retryCostMultiplierPercent: number,
|
|
hourlyCostMultiplierPercent: number,
|
|
timeCostUnit: AppConfig['planCost']['timeCostUnit'],
|
|
) {
|
|
if (totalTokens <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
const durationSeconds = Math.max(0, Number(processingDurationSeconds ?? 0));
|
|
const durationUnits =
|
|
timeCostUnit === 'second' ? durationSeconds : timeCostUnit === 'minute' ? durationSeconds / 60 : durationSeconds / 3600;
|
|
const additiveMultiplier =
|
|
1 + retryCount * (retryCostMultiplierPercent / 100) + durationUnits * (hourlyCostMultiplierPercent / 100);
|
|
|
|
return (totalTokens / 1_000_000) * baseCostPerMillionTokens * additiveMultiplier;
|
|
}
|
|
|
|
function getAutomationCostTier(
|
|
cost: number,
|
|
baseCostPerMillionTokens: number,
|
|
attentionCostThresholdMultiplier: number,
|
|
warningCostThresholdMultiplier: number,
|
|
highCostThresholdMultiplier: number,
|
|
) {
|
|
const attentionThreshold = baseCostPerMillionTokens * Math.max(0.1, attentionCostThresholdMultiplier);
|
|
const warningThreshold = baseCostPerMillionTokens * Math.max(attentionCostThresholdMultiplier, warningCostThresholdMultiplier);
|
|
const highThreshold = baseCostPerMillionTokens * Math.max(warningCostThresholdMultiplier, highCostThresholdMultiplier);
|
|
|
|
if (cost <= attentionThreshold) {
|
|
return { color: 'success', label: '안정' } as const;
|
|
}
|
|
|
|
if (cost <= warningThreshold) {
|
|
return { color: 'processing', label: '관심' } as const;
|
|
}
|
|
|
|
if (cost <= highThreshold) {
|
|
return { color: 'warning', label: '주의' } as const;
|
|
}
|
|
|
|
return { color: 'error', label: '높음' } as const;
|
|
}
|
|
|
|
function buildAutomationUsageSummary(
|
|
sourceWorks: Array<Pick<PlanSourceWorkHistory, 'summary' | 'commandLog' | 'createdAt'>>,
|
|
appConfig: Pick<AppConfig, 'planCost'>,
|
|
): AutomationUsageSummary | null {
|
|
const tokenSummaryText = summarizeAutomationTokenUsage(sourceWorks);
|
|
|
|
if (!tokenSummaryText) {
|
|
return null;
|
|
}
|
|
|
|
const totals = new Map<string, number>();
|
|
|
|
sourceWorks.forEach((sourceWork) => {
|
|
const tokenUsageText = extractAutomationTokenUsageText(sourceWork);
|
|
|
|
if (!tokenUsageText) {
|
|
return;
|
|
}
|
|
|
|
const metrics = parseAutomationTokenUsageMetrics(tokenUsageText);
|
|
|
|
if (!metrics) {
|
|
return;
|
|
}
|
|
|
|
metrics.forEach((value, key) => {
|
|
totals.set(key, (totals.get(key) ?? 0) + value);
|
|
});
|
|
});
|
|
|
|
const totalTokens = getAutomationTotalTokenCount(totals);
|
|
const retryCount = Math.max(0, sourceWorks.length - 1);
|
|
const processingDurationSeconds =
|
|
sourceWorks.length > 1
|
|
? Math.max(
|
|
0,
|
|
Math.round(
|
|
(new Date(sourceWorks[sourceWorks.length - 1].createdAt).getTime() - new Date(sourceWorks[0].createdAt).getTime()) /
|
|
1_000,
|
|
),
|
|
)
|
|
: null;
|
|
const estimatedCost = estimateAutomationCost(
|
|
totalTokens,
|
|
retryCount,
|
|
processingDurationSeconds,
|
|
appConfig.planCost.baseCostPerMillionTokens,
|
|
appConfig.planCost.retryCostMultiplierPercent,
|
|
appConfig.planCost.hourlyCostMultiplierPercent,
|
|
appConfig.planCost.timeCostUnit,
|
|
);
|
|
const tier = getAutomationCostTier(
|
|
estimatedCost,
|
|
appConfig.planCost.baseCostPerMillionTokens,
|
|
appConfig.planCost.attentionCostThresholdMultiplier,
|
|
appConfig.planCost.warningCostThresholdMultiplier,
|
|
appConfig.planCost.highCostThresholdMultiplier,
|
|
);
|
|
|
|
return {
|
|
costLabel: `예상 비용 ${formatCostMetricValue(estimatedCost)}`,
|
|
tokenLabel: `누적 토큰 사용량 ${tokenSummaryText}`,
|
|
description: [retryCount > 0 ? `재처리 ${retryCount}회 포함` : null, formatProcessingDurationLabel(processingDurationSeconds)]
|
|
.filter(Boolean)
|
|
.join(' · ') || null,
|
|
tierColor: tier.color,
|
|
tierLabel: tier.label,
|
|
};
|
|
}
|
|
|
|
function buildAutomationUsageSummaryByHistoryId(
|
|
sourceWorks: PlanSourceWorkHistory[],
|
|
appConfig: Pick<AppConfig, 'planCost'>,
|
|
) {
|
|
const summaries = new Map<number, AutomationUsageSummary>();
|
|
const orderedSourceWorks = sourceWorks
|
|
.slice()
|
|
.sort((left, right) => new Date(left.createdAt).getTime() - new Date(right.createdAt).getTime());
|
|
const cumulativeSourceWorks: Array<Pick<PlanSourceWorkHistory, 'summary' | 'commandLog' | 'createdAt'>> = [];
|
|
|
|
orderedSourceWorks.forEach((sourceWork) => {
|
|
cumulativeSourceWorks.push(sourceWork);
|
|
const summary = buildAutomationUsageSummary(cumulativeSourceWorks, appConfig);
|
|
|
|
if (summary) {
|
|
summaries.set(sourceWork.id, summary);
|
|
}
|
|
});
|
|
|
|
return summaries;
|
|
}
|
|
|
|
function AutomationStatusBar({
|
|
item,
|
|
usageSummary,
|
|
compact = false,
|
|
}: {
|
|
item: Pick<PlanItem, 'status' | 'workerStatus' | 'lastError' | 'startedAt'>;
|
|
usageSummary?: AutomationUsageSummary | null;
|
|
compact?: boolean;
|
|
}) {
|
|
const [elapsedLabel, setElapsedLabel] = useState(() => formatElapsedDuration(item.startedAt));
|
|
|
|
useEffect(() => {
|
|
setElapsedLabel(formatElapsedDuration(item.startedAt));
|
|
|
|
if (!item.startedAt || !isAutomationInProgress(item.workerStatus)) {
|
|
return;
|
|
}
|
|
|
|
const intervalId = window.setInterval(() => {
|
|
setElapsedLabel(formatElapsedDuration(item.startedAt));
|
|
}, 30000);
|
|
|
|
return () => {
|
|
window.clearInterval(intervalId);
|
|
};
|
|
}, [item.startedAt, item.workerStatus]);
|
|
|
|
const presentation = getAutomationStatusPresentation(item);
|
|
const classes = [
|
|
'plan-board-page__automation-status',
|
|
`plan-board-page__automation-status--${presentation.tone}`,
|
|
compact ? 'plan-board-page__automation-status--compact' : '',
|
|
presentation.active ? 'plan-board-page__automation-status--active' : '',
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ');
|
|
|
|
return (
|
|
<div className={classes}>
|
|
<span className="plan-board-page__automation-status-fill" />
|
|
<div className="plan-board-page__automation-status-copy">
|
|
<Text className="plan-board-page__automation-status-text">{presentation.label}</Text>
|
|
{presentation.description || elapsedLabel ? (
|
|
<Text className="plan-board-page__automation-status-description" type="secondary">
|
|
{[presentation.description, presentation.active ? elapsedLabel : null].filter(Boolean).join(' · ')}
|
|
</Text>
|
|
) : null}
|
|
{usageSummary ? (
|
|
<Space size={[8, 4]} wrap>
|
|
<Text className="plan-board-page__automation-status-description" type="secondary">
|
|
{usageSummary.costLabel}
|
|
</Text>
|
|
<Text className="plan-board-page__automation-status-description" type="secondary">
|
|
{usageSummary.tokenLabel}
|
|
</Text>
|
|
{usageSummary.tierColor && usageSummary.tierLabel ? (
|
|
<Tag color={usageSummary.tierColor}>{usageSummary.tierLabel}</Tag>
|
|
) : null}
|
|
{usageSummary.description ? (
|
|
<Text className="plan-board-page__automation-status-description" type="secondary">
|
|
{usageSummary.description}
|
|
</Text>
|
|
) : null}
|
|
</Space>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|