diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx index d305b5e..7d2b638 100644 --- a/frontend/src/components/StrategyEditorPage.tsx +++ b/frontend/src/components/StrategyEditorPage.tsx @@ -45,6 +45,7 @@ import { type StartCombineOp, } from '../utils/strategyStartNodes'; import IndicatorPaletteTab from './strategyEditor/IndicatorPaletteTab'; +import TemplatePaletteTab, { type TemplatePaletteRow } from './strategyEditor/TemplatePaletteTab'; import SidewaysFilterPaletteTab from './strategyEditor/SidewaysFilterPaletteTab'; import { buildSidewaysFilterNode, loadSidewaysPaletteItems } from '../utils/sidewaysFilterPaletteStorage'; import { @@ -121,6 +122,7 @@ import { import { addUserStrategyTemplate, deleteUserStrategyTemplate, + updateUserStrategyTemplate, hideBuiltinTemplate, loadHiddenBuiltinTemplateIds, loadUserStrategyTemplates, @@ -280,9 +282,13 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop () => loadHiddenBuiltinTemplateIds(), ); const [templateSaveOpen, setTemplateSaveOpen] = useState(false); + const [templateModalMode, setTemplateModalMode] = useState<'add' | 'edit'>('add'); + const [templateEditId, setTemplateEditId] = useState(null); + const [templateSaveUpdateContent, setTemplateSaveUpdateContent] = useState(false); const [templateSaveLabel, setTemplateSaveLabel] = useState(''); const [templateSaveDesc, setTemplateSaveDesc] = useState(''); const [templateSaveLabelError, setTemplateSaveLabelError] = useState(false); + const [selectedTemplateKey, setSelectedTemplateKey] = useState(null); const [selectedPaletteKey, setSelectedPaletteKey] = useState(null); const [stratName, setStratName] = useState(''); const [stratDesc, setStratDesc] = useState(''); @@ -1416,24 +1422,87 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop showSnack('템플릿을 목록에서 제거했습니다'); }, []); - const openTemplateSaveModal = useCallback(() => { + const openTemplateAddModal = useCallback(() => { const hasContent = encodeConditionForSave(currentEditorState) != null || currentOrphans.length > 0; if (!hasContent) { showSnack('저장할 조건이 없습니다', false); return; } + setTemplateModalMode('add'); + setTemplateEditId(null); + setTemplateSaveUpdateContent(false); setTemplateSaveLabel(stratName.trim() || `${signalTab === 'buy' ? '매수' : '매도'} 템플릿`); setTemplateSaveDesc(''); setTemplateSaveLabelError(false); setTemplateSaveOpen(true); }, [currentEditorState, currentOrphans.length, stratName, signalTab]); + const openTemplateEditModal = useCallback(() => { + if (!selectedTemplateKey) { + window.alert('수정할 템플릿을 먼저 선택해 주세요.'); + return; + } + const row = templateRows.find(r => r.key === selectedTemplateKey); + if (!row) return; + if (row.source !== 'user' || !row.user) { + window.alert('내 템플릿만 수정할 수 있습니다.'); + return; + } + setTemplateModalMode('edit'); + setTemplateEditId(row.user.id); + setTemplateSaveUpdateContent(false); + setTemplateSaveLabel(row.user.label); + setTemplateSaveDesc(row.user.description ?? ''); + setTemplateSaveLabelError(false); + setTemplateSaveOpen(true); + }, [selectedTemplateKey, templateRows]); + + const handleTemplateToolbarDelete = useCallback(() => { + if (!selectedTemplateKey) { + window.alert('삭제할 템플릿을 먼저 선택해 주세요.'); + return; + } + const row = templateRows.find(r => r.key === selectedTemplateKey); + if (!row) return; + if (row.source === 'user' && row.user) { + handleDeleteUserTemplate(row.user.id, row.label); + } else if (row.source === 'builtin' && row.builtinId) { + handleDeleteBuiltinTemplate(row.builtinId, row.label); + } + setSelectedTemplateKey(null); + }, [selectedTemplateKey, templateRows, handleDeleteUserTemplate, handleDeleteBuiltinTemplate]); + + const handleTemplateApply = useCallback((row: TemplatePaletteRow) => { + if (row.source === 'user' && row.user) applyUserTemplate(row.user); + else if (row.builtin) handleTemplate(row.builtin); + }, [applyUserTemplate, handleTemplate]); + const handleTemplateSaveConfirm = useCallback(() => { const label = templateSaveLabel.trim(); if (!label) { setTemplateSaveLabelError(true); return; } + if (templateModalMode === 'edit' && templateEditId) { + const patch: Parameters[1] = { + label, + description: templateSaveDesc.trim() || undefined, + }; + if (templateSaveUpdateContent) { + const hasContent = encodeConditionForSave(currentEditorState) != null || currentOrphans.length > 0; + if (!hasContent) { + showSnack('갱신할 조건이 없습니다', false); + return; + } + patch.signal = signalTab; + patch.editorState = toEditorState(currentRoot, currentLayout); + patch.orphans = currentOrphans; + } + setUserTemplates(updateUserStrategyTemplate(templateEditId, patch)); + setTemplateSaveOpen(false); + showSnack(`"${label}" 템플릿을 수정했습니다`); + return; + } const saved = addUserStrategyTemplate({ label, description: templateSaveDesc.trim() || undefined, @@ -1442,16 +1511,21 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop orphans: currentOrphans, }); setUserTemplates(prev => [...prev, saved]); + setSelectedTemplateKey(saved.id); setTemplateSaveOpen(false); setRightTab('templates'); showSnack(`"${saved.label}" 템플릿을 저장했습니다`); }, [ templateSaveLabel, templateSaveDesc, + templateModalMode, + templateEditId, + templateSaveUpdateContent, signalTab, currentRoot, currentLayout, currentOrphans, + currentEditorState, ]); const handleQuickRun = useCallback(async () => { @@ -1805,7 +1879,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop - - - )) - )} - - + )} @@ -2213,10 +2240,15 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop {templateSaveOpen && ( - { setTemplateSaveLabelError(false); setTemplateSaveOpen(false); }} title="템플릿 저장"> + { setTemplateSaveLabelError(false); setTemplateSaveOpen(false); }} + title={templateModalMode === 'edit' ? '템플릿 수정' : '템플릿 저장'} + >

