Files
ai-code-app/src/features/planBoard/PlanBoardPage.tsx

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