chore: sync local workspace changes

This commit is contained in:
2026-05-07 11:03:47 +09:00
parent 2df0ba30cb
commit 82c0d8a197
217 changed files with 44873 additions and 1678 deletions

1190
src/features/board/BoardPage.tsx Executable file → Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
import { appendClientIdHeader } from '../../app/main/clientIdentity';
import { normalizeAutomationTypeId } from '../../app/main/automationTypeAccess';
import { getRegisteredAccessToken } from '../../app/main/tokenAccess';
import type { BoardAttachment, BoardAutomationType, BoardDraft, BoardPost } from './types';
import type { BoardAttachment, BoardAutomationType, BoardDraft, BoardPost, BoardRequestItem } from './types';
class BoardApiError extends Error {
status: number;
@@ -45,6 +45,54 @@ function normalizeBoardPost(item: BoardPost): BoardPost {
...item,
automationType: normalizeBoardAutomationType(item.automationType),
attachments: Array.isArray(item.attachments) ? item.attachments.map(normalizeBoardAttachment).filter(Boolean) as BoardAttachment[] : [],
requestExecutionMode:
item.requestExecutionMode === 'after_previous_finished' || item.requestExecutionMode === 'after_previous_success'
? item.requestExecutionMode
: 'all_at_once',
requestItems: Array.isArray(item.requestItems)
? item.requestItems.map((requestItem) => ({
...requestItem,
id: Number(requestItem.id ?? 0),
boardPostId: Number(requestItem.boardPostId ?? item.id ?? 0),
sequence: Number(requestItem.sequence ?? 0),
title: String(requestItem.title ?? '').trim(),
content: String(requestItem.content ?? ''),
planItemId:
requestItem.planItemId === null || requestItem.planItemId === undefined
? null
: Number(requestItem.planItemId),
automationReceivedAt:
requestItem.automationReceivedAt === null || requestItem.automationReceivedAt === undefined
? null
: String(requestItem.automationReceivedAt),
workflowState: String(requestItem.workflowState ?? 'pending') as BoardRequestItem['workflowState'],
status: String(requestItem.status ?? 'pending') as BoardRequestItem['status'],
statusLabel: String(requestItem.statusLabel ?? '').trim() || '미접수',
planStatus:
requestItem.planStatus === null || requestItem.planStatus === undefined
? null
: String(requestItem.planStatus),
workerStatus:
requestItem.workerStatus === null || requestItem.workerStatus === undefined
? null
: String(requestItem.workerStatus),
lastError:
requestItem.lastError === null || requestItem.lastError === undefined
? null
: String(requestItem.lastError),
createdAt: String(requestItem.createdAt ?? ''),
updatedAt: String(requestItem.updatedAt ?? ''),
}))
: [],
requestSummary: {
total: Math.max(0, Number(item.requestSummary?.total ?? 0) || 0),
completed: Math.max(0, Number(item.requestSummary?.completed ?? 0) || 0),
failed: Math.max(0, Number(item.requestSummary?.failed ?? 0) || 0),
running: Math.max(0, Number(item.requestSummary?.running ?? 0) || 0),
queued: Math.max(0, Number(item.requestSummary?.queued ?? 0) || 0),
waiting: Math.max(0, Number(item.requestSummary?.waiting ?? 0) || 0),
blocked: Math.max(0, Number(item.requestSummary?.blocked ?? 0) || 0),
},
};
}
@@ -176,6 +224,11 @@ export async function createBoardPost(draft: BoardDraft) {
content: draft.content,
attachments: draft.attachments,
automationType: draft.automationType,
requestExecutionMode: draft.requestExecutionMode,
requestItems: draft.requestItems.map((requestItem) => ({
title: requestItem.title,
content: requestItem.content,
})),
}),
});
@@ -194,6 +247,11 @@ export async function updateBoardPost(draft: BoardDraft) {
content: draft.content,
attachments: draft.attachments,
automationType: draft.automationType,
requestExecutionMode: draft.requestExecutionMode,
requestItems: draft.requestItems.map((requestItem) => ({
title: requestItem.title,
content: requestItem.content,
})),
}),
});
@@ -204,7 +262,7 @@ export async function receiveBoardPostAutomation(id: number) {
const response = await request<{
ok: boolean;
item: BoardPost;
planItemId: number | null;
planItemIds: number[];
alreadyReceived: boolean;
}>(`/board/posts/${id}/actions/automation-receive`, {
method: 'POST',
@@ -213,7 +271,7 @@ export async function receiveBoardPostAutomation(id: number) {
return {
item: normalizeBoardPost(response.item),
planItemId: response.planItemId,
planItemIds: Array.isArray(response.planItemIds) ? response.planItemIds : [],
alreadyReceived: response.alreadyReceived,
};
}

View File

@@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });

View File

@@ -9,6 +9,26 @@ export type BoardAttachment = {
mimeType: string;
};
export type BoardRequestExecutionMode = 'all_at_once' | 'after_previous_finished' | 'after_previous_success';
export type BoardRequestItem = {
id: number;
boardPostId: number;
sequence: number;
title: string;
content: string;
planItemId: number | null;
automationReceivedAt: string | null;
workflowState: 'pending' | 'waiting' | 'registered' | 'completed' | 'failed' | 'blocked';
status: 'pending' | 'waiting' | 'queued' | 'in_progress' | 'completed' | 'failed' | 'blocked';
statusLabel: string;
planStatus: string | null;
workerStatus: string | null;
lastError: string | null;
createdAt: string;
updatedAt: string;
};
export type BoardPost = {
id: number;
title: string;
@@ -18,6 +38,18 @@ export type BoardPost = {
automationType: BoardAutomationType;
automationPlanItemId: number | null;
automationReceivedAt: string | null;
automationContextIds?: string[];
requestExecutionMode: BoardRequestExecutionMode;
requestItems: BoardRequestItem[];
requestSummary: {
total: number;
completed: number;
failed: number;
running: number;
queued: number;
waiting: number;
blocked: number;
};
createdAt: string;
updatedAt: string;
};
@@ -28,4 +60,10 @@ export type BoardDraft = {
content: string;
attachments: BoardAttachment[];
automationType: BoardAutomationType;
requestExecutionMode: BoardRequestExecutionMode;
requestItems: Array<{
key: string;
title: string;
content: string;
}>;
};

View File

@@ -29,11 +29,22 @@
- 컴포넌트 간 이벤트 전달, 상태 동기화, focus/selection 이동, 표시/숨김 제어를 기능 명세로 기술할 수 있다
- 한 컴포넌트의 입력/액션이 다른 컴포넌트 목록, 상세, 요약, CTA 상태를 바꾸는 흐름을 기술할 수 있다
- 서버 저장, 조회, 갱신, 삭제 같은 API 연결과 그 응답이 다른 컴포넌트에 반영되는 흐름도 기능 명세 범위에 포함한다
- 가능하면 공통 컴포넌트나 위젯 본체를 직접 수정하기보다, 현재 레이아웃에서 필요한 `props`를 내려 동작과 표시를 조정하는 방식으로 구현한다
- `Layout Editor 실행` 요청은 기본적으로 "현재 화면 조합을 props/배치/상호작용으로 맞춘다"는 의미로 해석하고, 공통 패키지 내부 구현 변경은 최후 수단으로만 검토한다
구현 우선순위:
- 1순위는 기존 컴포넌트/위젯 조합과 `props` 조정만으로 요구사항을 만족시키는 것이다
- 2순위는 현재 프로젝트 전용 래퍼, feature 레이어, 어댑터를 추가해 공통 패키지 수정 없이 화면 요구를 흡수하는 것이다
- 공통 컴포넌트/위젯 수정이 정말 필요할 때만 기존 사용처를 모두 확인한 뒤 제한적으로 수정한다
- 공통 컴포넌트/위젯에 새 동작을 추가할 때는 기본값 `props`를 기존 동작과 동일하게 유지해, 명시적으로 opt-in한 화면만 달라지게 만든다
금지 해석:
- 위젯 또는 컴포넌트 샘플 메타에서 "기본 기능"을 자동으로 끌어와 `Layout Editor` 기능 명세처럼 취급하지 않는다
- 사용자가 직접 추가하지 않은 항목을 기능 명세 목록에 자동 주입하지 않는다
- 레이아웃 기능 명세와 위젯 고유 스펙 문서를 같은 개념으로 섞지 않는다
- 현재 레이아웃 요구를 맞추기 위해 공통 위젯 내부 코드를 바로 덧대고, 그 부작용을 기존 화면이 함께 떠안게 만드는 방식은 지양한다
- 기존 화면 영향도 검토 없이 공통 컴포넌트/위젯의 기본 동작, 기본 스타일, 기본 데이터 흐름을 바꾸지 않는다
구현/문서 작성 시에는 항상 "이 기능은 위젯의 본래 능력 설명인가, 아니면 현재 레이아웃에서 그 컴포넌트가 수행할 역할 설명인가"를 먼저 구분합니다. `Layout Editor` 문맥에서는 후자만 다룹니다.

View File

@@ -0,0 +1,317 @@
.feature-menu-layout-page {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 12px;
height: 100%;
min-height: 0;
padding: 12px;
box-sizing: border-box;
overflow: hidden;
background: linear-gradient(180deg, #f4f6f8 0%, #eef1f4 100%);
}
.feature-menu-layout-page__filters,
.feature-menu-layout-page__editor-shell {
box-sizing: border-box;
border: 1px solid #d6dde5;
border-radius: 0;
background: #f8fafc;
box-shadow: none;
}
.feature-menu-layout-page__filters {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 10px;
padding: 12px;
}
.feature-menu-layout-page__field {
display: flex;
flex-direction: column;
gap: 6px;
}
.feature-menu-layout-page__field-label {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
color: #51606f;
text-transform: uppercase;
}
.feature-menu-layout-page__select.ant-select,
.feature-menu-layout-page__select .ant-select-selector,
.feature-menu-layout-page__textarea.ant-input {
border-radius: 0;
}
.feature-menu-layout-page__select .ant-select-selector,
.feature-menu-layout-page__textarea.ant-input {
border-color: #c8d1db;
background: #fff;
box-shadow: none;
}
.feature-menu-layout-page__run-button.ant-btn {
height: 44px;
width: 44px;
min-width: 44px;
padding-inline: 0;
border-radius: 0;
justify-content: center;
}
.feature-menu-layout-page__editor-shell {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
min-height: 0;
height: 100%;
padding: 10px;
overflow: hidden;
}
.feature-menu-layout-page__editor-fields {
display: grid;
grid-template-rows: minmax(0, 1fr);
min-height: 0;
box-sizing: border-box;
height: 100%;
overflow: hidden;
}
.feature-menu-layout-page__textarea.ant-input {
border-radius: 0;
display: block;
width: 100%;
box-sizing: border-box;
align-self: stretch;
height: 100% !important;
min-height: 0 !important;
max-height: none;
overflow-y: auto !important;
resize: none;
padding: 14px 16px;
font-size: 14px;
line-height: 1.65;
color: #16202a;
}
.feature-menu-layout-page__textarea.ant-input::placeholder {
color: #8a97a6;
}
.feature-menu-layout-page__editor-toolbar {
display: flex;
justify-content: flex-end;
padding-bottom: 8px;
margin-bottom: 8px;
border-bottom: 1px solid #d6dde5;
}
.feature-menu-layout-page__empty {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
min-height: 240px;
}
.feature-menu-layout-page__tabs {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
flex: 1;
min-height: 0;
box-sizing: border-box;
overflow: hidden;
}
.feature-menu-layout-page__tabs .ant-tabs-content-holder,
.feature-menu-layout-page__tabs .ant-tabs-content,
.feature-menu-layout-page__tabs .ant-tabs-tabpane,
.feature-menu-layout-page__tabs .ant-tabs-tabpane-active {
display: flex;
flex: 1 1 auto;
min-height: 0;
height: 100%;
flex-direction: column;
overflow: auto;
}
.feature-menu-layout-page__tabs .ant-tabs-nav {
margin: 0 0 8px;
align-items: center;
border-bottom: 1px solid #d6dde5;
padding-bottom: 8px;
}
.feature-menu-layout-page__tabs .ant-tabs-tab {
border-radius: 0;
}
.feature-menu-layout-page__tabs .ant-tabs-nav::before {
display: none;
}
.feature-menu-layout-page__tabs .ant-tabs-nav-list {
min-width: 0;
}
.feature-menu-layout-page__tabs .ant-tabs-nav-wrap {
min-width: 0;
}
.feature-menu-layout-page__tab-actions {
justify-content: flex-start;
}
.feature-menu-layout-page__tab-actions .ant-btn {
width: 40px;
min-width: 40px;
padding-inline: 0;
border-radius: 0;
box-shadow: none;
}
.feature-menu-layout-page__notes {
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
box-sizing: border-box;
border: 1px solid #d6dde5;
background: #fff;
padding: 14px 16px;
overflow: auto;
padding-bottom: 28px;
}
.feature-menu-layout-page__notes--empty {
align-items: center;
justify-content: center;
}
.feature-menu-layout-page__notes-body.ant-typography {
margin-bottom: 0;
white-space: pre-wrap;
color: #16202a;
}
.feature-menu-layout-page__notes-empty.ant-empty {
margin-block: 0;
}
.feature-menu-layout-page--editor-maximized {
padding: 0;
}
.feature-menu-layout-page--editor-maximized .feature-menu-layout-page__filters {
display: none;
}
.feature-menu-layout-page--editor-maximized .feature-menu-layout-page__editor-shell {
position: fixed;
inset: 16px;
z-index: 40;
padding: 12px;
background: #f8fafc;
}
@media (min-width: 960px) {
.feature-menu-layout-page__filters {
grid-template-columns: minmax(220px, 1fr) minmax(220px, 1fr) auto;
align-items: end;
}
.feature-menu-layout-page__run-button.ant-btn {
min-width: 44px;
}
}
@media (max-width: 720px) {
.feature-menu-layout-page {
grid-template-rows: auto minmax(0, 1fr);
padding: 4px 4px calc(2px + env(safe-area-inset-bottom, 0px));
gap: 3px;
overflow: hidden;
}
.feature-menu-layout-page__filters {
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
column-gap: 6px;
row-gap: 6px;
}
.feature-menu-layout-page__filters,
.feature-menu-layout-page__editor-shell {
padding: 3px;
}
.feature-menu-layout-page__editor-shell {
grid-template-rows: auto minmax(0, 1fr);
align-self: stretch;
height: calc(100% - 24px);
}
.feature-menu-layout-page__field:first-of-type {
grid-column: 1 / -1;
}
.feature-menu-layout-page__editor-toolbar {
padding-bottom: 0;
margin-bottom: 0;
}
.feature-menu-layout-page__tabs .ant-tabs-nav {
align-items: flex-start;
margin-bottom: 0;
padding-bottom: 0;
}
.feature-menu-layout-page__tabs .ant-tabs-nav-list {
width: 100%;
}
.feature-menu-layout-page__tabs,
.feature-menu-layout-page__tabs .ant-tabs-content-holder,
.feature-menu-layout-page__tabs .ant-tabs-content,
.feature-menu-layout-page__tabs .ant-tabs-tabpane,
.feature-menu-layout-page__tabs .ant-tabs-tabpane-active {
height: 100%;
overflow: hidden;
}
.feature-menu-layout-page__editor-fields {
grid-template-rows: minmax(0, 1fr);
height: 100%;
overflow: hidden;
}
.feature-menu-layout-page__textarea.ant-input {
align-self: stretch;
height: calc(100% - 4px) !important;
min-height: 0 !important;
max-height: none;
padding: 8px 10px;
}
.feature-menu-layout-page__notes {
height: calc(100% - 4px);
max-height: none;
padding: 7px 12px 7px;
padding-bottom: 7px;
}
.feature-menu-layout-page__tab-actions .ant-btn,
.feature-menu-layout-page__run-button.ant-btn {
width: 36px;
height: 36px;
min-width: 36px;
}
.feature-menu-layout-page--editor-maximized .feature-menu-layout-page__editor-shell {
inset: 6px;
padding: 6px;
}
}

View File

@@ -0,0 +1,520 @@
import { ArrowsAltOutlined, DeleteOutlined, PlayCircleOutlined, PlusOutlined, SaveOutlined, ShrinkOutlined } from '@ant-design/icons';
import { Button, Empty, Input, Modal, Space, Tabs, Tooltip, Typography, message } from 'antd';
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import { canUseChatType, resolveCurrentChatPermissionRoles, useChatTypeRegistry } from '../../../app/main/chatTypeAccess';
import { createChatConversationRoom, fetchChatConversations } from '../../../app/main/mainChatPanel';
import {
LAYOUT_EDITOR_GUIDED_CHAT_TYPE_DESCRIPTION,
LAYOUT_EDITOR_GUIDED_CHAT_TYPE_ID,
LAYOUT_EDITOR_GUIDED_CHAT_TYPE_NAME,
} from '../../../app/main/chatTypeDefaults';
import { buildChatPath } from '../../../app/main/routes';
import { useTokenAccess } from '../../../app/main/tokenAccess';
import { SelectUI, type SelectOptionItem } from '../../../components/inputs/select';
import { resolvePreferredLayoutCodexChatType } from '../../../views/play/layoutCodexChatType';
import { listSavedLayouts, saveLayout, type SavedLayoutRecord } from '../../../views/play/layoutStorage';
import { isReusableLayoutConversation, resolveLayoutCodexRequestSocketUrl, resolveLayoutConversationTitle } from './featureMenu.chat';
import type { FeatureMenuTabKey, LayoutInteractionRule } from './featureMenu.types';
import { buildFeatureMenuPrompt, normalizeLayoutInteractions } from './featureMenu.utils';
import './FeatureMenuLayoutPage.css';
const { Paragraph, Text } = Typography;
type FeatureMenuLayoutPageProps = {
layoutId: string;
savedLayouts: SavedLayoutRecord[];
onSavedLayoutsChange?: (layouts: SavedLayoutRecord[]) => void;
};
export function FeatureMenuLayoutPage({ layoutId, savedLayouts, onSavedLayoutsChange }: FeatureMenuLayoutPageProps) {
const navigate = useNavigate();
const { chatTypes } = useChatTypeRegistry();
const { hasAccess } = useTokenAccess();
const [messageApi, contextHolder] = message.useMessage();
const [modalApi, modalContextHolder] = Modal.useModal();
const [selectedLayoutId, setSelectedLayoutId] = useState<string | null>(null);
const [selectedFeatureId, setSelectedFeatureId] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<FeatureMenuTabKey>('description');
const [isEditorMaximized, setIsEditorMaximized] = useState(false);
const [draftBody, setDraftBody] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [isSending, setIsSending] = useState(false);
const chatPermissionRoles = useMemo(() => resolveCurrentChatPermissionRoles(hasAccess), [hasAccess]);
const availableChatTypes = useMemo(
() => chatTypes.filter((item) => canUseChatType(item, chatPermissionRoles)),
[chatPermissionRoles, chatTypes],
);
const preferredCodexChatType = useMemo(
() => resolvePreferredLayoutCodexChatType(availableChatTypes),
[availableChatTypes],
);
const targetChatType = preferredCodexChatType ?? {
id: LAYOUT_EDITOR_GUIDED_CHAT_TYPE_ID,
name: LAYOUT_EDITOR_GUIDED_CHAT_TYPE_NAME,
description: LAYOUT_EDITOR_GUIDED_CHAT_TYPE_DESCRIPTION,
};
const layoutOptions = useMemo<SelectOptionItem[]>(
() =>
[...savedLayouts]
.sort((left, right) => {
if (left.id === layoutId) {
return -1;
}
if (right.id === layoutId) {
return 1;
}
return 0;
})
.map((item) => ({
code: item.id,
value: item.name,
})),
[layoutId, savedLayouts],
);
useEffect(() => {
if (savedLayouts.length === 0) {
setSelectedLayoutId(null);
return;
}
if (selectedLayoutId && savedLayouts.some((item) => item.id === selectedLayoutId)) {
return;
}
const fallback = savedLayouts.find((item) => item.id === layoutId) ?? savedLayouts[0];
setSelectedLayoutId(fallback?.id ?? null);
}, [layoutId, savedLayouts, selectedLayoutId]);
const selectedLayout = selectedLayoutId ? savedLayouts.find((item) => item.id === selectedLayoutId) ?? null : null;
const selectedLayoutInteractions = useMemo<LayoutInteractionRule[]>(
() => normalizeLayoutInteractions(selectedLayout?.tree ?? null),
[selectedLayout?.tree],
);
const featureOptions = useMemo<SelectOptionItem[]>(
() =>
selectedLayoutInteractions.map((item, index) => ({
code: item.id,
value: item.title || `기능설명 ${index + 1}`,
})),
[selectedLayoutInteractions],
);
useEffect(() => {
if (featureOptions.length === 0) {
setSelectedFeatureId(null);
return;
}
if (selectedFeatureId && featureOptions.some((item) => item.code === selectedFeatureId)) {
return;
}
setSelectedFeatureId(featureOptions[0]?.code ?? null);
}, [featureOptions, selectedFeatureId]);
const selectedFeature =
selectedFeatureId ? selectedLayoutInteractions.find((item) => item.id === selectedFeatureId) ?? null : null;
useEffect(() => {
setDraftBody(selectedFeature?.description ?? '');
setActiveTab('description');
}, [selectedFeature?.description, selectedFeature?.id]);
const isDirty = Boolean(selectedFeature && selectedFeature.description !== draftBody);
const refreshLayouts = async () => {
const nextLayouts = await listSavedLayouts();
onSavedLayoutsChange?.(nextLayouts);
return nextLayouts;
};
const handleSave = async () => {
if (!selectedLayout || !selectedFeature) {
return;
}
const nextDescription = draftBody.trim();
if (!nextDescription) {
void messageApi.warning('기능설명 본문을 입력하세요.');
return;
}
setIsSaving(true);
try {
const nextInteractions = selectedLayoutInteractions.map((item) =>
item.id === selectedFeature.id
? {
...item,
description: nextDescription,
}
: item,
);
const nextTree =
selectedLayout.tree && typeof selectedLayout.tree === 'object'
? {
...(selectedLayout.tree as Record<string, unknown>),
interactions: nextInteractions,
interactionMode: 'scoped-v2',
}
: {
root: null,
interactions: nextInteractions,
interactionMode: 'scoped-v2',
};
await saveLayout({
...selectedLayout,
updatedAt: new Date().toISOString(),
tree: nextTree,
});
await refreshLayouts();
void messageApi.success('기능설명을 저장했습니다.');
} catch (error) {
void messageApi.error(error instanceof Error ? error.message : '기능설명 저장에 실패했습니다.');
} finally {
setIsSaving(false);
}
};
const handleAdd = async () => {
if (!selectedLayout) {
return;
}
const nextIndex = selectedLayoutInteractions.length + 1;
const nextFeatureId =
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `feature-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
const nextFeature: LayoutInteractionRule = {
id: nextFeatureId,
title: `기능설명 ${nextIndex}`,
description: '',
implementationNotes: '',
};
const nextInteractions = [...selectedLayoutInteractions, nextFeature];
const nextTree =
selectedLayout.tree && typeof selectedLayout.tree === 'object'
? {
...(selectedLayout.tree as Record<string, unknown>),
interactions: nextInteractions,
interactionMode: 'scoped-v2',
}
: {
root: null,
interactions: nextInteractions,
interactionMode: 'scoped-v2',
};
setIsSaving(true);
try {
await saveLayout({
...selectedLayout,
updatedAt: new Date().toISOString(),
tree: nextTree,
});
await refreshLayouts();
setSelectedFeatureId(nextFeatureId);
setDraftBody('');
setActiveTab('description');
void messageApi.success('기능설명 항목을 추가했습니다.');
} catch (error) {
void messageApi.error(error instanceof Error ? error.message : '기능설명 항목 추가에 실패했습니다.');
} finally {
setIsSaving(false);
}
};
const handleDelete = () => {
if (!selectedLayout || !selectedFeature) {
return;
}
void modalApi.confirm({
title: '선택한 기능설명을 삭제할까요?',
content: '삭제 후 되돌릴 수 없습니다.',
okText: '삭제',
cancelText: '취소',
okButtonProps: { danger: true },
async onOk() {
const nextInteractions = selectedLayoutInteractions.filter((item) => item.id !== selectedFeature.id);
const nextTree =
selectedLayout.tree && typeof selectedLayout.tree === 'object'
? {
...(selectedLayout.tree as Record<string, unknown>),
interactions: nextInteractions,
interactionMode: 'scoped-v2',
}
: {
root: null,
interactions: nextInteractions,
interactionMode: 'scoped-v2',
};
await saveLayout({
...selectedLayout,
updatedAt: new Date().toISOString(),
tree: nextTree,
});
await refreshLayouts();
void messageApi.success('기능설명을 삭제했습니다.');
},
});
};
const handleCodexExecute = async () => {
if (!selectedLayout) {
return;
}
const rules = selectedFeature ? [selectedFeature] : selectedLayoutInteractions;
const prompt = buildFeatureMenuPrompt(selectedLayout, rules);
const conversationTitle = resolveLayoutConversationTitle(selectedLayout.name);
setIsSending(true);
try {
const conversations = await fetchChatConversations();
const matchedConversation = conversations.find((item) =>
isReusableLayoutConversation(item, conversationTitle, targetChatType.id),
);
const shouldCreateConversation = !matchedConversation;
const targetSessionId =
matchedConversation?.sessionId ||
(typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `chat-session-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`);
if (shouldCreateConversation) {
await createChatConversationRoom({
sessionId: targetSessionId,
title: conversationTitle,
chatTypeId: targetChatType.id,
lastChatTypeId: targetChatType.id,
contextLabel: targetChatType.name,
contextDescription: targetChatType.description,
notifyOffline: true,
});
}
const requestId = `client-${Date.now().toString(36)}`;
const socketUrl = resolveLayoutCodexRequestSocketUrl(targetSessionId);
await new Promise<void>((resolve, reject) => {
const socket = new WebSocket(socketUrl);
const timeoutId = window.setTimeout(() => {
socket.close();
reject(new Error('Codex 요청 연결 시간이 초과되었습니다.'));
}, 8000);
socket.addEventListener('open', () => {
socket.send(
JSON.stringify({
type: 'message:send',
payload: {
text: prompt,
chatTypeId: targetChatType.id,
chatTypeLabel: targetChatType.name,
chatTypeDescription: targetChatType.description,
requestId,
mode: 'queue',
},
}),
);
window.setTimeout(() => {
window.clearTimeout(timeoutId);
socket.close();
resolve();
}, 120);
});
socket.addEventListener('error', () => {
window.clearTimeout(timeoutId);
reject(new Error('Codex 요청 연결에 실패했습니다.'));
});
socket.addEventListener('close', (event) => {
if (!event.wasClean && event.code !== 1000) {
window.clearTimeout(timeoutId);
reject(new Error('Codex 요청 연결이 비정상 종료되었습니다.'));
}
});
});
void messageApi.success('Codex 실행 요청을 전송했습니다.');
navigate(`${buildChatPath('live')}?sessionId=${encodeURIComponent(targetSessionId)}`);
} catch (error) {
void messageApi.error(error instanceof Error ? error.message : 'Codex 실행 요청에 실패했습니다.');
} finally {
setIsSending(false);
}
};
const editorTabItems = [
{
key: 'description',
label: '기능설명 입력',
children: (
<div className="feature-menu-layout-page__editor-fields">
<Input.TextArea
value={draftBody}
placeholder="기능설명 본문을 입력하세요."
autoSize={false}
className="feature-menu-layout-page__textarea"
onChange={(event) => {
setDraftBody(event.target.value);
}}
/>
</div>
),
},
{
key: 'notes',
label: 'Codex 설명',
children: selectedFeature?.implementationNotes.trim() ? (
<div className="feature-menu-layout-page__notes">
<Paragraph className="feature-menu-layout-page__notes-body">{selectedFeature.implementationNotes}</Paragraph>
</div>
) : (
<div className="feature-menu-layout-page__notes feature-menu-layout-page__notes--empty">
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={null}
className="feature-menu-layout-page__notes-empty"
/>
</div>
),
},
] satisfies Array<{ key: FeatureMenuTabKey; label: string; children: ReactNode }>;
return (
<div
className={`feature-menu-layout-page${isEditorMaximized ? ' feature-menu-layout-page--editor-maximized' : ''}`}
data-layout-id={layoutId}
>
{contextHolder}
{modalContextHolder}
<section className="feature-menu-layout-page__filters">
<div className="feature-menu-layout-page__field">
<Text className="feature-menu-layout-page__field-label"></Text>
<SelectUI
data={layoutOptions}
value={selectedLayoutId ?? undefined}
allowClear={false}
className="feature-menu-layout-page__select"
onChange={(nextCode) => {
setSelectedLayoutId(nextCode ?? null);
}}
/>
</div>
<div className="feature-menu-layout-page__field">
<Text className="feature-menu-layout-page__field-label"> </Text>
<SelectUI
data={featureOptions}
value={selectedFeatureId ?? undefined}
allowClear={false}
disabled={featureOptions.length === 0}
className="feature-menu-layout-page__select"
onChange={(nextCode) => {
setSelectedFeatureId(nextCode ?? null);
}}
/>
</div>
<Tooltip title="Codex 실행">
<Button
type="primary"
size="large"
icon={<PlayCircleOutlined />}
className="feature-menu-layout-page__run-button"
disabled={!selectedLayout}
loading={isSending}
aria-label="Codex 실행"
onClick={() => {
void handleCodexExecute();
}}
/>
</Tooltip>
</section>
<section className="feature-menu-layout-page__editor-shell">
{selectedFeature ? (
<>
<div className="feature-menu-layout-page__editor-toolbar">
<Space size={8} wrap className="feature-menu-layout-page__tab-actions">
<Tooltip title="추가">
<Button
icon={<PlusOutlined />}
disabled={!selectedLayout}
loading={isSaving && !selectedFeature}
aria-label="추가"
onClick={() => {
void handleAdd();
}}
/>
</Tooltip>
<Tooltip title={isEditorMaximized ? '입력 축소' : '입력 최대화'}>
<Button
type={isEditorMaximized ? 'primary' : 'default'}
icon={isEditorMaximized ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
aria-label={isEditorMaximized ? '입력 축소' : '입력 최대화'}
onClick={() => {
setIsEditorMaximized((current) => !current);
}}
/>
</Tooltip>
<Tooltip title="저장">
<Button
type="primary"
icon={<SaveOutlined />}
disabled={!selectedFeature || !isDirty}
loading={isSaving}
aria-label="저장"
onClick={() => {
void handleSave();
}}
/>
</Tooltip>
<Tooltip title="삭제">
<Button
danger
icon={<DeleteOutlined />}
disabled={!selectedFeature}
aria-label="삭제"
onClick={handleDelete}
/>
</Tooltip>
</Space>
</div>
<Tabs
activeKey={activeTab}
className="feature-menu-layout-page__tabs"
tabPosition="top"
items={editorTabItems}
onChange={(nextKey) => {
setActiveTab(nextKey as FeatureMenuTabKey);
}}
/>
</>
) : (
<div className="feature-menu-layout-page__empty">
<Empty description="선택 가능한 기능설명 항목이 없습니다." />
</div>
)}
</section>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { resolveChatWebSocketUrl } from '../../../app/main/mainChatPanel/chatUtils';
export function resolveLayoutConversationTitle(layoutName: string) {
const normalized = layoutName.trim() || '이름 없는 레이아웃';
return `${normalized} 명세`;
}
export function resolveLayoutCodexRequestSocketUrl(sessionId: string) {
const resolvedUrl = new URL(resolveChatWebSocketUrl(sessionId));
if (typeof window === 'undefined') {
return resolvedUrl.toString();
}
if (['127.0.0.1', 'localhost', '0.0.0.0'].includes(window.location.hostname)) {
resolvedUrl.protocol = 'wss:';
resolvedUrl.hostname = 'test.sm-home.cloud';
resolvedUrl.port = '';
resolvedUrl.pathname = '/ws/chat';
}
return resolvedUrl.toString();
}
export function isReusableLayoutConversation(
item: { title: string; chatTypeId: string | null; lastChatTypeId: string | null },
expectedTitle: string,
chatTypeId: string,
) {
return item.title.trim() === expectedTitle && (item.chatTypeId === chatTypeId || item.lastChatTypeId === chatTypeId);
}

View File

@@ -0,0 +1,8 @@
export type LayoutInteractionRule = {
id: string;
title: string;
description: string;
implementationNotes: string;
};
export type FeatureMenuTabKey = 'description' | 'notes';

View File

@@ -0,0 +1,72 @@
import type { SavedLayoutRecord } from '../../../views/play/layoutStorage';
import type { LayoutInteractionRule } from './featureMenu.types';
function normalizeText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
export function normalizeLayoutInteractions(tree: unknown) {
if (!tree || typeof tree !== 'object') {
return [] as LayoutInteractionRule[];
}
const candidate = tree as { interactions?: unknown };
if (!Array.isArray(candidate.interactions)) {
return [];
}
return candidate.interactions
.map((item) => {
if (!item || typeof item !== 'object') {
return null;
}
const next = item as Record<string, unknown>;
const id = normalizeText(next.id);
if (!id) {
return null;
}
return {
id,
title: normalizeText(next.title),
description: typeof next.description === 'string' ? next.description : '',
implementationNotes: typeof next.implementationNotes === 'string' ? next.implementationNotes : '',
};
})
.filter((item): item is LayoutInteractionRule => item !== null);
}
export function buildFeatureMenuPrompt(layout: SavedLayoutRecord, rules: LayoutInteractionRule[]) {
const normalizedRules = rules.filter((rule) => rule.title.trim() && rule.description.trim());
if (normalizedRules.length === 0) {
return `${layout.name} 레이아웃에는 아직 실행할 기능설명 항목이 없습니다.`;
}
const featureBody = normalizedRules
.map((rule, index) => {
const sections = [`${index + 1}. 기능 제목: ${rule.title.trim()}`, `설명: ${rule.description.trim()}`];
if (rule.implementationNotes.trim()) {
sections.push(`관련 설명: ${rule.implementationNotes.trim()}`);
}
sections.push('이 항목에 필요한 React UI, 상태 연결, API 연동만 구현하고 다른 기능은 건드리지 마세요.');
return sections.join('\n');
})
.join('\n\n');
return [
`레이아웃 이름: ${layout.name}`,
'아래 기능설명 기준으로 실제 메뉴 화면 구현을 진행해 주세요.',
'실제 개발이 진행되면 설계문서 전체 최종본은 src/features/layout/feature-menu/resources/feature-menu-final.md에 함께 반영해 주세요.',
'설명 문구를 화면에 그대로 노출하지 말고 동작 구현에만 사용해 주세요.',
'기능설명 입력과 관련 설명은 같은 본문에 섞지 말고 탭으로 구분해 주세요.',
'상시 노출 액션 버튼은 문구 없이 아이콘만 사용하고 tooltip과 aria-label로 의미를 보완해 주세요.',
'저장된 레이아웃 구조와 pane 수는 유지하세요.',
'요구사항:',
featureBody,
].join('\n');
}

View File

@@ -0,0 +1 @@
export { FeatureMenuLayoutPage } from './FeatureMenuLayoutPage';

View File

@@ -0,0 +1,23 @@
# 기능설명 관리 패키지 분석 문서
## 요청 목표
- 기존 `기능설명 관리` 화면에서 모바일 기준 가시성을 높이고, 기능설명 편집 흐름을 단순화한다.
- 최종 산출물을 세션 리소스에만 남기지 않고 `feature-menu` 패키지 내부에서도 바로 추적 가능하게 정리한다.
## 작업 대상
- 패키지 루트: `src/features/layout/feature-menu/`
- 관련 저장 레이아웃 ID: `layout-1777643627048`
- 검증 기준 도메인: `https://test.sm-home.cloud/`
- 최종 확인 기준: `4173 preview`
## 확인된 기존 문제
- 상단 description 요약이 모바일 세로 공간을 과도하게 차지했다.
- 하단 액션과 탭 구성이 좁은 화면에서 잘리거나 입력 영역을 압박했다.
- 기능설명 제목은 선택만 가능하고 편집 입력이 없어 수정 흐름이 한 번에 이어지지 않았다.
- 최종 설계/검증 근거가 세션 리소스에 분산돼 패키지 단위 추적성이 약했다.
## 최종 판단
- 이 화면은 신규 메뉴가 아니라 기존 `feature-menu` 패키지의 수정 작업으로 유지한다.
- 기능설명 입력과 Codex 설명은 탭으로 분리하되, 제목 입력은 제거하고 본문 textarea 단일 편집 흐름으로 유지한다.
- 최종 설계 문서, 구현 완료 문서, 검증 이미지까지 패키지 내부 `resources/`에서 같이 관리한다.
- 헤더 가림 수정 이후 완료 기준 산출물은 패키지 내부 최종 경로로 이관해 세션 리소스 의존도를 줄인다.

View File

@@ -0,0 +1,72 @@
# 기능설명 관리 패키지 최종 설계문서
## 문서 목적
- 이 문서는 `src/features/layout/feature-menu/` 패키지의 최종 설계 기준 문서다.
- 실제 개발이 진행된 뒤에는 세션 리소스 문서보다 이 문서를 우선 기준으로 사용한다.
- 세션 리소스 문서는 대화 기록용 보조 산출물로만 유지하고, 최종 분석/검증 산출물은 패키지 내부 `resources/`를 기준으로 관리한다.
## 대상 범위
- 메뉴명: `기능설명 관리`
- 관련 저장 레이아웃 ID: `layout-1777643627048`
- 패키지 루트: `src/features/layout/feature-menu/`
- 진입 시 현재 메뉴 레이아웃 ID를 우선 선택하고, 같은 이름/ID 레코드에 바로 덮어쓴다.
- 모바일에서는 전체 편집 레이아웃이 부모 높이를 넘지 않도록 루트와 편집 박스의 높이 계산을 `border-box` 기준으로 고정하고, 편집 셸은 남는 높이를 강제로 늘리지 않고 내용 기준 높이로 먼저 맞춘다.
## 패키지 구조 기준
- 전용 화면과 로직은 `feature-menu` 패키지 내부에만 둔다.
- 현재 패키지 구성은 `FeatureMenuLayoutPage.tsx`, `FeatureMenuLayoutPage.css`, `featureMenu.types.ts`, `featureMenu.utils.ts`, `featureMenu.chat.ts`로 분리한다.
- 공용 app 계층으로 승격하는 작업은 다른 화면 재사용 근거가 확인될 때만 진행한다.
## 화면 역할
- 이 메뉴는 일반 메모 관리가 아니라 선택한 레이아웃의 `tree.interactions`를 선택, 편집, 실행하는 관리 화면이다.
- 선택된 기능설명의 저장 대상은 `selectedLayout.tree.interactions[].description`이다.
- `implementationNotes`는 본문 저장 대상이 아니라 관련 설명 탭에서만 읽는 보조 메타데이터다.
## 화면 구성 최종본
1. 상단 필터 영역
- `레이아웃명` 선택
- `기능설명 선택`
- `Codex 실행` 아이콘 버튼
2. 본문 편집 영역
- 상단 요약 description 박스는 두지 않는다.
- `추가`, `저장`, `삭제` 액션 아이콘은 두번째 섹션 상단에 고정해 하단 잘림을 피한다.
- 편집영역 툴바에는 문구 없는 `입력 최대화` 아이콘을 함께 둔다.
- 탭 버튼은 본문 상단에 두고, 선택된 탭 내용이 아래로 바로 이어지게 한다.
- 탭 구성: `기능설명 입력`, `Codex 설명`
- `기능설명 입력` 탭은 별도 제목 입력 없이 textarea 하나만 둔다.
- 기능설명 제목은 상단 `기능설명 선택` 드롭다운 항목명으로만 유지한다.
- `기능설명 입력` textarea는 편집 카드 높이를 최대한 채우도록 늘린다.
- `Codex 설명`이 비어 있을 때는 설명 문구를 따로 노출하지 않는다.
- 완료 기준 문서와 검증 산출물은 세션 리소스가 아니라 이 패키지 내부 경로를 우선 기준으로 본다.
## UI 규칙
- 기능설명 입력과 관련 설명은 같은 본문에 섞지 않고 탭으로 분리한다.
- 상시 노출 액션 버튼은 문구 없이 아이콘만 사용한다.
- 버튼 의미는 tooltip과 `aria-label`로 보완한다.
- 모바일에서는 설명성 문구보다 입력 영역과 하단 액션/탭 가시성을 우선한다.
- 모바일에서는 첫 섹션 높이를 줄여 두번째 섹션이 더 위에서 시작되도록 배치한다.
- `textarea`는 일반 상태와 최대화 상태 모두에서 마지막 줄이 잘리지 않게 내부 스크롤을 유지한다.
- 하단 입력 마지막 줄이 잘리지 않도록 탭 본문은 내부 스크롤과 하단 여백을 유지한다.
- 모바일에서는 제목 input, 탭 헤더, textarea가 같은 편집 카드 안에서 보이되, 편집 카드 자체는 다시 `auto + 1fr`로 남는 높이를 채운다.
- 모바일 편집 셸과 탭 본문은 `1fr` 채움을 유지하되, 하단 safe-area를 포함한 바깥 padding을 최소한으로 남긴다.
- 모바일에서는 아이폰 12 Pro 실기기 기준으로 페이지 하단 padding을 `calc(2px + env(safe-area-inset-bottom))`로 더 줄이고, 편집 셸 높이 감산은 `24px`, 입력/설명 패널 감산은 `4px` 수준으로 맞춰 wrapper와 입력 영역이 함께 더 아래까지 늘어나게 한다.
- 모바일 `Codex 설명` 탭도 같은 높이 체계를 유지하고, 하단 padding만 줄여 wrapper 하단의 큰 빈 영역처럼 보이지 않게 한다.
- 전체 페이지 overflow는 숨기고, 넘치는 내용은 페이지 바깥이 아니라 textarea 또는 `Codex 설명` 패널 내부 스크롤에서만 처리한다.
## Codex Live 실행 규칙
- 이 메뉴에서 Codex 실행 시 현재 선택된 레이아웃과 기능설명 본문을 프롬프트 입력으로 사용한다.
- 실제 구현 요청을 보낼 때는 이 패키지 최종 설계문서를 함께 갱신 대상으로 간주한다.
- 후속 개발에서 설계가 바뀌면 세션 문서만 수정하지 말고 이 문서를 먼저 갱신한다.
## 검증 기준
- 실제 수정본이 있으면 문서 설명보다 화면 결과와 preview 검증을 우선한다.
- 검증 대상 기본 도메인은 `https://test.sm-home.cloud/`다.
- 최종 검증 산출물은 `resources/verification/` 아래에 패키지 기준으로 함께 보관한다.
## 패키지 내부 산출물
- 분석 문서: `resources/feature-menu-analysis.md`
- 개발 완료 문서: `resources/feature-menu-implementation.md`
- 최종 preview 검증 이미지: `resources/verification/feature-menu-preview-mobile-final.png`
- 최종 preview 검증 이미지: `resources/verification/feature-menu-preview-desktop-final.png`
- `test.sm-home.cloud` 비교 검증 이미지: `resources/verification/feature-menu-test-sm-home-mobile.png`

View File

@@ -0,0 +1,58 @@
# 기능설명 관리 패키지 개발 완료 문서
## 반영 내용
- 상단 description 요약 영역을 제거했다.
- 상단 필터에는 `Codex 실행` 아이콘 버튼만 유지했다.
- 상단 필터의 두번째 선택은 `기능설명 선택`으로 유지하고, 제목은 드롭다운 항목명으로만 유지하게 바꿨다.
- 본문은 `기능설명 입력`, `Codex 설명` 탭으로 구성했다.
- 후속 단순화 요청에 따라 `기능설명 입력` 탭의 제목 `input`은 제거하고 textarea 하나만 남겼다.
- `추가`, `저장`, `삭제` 아이콘 액션은 두번째 섹션 상단으로 옮겼다.
- 편집영역 툴바에 문구 없는 `입력 최대화` 아이콘 토글을 추가했다.
- `기능설명 입력` textarea는 편집 영역의 남는 세로 공간을 `100%` 채우도록 다시 조정했다.
- 모바일에서는 편집 필드를 grid 행으로 재구성하고 툴바/탭/입력 패딩을 더 줄여 textarea가 부모 영역을 넘치지 않게 조정했다.
- 이번 수정에서는 편집 셸과 탭 본문 자체를 `minmax(0, 1fr)` 기반 grid로 다시 묶고, `textarea`를 남은 높이만 채우는 방식으로 바꿨다.
- 후속 미세조정으로 모바일 `textarea``calc(100% - 52px)`까지만 차도록 다시 줄여, 하단 테두리가 화면 안에서 분명히 보이도록 맞췄다.
- 추가 미세조정으로 모바일 wrapper 자체가 덜 눌려 보이도록 페이지/편집 셸 패딩과 탭 간격을 한 번 더 줄이고, `textarea``calc(100% - 60px)`까지만 차도록 낮췄다.
- 이번 후속 수정에서는 textarea 자체보다 부모 wrapper가 길게 늘어난 점을 기준으로, 모바일에서 루트/편집 셸/탭/입력 필드의 `1fr` 확장을 풀고 내용 기준 높이로 다시 줄였다.
- 이번 최신 수정에서는 너무 줄어든 모바일 높이를 다시 되돌려, 루트/편집 셸/탭/입력 필드를 `auto + 1fr` 채움 구조로 복구하고 바깥 패딩, 탭 간격, `notes` 하단 padding만 더 줄였다.
- 이번 최신 미세조정에서는 부모 카드가 덜 잘리고 textarea는 조금 더 다시 커지도록, 모바일 편집 셸 높이는 `30px` 안쪽으로만 줄이고 제목행/툴바/탭 간격을 더 압축한 뒤 `textarea``Codex 설명` 패널 높이는 각각 `30px` 안쪽 기준으로 다시 맞췄다.
- 이번 최신 재조정에서는 부모 카드 하단선을 더 확실히 보이게 하려고 모바일 편집 셸 높이 감산을 `42px`로 늘리고, 대신 `textarea``Codex 설명` 패널 높이 감산은 `24px`로만 유지해 입력 높이 손실을 최소화했다.
- 이번 최신 재조정에서는 wrapper 하단선이 실제로 보이도록 모바일 편집 셸 감산을 `56px`로 더 키우고, 대신 필터/툴바/탭/제목행 고정 높이를 더 줄인 뒤 `textarea``Codex 설명` 패널 감산은 `20px`로만 유지했다.
- 이번 최신 후속 조정에서는 아이폰 12 Pro 실기기 캡처 기준으로 하단 safe-area 여유와 편집 셸 감산이 과하다고 보고, 모바일 페이지 하단 padding을 `calc(4px + env(safe-area-inset-bottom))`로 줄이고 편집 셸 감산도 `44px`로 낮췄다. 동시에 입력/설명 패널 감산은 `12px`로 완화해 두번째 카드가 더 아래까지 늘어나도록 다시 키웠다.
- 이번 추가 보정은 원복 요청에 따라 되돌렸고, 모바일 기준은 다시 아이폰 12 Pro 실기기 캡처를 따라 페이지 하단 padding `calc(4px + env(safe-area-inset-bottom))`, 편집 셸 감산 `44px`, 입력/설명 패널 감산 `12px` 조합으로 복구했다.
- 이번 최신 조정에서는 모바일 하단 여백을 더 줄여달라는 요청에 맞춰 페이지 하단 padding을 `calc(2px + env(safe-area-inset-bottom))`로 더 낮추고, 편집 셸 감산을 `24px`, 입력/설명 패널 감산을 `4px`로 완화해 wrapper와 textarea를 함께 다시 키웠다.
- `play-saved` 모바일 레이아웃도 헤더 높이를 `52px` 기준으로 맞춰 상단 가림을 제거했다.
- 진입 직후에는 현재 메뉴 레이아웃 ID를 먼저 선택하도록 바꿔, 이전 다른 레이아웃이 기본값으로 먼저 보이지 않게 맞췄다.
- 모바일 하단 여백처럼 보이던 현상은 페이지 루트가 `height: 100%` 상태에서 `padding`까지 바깥으로 더해지던 문제여서, 루트/편집 셸/탭 영역에 `box-sizing: border-box`를 맞춰 전체 레이아웃 overflow를 막았다.
-`Codex 설명` 탭에서는 설명성 문구를 제거했다.
- Codex Live 실패 응답 복구 로직은 `src/app/main/mainChatPanel/chatUtils.ts`에서 별도로 보완됐다.
- 최종 완료 기준 문서와 검증 이미지는 `feature-menu` 패키지 내부 `resources/` 최종 경로로 이관했다.
## 산출물 위치
- 최종 설계 문서: `resources/feature-menu-final.md`
- 최종 분석 문서: `resources/feature-menu-analysis.md`
- 최종 preview 모바일: `resources/verification/feature-menu-preview-mobile-final.png`
- 최종 preview 데스크톱: `resources/verification/feature-menu-preview-desktop-final.png`
- `test.sm-home.cloud` 재현 확인 이미지: `resources/verification/feature-menu-test-sm-home-mobile.png`
## 검증 결과
- `test.sm-home.cloud` 모바일 재현에서는 textarea 하단이 편집 쉘 아래로 약 `91px` 넘치는 기존 상태를 다시 확인했다.
- `2026-05-03` `4173 preview` 모바일 재검증에서는 `tabs.bottom = 651`, `textarea.bottom = 651`로 맞춰졌고, 마지막 줄까지 내부 스크롤로 확인됐다.
- 최종 반영 결과는 `4173 preview` 기준 모바일/데스크톱 캡처로 보관했다.
- 같은 날짜 후속 개선으로 루트/편집 셸/탭 본문을 다시 `auto + minmax(0, 1fr)` 구조로 복구하고, textarea를 `autoSize` 대신 탭 본문 남는 높이 전체를 채우는 방식으로 조정했다.
- `2026-05-03` `4173 preview` 최종 재검증에서는 모바일 기준 `bodyScrollHeight = 844`, `root.bottom = 844`, `textarea.bottom = 831`, `textarea.height = 487.17`로 남는 공간을 채우면서도 페이지 바깥 overflow는 발생하지 않았다.
- 같은 날짜 추가 미세조정 뒤 `4173 preview` 모바일 재검증에서는 `bodyScrollHeight = 664`, `root.bottom = 664`, `textarea.bottom = 599`, `textarea.height = 255.17`로 textarea 하단 여유를 더 확보했고, 페이지 바깥 overflow는 없었다.
- 같은 날짜 최신 재검증에서는 `test.sm-home.cloud`가 여전히 기존 번들로 `textarea.bottom = 747.25`인 반면, `4173 preview` 수정본은 `shell.bottom = 660`, `tabs.bottom = 655`, `textarea.bottom = 595`, `textarea.height = 272.17`로 wrapper 외곽과 하단 입력 영역이 더 안쪽에 들어오도록 정리됐다.
- 이번 후속 수정 검증은 동일한 모바일 문제 이미지 기준으로 부모 wrapper 하단의 과한 빈 영역이 사라졌는지 확인하는 것이 목적이다.
- `2026-05-03` `4173 preview` 모바일 재검증에서는 `shell.bottom = 547.83`, `tabs.bottom = 542.83`, `textarea.bottom = 542.83`, `textarea.height = 220`으로 wrapper 자체가 이전보다 약 `292px` 짧아졌다.
- 같은 날짜 최신 재검증에서는 `4173 preview` 모바일 기준 `shell.bottom = 840`, `tabs.bottom = 836`, `textarea.bottom = 836`, `textarea.height = 521.17`로 다시 하단까지 거의 채우면서도 카드 외곽 하단 선이 캡처 안에서 유지됐다.
- 같은 날짜 최신 재검증에서는 `4173 preview` 모바일 기준 `shell.bottom = 806`, `tabs.bottom = 801`, `textarea.bottom = 771`, `textarea.height = 455.17`로 부모 카드 하단이 다시 화면 안에 들어오면서 textarea 높이도 이전 `v30`보다 `187px` 커졌다.
- 같은 날짜 최신 재검증에서는 `4173 preview` 모바일 기준 `shell.bottom = 794`, `tabs.bottom = 789`, `textarea.bottom = 765`, `textarea.height = 449.17`로 부모 카드 하단선을 `v31`보다 `12px` 더 위로 올리면서도 textarea 높이는 `6px`만 줄였다.
- 같은 날짜 최신 재검증에서는 `4173 preview` 모바일 기준 `shell.bottom = 778`, `tabs.bottom = 774`, `textarea.bottom = 754`, `textarea.height = 447.17`로 wrapper 하단선이 캡처 안에서 분명히 보이면서도 textarea 높이는 직전 대비 `2px`만 줄었다.
- 같은 날짜 원복 기준은 아이폰 12 Pro viewport에서 `test.sm-home.cloud``shell.bottom = 598`, `notes.bottom = 574`인 반면, `4173 preview` 수정본은 `shell.bottom = 616`, `notes.bottom = 600`으로 두번째 카드가 실제로 더 아래까지 내려오던 시점의 값으로 맞춘다.
- 이번 최신 조정 검증은 모바일 wrapper와 textarea를 동시에 더 늘리는 것이 목적이며, `4173 preview` 기준으로 다시 확인한다.
- 같은 날짜 최신 `v36` 검증에서는 아이폰 12 Pro viewport 기준 `4173 preview``shell.bottom = 586`, `tabs.bottom = 582`, `textarea.bottom = 578`, `textarea.height = 323.17`로 확인됐고, 같은 조건 `test.sm-home.cloud``shell.bottom = 564`, `tabs.bottom = 560`, `textarea.bottom = 548`, `textarea.height = 293.17`이었다.
- 같은 날짜 데스크톱 `4173 preview` 재검증에서는 `shell.bottom = 1088`, `tabs.bottom = 1077`, `textarea.bottom = 1077`로 기존 데스크톱 채움 구조는 유지됐다.
- 같은 날짜 데스크톱 `4173 preview` 재검증에서는 `shell.bottom = 1188`, `tabs.bottom = 1177`, `textarea.bottom = 1177`로 데스크톱 채움 구조가 그대로 유지됐다.
- 같은 날짜 후속 수정으로 `기능설명 입력` 탭은 `title input` 없이 textarea 하나만 남았고, `4173 preview` 기준 `titleInputCount = 0`, `textareaCount = 1`로 확인했다.
- 이번 이관 작업은 패키지 내부 문서/리소스 구조 정리이며 동작 로직 추가 변경은 포함하지 않는다.

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

View File

@@ -170,6 +170,7 @@ export function StockAlertLayoutProvider({ children }: PropsWithChildren) {
alertTypeLabels: toAlertTypeLabels(nextAlertTypes),
currentPrice: null,
changeRate: null,
volumeRate5d: null,
quotedAt: null,
createdAt: null,
updatedAt: null,
@@ -546,39 +547,39 @@ export function StockAlertGridPane() {
field: 'stockName',
headerName: '종목명',
editable: false,
minWidth: 170,
flex: 1.3,
minWidth: 150,
flex: 1.05,
},
{
field: 'changeRate',
headerName: '등락률',
editable: false,
minWidth: 130,
flex: 0.9,
minWidth: 104,
flex: 0.72,
cellRenderer: ChangeRateCellRenderer,
},
{
field: 'currentPrice',
headerName: '현재가',
editable: false,
minWidth: 120,
flex: 0.9,
minWidth: 110,
flex: 0.8,
valueFormatter: (params: ValueFormatterParams<StockAlertDraftRow, number | null>) => formatPrice(params.value ?? null),
},
{
field: 'quotedAt',
headerName: '기준일시',
editable: false,
minWidth: 190,
flex: 1.2,
minWidth: 168,
flex: 1,
valueFormatter: (params: ValueFormatterParams<StockAlertDraftRow, string | null>) => formatQuotedAt(params.value ?? null),
},
{
field: 'alertTypes',
headerName: '알림유형',
editable: false,
minWidth: 220,
flex: 1.1,
minWidth: 180,
flex: 0.95,
cellRenderer: AlertTypeCellEditorRenderer,
cellRendererParams: (params: ICellRendererParams<StockAlertDraftRow>) => ({
isOpen: params.data?.id === activeAlertTypeEditorRowId,

View File

@@ -12,6 +12,7 @@ export type StockAlertItem = {
alertTypeLabels: string[];
currentPrice: number | null;
changeRate: number | null;
volumeRate5d: number | null;
quotedAt: string | null;
createdAt: string;
updatedAt: string;
@@ -26,6 +27,7 @@ export type StockAlertDraftRow = {
alertTypeLabels: string[];
currentPrice: number | null;
changeRate: number | null;
volumeRate5d: number | null;
quotedAt: string | null;
createdAt: string | null;
updatedAt: string | null;
@@ -61,6 +63,7 @@ function toDraftRow(item: StockAlertItem): StockAlertDraftRow {
alertTypeLabels: item.alertTypeLabels,
currentPrice: item.currentPrice,
changeRate: item.changeRate,
volumeRate5d: item.volumeRate5d,
quotedAt: item.quotedAt,
createdAt: item.createdAt,
updatedAt: item.updatedAt,
@@ -81,6 +84,7 @@ export function createEmptyStockAlertRow(): StockAlertDraftRow {
alertTypeLabels: ['현재가'],
currentPrice: null,
changeRate: null,
volumeRate5d: null,
quotedAt: null,
createdAt: null,
updatedAt: null,

View File

@@ -880,6 +880,8 @@ export function PlanBoardPage({
return [];
}
const sourceWorkCount = Number(selectedItem.usageSnapshot?.sourceWorkCount ?? 0);
const hasSourceWork = Number.isFinite(sourceWorkCount) && sourceWorkCount > 0;
const needsWorkRetryBecauseBranchMissing =
(selectedItem.status === '작업완료' || selectedItem.status === '릴리즈완료') &&
(selectedItem.workerStatus === 'release반영실패' || selectedItem.workerStatus === 'main반영실패') &&
@@ -917,9 +919,10 @@ export function PlanBoardPage({
if (
selectedItem.status === '릴리즈완료' ||
(selectedItem.status === '작업완료' && selectedItem.workerStatus === 'release반영실패')
(selectedItem.status === '작업완료' && selectedItem.workerStatus === 'release반영실패') ||
(selectedItem.workerStatus === 'main반영실패' && !hasSourceWork)
) {
buttons.push({ key: 'cancel-release', label: '작업취소' });
buttons.push({ key: 'cancel-release', label: '취소완료 처리' });
}
buttons.push({
@@ -2762,7 +2765,7 @@ function getActionSuccessMessage(action: PlanActionType) {
}
if (action === 'cancel-release') {
return 'release 배포 내역을 롤백하고 작업취소로 완료 처리했습니다.';
return '취소완료 처리했습니다.';
}
return '이슈 브랜치 기준으로 main 반영을 요청했습니다.';

View File

@@ -1,4 +1,4 @@
import { CopyOutlined } from '@ant-design/icons';
import { CopyOutlined, PlusOutlined } from '@ant-design/icons';
import {
Alert,
Button,
@@ -44,8 +44,10 @@ import {
type PlanScheduleMode,
type PlanScheduleRepeatUnit,
type PlanScheduledTask,
type PlanScheduledTaskDateRange,
type PlanScheduledTaskDraft,
type PlanScheduledTaskSaveResult,
type PlanScheduledTaskTimeWindow,
} from './api';
const { Paragraph, Text, Title } = Typography;
@@ -98,6 +100,46 @@ const DEFAULT_DAILY_RUN_TIME = '09:00';
const KST_TIME_ZONE = 'Asia/Seoul';
const DAY_MS = 24 * 60 * 60 * 1000;
const TIME_OF_DAY_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/;
const DATE_KEY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const WEEKDAY_OPTIONS = [
{ label: '월', value: 1 },
{ label: '화', value: 2 },
{ label: '수', value: 3 },
{ label: '목', value: 4 },
{ label: '금', value: 5 },
{ label: '토', value: 6 },
{ label: '일', value: 0 },
] as const;
const WEEKDAY_LABELS: Record<number, string> = Object.fromEntries(
WEEKDAY_OPTIONS.map((item) => [item.value, item.label] as const),
);
function normalizeScheduleWeekdays(value: number[] | null | undefined) {
if (!Array.isArray(value)) {
return [];
}
return Array.from(
new Set(value.map((item) => Number(item)).filter((item) => Number.isInteger(item) && item >= 0 && item <= 6)),
).sort((left, right) => left - right);
}
function buildScheduleWeekdaysSummary(value: number[] | null | undefined) {
const weekdays = normalizeScheduleWeekdays(value);
if (!weekdays.length) {
return '매일';
}
return weekdays.map((weekday) => WEEKDAY_LABELS[weekday] ?? String(weekday)).join(', ');
}
function createEmptyScheduleTimeWindow(): PlanScheduledTaskTimeWindow {
return {
startTime: null,
endTime: null,
};
}
function getRepeatIntervalSeconds(value: number, unit: PlanScheduleRepeatUnit) {
const normalizedValue = Math.max(1, Math.round(Number(value) || 1));
@@ -195,6 +237,87 @@ function normalizeOptionalTimeOfDay(value: string | null | undefined) {
return typeof value === 'string' && TIME_OF_DAY_PATTERN.test(value) ? value : null;
}
function normalizeDateKey(value: string | null | undefined) {
const trimmedValue = typeof value === 'string' ? value.trim() : '';
return DATE_KEY_PATTERN.test(trimmedValue) ? trimmedValue : null;
}
function normalizeScheduleDateRanges(value: PlanScheduledTaskDateRange[] | null | undefined) {
if (!Array.isArray(value)) {
return [];
}
return value
.map((range) => ({
startDate: normalizeDateKey(range?.startDate) ?? '',
endDate: normalizeDateKey(range?.endDate) ?? '',
}))
.filter((range) => range.startDate && range.endDate)
.sort((left, right) =>
left.startDate === right.startDate
? left.endDate.localeCompare(right.endDate)
: left.startDate.localeCompare(right.startDate),
)
.filter((range, index, ranges) => {
const previousRange = ranges[index - 1];
return !previousRange || previousRange.startDate !== range.startDate || previousRange.endDate !== range.endDate;
});
}
function normalizeScheduleTimeWindows(value: PlanScheduledTaskTimeWindow[] | null | undefined) {
if (!Array.isArray(value)) {
return [];
}
return value
.map((window) => ({
startTime: normalizeOptionalTimeOfDay(window?.startTime),
endTime: normalizeOptionalTimeOfDay(window?.endTime),
}))
.filter((window) => window.startTime || window.endTime)
.sort((left, right) => {
const leftKey = `${left.startTime ?? ''}:${left.endTime ?? ''}`;
const rightKey = `${right.startTime ?? ''}:${right.endTime ?? ''}`;
return leftKey.localeCompare(rightKey);
})
.filter((window, index, windows) => {
const previousWindow = windows[index - 1];
return !previousWindow
|| previousWindow.startTime !== window.startTime
|| previousWindow.endTime !== window.endTime;
});
}
function formatSingleRepeatWindowLabel(window: PlanScheduledTaskTimeWindow) {
if (window.startTime && window.endTime) {
return `${window.startTime}~${window.endTime}`;
}
if (window.startTime) {
return `${window.startTime} 이후`;
}
if (window.endTime) {
return `${window.endTime} 이전`;
}
return '시간 제한 없음';
}
function buildRepeatWindowsSummary(value: PlanScheduledTaskTimeWindow[] | null | undefined) {
const normalizedWindows = normalizeScheduleTimeWindows(value);
if (!normalizedWindows.length) {
return '시간 제한 없음';
}
if (normalizedWindows.length === 1) {
return formatSingleRepeatWindowLabel(normalizedWindows[0]);
}
return `${formatSingleRepeatWindowLabel(normalizedWindows[0])}${normalizedWindows.length - 1}`;
}
function updateOptionalTimeOfDay(
value: string | null | undefined,
part: 'hour' | 'minute',
@@ -208,25 +331,6 @@ function updateOptionalTimeOfDay(
return part === 'hour' ? `${nextPartValue}:${minute}` : `${hour}:${nextPartValue}`;
}
function formatRepeatWindowLabel(startTime: string | null | undefined, endTime: string | null | undefined) {
const normalizedStartTime = normalizeOptionalTimeOfDay(startTime);
const normalizedEndTime = normalizeOptionalTimeOfDay(endTime);
if (!normalizedStartTime && !normalizedEndTime) {
return '시간 제한 없음';
}
if (normalizedStartTime && normalizedEndTime) {
return `${normalizedStartTime}~${normalizedEndTime}`;
}
if (normalizedStartTime) {
return `${normalizedStartTime} 이후`;
}
return `${normalizedEndTime} 이전`;
}
function formatScheduleCycle(item: PlanScheduledTask) {
const scheduleMode = normalizeScheduleMode(item.scheduleMode);
@@ -234,7 +338,7 @@ function formatScheduleCycle(item: PlanScheduledTask) {
return `매일 ${normalizeDailyRunTime(item.dailyRunTime)} 실행`;
}
return `${formatRepeatInterval(item.repeatIntervalValue, item.repeatIntervalUnit, item.repeatIntervalSeconds)} · ${formatRepeatWindowLabel(item.repeatWindowStartTime, item.repeatWindowEndTime)}`;
return `${formatRepeatInterval(item.repeatIntervalValue, item.repeatIntervalUnit, item.repeatIntervalSeconds)} · ${buildRepeatWindowsSummary(item.repeatWindows)}`;
}
function getValidDate(value: string | null | undefined) {
@@ -277,11 +381,68 @@ function createDateFromKstParts(year: number, month: number, day: number, hour:
return new Date(Date.UTC(year, month - 1, day, hour - 9, minute, 0, 0));
}
function createDateFromKstDateKey(dateKey: string, hour: number, minute: number) {
const [year, month, day] = dateKey.split('-').map((value) => Number(value));
return createDateFromKstParts(year, month, day, hour, minute);
}
function resolveRangeStartRunAt(
item: PlanScheduledTask,
dateRange: PlanScheduledTaskDateRange,
) {
if (normalizeScheduleMode(item.scheduleMode) === 'daily') {
const [hour, minute] = normalizeDailyRunTime(item.dailyRunTime).split(':').map((value) => Number(value));
return createDateFromKstDateKey(dateRange.startDate, hour, minute);
}
const startTime = normalizeScheduleTimeWindows(item.repeatWindows)[0]?.startTime ?? normalizeOptionalTimeOfDay(item.repeatWindowStartTime);
const [hour, minute] = (startTime ?? '00:00').split(':').map((value) => Number(value));
const rangeStartAt = createDateFromKstDateKey(dateRange.startDate, hour, minute);
if (item.immediateRunEnabled) {
return rangeStartAt;
}
return new Date(rangeStartAt.getTime() + Math.max(1, item.repeatIntervalSeconds) * 1000);
}
function isWithinScheduleDateRanges(ranges: PlanScheduledTaskDateRange[], now = new Date()) {
const normalizedRanges = normalizeScheduleDateRanges(ranges);
if (!normalizedRanges.length) {
return true;
}
const dateKey = getKstDateKey(now);
return normalizedRanges.some((range) => range.startDate <= dateKey && dateKey <= range.endDate);
}
function resolveUpcomingScheduleRange(
ranges: PlanScheduledTaskDateRange[],
now = new Date(),
) {
const normalizedRanges = normalizeScheduleDateRanges(ranges);
const dateKey = getKstDateKey(now);
if (!dateKey) {
return null;
}
return normalizedRanges.find((range) => range.startDate > dateKey) ?? null;
}
function resolveNextPlanScheduleRunAt(item: PlanScheduledTask, now = new Date()) {
if (!item.enabled) {
return null;
}
const scheduleDateRanges = normalizeScheduleDateRanges(item.scheduleDateRanges);
if (scheduleDateRanges.length && !isWithinScheduleDateRanges(scheduleDateRanges, now)) {
const upcomingRange = resolveUpcomingScheduleRange(scheduleDateRanges, now);
return upcomingRange ? resolveRangeStartRunAt(item, upcomingRange) : null;
}
const lastRegisteredAt = getValidDate(item.lastRegisteredAt);
if (!lastRegisteredAt && item.immediateRunEnabled) {
@@ -333,6 +494,9 @@ function createEmptyScheduleDraft(defaultReleaseTarget = 'release'): PlanSchedul
repeatIntervalSeconds: getRepeatIntervalSeconds(60, 'minute'),
repeatIntervalMinutes: 60,
dailyRunTime: DEFAULT_DAILY_RUN_TIME,
scheduleWeekdays: [],
scheduleDateRanges: [],
repeatWindows: [],
repeatWindowStartTime: null,
repeatWindowEndTime: null,
};
@@ -366,6 +530,9 @@ function toDraft(item: PlanScheduledTask): PlanScheduledTaskDraft {
repeatIntervalSeconds: item.repeatIntervalSeconds ?? getRepeatIntervalSeconds(repeatIntervalValue, repeatIntervalUnit),
repeatIntervalMinutes: item.repeatIntervalMinutes ?? getRepeatIntervalMinutes(repeatIntervalValue, repeatIntervalUnit),
dailyRunTime: normalizeDailyRunTime(item.dailyRunTime),
scheduleWeekdays: normalizeScheduleWeekdays(item.scheduleWeekdays),
scheduleDateRanges: normalizeScheduleDateRanges(item.scheduleDateRanges),
repeatWindows: normalizeScheduleTimeWindows(item.repeatWindows),
repeatWindowStartTime: normalizeOptionalTimeOfDay(item.repeatWindowStartTime),
repeatWindowEndTime: normalizeOptionalTimeOfDay(item.repeatWindowEndTime),
};
@@ -386,13 +553,16 @@ function formatNextPlanScheduleRunAt(item: PlanScheduledTask) {
return '중지';
}
return formatPlanScheduleDateTime(resolveNextPlanScheduleRunAt(item));
const nextRunAt = resolveNextPlanScheduleRunAt(item);
return nextRunAt ? formatPlanScheduleDateTime(nextRunAt) : '기간 종료';
}
function validateScheduleDraft(draft: PlanScheduledTaskDraft, items: PlanScheduledTask[]) {
const messages: string[] = [];
const workId = draft.workId.trim();
const note = draft.note.trim();
const normalizedScheduleWeekdays = normalizeScheduleWeekdays(draft.scheduleWeekdays);
const normalizedRepeatWindows = normalizeScheduleTimeWindows(draft.repeatWindows);
if (!workId) {
messages.push('작업 ID를 입력하세요.');
@@ -404,6 +574,16 @@ function validateScheduleDraft(draft: PlanScheduledTaskDraft, items: PlanSchedul
messages.push('반복 등록할 작업 메모를 입력하세요.');
}
if (normalizedScheduleWeekdays.length > 7) {
messages.push('적용 요일은 최대 7개까지 등록할 수 있습니다.');
}
if (draft.scheduleMode === 'interval') {
if (normalizedRepeatWindows.length > 24) {
messages.push('적용 시간은 최대 24개까지 등록할 수 있습니다.');
}
}
if (!draft.enabled) {
messages.push('비활성 스케줄은 자동 등록되지 않습니다.');
}
@@ -489,10 +669,13 @@ export function PlanSchedulePage() {
repeatIntervalValue,
repeatIntervalSeconds: getRepeatIntervalSeconds(repeatIntervalValue, draft.repeatIntervalUnit),
repeatIntervalMinutes: getRepeatIntervalMinutes(repeatIntervalValue, draft.repeatIntervalUnit),
scheduleWeekdays: normalizeScheduleWeekdays(draft.scheduleWeekdays),
scheduleDateRanges: draft.scheduleMode === 'interval' ? [] : normalizeScheduleDateRanges(draft.scheduleDateRanges),
repeatWindows: draft.scheduleMode === 'interval' ? normalizeScheduleTimeWindows(draft.repeatWindows) : [],
repeatWindowStartTime:
draft.scheduleMode === 'interval' ? normalizeOptionalTimeOfDay(draft.repeatWindowStartTime) : null,
draft.scheduleMode === 'interval' ? normalizeScheduleTimeWindows(draft.repeatWindows)[0]?.startTime ?? null : null,
repeatWindowEndTime:
draft.scheduleMode === 'interval' ? normalizeOptionalTimeOfDay(draft.repeatWindowEndTime) : null,
draft.scheduleMode === 'interval' ? normalizeScheduleTimeWindows(draft.repeatWindows)[0]?.endTime ?? null : null,
};
const saveResult = draft.id
? await updatePlanScheduledTask(draftToSave)
@@ -702,6 +885,14 @@ const PlanScheduleList = memo(function PlanScheduleList({
{item.executionMode === 'managed-service' ? '외부 서비스 관리형' : 'Codex 직접'}
</Tag>
<Tag>{formatScheduleCycle(item)}</Tag>
<Tag color={item.scheduleWeekdays.length ? 'cyan' : 'default'}>
{item.scheduleWeekdays.length ? `요일 ${buildScheduleWeekdaysSummary(item.scheduleWeekdays)}` : '매일'}
</Tag>
{item.scheduleMode === 'interval' ? (
<Tag color={item.repeatWindows.length ? 'magenta' : 'default'}>
{item.repeatWindows.length ? `시간 ${item.repeatWindows.length}` : '상시'}
</Tag>
) : null}
<Tag color="blue"> {formatNextPlanScheduleRunAt(item)}</Tag>
<Tag>{item.immediateRunEnabled ? '즉시실행' : '주기 후 실행'}</Tag>
{item.refreshContextSnapshotOnNextRun ? <Tag color="purple"> </Tag> : null}
@@ -755,6 +946,12 @@ function PlanScheduleDetail({
description={
<Space direction="vertical" size={4}>
<Text> : {formatNextPlanScheduleRunAt(selectedItem)}</Text>
<Text>
: {buildScheduleWeekdaysSummary(selectedItem.scheduleWeekdays)}
</Text>
{selectedItem.scheduleMode === 'interval' ? (
<Text> : {buildRepeatWindowsSummary(selectedItem.repeatWindows)}</Text>
) : null}
<Text> : {selectedItem.refreshContextSnapshotOnNextRun ? '다음 실행 1회' : '없음'}</Text>
<Text> : {selectedItem.executionMode === 'managed-service' ? '별도 서비스 관리형' : 'Codex 직접 처리'}</Text>
{selectedItem.executionMode === 'managed-service' ? (
@@ -913,46 +1110,64 @@ function PlanScheduleDetail({
onChange={(key) => onChangeDraft((previous) => ({ ...previous, scheduleMode: key as PlanScheduleMode }))}
/>
{draft.scheduleMode === 'daily' ? (
<Space align="center" wrap>
<Text></Text>
<Select
style={{ width: 96 }}
options={HOUR_OPTIONS}
value={normalizeDailyRunTime(draft.dailyRunTime).split(':')[0]}
popupClassName="plan-schedule-page__select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={!hasAccess}
onChange={(value) =>
onChangeDraft((previous) => ({
...previous,
dailyRunTime: updateDailyRunTime(previous.dailyRunTime, 'hour', value),
repeatIntervalValue: 1,
repeatIntervalUnit: 'day',
repeatIntervalSeconds: getRepeatIntervalSeconds(1, 'day'),
repeatIntervalMinutes: getRepeatIntervalMinutes(1, 'day'),
}))
}
/>
<Select
style={{ width: 96 }}
options={MINUTE_OPTIONS}
value={normalizeDailyRunTime(draft.dailyRunTime).split(':')[1]}
popupClassName="plan-schedule-page__select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={!hasAccess}
onChange={(value) =>
onChangeDraft((previous) => ({
...previous,
dailyRunTime: updateDailyRunTime(previous.dailyRunTime, 'minute', value),
repeatIntervalValue: 1,
repeatIntervalUnit: 'day',
repeatIntervalSeconds: getRepeatIntervalSeconds(1, 'day'),
repeatIntervalMinutes: getRepeatIntervalMinutes(1, 'day'),
}))
}
/>
<Text type="secondary"> </Text>
</Space>
<>
<div className="plan-schedule-page__weekday-section">
<Text strong> </Text>
<Checkbox.Group
className="plan-schedule-page__weekday-group"
options={WEEKDAY_OPTIONS.map((item) => ({ label: item.label, value: item.value }))}
value={normalizeScheduleWeekdays(draft.scheduleWeekdays)}
disabled={!hasAccess}
onChange={(value) =>
onChangeDraft((previous) => ({
...previous,
scheduleWeekdays: normalizeScheduleWeekdays(value as number[]),
}))
}
/>
<Text type="secondary"> .</Text>
</div>
<Space align="center" wrap>
<Text>{draft.scheduleWeekdays.length ? `${buildScheduleWeekdaysSummary(draft.scheduleWeekdays)} ` : '매일 '}</Text>
<Select
style={{ width: 96 }}
options={HOUR_OPTIONS}
value={normalizeDailyRunTime(draft.dailyRunTime).split(':')[0]}
popupClassName="plan-schedule-page__select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={!hasAccess}
onChange={(value) =>
onChangeDraft((previous) => ({
...previous,
dailyRunTime: updateDailyRunTime(previous.dailyRunTime, 'hour', value),
repeatIntervalValue: 1,
repeatIntervalUnit: 'day',
repeatIntervalSeconds: getRepeatIntervalSeconds(1, 'day'),
repeatIntervalMinutes: getRepeatIntervalMinutes(1, 'day'),
}))
}
/>
<Select
style={{ width: 96 }}
options={MINUTE_OPTIONS}
value={normalizeDailyRunTime(draft.dailyRunTime).split(':')[1]}
popupClassName="plan-schedule-page__select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={!hasAccess}
onChange={(value) =>
onChangeDraft((previous) => ({
...previous,
dailyRunTime: updateDailyRunTime(previous.dailyRunTime, 'minute', value),
repeatIntervalValue: 1,
repeatIntervalUnit: 'day',
repeatIntervalSeconds: getRepeatIntervalSeconds(1, 'day'),
repeatIntervalMinutes: getRepeatIntervalMinutes(1, 'day'),
}))
}
/>
<Text type="secondary"> </Text>
</Space>
</>
) : (
<>
<Space align="center" wrap>
@@ -1020,76 +1235,146 @@ function PlanScheduleDetail({
</Button>
))}
</Space>
<Space align="center" wrap style={{ marginTop: 12 }}>
<Text type="secondary"> </Text>
<Select
allowClear
placeholder="제한없음"
style={{ width: 96 }}
options={HOUR_OPTIONS}
value={normalizeOptionalTimeOfDay(draft.repeatWindowStartTime)?.split(':')[0]}
popupClassName="plan-schedule-page__select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
<div className="plan-schedule-page__weekday-section" style={{ marginTop: 12 }}>
<Flex justify="space-between" align="center" gap={8} wrap>
<Text strong> </Text>
<Text type="secondary"> .</Text>
</Flex>
<Checkbox.Group
className="plan-schedule-page__weekday-group"
options={WEEKDAY_OPTIONS.map((item) => ({ label: item.label, value: item.value }))}
value={normalizeScheduleWeekdays(draft.scheduleWeekdays)}
disabled={!hasAccess}
onChange={(value) =>
onChangeDraft((previous) => ({
...previous,
repeatWindowStartTime: updateOptionalTimeOfDay(previous.repeatWindowStartTime, 'hour', value),
scheduleWeekdays: normalizeScheduleWeekdays(value as number[]),
}))
}
/>
<Select
allowClear
placeholder="제한없음"
style={{ width: 96 }}
options={MINUTE_OPTIONS}
value={normalizeOptionalTimeOfDay(draft.repeatWindowStartTime)?.split(':')[1]}
popupClassName="plan-schedule-page__select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={!hasAccess}
onChange={(value) =>
onChangeDraft((previous) => ({
...previous,
repeatWindowStartTime: updateOptionalTimeOfDay(previous.repeatWindowStartTime, 'minute', value),
}))
}
/>
<Text type="secondary"> </Text>
<Select
allowClear
placeholder="제한없음"
style={{ width: 96 }}
options={HOUR_OPTIONS}
value={normalizeOptionalTimeOfDay(draft.repeatWindowEndTime)?.split(':')[0]}
popupClassName="plan-schedule-page__select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={!hasAccess}
onChange={(value) =>
onChangeDraft((previous) => ({
...previous,
repeatWindowEndTime: updateOptionalTimeOfDay(previous.repeatWindowEndTime, 'hour', value),
}))
}
/>
<Select
allowClear
placeholder="제한없음"
style={{ width: 96 }}
options={MINUTE_OPTIONS}
value={normalizeOptionalTimeOfDay(draft.repeatWindowEndTime)?.split(':')[1]}
popupClassName="plan-schedule-page__select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={!hasAccess}
onChange={(value) =>
onChangeDraft((previous) => ({
...previous,
repeatWindowEndTime: updateOptionalTimeOfDay(previous.repeatWindowEndTime, 'minute', value),
}))
}
/>
</Space>
<div style={{ marginTop: 8 }}>
<Text type="secondary"> .</Text>
</div>
<div className="plan-schedule-page__time-window-section" style={{ marginTop: 12 }}>
<Flex justify="space-between" align="center" gap={8} wrap>
<Text strong> </Text>
<Button
size="small"
icon={<PlusOutlined />}
disabled={!hasAccess}
onClick={() =>
onChangeDraft((previous) => ({
...previous,
repeatWindows: [...previous.repeatWindows, createEmptyScheduleTimeWindow()],
}))
}
>
</Button>
</Flex>
{draft.repeatWindows.length ? (
<div className="plan-schedule-page__time-window-list">
{draft.repeatWindows.map((window, index) => (
<div key={`schedule-time-window-${index}`} className="plan-schedule-page__time-window-item">
<Text type="secondary"></Text>
<Select
allowClear
placeholder="시"
style={{ width: 88 }}
options={HOUR_OPTIONS}
value={normalizeOptionalTimeOfDay(window.startTime)?.split(':')[0]}
popupClassName="plan-schedule-page__select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={!hasAccess}
onChange={(value) =>
onChangeDraft((previous) => ({
...previous,
repeatWindows: previous.repeatWindows.map((item, itemIndex) =>
itemIndex === index
? { ...item, startTime: updateOptionalTimeOfDay(item.startTime, 'hour', value) }
: item,
),
}))
}
/>
<Select
allowClear
placeholder="분"
style={{ width: 88 }}
options={MINUTE_OPTIONS}
value={normalizeOptionalTimeOfDay(window.startTime)?.split(':')[1]}
popupClassName="plan-schedule-page__select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={!hasAccess}
onChange={(value) =>
onChangeDraft((previous) => ({
...previous,
repeatWindows: previous.repeatWindows.map((item, itemIndex) =>
itemIndex === index
? { ...item, startTime: updateOptionalTimeOfDay(item.startTime, 'minute', value) }
: item,
),
}))
}
/>
<Text type="secondary"></Text>
<Select
allowClear
placeholder="시"
style={{ width: 88 }}
options={HOUR_OPTIONS}
value={normalizeOptionalTimeOfDay(window.endTime)?.split(':')[0]}
popupClassName="plan-schedule-page__select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={!hasAccess}
onChange={(value) =>
onChangeDraft((previous) => ({
...previous,
repeatWindows: previous.repeatWindows.map((item, itemIndex) =>
itemIndex === index
? { ...item, endTime: updateOptionalTimeOfDay(item.endTime, 'hour', value) }
: item,
),
}))
}
/>
<Select
allowClear
placeholder="분"
style={{ width: 88 }}
options={MINUTE_OPTIONS}
value={normalizeOptionalTimeOfDay(window.endTime)?.split(':')[1]}
popupClassName="plan-schedule-page__select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={!hasAccess}
onChange={(value) =>
onChangeDraft((previous) => ({
...previous,
repeatWindows: previous.repeatWindows.map((item, itemIndex) =>
itemIndex === index
? { ...item, endTime: updateOptionalTimeOfDay(item.endTime, 'minute', value) }
: item,
),
}))
}
/>
<Button
danger
size="small"
disabled={!hasAccess}
onClick={() =>
onChangeDraft((previous) => ({
...previous,
repeatWindows: previous.repeatWindows.filter((_, itemIndex) => itemIndex !== index),
}))
}
>
</Button>
</div>
))}
</div>
) : (
<Text type="secondary"> .</Text>
)}
</div>
</>
)}

View File

@@ -0,0 +1,818 @@
"use strict";
var __extends = (this && this.__extends) || (function () {
var extendStatics = function (d, b) {
extendStatics = Object.setPrototypeOf ||
({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||
function (d, b) { for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p]; };
return extendStatics(d, b);
};
return function (d, b) {
if (typeof b !== "function" && b !== null)
throw new TypeError("Class extends value " + String(b) + " is not a constructor or null");
extendStatics(d, b);
function __() { this.constructor = d; }
d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
};
})();
var __assign = (this && this.__assign) || function () {
__assign = Object.assign || function(t) {
for (var s, i = 1, n = arguments.length; i < n; i++) {
s = arguments[i];
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
t[p] = s[p];
}
return t;
};
return __assign.apply(this, arguments);
};
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator = (this && this.__generator) || function (thisArg, body) {
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
function verb(n) { return function (v) { return step([n, v]); }; }
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (g && (g = 0, op[0] && (_ = 0)), _) try {
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0: case 1: t = op; break;
case 4: _.label++; return { value: op[1], done: false };
case 5: _.label++; y = op[1]; op = [0]; continue;
case 7: op = _.ops.pop(); _.trys.pop(); continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
if (t[2]) _.ops.pop();
_.trys.pop(); continue;
}
op = body.call(thisArg, _);
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.setupPlanBoard = setupPlanBoard;
exports.fetchPlanItems = fetchPlanItems;
exports.fetchPlanItemsWithLatestSourceWorks = fetchPlanItemsWithLatestSourceWorks;
exports.createPlanItem = createPlanItem;
exports.updatePlanItem = updatePlanItem;
exports.updatePlanItemJangsingProcessingRequired = updatePlanItemJangsingProcessingRequired;
exports.deletePlanItem = deletePlanItem;
exports.runPlanAction = runPlanAction;
exports.fetchPlanIssueHistories = fetchPlanIssueHistories;
exports.appendPlanIssueAction = appendPlanIssueAction;
exports.fetchPlanActionHistories = fetchPlanActionHistories;
exports.appendPlanActionHistory = appendPlanActionHistory;
exports.fetchPlanSourceWorkHistories = fetchPlanSourceWorkHistories;
exports.fetchReleaseReviewBoardItems = fetchReleaseReviewBoardItems;
exports.updatePlanReleaseReview = updatePlanReleaseReview;
exports.fetchPlanSourceWorkHistory = fetchPlanSourceWorkHistory;
exports.fetchPlanScheduledTasks = fetchPlanScheduledTasks;
exports.createPlanScheduledTask = createPlanScheduledTask;
exports.updatePlanScheduledTask = updatePlanScheduledTask;
exports.deletePlanScheduledTask = deletePlanScheduledTask;
var clientIdentity_1 = require("../../app/main/clientIdentity");
var automationTypeAccess_1 = require("../../app/main/automationTypeAccess");
var tokenAccess_1 = require("../../app/main/tokenAccess");
function resolvePlanApiBaseUrl() {
if (import.meta.env.VITE_WORK_SERVER_URL) {
return import.meta.env.VITE_WORK_SERVER_URL;
}
return '/api';
}
function normalizePlanAutomationType(value) {
return (0, automationTypeAccess_1.normalizeAutomationTypeId)(value);
}
function resolveWorkServerFallbackBaseUrl() {
if (typeof window === 'undefined') {
return null;
}
var hostname = window.location.hostname;
var isLocalWorkServerHost = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '0.0.0.0';
if (!isLocalWorkServerHost) {
return null;
}
var fallbackUrl = new URL(window.location.origin);
fallbackUrl.port = '3100';
fallbackUrl.pathname = '/api';
fallbackUrl.search = '';
fallbackUrl.hash = '';
return fallbackUrl.toString().replace(/\/+$/, '');
}
var PLAN_API_BASE_URL = resolvePlanApiBaseUrl();
var PLAN_API_FALLBACK_BASE_URL = !import.meta.env.VITE_WORK_SERVER_URL && PLAN_API_BASE_URL === '/api'
? resolveWorkServerFallbackBaseUrl()
: null;
var PlanApiError = /** @class */ (function (_super) {
__extends(PlanApiError, _super);
function PlanApiError(message, status) {
var _this = _super.call(this, message) || this;
_this.name = 'PlanApiError';
_this.status = status;
return _this;
}
return PlanApiError;
}(Error));
function requestOnce(baseUrl, path, init) {
return __awaiter(this, void 0, void 0, function () {
var headers, hasBody, method, timeoutMs, controller, timeoutId, token, response, error_1, text, payload, contentType, text;
var _a, _b, _c, _d;
return __generator(this, function (_e) {
switch (_e.label) {
case 0:
headers = (0, clientIdentity_1.appendClientIdHeader)(init === null || init === void 0 ? void 0 : init.headers);
hasBody = (init === null || init === void 0 ? void 0 : init.body) !== undefined && init.body !== null;
method = (_b = (_a = init === null || init === void 0 ? void 0 : init.method) === null || _a === void 0 ? void 0 : _a.toUpperCase()) !== null && _b !== void 0 ? _b : 'GET';
timeoutMs = 8000;
controller = new AbortController();
timeoutId = setTimeout(function () {
controller.abort();
}, timeoutMs);
if (hasBody && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
token = (0, tokenAccess_1.getRegisteredAccessToken)();
if (token && !headers.has('X-Access-Token')) {
headers.set('X-Access-Token', token);
}
_e.label = 1;
case 1:
_e.trys.push([1, 3, , 4]);
return [4 /*yield*/, fetch("".concat(baseUrl).concat(path), __assign(__assign({}, init), { headers: headers, signal: controller.signal, cache: (_c = init === null || init === void 0 ? void 0 : init.cache) !== null && _c !== void 0 ? _c : (method === 'GET' ? 'no-store' : undefined) }))];
case 2:
response = _e.sent();
return [3 /*break*/, 4];
case 3:
error_1 = _e.sent();
clearTimeout(timeoutId);
if (error_1 instanceof DOMException && error_1.name === 'AbortError') {
throw new PlanApiError('서버 응답이 지연됩니다. 잠시 후 다시 시도해 주세요.', 408);
}
throw error_1;
case 4:
clearTimeout(timeoutId);
if (!!response.ok) return [3 /*break*/, 6];
return [4 /*yield*/, response.text()];
case 5:
text = _e.sent();
try {
payload = JSON.parse(text);
throw new PlanApiError(payload.message || "요청 처리에 실패했습니다.", response.status);
}
catch (_f) {
throw new PlanApiError(text || "요청 처리에 실패했습니다.", response.status);
}
_e.label = 6;
case 6:
contentType = (_d = response.headers.get("content-type")) !== null && _d !== void 0 ? _d : "";
if (!!contentType.toLowerCase().includes("application/json")) return [3 /*break*/, 8];
return [4 /*yield*/, response.text()];
case 7:
text = _e.sent();
throw new PlanApiError(text ? "서버 응답이 JSON이 아닙니다." : "서버 응답을 확인할 수 없습니다.", 502);
case 8: return [2 /*return*/, response.json()];
}
});
});
}
function request(path, init) {
return __awaiter(this, void 0, void 0, function () {
var error_2, shouldRetryWithFallback;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
_a.trys.push([0, 2, , 3]);
return [4 /*yield*/, requestOnce(PLAN_API_BASE_URL, path, init)];
case 1: return [2 /*return*/, _a.sent()];
case 2:
error_2 = _a.sent();
shouldRetryWithFallback = PLAN_API_FALLBACK_BASE_URL &&
PLAN_API_FALLBACK_BASE_URL !== PLAN_API_BASE_URL &&
(error_2 instanceof PlanApiError
? error_2.status === 404 || error_2.status === 408 || error_2.status === 502
: error_2 instanceof Error && (/not found/i.test(error_2.message) || /404/.test(error_2.message)));
if (!shouldRetryWithFallback) {
throw error_2;
}
return [2 /*return*/, requestOnce(PLAN_API_FALLBACK_BASE_URL, path, init)];
case 3: return [2 /*return*/];
}
});
});
}
function requestWithMetaOnce(baseUrl, path, init) {
return __awaiter(this, void 0, void 0, function () {
var headers, hasBody, method, timeoutMs, controller, timeoutId, token, startedAt, response, error_3, text, meta, payload, contentType;
var _a, _b, _c, _d;
return __generator(this, function (_e) {
switch (_e.label) {
case 0:
headers = (0, clientIdentity_1.appendClientIdHeader)(init === null || init === void 0 ? void 0 : init.headers);
hasBody = (init === null || init === void 0 ? void 0 : init.body) !== undefined && init.body !== null;
method = (_b = (_a = init === null || init === void 0 ? void 0 : init.method) === null || _a === void 0 ? void 0 : _a.toUpperCase()) !== null && _b !== void 0 ? _b : 'GET';
timeoutMs = 8000;
controller = new AbortController();
timeoutId = setTimeout(function () {
controller.abort();
}, timeoutMs);
if (hasBody && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
token = (0, tokenAccess_1.getRegisteredAccessToken)();
if (token && !headers.has('X-Access-Token')) {
headers.set('X-Access-Token', token);
}
startedAt = performance.now();
_e.label = 1;
case 1:
_e.trys.push([1, 3, , 4]);
return [4 /*yield*/, fetch("".concat(baseUrl).concat(path), __assign(__assign({}, init), { headers: headers, signal: controller.signal, cache: (_c = init === null || init === void 0 ? void 0 : init.cache) !== null && _c !== void 0 ? _c : (method === 'GET' ? 'no-store' : undefined) }))];
case 2:
response = _e.sent();
return [3 /*break*/, 4];
case 3:
error_3 = _e.sent();
clearTimeout(timeoutId);
if (error_3 instanceof DOMException && error_3.name === 'AbortError') {
throw new PlanApiError('서버 응답이 지연됩니다. 잠시 후 다시 시도해 주세요.', 408);
}
throw error_3;
case 4:
clearTimeout(timeoutId);
return [4 /*yield*/, response.text()];
case 5:
text = _e.sent();
meta = {
durationMs: Math.max(0, Math.round(performance.now() - startedAt)),
responseBytes: new TextEncoder().encode(text).length,
};
if (!response.ok) {
try {
payload = JSON.parse(text);
throw new PlanApiError(payload.message || '요청 처리에 실패했습니다.', response.status);
}
catch (_f) {
throw new PlanApiError(text || '요청 처리에 실패했습니다.', response.status);
}
}
contentType = (_d = response.headers.get('content-type')) !== null && _d !== void 0 ? _d : '';
if (!contentType.toLowerCase().includes('application/json')) {
throw new PlanApiError(text ? '서버 응답이 JSON이 아닙니다.' : '서버 응답을 확인할 수 없습니다.', 502);
}
return [2 /*return*/, {
data: JSON.parse(text),
meta: meta,
}];
}
});
});
}
function requestWithMeta(path, init) {
return __awaiter(this, void 0, void 0, function () {
var error_4, shouldRetryWithFallback;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
_a.trys.push([0, 2, , 3]);
return [4 /*yield*/, requestWithMetaOnce(PLAN_API_BASE_URL, path, init)];
case 1: return [2 /*return*/, _a.sent()];
case 2:
error_4 = _a.sent();
shouldRetryWithFallback = PLAN_API_FALLBACK_BASE_URL &&
PLAN_API_FALLBACK_BASE_URL !== PLAN_API_BASE_URL &&
(error_4 instanceof PlanApiError
? error_4.status === 404 || error_4.status === 408 || error_4.status === 502
: error_4 instanceof Error && (/not found/i.test(error_4.message) || /404/.test(error_4.message)));
if (!shouldRetryWithFallback) {
throw error_4;
}
return [2 /*return*/, requestWithMetaOnce(PLAN_API_FALLBACK_BASE_URL, path, init)];
case 3: return [2 /*return*/];
}
});
});
}
function setupPlanBoard() {
return __awaiter(this, void 0, void 0, function () {
return __generator(this, function (_a) {
return [2 /*return*/, request('/plan/setup', {
method: 'POST',
body: JSON.stringify({}),
})];
});
});
}
function fetchPlanItems(status) {
return __awaiter(this, void 0, void 0, function () {
var query, response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
void status;
query = '';
return [4 /*yield*/, request("/plan/items".concat(query))];
case 1:
response = _a.sent();
return [2 /*return*/, response.items.map(normalizePlanItem)];
}
});
});
}
function fetchPlanItemsWithLatestSourceWorks(status) {
return __awaiter(this, void 0, void 0, function () {
var query, response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
void status;
query = '';
return [4 /*yield*/, requestWithMeta("/plan/items".concat(query))];
case 1:
response = _a.sent();
return [2 /*return*/, {
items: response.data.items.map(normalizePlanItem),
meta: response.meta,
}];
}
});
});
}
function createPlanItem(draft) {
return __awaiter(this, void 0, void 0, function () {
var response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, request('/plan/items', {
method: 'POST',
body: JSON.stringify({
workId: draft.workId,
note: draft.note,
automationType: draft.automationType,
automationContextIds: draft.automationContextIds,
jangsingProcessingRequired: draft.jangsingProcessingRequired,
autoDeployToMain: draft.autoDeployToMain,
suppressWebPush: draft.suppressWebPush,
}),
})];
case 1:
response = _a.sent();
return [2 /*return*/, normalizePlanItem(response.item)];
}
});
});
}
function updatePlanItem(draft) {
return __awaiter(this, void 0, void 0, function () {
var response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
if (!draft.id) {
throw new Error('수정할 작업 항목 ID가 없습니다.');
}
return [4 /*yield*/, request("/plan/items/".concat(draft.id), {
method: 'PATCH',
body: JSON.stringify({
workId: draft.workId,
note: draft.note,
automationType: draft.automationType,
automationContextIds: draft.automationContextIds,
jangsingProcessingRequired: draft.jangsingProcessingRequired,
autoDeployToMain: draft.autoDeployToMain,
suppressWebPush: draft.suppressWebPush,
}),
})];
case 1:
response = _a.sent();
return [2 /*return*/, normalizePlanItem(response.item)];
}
});
});
}
function updatePlanItemJangsingProcessingRequired(id, jangsingProcessingRequired) {
return __awaiter(this, void 0, void 0, function () {
var response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, request("/plan/items/".concat(id), {
method: 'PATCH',
body: JSON.stringify({
jangsingProcessingRequired: jangsingProcessingRequired,
}),
})];
case 1:
response = _a.sent();
return [2 /*return*/, normalizePlanItem(response.item)];
}
});
});
}
function deletePlanItem(id) {
return __awaiter(this, void 0, void 0, function () {
var response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, request("/plan/items/".concat(id), {
method: 'DELETE',
})];
case 1:
response = _a.sent();
return [2 /*return*/, response.id];
}
});
});
}
function runPlanAction(id, action) {
return __awaiter(this, void 0, void 0, function () {
var response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, request("/plan/items/".concat(id, "/actions/").concat(action), {
method: 'POST',
body: JSON.stringify({}),
})];
case 1:
response = _a.sent();
return [2 /*return*/, {
item: normalizePlanItem(response.item),
message: response.message,
}];
}
});
});
}
function fetchPlanIssueHistories(id) {
return __awaiter(this, void 0, void 0, function () {
var response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, request("/plan/items/".concat(id, "/issues"))];
case 1:
response = _a.sent();
return [2 /*return*/, response.items];
}
});
});
}
function appendPlanIssueAction(id_1, actionNote_1) {
return __awaiter(this, arguments, void 0, function (id, actionNote, resolve, retry) {
var response;
if (resolve === void 0) { resolve = false; }
if (retry === void 0) { retry = false; }
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, request("/plan/items/".concat(id, "/issues/action"), {
method: 'POST',
body: JSON.stringify({
actionNote: actionNote,
resolve: resolve,
retry: retry,
}),
})];
case 1:
response = _a.sent();
return [2 /*return*/, {
item: response.item,
planItem: response.planItem ? normalizePlanItem(response.planItem) : undefined,
message: response.message,
}];
}
});
});
}
function fetchPlanActionHistories(id) {
return __awaiter(this, void 0, void 0, function () {
var response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, request("/plan/items/".concat(id, "/actions"))];
case 1:
response = _a.sent();
return [2 /*return*/, response.items];
}
});
});
}
function appendPlanActionHistory(id, actionNote) {
return __awaiter(this, void 0, void 0, function () {
var response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, request("/plan/items/".concat(id, "/actions/note"), {
method: 'POST',
body: JSON.stringify({
actionNote: actionNote,
actionType: '추가조치',
}),
})];
case 1:
response = _a.sent();
return [2 /*return*/, {
item: response.item,
planItem: response.planItem ? normalizePlanItem(response.planItem) : undefined,
message: response.message,
}];
}
});
});
}
function normalizePlanItem(item) {
return __assign(__assign({}, item), { automationType: normalizePlanAutomationType(item.automationType), automationBehaviorType: (0, automationTypeAccess_1.normalizeAutomationTypeId)(item.automationBehaviorType), automationContextIds: Array.isArray(item.automationContextIds)
? item.automationContextIds.map(function (value) { return String(value).trim(); }).filter(Boolean)
: [], releaseReviewNote: typeof item.releaseReviewNote === 'string' ? item.releaseReviewNote : '', usageSnapshot: normalizePlanAutomationUsageSnapshot(item.usageSnapshot) });
}
function normalizePlanAutomationUsageSnapshot(value) {
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r;
if (!value) {
return null;
}
return {
tokenTotals: {
total: Number((_b = (_a = value.tokenTotals) === null || _a === void 0 ? void 0 : _a.total) !== null && _b !== void 0 ? _b : 0),
input: Number((_d = (_c = value.tokenTotals) === null || _c === void 0 ? void 0 : _c.input) !== null && _d !== void 0 ? _d : 0),
output: Number((_f = (_e = value.tokenTotals) === null || _e === void 0 ? void 0 : _e.output) !== null && _f !== void 0 ? _f : 0),
cached: Number((_h = (_g = value.tokenTotals) === null || _g === void 0 ? void 0 : _g.cached) !== null && _h !== void 0 ? _h : 0),
reasoning: Number((_k = (_j = value.tokenTotals) === null || _j === void 0 ? void 0 : _j.reasoning) !== null && _k !== void 0 ? _k : 0),
},
totalTokens: Number((_l = value.totalTokens) !== null && _l !== void 0 ? _l : 0),
retryCount: Number((_m = value.retryCount) !== null && _m !== void 0 ? _m : 0),
sourceWorkCount: Number((_o = value.sourceWorkCount) !== null && _o !== void 0 ? _o : 0),
processingStartedAt: (_p = value.processingStartedAt) !== null && _p !== void 0 ? _p : null,
processingEndedAt: (_q = value.processingEndedAt) !== null && _q !== void 0 ? _q : null,
processingEndedAtSource: (_r = value.processingEndedAtSource) !== null && _r !== void 0 ? _r : null,
processingDurationSeconds: value.processingDurationSeconds === null || value.processingDurationSeconds === undefined
? null
: Number(value.processingDurationSeconds),
};
}
function normalizePlanScheduledTaskDateRanges(value) {
if (!Array.isArray(value)) {
return [];
}
return value
.map(function (item) { return ({
startDate: typeof (item === null || item === void 0 ? void 0 : item.startDate) === 'string' ? item.startDate.trim() : '',
endDate: typeof (item === null || item === void 0 ? void 0 : item.endDate) === 'string' ? item.endDate.trim() : '',
}); })
.filter(function (item) { return item.startDate && item.endDate; });
}
function normalizePlanScheduledTaskWeekdays(value) {
if (!Array.isArray(value)) {
return [];
}
return Array.from(new Set(value
.map(function (item) { return Number(item); })
.filter(function (item) { return Number.isInteger(item) && item >= 0 && item <= 6; }))).sort(function (left, right) { return left - right; });
}
function normalizePlanScheduledTaskTimeWindows(value) {
if (!Array.isArray(value)) {
return [];
}
return value
.map(function (item) { return ({
startTime: typeof (item === null || item === void 0 ? void 0 : item.startTime) === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(item.startTime.trim())
? item.startTime.trim()
: null,
endTime: typeof (item === null || item === void 0 ? void 0 : item.endTime) === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(item.endTime.trim())
? item.endTime.trim()
: null,
}); })
.filter(function (item) { return item.startTime || item.endTime; });
}
function fetchPlanSourceWorkHistories(id) {
return __awaiter(this, void 0, void 0, function () {
var response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, request("/plan/items/".concat(id, "/source-works"))];
case 1:
response = _a.sent();
return [2 /*return*/, response.items];
}
});
});
}
function fetchReleaseReviewBoardItems() {
return __awaiter(this, void 0, void 0, function () {
var response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, request('/plan/release-reviews')];
case 1:
response = _a.sent();
return [2 /*return*/, response.items];
}
});
});
}
function updatePlanReleaseReview(planItemId, payload) {
return __awaiter(this, void 0, void 0, function () {
var response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, request("/plan/release-reviews/".concat(planItemId), {
method: 'PATCH',
body: JSON.stringify(payload),
})];
case 1:
response = _a.sent();
return [2 /*return*/, response.item];
}
});
});
}
function fetchPlanSourceWorkHistory(id, sourceWorkId) {
return __awaiter(this, void 0, void 0, function () {
var response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, request("/plan/items/".concat(id, "/source-works/").concat(sourceWorkId))];
case 1:
response = _a.sent();
return [2 /*return*/, response.item];
}
});
});
}
function requestPlanScheduleTask() {
return __awaiter(this, arguments, void 0, function (pathSuffix, init) {
var paths, lastNotFoundError, _i, paths_1, path, error_5;
if (pathSuffix === void 0) { pathSuffix = ''; }
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
paths = [
"/plan/scheduled-tasks".concat(pathSuffix),
"/plan/schedule/tasks".concat(pathSuffix),
"/plan/schedule".concat(pathSuffix),
"/plan/schedules".concat(pathSuffix),
"/plans/scheduled-tasks".concat(pathSuffix),
"/plans/schedule/tasks".concat(pathSuffix),
"/plans/schedule".concat(pathSuffix),
"/plans/schedules".concat(pathSuffix),
];
lastNotFoundError = null;
_i = 0, paths_1 = paths;
_a.label = 1;
case 1:
if (!(_i < paths_1.length)) return [3 /*break*/, 6];
path = paths_1[_i];
_a.label = 2;
case 2:
_a.trys.push([2, 4, , 5]);
return [4 /*yield*/, request(path, init)];
case 3: return [2 /*return*/, _a.sent()];
case 4:
error_5 = _a.sent();
if (error_5 instanceof PlanApiError && error_5.status === 404) {
lastNotFoundError = error_5;
return [3 /*break*/, 5];
}
throw error_5;
case 5:
_i++;
return [3 /*break*/, 1];
case 6: throw lastNotFoundError !== null && lastNotFoundError !== void 0 ? lastNotFoundError : new PlanApiError('스케줄 API 경로를 찾을 수 없습니다.', 404);
}
});
});
}
function fetchPlanScheduledTasks() {
return __awaiter(this, void 0, void 0, function () {
var response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, requestPlanScheduleTask()];
case 1:
response = _a.sent();
return [2 /*return*/, response.items.map(function (item) { return (__assign(__assign({}, item), { automationType: normalizePlanAutomationType(item.automationType), automationContextIds: Array.isArray(item.automationContextIds)
? item.automationContextIds.map(function (value) { return String(value).trim(); }).filter(Boolean)
: [], scheduleWeekdays: normalizePlanScheduledTaskWeekdays(item.scheduleWeekdays), scheduleDateRanges: normalizePlanScheduledTaskDateRanges(item.scheduleDateRanges), repeatWindows: normalizePlanScheduledTaskTimeWindows(item.repeatWindows), refreshContextSnapshotOnNextRun: Boolean(item.refreshContextSnapshotOnNextRun), recreateManagedServiceOnNextSave: Boolean(item.recreateManagedServiceOnNextSave) })); })];
}
});
});
}
function createPlanScheduledTask(draft) {
return __awaiter(this, void 0, void 0, function () {
var response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, requestPlanScheduleTask('', {
method: 'POST',
body: JSON.stringify({
workId: draft.workId,
note: draft.note,
automationType: draft.automationType,
automationContextIds: draft.automationContextIds,
releaseTarget: draft.releaseTarget,
jangsingProcessingRequired: draft.jangsingProcessingRequired,
autoDeployToMain: draft.autoDeployToMain,
suppressWebPush: draft.suppressWebPush,
enabled: draft.enabled,
immediateRunEnabled: draft.immediateRunEnabled,
refreshContextSnapshotOnNextRun: draft.refreshContextSnapshotOnNextRun,
executionMode: draft.executionMode,
recreateManagedServiceOnNextSave: draft.recreateManagedServiceOnNextSave,
scheduleMode: draft.scheduleMode,
repeatIntervalValue: draft.repeatIntervalValue,
repeatIntervalUnit: draft.repeatIntervalUnit,
repeatIntervalSeconds: draft.repeatIntervalSeconds,
repeatIntervalMinutes: draft.repeatIntervalMinutes,
dailyRunTime: draft.dailyRunTime,
scheduleWeekdays: draft.scheduleWeekdays,
scheduleDateRanges: draft.scheduleDateRanges,
repeatWindows: draft.repeatWindows,
repeatWindowStartTime: draft.repeatWindowStartTime,
repeatWindowEndTime: draft.repeatWindowEndTime,
}),
})];
case 1:
response = _a.sent();
return [2 /*return*/, {
item: __assign(__assign({}, response.item), { automationType: normalizePlanAutomationType(response.item.automationType), automationContextIds: Array.isArray(response.item.automationContextIds)
? response.item.automationContextIds.map(function (value) { return String(value).trim(); }).filter(Boolean)
: [], scheduleWeekdays: normalizePlanScheduledTaskWeekdays(response.item.scheduleWeekdays), scheduleDateRanges: normalizePlanScheduledTaskDateRanges(response.item.scheduleDateRanges), repeatWindows: normalizePlanScheduledTaskTimeWindows(response.item.repeatWindows), refreshContextSnapshotOnNextRun: Boolean(response.item.refreshContextSnapshotOnNextRun), recreateManagedServiceOnNextSave: Boolean(response.item.recreateManagedServiceOnNextSave) }),
registeredPlan: response.registeredPlan,
registeredBoardPosts: Array.isArray(response.registeredBoardPosts) ? response.registeredBoardPosts : [],
}];
}
});
});
}
function updatePlanScheduledTask(draft) {
return __awaiter(this, void 0, void 0, function () {
var response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
if (!draft.id) {
throw new Error('수정할 스케줄 ID가 없습니다.');
}
return [4 /*yield*/, requestPlanScheduleTask("/".concat(draft.id), {
method: 'PATCH',
body: JSON.stringify({
workId: draft.workId,
note: draft.note,
automationType: draft.automationType,
automationContextIds: draft.automationContextIds,
releaseTarget: draft.releaseTarget,
jangsingProcessingRequired: draft.jangsingProcessingRequired,
autoDeployToMain: draft.autoDeployToMain,
suppressWebPush: draft.suppressWebPush,
enabled: draft.enabled,
immediateRunEnabled: draft.immediateRunEnabled,
refreshContextSnapshotOnNextRun: draft.refreshContextSnapshotOnNextRun,
executionMode: draft.executionMode,
recreateManagedServiceOnNextSave: draft.recreateManagedServiceOnNextSave,
scheduleMode: draft.scheduleMode,
repeatIntervalValue: draft.repeatIntervalValue,
repeatIntervalUnit: draft.repeatIntervalUnit,
repeatIntervalSeconds: draft.repeatIntervalSeconds,
repeatIntervalMinutes: draft.repeatIntervalMinutes,
dailyRunTime: draft.dailyRunTime,
scheduleWeekdays: draft.scheduleWeekdays,
scheduleDateRanges: draft.scheduleDateRanges,
repeatWindows: draft.repeatWindows,
repeatWindowStartTime: draft.repeatWindowStartTime,
repeatWindowEndTime: draft.repeatWindowEndTime,
}),
})];
case 1:
response = _a.sent();
return [2 /*return*/, {
item: __assign(__assign({}, response.item), { automationType: normalizePlanAutomationType(response.item.automationType), automationContextIds: Array.isArray(response.item.automationContextIds)
? response.item.automationContextIds.map(function (value) { return String(value).trim(); }).filter(Boolean)
: [], scheduleWeekdays: normalizePlanScheduledTaskWeekdays(response.item.scheduleWeekdays), scheduleDateRanges: normalizePlanScheduledTaskDateRanges(response.item.scheduleDateRanges), repeatWindows: normalizePlanScheduledTaskTimeWindows(response.item.repeatWindows), refreshContextSnapshotOnNextRun: Boolean(response.item.refreshContextSnapshotOnNextRun), recreateManagedServiceOnNextSave: Boolean(response.item.recreateManagedServiceOnNextSave) }),
registeredPlan: response.registeredPlan,
registeredBoardPosts: Array.isArray(response.registeredBoardPosts) ? response.registeredBoardPosts : [],
}];
}
});
});
}
function deletePlanScheduledTask(id) {
return __awaiter(this, void 0, void 0, function () {
var response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, requestPlanScheduleTask("/".concat(id), {
method: 'DELETE',
})];
case 1:
response = _a.sent();
return [2 /*return*/, response.id];
}
});
});
}

