chore: sync local workspace changes

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

View File

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

View File

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