chore: sync local workspace changes
1190
src/features/board/BoardPage.tsx
Executable file → Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
2
src/features/board/types.js
Normal file
@@ -0,0 +1,2 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
@@ -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;
|
||||
}>;
|
||||
};
|
||||
|
||||
@@ -29,11 +29,22 @@
|
||||
- 컴포넌트 간 이벤트 전달, 상태 동기화, focus/selection 이동, 표시/숨김 제어를 기능 명세로 기술할 수 있다
|
||||
- 한 컴포넌트의 입력/액션이 다른 컴포넌트 목록, 상세, 요약, CTA 상태를 바꾸는 흐름을 기술할 수 있다
|
||||
- 서버 저장, 조회, 갱신, 삭제 같은 API 연결과 그 응답이 다른 컴포넌트에 반영되는 흐름도 기능 명세 범위에 포함한다
|
||||
- 가능하면 공통 컴포넌트나 위젯 본체를 직접 수정하기보다, 현재 레이아웃에서 필요한 `props`를 내려 동작과 표시를 조정하는 방식으로 구현한다
|
||||
- `Layout Editor 실행` 요청은 기본적으로 "현재 화면 조합을 props/배치/상호작용으로 맞춘다"는 의미로 해석하고, 공통 패키지 내부 구현 변경은 최후 수단으로만 검토한다
|
||||
|
||||
구현 우선순위:
|
||||
|
||||
- 1순위는 기존 컴포넌트/위젯 조합과 `props` 조정만으로 요구사항을 만족시키는 것이다
|
||||
- 2순위는 현재 프로젝트 전용 래퍼, feature 레이어, 어댑터를 추가해 공통 패키지 수정 없이 화면 요구를 흡수하는 것이다
|
||||
- 공통 컴포넌트/위젯 수정이 정말 필요할 때만 기존 사용처를 모두 확인한 뒤 제한적으로 수정한다
|
||||
- 공통 컴포넌트/위젯에 새 동작을 추가할 때는 기본값 `props`를 기존 동작과 동일하게 유지해, 명시적으로 opt-in한 화면만 달라지게 만든다
|
||||
|
||||
금지 해석:
|
||||
|
||||
- 위젯 또는 컴포넌트 샘플 메타에서 "기본 기능"을 자동으로 끌어와 `Layout Editor` 기능 명세처럼 취급하지 않는다
|
||||
- 사용자가 직접 추가하지 않은 항목을 기능 명세 목록에 자동 주입하지 않는다
|
||||
- 레이아웃 기능 명세와 위젯 고유 스펙 문서를 같은 개념으로 섞지 않는다
|
||||
- 현재 레이아웃 요구를 맞추기 위해 공통 위젯 내부 코드를 바로 덧대고, 그 부작용을 기존 화면이 함께 떠안게 만드는 방식은 지양한다
|
||||
- 기존 화면 영향도 검토 없이 공통 컴포넌트/위젯의 기본 동작, 기본 스타일, 기본 데이터 흐름을 바꾸지 않는다
|
||||
|
||||
구현/문서 작성 시에는 항상 "이 기능은 위젯의 본래 능력 설명인가, 아니면 현재 레이아웃에서 그 컴포넌트가 수행할 역할 설명인가"를 먼저 구분합니다. `Layout Editor` 문맥에서는 후자만 다룹니다.
|
||||
|
||||
317
src/features/layout/feature-menu/FeatureMenuLayoutPage.css
Normal 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;
|
||||
}
|
||||
}
|
||||
520
src/features/layout/feature-menu/FeatureMenuLayoutPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
src/features/layout/feature-menu/featureMenu.chat.ts
Normal 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);
|
||||
}
|
||||
8
src/features/layout/feature-menu/featureMenu.types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type LayoutInteractionRule = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
implementationNotes: string;
|
||||
};
|
||||
|
||||
export type FeatureMenuTabKey = 'description' | 'notes';
|
||||
72
src/features/layout/feature-menu/featureMenu.utils.ts
Normal 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');
|
||||
}
|
||||
1
src/features/layout/feature-menu/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { FeatureMenuLayoutPage } from './FeatureMenuLayoutPage';
|
||||
@@ -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/`에서 같이 관리한다.
|
||||
- 헤더 가림 수정 이후 완료 기준 산출물은 패키지 내부 최종 경로로 이관해 세션 리소스 의존도를 줄인다.
|
||||
@@ -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`
|
||||
@@ -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`로 확인했다.
|
||||
- 이번 이관 작업은 패키지 내부 문서/리소스 구조 정리이며 동작 로직 추가 변경은 포함하지 않는다.
|
||||
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 120 KiB |
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 반영을 요청했습니다.';
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
818
src/features/planBoard/api.js
Normal 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];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
27
src/features/planBoard/noteMasking.js
Normal 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('');
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
43
src/features/planBoard/quickFilters.js
Normal 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;
|
||||
}
|
||||
6
src/features/planBoard/types.js
Normal 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'];
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||