View File

@@ -420,6 +420,52 @@ function normalizePlanAutomationUsageSnapshot(value: PlanAutomationUsageSnapshot
} satisfies PlanAutomationUsageSnapshot;
}
function normalizePlanScheduledTaskDateRanges(value: unknown): PlanScheduledTaskDateRange[] {
if (!Array.isArray(value)) {
return [];
}
return value
.map((item) => ({
startDate: typeof item?.startDate === 'string' ? item.startDate.trim() : '',
endDate: typeof item?.endDate === 'string' ? item.endDate.trim() : '',
}))
.filter((item) => item.startDate && item.endDate);
}
function normalizePlanScheduledTaskWeekdays(value: unknown): number[] {
if (!Array.isArray(value)) {
return [];
}
return Array.from(
new Set(
value
.map((item) => Number(item))
.filter((item) => Number.isInteger(item) && item >= 0 && item <= 6),
),
).sort((left, right) => left - right);
}
function normalizePlanScheduledTaskTimeWindows(value: unknown): PlanScheduledTaskTimeWindow[] {
if (!Array.isArray(value)) {
return [];
}
return value
.map((item) => ({
startTime:
typeof item?.startTime === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(item.startTime.trim())
? item.startTime.trim()
: null,
endTime:
typeof item?.endTime === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(item.endTime.trim())
? item.endTime.trim()
: null,
}))
.filter((item) => item.startTime || item.endTime);
}
export async function fetchPlanSourceWorkHistories(id: number) {
const response = await request<{ items: PlanSourceWorkHistory[] }>(`/plan/items/${id}/source-works`);
return response.items;
@@ -479,6 +525,9 @@ export type PlanScheduledTask = {
repeatIntervalSeconds: number;
repeatIntervalMinutes: number;
dailyRunTime: string;
scheduleWeekdays: number[];
scheduleDateRanges: PlanScheduledTaskDateRange[];
repeatWindows: PlanScheduledTaskTimeWindow[];
repeatWindowStartTime: string | null;
repeatWindowEndTime: string | null;
lastRegisteredAt: string | null;
@@ -486,6 +535,16 @@ export type PlanScheduledTask = {
updatedAt: string;
};
export type PlanScheduledTaskDateRange = {
startDate: string;
endDate: string;
};
export type PlanScheduledTaskTimeWindow = {
startTime: string | null;
endTime: string | null;
};
export type PlanScheduleExecutionMode = 'codex' | 'managed-service';
export type PlanScheduleMode = 'interval' | 'daily';
export type PlanScheduleRepeatUnit = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month';
@@ -511,6 +570,9 @@ export type PlanScheduledTaskDraft = {
repeatIntervalSeconds: number;
repeatIntervalMinutes: number;
dailyRunTime: string;
scheduleWeekdays: number[];
scheduleDateRanges: PlanScheduledTaskDateRange[];
repeatWindows: PlanScheduledTaskTimeWindow[];
repeatWindowStartTime: string | null;
repeatWindowEndTime: string | null;
};
@@ -559,6 +621,9 @@ export async function fetchPlanScheduledTasks() {
automationContextIds: Array.isArray(item.automationContextIds)
? item.automationContextIds.map((value) => String(value).trim()).filter(Boolean)
: [],
scheduleWeekdays: normalizePlanScheduledTaskWeekdays(item.scheduleWeekdays),
scheduleDateRanges: normalizePlanScheduledTaskDateRanges(item.scheduleDateRanges),
repeatWindows: normalizePlanScheduledTaskTimeWindows(item.repeatWindows),
refreshContextSnapshotOnNextRun: Boolean(item.refreshContextSnapshotOnNextRun),
recreateManagedServiceOnNextSave: Boolean(item.recreateManagedServiceOnNextSave),
}));
@@ -592,6 +657,9 @@ export async function createPlanScheduledTask(draft: PlanScheduledTaskDraft) {
repeatIntervalSeconds: draft.repeatIntervalSeconds,
repeatIntervalMinutes: draft.repeatIntervalMinutes,
dailyRunTime: draft.dailyRunTime,
scheduleWeekdays: draft.scheduleWeekdays,
scheduleDateRanges: draft.scheduleDateRanges,
repeatWindows: draft.repeatWindows,
repeatWindowStartTime: draft.repeatWindowStartTime,
repeatWindowEndTime: draft.repeatWindowEndTime,
}),
@@ -604,6 +672,9 @@ export async function createPlanScheduledTask(draft: PlanScheduledTaskDraft) {
automationContextIds: Array.isArray(response.item.automationContextIds)
? response.item.automationContextIds.map((value) => String(value).trim()).filter(Boolean)
: [],
scheduleWeekdays: normalizePlanScheduledTaskWeekdays(response.item.scheduleWeekdays),
scheduleDateRanges: normalizePlanScheduledTaskDateRanges(response.item.scheduleDateRanges),
repeatWindows: normalizePlanScheduledTaskTimeWindows(response.item.repeatWindows),
refreshContextSnapshotOnNextRun: Boolean(response.item.refreshContextSnapshotOnNextRun),
recreateManagedServiceOnNextSave: Boolean(response.item.recreateManagedServiceOnNextSave),
},
@@ -644,6 +715,9 @@ export async function updatePlanScheduledTask(draft: PlanScheduledTaskDraft) {
repeatIntervalSeconds: draft.repeatIntervalSeconds,
repeatIntervalMinutes: draft.repeatIntervalMinutes,
dailyRunTime: draft.dailyRunTime,
scheduleWeekdays: draft.scheduleWeekdays,
scheduleDateRanges: draft.scheduleDateRanges,
repeatWindows: draft.repeatWindows,
repeatWindowStartTime: draft.repeatWindowStartTime,
repeatWindowEndTime: draft.repeatWindowEndTime,
}),
@@ -656,6 +730,9 @@ export async function updatePlanScheduledTask(draft: PlanScheduledTaskDraft) {
automationContextIds: Array.isArray(response.item.automationContextIds)
? response.item.automationContextIds.map((value) => String(value).trim()).filter(Boolean)
: [],
scheduleWeekdays: normalizePlanScheduledTaskWeekdays(response.item.scheduleWeekdays),
scheduleDateRanges: normalizePlanScheduledTaskDateRanges(response.item.scheduleDateRanges),
repeatWindows: normalizePlanScheduledTaskTimeWindows(response.item.repeatWindows),
refreshContextSnapshotOnNextRun: Boolean(response.item.refreshContextSnapshotOnNextRun),
recreateManagedServiceOnNextSave: Boolean(response.item.recreateManagedServiceOnNextSave),
},