- 현재 {signalTab === 'buy' ? '매수' : '매도'} 탭 조건을 템플릿 목록에 저장합니다. + {templateModalMode === 'edit' + ? <>선택한 내 템플릿의 이름·설명을 수정합니다. + : <>현재 {signalTab === 'buy' ? '매수' : '매도'} 탭 조건을 템플릿 목록에 저장합니다.}

+ {templateModalMode === 'edit' && ( + + )}
- +
diff --git a/frontend/src/components/strategyEditor/TemplatePaletteTab.tsx b/frontend/src/components/strategyEditor/TemplatePaletteTab.tsx new file mode 100644 index 0000000..398782c --- /dev/null +++ b/frontend/src/components/strategyEditor/TemplatePaletteTab.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import type { StrategyTemplateDef } from '../../utils/strategyPresets'; +import type { UserStrategyTemplate } from '../../utils/strategyTemplateStorage'; + +export type TemplatePaletteRow = { + key: string; + source: 'user' | 'builtin'; + label: string; + description?: string; + signal: 'buy' | 'sell'; + user?: UserStrategyTemplate; + builtinId?: string; + builtin?: StrategyTemplateDef; +}; + +interface Props { + rows: TemplatePaletteRow[]; + searchQuery: string; + onSearchChange: (query: string) => void; + selectedKey: string | null; + onSelectKey: (key: string | null) => void; + onApply: (row: TemplatePaletteRow) => void; + onAdd: () => void; + onEdit: () => void; + onDelete: () => void; +} + +export default function TemplatePaletteTab({ + rows, + searchQuery, + onSearchChange, + selectedKey, + onSelectKey, + onApply, + onAdd, + onEdit, + onDelete, +}: Props) { + return ( +
+
+ 템플릿 +
+ + + +
+
+ + onSearchChange(e.target.value)} + /> + +
+ {rows.length === 0 ? ( +
검색 결과가 없습니다
+ ) : ( + rows.map(row => ( +
onSelectKey(row.key)} + onDoubleClick={() => onApply(row)} + role="button" + tabIndex={0} + onKeyDown={e => { + if (e.key === 'Enter') { + e.preventDefault(); + onApply(row); + } + }} + > +
+ + {row.source === 'user' && 내 템플릿} + {row.label} + + {row.description && ( + {row.description} + )} + + {row.signal === 'buy' ? '매수' : '매도'} + +
+
+ )) + )} +
+
+ ); +} diff --git a/frontend/src/styles/strategyEditor.css b/frontend/src/styles/strategyEditor.css index a9084a2..ecc4a8b 100644 --- a/frontend/src/styles/strategyEditor.css +++ b/frontend/src/styles/strategyEditor.css @@ -2250,8 +2250,14 @@ .se-template-row { display: flex; align-items: stretch; - gap: 4px; margin-bottom: 6px; + border-radius: 8px; + cursor: pointer; + transition: border-color 0.12s, box-shadow 0.12s; +} +.se-template-row--selected .se-template-item { + border-color: var(--se-gold); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--se-gold) 35%, transparent); } .se-template-item { display: flex; @@ -2260,36 +2266,27 @@ gap: 6px 8px; flex: 1; min-width: 0; + width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid var(--se-border); background: var(--se-palette-card-bg); color: var(--se-text); - cursor: pointer; text-align: left; transition: border-color 0.12s, box-shadow 0.12s; } -.se-template-item:hover { +.se-template-row:hover .se-template-item { border-color: var(--se-palette-hover-border); box-shadow: 0 0 12px color-mix(in srgb, var(--se-gold) 10%, transparent); } -.se-template-delete { - flex-shrink: 0; - width: 34px; - border: 1px solid var(--se-border); - border-radius: 8px; - background: var(--se-palette-card-bg); - color: var(--se-text-muted); - cursor: pointer; +.se-palette-section--templates { display: flex; - align-items: center; - justify-content: center; - transition: color 0.12s, border-color 0.12s, background 0.12s; + flex-direction: column; + flex: 1; + min-height: 0; } -.se-template-delete:hover { - color: var(--se-danger); - border-color: color-mix(in srgb, var(--se-danger) 45%, var(--se-border)); - background: color-mix(in srgb, var(--se-danger) 8%, var(--se-palette-card-bg)); +.se-palette-section--templates .se-palette-search { + flex-shrink: 0; } .se-template-label { font-size: 0.76rem; @@ -2314,6 +2311,18 @@ color: var(--se-text-muted); line-height: 1.45; } +.se-template-save-update { + display: flex; + align-items: flex-start; + gap: 8px; + margin-top: 12px; + font-size: 0.76rem; + color: var(--se-text-muted); + cursor: pointer; +} +.se-template-save-update input { + margin-top: 2px; +} .se-template-desc { flex: 1 1 100%; font-size: 0.66rem; diff --git a/frontend/src/utils/strategyTemplateStorage.ts b/frontend/src/utils/strategyTemplateStorage.ts index 8b0ef14..cf97b12 100644 --- a/frontend/src/utils/strategyTemplateStorage.ts +++ b/frontend/src/utils/strategyTemplateStorage.ts @@ -65,6 +65,23 @@ export function deleteUserStrategyTemplate(id: string): UserStrategyTemplate[] { return templates; } +export function updateUserStrategyTemplate( + id: string, + patch: Partial>, +): UserStrategyTemplate[] { + const templates = loadUserStrategyTemplates().map(t => { + if (t.id !== id) return t; + return { + ...t, + ...patch, + ...(patch.editorState != null ? { editorState: cloneJson(patch.editorState) } : null), + ...(patch.orphans != null ? { orphans: cloneJson(patch.orphans) } : null), + }; + }); + saveUserStrategyTemplates(templates); + return templates; +} + export function loadHiddenBuiltinTemplateIds(): string[] { const raw = getUiPreferences().strategyEditor?.hiddenBuiltinTemplateIds; if (!Array.isArray(raw)) return [];