View File

@@ -0,0 +1,27 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.maskNotePreviewByWord = maskNotePreviewByWord;
function maskNotePreviewByWord(note) {
var trimmed = note.trim();
if (!trimmed) {
return '요청 내용이 마스킹되었습니다.';
}
return note
.split(/(\s+)/)
.map(function (segment) {
if (!segment || /\s+/.test(segment)) {
return segment;
}
if (segment.length === 1) {
return '*';
}
var visiblePrefixLength = segment.length >= 4 ? 1 : 0;
var visibleSuffixLength = segment.length >= 6 ? 1 : 0;
var maxMaskLength = Math.max(1, segment.length - visiblePrefixLength - visibleSuffixLength);
var maskLength = Math.min(maxMaskLength, Math.max(1, Math.ceil(segment.length * 0.4)));
var maskStart = Math.max(visiblePrefixLength, Math.floor((segment.length - maskLength) / 2));
var maskEnd = Math.min(segment.length - visibleSuffixLength, maskStart + maskLength);
return "".concat(segment.slice(0, maskStart)).concat('*'.repeat(maskEnd - maskStart)).concat(segment.slice(maskEnd));
})
.join('');
}

View File

@@ -1,14 +1,22 @@
.plan-board-page {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 16px;
min-width: 0;
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.plan-board-page__overview,
.plan-board-page__list-card,
.plan-board-page__chart-card,
.plan-board-page__editor-card {
display: flex;
flex-direction: column;
min-height: 0;
border: 0;
border-radius: 20px;
box-shadow: none;
@@ -16,9 +24,12 @@
.plan-board-page__split {
display: grid;
flex: 1 1 auto;
grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
gap: 16px;
min-width: 0;
min-height: 0;
align-items: stretch;
}
.plan-board-page__split--stacked {
@@ -28,7 +39,12 @@
.plan-board-page__list-card .ant-card-body,
.plan-board-page__editor-card .ant-card-body,
.plan-board-page__detail-card .ant-card-body {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.plan-board-page__detail-actions.ant-space {
@@ -122,8 +138,14 @@
.plan-board-page__list {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 10px;
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
padding-right: 4px;
}
.plan-board-page__list-filter-bar {

View File

@@ -218,6 +218,67 @@
width: 100%;
}
.plan-schedule-page__date-range-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.plan-schedule-page__date-range-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.plan-schedule-page__time-window-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.plan-schedule-page__time-window-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.plan-schedule-page__weekday-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.plan-schedule-page__weekday-group.ant-checkbox-group {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
}
.plan-schedule-page__weekday-group .ant-checkbox-wrapper {
margin-inline-start: 0;
}
.plan-schedule-page__date-range-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
}
.plan-schedule-page__time-window-item {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.plan-schedule-page__date-range-item .ant-input {
min-height: 42px;
border-radius: 12px;
border-color: rgba(22, 93, 255, 0.14);
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
}
.plan-schedule-page__overlay {
position: fixed;
inset: 0;
@@ -298,6 +359,14 @@
border-radius: 16px;
}
.plan-schedule-page__date-range-item {
grid-template-columns: minmax(0, 1fr);
}
.plan-schedule-page__time-window-item {
align-items: stretch;
}
.plan-schedule-page__overlay-card .ant-card-body {
padding: 14px 14px 18px;
}

View File

@@ -0,0 +1,43 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.isWorkingPlanItem = isWorkingPlanItem;
exports.normalizeWorkerStatus = normalizeWorkerStatus;
exports.isReleasePendingMainItem = isReleasePendingMainItem;
exports.isAutomationFailedItem = isAutomationFailedItem;
exports.getPlanQuickFilterLabel = getPlanQuickFilterLabel;
var MAIN_PENDING_WORKER_STATUSES = new Set(['main반영대기', 'main반영중', 'main반영실패']);
var AUTOMATION_FAILURE_WORKER_STATUSES = new Set([
'브랜치실패',
'자동작업실패',
'release반영실패',
'main반영실패',
]);
function isWorkingPlanItem(item) {
return item.status === '작업중';
}
function normalizeWorkerStatus(workerStatus) {
var _a;
return (_a = workerStatus === null || workerStatus === void 0 ? void 0 : workerStatus.replace(/\s+/g, '')) !== null && _a !== void 0 ? _a : '';
}
function isReleasePendingMainItem(item) {
var normalizedWorkerStatus = normalizeWorkerStatus(item.workerStatus);
if (MAIN_PENDING_WORKER_STATUSES.has(normalizedWorkerStatus)) {
return true;
}
return item.status === '릴리즈완료';
}
function isAutomationFailedItem(item) {
return AUTOMATION_FAILURE_WORKER_STATUSES.has(normalizeWorkerStatus(item.workerStatus));
}
function getPlanQuickFilterLabel(filter) {
if (filter === 'working') {
return '현재 작업중';
}
if (filter === 'release-pending-main') {
return '현재 release 상태';
}
if (filter === 'automation-failed') {
return '현재 자동화 실패';
}
return null;
}

View File

@@ -0,0 +1,6 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.PLAN_RELEASE_REVIEW_STATUSES = exports.PLAN_FILTER_STATUSES = exports.PLAN_STATUSES = void 0;
exports.PLAN_STATUSES = ['등록', '작업중', '작업완료', '릴리즈완료', '완료'];
exports.PLAN_FILTER_STATUSES = ['all', 'in-progress', 'done', 'error'];
exports.PLAN_RELEASE_REVIEW_STATUSES = ['pending', 'reviewing', 'approved', 'changes-requested'];

View File

@@ -346,6 +346,21 @@ export function ServerCommandPage() {
label: '확인시각',
children: formatDateTime(item.checkedAt),
},
{
key: 'latest-source-change-at',
label: '소스 수정일',
children: formatDateTime(item.latestSourceChangeAt),
},
{
key: 'latest-source-change-path',
label: '최근 소스 경로',
children: item.latestSourceChangePath?.trim() || '-',
},
{
key: 'latest-build-at',
label: '최신 빌드',
children: formatDateTime(item.latestBuiltAt),
},
{
key: 'content-type',
label: 'Content-Type',
@@ -401,6 +416,12 @@ export function ServerCommandPage() {
</Text>
) : null}
{item.updateSummary ? (
<Text type="secondary" className="server-command-page__preview">
{item.updateSummary}
</Text>
) : null}
{item.responsePreview ? (
<Text type="secondary" className="server-command-page__preview">
{item.responsePreview}

View File

@@ -1,6 +1,12 @@
import { appendClientIdHeader } from '../../app/main/clientIdentity';
import { getRegisteredAccessToken, isAllowedRegistrationToken } from '../../app/main/tokenAccess';
import type { ServerCommandActionResult, ServerCommandItem, ServerCommandKey } from './types';
import type {
ServerCommandActionResult,
ServerCommandItem,
ServerCommandKey,
ServerRestartReservation,
ServerRestartReservationStatus,
} from './types';
class ServerCommandApiError extends Error {
status: number;
@@ -41,6 +47,14 @@ function resolveServerCommandFallbackBaseUrl() {
return fallbackUrl.toString().replace(/\/+$/, '');
}
function getCurrentAppOrigin() {
if (typeof window === 'undefined') {
return '';
}
return window.location.origin;
}
const SERVER_COMMAND_API_BASE_URL = resolveServerCommandApiBaseUrl();
const SERVER_COMMAND_API_FALLBACK_BASE_URL =
!import.meta.env.VITE_WORK_SERVER_URL && SERVER_COMMAND_API_BASE_URL === '/api'
@@ -52,12 +66,27 @@ async function requestOnce<T>(baseUrl: string, path: string, init?: RequestInit)
const hasBody = init?.body !== undefined && init?.body !== null;
const method = init?.method?.toUpperCase() ?? 'GET';
const controller = new AbortController();
const abortFromExternalSignal = () => controller.abort(init?.signal?.reason);
const hasExternalSignal = Boolean(init?.signal);
const timeoutId = globalThis.setTimeout(() => controller.abort(), 8000);
if (init?.signal) {
if (init.signal.aborted) {
abortFromExternalSignal();
} else {
init.signal.addEventListener('abort', abortFromExternalSignal, { once: true });
}
}
if (hasBody && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
const appOrigin = getCurrentAppOrigin();
if (appOrigin && !headers.has('X-App-Origin')) {
headers.set('X-App-Origin', appOrigin);
}
const token = getRegisteredAccessToken();
if (!isAllowedRegistrationToken(token)) {
throw new ServerCommandApiError('권한 토큰 등록 후에만 Work Server API를 호출할 수 있습니다.', 403);
@@ -79,6 +108,14 @@ async function requestOnce<T>(baseUrl: string, path: string, init?: RequestInit)
} catch (error) {
globalThis.clearTimeout(timeoutId);
if (hasExternalSignal) {
init?.signal?.removeEventListener('abort', abortFromExternalSignal);
}
if (init?.signal?.aborted) {
throw error;
}
if (error instanceof DOMException && error.name === 'AbortError') {
throw new ServerCommandApiError('서버 명령 응답이 지연됩니다.', 408);
}
@@ -88,6 +125,10 @@ async function requestOnce<T>(baseUrl: string, path: string, init?: RequestInit)
globalThis.clearTimeout(timeoutId);
if (hasExternalSignal) {
init?.signal?.removeEventListener('abort', abortFromExternalSignal);
}
if (!response.ok) {
const text = await response.text();
@@ -236,16 +277,119 @@ function extractServerCommandActionResult(response: unknown): ServerCommandActio
};
}
function normalizeServerRestartReservationStatus(value: unknown): ServerRestartReservationStatus {
return value === 'waiting'
|| value === 'ready'
|| value === 'executing'
|| value === 'completed'
|| value === 'cancelled'
|| value === 'failed'
? value
: 'idle';
}
function extractServerRestartReservation(response: unknown): ServerRestartReservation {
if (!response || typeof response !== 'object') {
throw new Error('재기동 예약 응답 형식이 올바르지 않습니다.');
}
const payload = response as {
item?: unknown;
data?: {
item?: unknown;
};
};
const nestedData = payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data) ? payload.data : null;
const item = payload.item ?? nestedData?.item;
if (!item || typeof item !== 'object') {
throw new Error('재기동 예약 정보를 읽지 못했습니다.');
}
const reservation = item as Partial<ServerRestartReservation>;
const workloadSummary =
reservation.workloadSummary && typeof reservation.workloadSummary === 'object'
? reservation.workloadSummary
: {};
return {
enabled: reservation.enabled === true,
target: reservation.target === 'all' ? 'all' : 'all',
status: normalizeServerRestartReservationStatus(reservation.status),
requestedAt: typeof reservation.requestedAt === 'string' ? reservation.requestedAt : null,
requestedByClientId: typeof reservation.requestedByClientId === 'string' ? reservation.requestedByClientId : null,
lastCheckedAt: typeof reservation.lastCheckedAt === 'string' ? reservation.lastCheckedAt : null,
nextCheckAt: typeof reservation.nextCheckAt === 'string' ? reservation.nextCheckAt : null,
waitingReason: typeof reservation.waitingReason === 'string' ? reservation.waitingReason : null,
workloadSummary: {
codexRunningCount: Number(workloadSummary.codexRunningCount ?? 0),
codexQueuedCount: Number(workloadSummary.codexQueuedCount ?? 0),
automationRunningCount: Number(workloadSummary.automationRunningCount ?? 0),
automationQueuedCount: Number(workloadSummary.automationQueuedCount ?? 0),
},
startedAt: typeof reservation.startedAt === 'string' ? reservation.startedAt : null,
completedAt: typeof reservation.completedAt === 'string' ? reservation.completedAt : null,
cancelledAt: typeof reservation.cancelledAt === 'string' ? reservation.cancelledAt : null,
lastError: typeof reservation.lastError === 'string' ? reservation.lastError : null,
activeClientCount: Number(reservation.activeClientCount ?? 0),
notifiedActiveClientsAt:
typeof reservation.notifiedActiveClientsAt === 'string' ? reservation.notifiedActiveClientsAt : null,
appOrigin: typeof reservation.appOrigin === 'string' ? reservation.appOrigin : null,
autoExecuteAt: typeof reservation.autoExecuteAt === 'string' ? reservation.autoExecuteAt : null,
autoExecuteDelaySeconds: Number(reservation.autoExecuteDelaySeconds ?? 10),
updatedAt: typeof reservation.updatedAt === 'string' ? reservation.updatedAt : null,
};
}
export async function fetchServerCommands() {
const response = await request<unknown>('/server-commands');
return extractServerCommandItems(response);
}
export async function restartServerCommand(key: ServerCommandKey) {
export async function restartServerCommand(key: ServerCommandKey, options?: { signal?: AbortSignal }) {
const response = await request<unknown>(`/server-commands/${key}/actions/restart`, {
method: 'POST',
body: JSON.stringify({}),
signal: options?.signal,
});
return extractServerCommandActionResult(response);
}
export async function fetchServerRestartReservation(options?: { signal?: AbortSignal }) {
const response = await request<unknown>('/server-commands/restart-reservation', {
signal: options?.signal,
});
return extractServerRestartReservation(response);
}
export async function scheduleServerRestartReservation(options?: {
signal?: AbortSignal;
autoExecuteDelaySeconds?: number;
}) {
const response = await request<unknown>('/server-commands/restart-reservation', {
method: 'PUT',
body: JSON.stringify({
autoExecuteDelaySeconds: options?.autoExecuteDelaySeconds,
}),
signal: options?.signal,
});
return extractServerRestartReservation(response);
}
export async function cancelServerRestartReservation(options?: { signal?: AbortSignal }) {
const response = await request<unknown>('/server-commands/restart-reservation', {
method: 'DELETE',
signal: options?.signal,
});
return extractServerRestartReservation(response);
}
export async function confirmServerRestartReservation(options?: { signal?: AbortSignal }) {
const response = await request<unknown>('/server-commands/restart-reservation/confirm', {
method: 'POST',
body: JSON.stringify({}),
signal: options?.signal,
});
return extractServerRestartReservation(response);
}

View File

@@ -1,5 +1,21 @@
.server-command-page {
width: 100%;
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
overflow: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.server-command-page.ant-space,
.server-command-page.ant-space > .ant-space-item {
width: 100%;
}
.server-command-page.ant-space {
flex: 1 1 auto;
}
.server-command-page .ant-alert-description {

View File

@@ -38,3 +38,39 @@ export type ServerCommandActionResult = {
commandOutput: string | null;
restartState: 'completed' | 'accepted';
};
export type ServerRestartReservationStatus =
| 'idle'
| 'waiting'
| 'ready'
| 'executing'
| 'completed'
| 'cancelled'
| 'failed';
export type ServerRestartReservation = {
enabled: boolean;
target: 'all';
status: ServerRestartReservationStatus;
requestedAt: string | null;
requestedByClientId: string | null;
lastCheckedAt: string | null;
nextCheckAt: string | null;
waitingReason: string | null;
workloadSummary: {
codexRunningCount: number;
codexQueuedCount: number;
automationRunningCount: number;
automationQueuedCount: number;
};
startedAt: string | null;
completedAt: string | null;
cancelledAt: string | null;
lastError: string | null;
activeClientCount: number;
notifiedActiveClientsAt: string | null;
appOrigin: string | null;
autoExecuteAt: string | null;
autoExecuteDelaySeconds: number;
updatedAt: string | null;
};