전략편집기 템플릿 삭제 기능 추가
This commit is contained in:
@@ -82,12 +82,15 @@ import {
|
||||
} from '../utils/strategyEditorModeStorage';
|
||||
import {
|
||||
getStrategyTemplates,
|
||||
getBuiltinTemplateId,
|
||||
simpleTemplateToNode,
|
||||
type StrategyTemplateDef,
|
||||
} from '../utils/strategyPresets';
|
||||
import {
|
||||
addUserStrategyTemplate,
|
||||
deleteUserStrategyTemplate,
|
||||
hideBuiltinTemplate,
|
||||
loadHiddenBuiltinTemplateIds,
|
||||
loadUserStrategyTemplates,
|
||||
type UserStrategyTemplate,
|
||||
} from '../utils/strategyTemplateStorage';
|
||||
@@ -197,6 +200,29 @@ function applyFlowLayoutFromStore(
|
||||
}
|
||||
}
|
||||
|
||||
type SaveDialogMode = 'update' | 'create' | 'saveAs';
|
||||
|
||||
function suggestSaveAsName(baseName: string, existing: StrategyDto[]): string {
|
||||
const trimmed = baseName.trim() || '새 전략';
|
||||
const names = new Set(existing.map(s => s.name.trim()));
|
||||
let candidate = `${trimmed} (복사)`;
|
||||
let n = 2;
|
||||
while (names.has(candidate)) {
|
||||
candidate = `${trimmed} (복사 ${n})`;
|
||||
n += 1;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function isDuplicateStrategyName(
|
||||
name: string,
|
||||
strategies: StrategyDto[],
|
||||
excludeId?: number | null,
|
||||
): boolean {
|
||||
const trimmed = name.trim();
|
||||
return strategies.some(s => s.name.trim() === trimmed && s.id !== excludeId);
|
||||
}
|
||||
|
||||
export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Props) {
|
||||
const { getParams, getVisualConfig, settingsRevision, settingsLoaded } = useIndicatorSettings();
|
||||
const DEF = useMemo(
|
||||
@@ -217,6 +243,9 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
const [paletteSearch, setPaletteSearch] = useState('');
|
||||
const [templateSearch, setTemplateSearch] = useState('');
|
||||
const [userTemplates, setUserTemplates] = useState<UserStrategyTemplate[]>(() => loadUserStrategyTemplates());
|
||||
const [hiddenBuiltinTemplateIds, setHiddenBuiltinTemplateIds] = useState<string[]>(
|
||||
() => loadHiddenBuiltinTemplateIds(),
|
||||
);
|
||||
const [templateSaveOpen, setTemplateSaveOpen] = useState(false);
|
||||
const [templateSaveLabel, setTemplateSaveLabel] = useState('');
|
||||
const [templateSaveDesc, setTemplateSaveDesc] = useState('');
|
||||
@@ -226,7 +255,11 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
const [stratDesc, setStratDesc] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveOpen, setSaveOpen] = useState(false);
|
||||
const [saveMode, setSaveMode] = useState<SaveDialogMode>('create');
|
||||
const [saveDraftName, setSaveDraftName] = useState('');
|
||||
const [saveDraftDesc, setSaveDraftDesc] = useState('');
|
||||
const [saveNameError, setSaveNameError] = useState(false);
|
||||
const [saveDuplicateError, setSaveDuplicateError] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [descOpen, setDescOpen] = useState(false);
|
||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||
@@ -824,13 +857,31 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
resetFlowLayout('draft', signalTab);
|
||||
};
|
||||
|
||||
const openSaveDialog = useCallback((mode: SaveDialogMode) => {
|
||||
setSaveMode(mode);
|
||||
setSaveDraftName(mode === 'saveAs' ? suggestSaveAsName(stratName, strategies) : stratName);
|
||||
setSaveDraftDesc(stratDesc);
|
||||
setSaveNameError(false);
|
||||
setSaveDuplicateError(false);
|
||||
setSaveOpen(true);
|
||||
}, [stratName, stratDesc, strategies]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!stratName.trim()) {
|
||||
const name = saveDraftName.trim();
|
||||
if (!name) {
|
||||
setSaveNameError(true);
|
||||
showSnack('전략 이름을 입력하세요', false);
|
||||
return;
|
||||
}
|
||||
const asNew = saveMode === 'saveAs' || saveMode === 'create';
|
||||
const excludeId = asNew ? null : selectedId;
|
||||
if (isDuplicateStrategyName(name, strategies, excludeId)) {
|
||||
setSaveDuplicateError(true);
|
||||
showSnack('같은 이름의 전략이 이미 있습니다', false);
|
||||
return;
|
||||
}
|
||||
setSaveNameError(false);
|
||||
setSaveDuplicateError(false);
|
||||
if (editorMode === 'graph') layoutFlushRef.current?.();
|
||||
if (strategyAutosaveTimerRef.current != null) {
|
||||
window.clearTimeout(strategyAutosaveTimerRef.current);
|
||||
@@ -844,9 +895,9 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
try {
|
||||
const flowLayout = buildCurrentFlowLayout();
|
||||
const payload: ApiStrategyDto = {
|
||||
id: selectedId ?? undefined,
|
||||
name: stratName,
|
||||
description: stratDesc,
|
||||
id: asNew ? undefined : (selectedId ?? undefined),
|
||||
name,
|
||||
description: saveDraftDesc,
|
||||
buyCondition: encodedBuy,
|
||||
sellCondition: encodedSell,
|
||||
flowLayout,
|
||||
@@ -854,42 +905,40 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
};
|
||||
const saved = await saveStrategy(payload);
|
||||
const now = new Date().toISOString();
|
||||
const dbId = saved?.id ?? selectedId ?? Date.now();
|
||||
setStrategies(prev => {
|
||||
const existing = prev.find(s => s.id === selectedId);
|
||||
if (existing && selectedId) {
|
||||
return prev.map(s => s.id === selectedId
|
||||
? {
|
||||
...s,
|
||||
id: dbId,
|
||||
name: stratName,
|
||||
description: stratDesc,
|
||||
buyCondition: encodedBuy,
|
||||
sellCondition: encodedSell,
|
||||
flowLayout,
|
||||
updatedAt: saved?.updatedAt ?? now,
|
||||
}
|
||||
: s);
|
||||
}
|
||||
const dbId = saved?.id ?? (asNew ? Date.now() : selectedId ?? Date.now());
|
||||
const entry: StrategyDto = {
|
||||
id: dbId,
|
||||
name,
|
||||
description: saveDraftDesc,
|
||||
buyCondition: encodedBuy,
|
||||
sellCondition: encodedSell,
|
||||
flowLayout,
|
||||
enabled: true,
|
||||
createdAt: saved?.createdAt ?? now,
|
||||
updatedAt: saved?.updatedAt ?? now,
|
||||
};
|
||||
if (asNew) {
|
||||
setStrategies(prev => [...prev, entry]);
|
||||
setSelectedId(dbId);
|
||||
return [...prev, {
|
||||
id: dbId,
|
||||
name: stratName,
|
||||
description: stratDesc,
|
||||
buyCondition: encodedBuy,
|
||||
sellCondition: encodedSell,
|
||||
flowLayout,
|
||||
enabled: true,
|
||||
createdAt: saved?.createdAt ?? now,
|
||||
updatedAt: saved?.updatedAt ?? now,
|
||||
}];
|
||||
});
|
||||
if (!selectedId) setSelectedId(dbId);
|
||||
} else if (selectedId) {
|
||||
setStrategies(prev => prev.map(s => (s.id === selectedId ? { ...s, ...entry, id: dbId } : s)));
|
||||
} else {
|
||||
setStrategies(prev => [...prev, entry]);
|
||||
setSelectedId(dbId);
|
||||
}
|
||||
setStratName(name);
|
||||
setStratDesc(saveDraftDesc);
|
||||
bumpLayoutSeed(String(dbId), signalTab);
|
||||
setSaveOpen(false);
|
||||
setSaveToast(true);
|
||||
setTimeout(() => setSaveToast(false), 2500);
|
||||
showSnack(selectedId ? '전략이 수정되었습니다' : '전략이 저장되었습니다');
|
||||
if (saveMode === 'saveAs') {
|
||||
showSnack('새 전략으로 저장되었습니다');
|
||||
} else if (saveMode === 'update') {
|
||||
showSnack('전략이 수정되었습니다');
|
||||
} else {
|
||||
showSnack('전략이 저장되었습니다');
|
||||
}
|
||||
} catch (e) {
|
||||
showSnack(e instanceof Error ? e.message : '저장 실패', false);
|
||||
} finally {
|
||||
@@ -1022,13 +1071,56 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
showSnack('전략 목록 형식 파일이 필요합니다', false);
|
||||
return;
|
||||
}
|
||||
const items = result.data.strategies;
|
||||
if (hasRegisteredUser()) {
|
||||
const persisted: StrategyDto[] = [];
|
||||
let failed = 0;
|
||||
for (const item of items) {
|
||||
try {
|
||||
const saved = await saveStrategy({
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
buyCondition: item.buyCondition,
|
||||
sellCondition: item.sellCondition,
|
||||
flowLayout: item.flowLayout,
|
||||
enabled: item.enabled ?? true,
|
||||
});
|
||||
if (saved?.id) {
|
||||
persisted.push({
|
||||
id: saved.id,
|
||||
name: saved.name,
|
||||
description: saved.description,
|
||||
buyCondition: saved.buyCondition as LogicNode | null ?? null,
|
||||
sellCondition: saved.sellCondition as LogicNode | null ?? null,
|
||||
flowLayout: normalizeStrategyFlowLayout(saved.flowLayout) ?? item.flowLayout,
|
||||
enabled: saved.enabled ?? true,
|
||||
createdAt: saved.createdAt,
|
||||
updatedAt: saved.updatedAt,
|
||||
});
|
||||
} else {
|
||||
failed += 1;
|
||||
}
|
||||
} catch {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
if (persisted.length === 0) {
|
||||
showSnack('전략 저장에 실패했습니다', false);
|
||||
return;
|
||||
}
|
||||
setStrategies(prev => [...prev, ...persisted]);
|
||||
showSnack(
|
||||
failed > 0
|
||||
? `${persisted.length}개 저장, ${failed}개 실패`
|
||||
: `${persisted.length}개 전략을 가져와 저장했습니다`,
|
||||
failed === 0,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const baseId = Date.now();
|
||||
const imported = result.data.strategies.map((item, index) => {
|
||||
const id = baseId + index;
|
||||
return listImportItemToStrategyDto(item, id);
|
||||
});
|
||||
const imported = items.map((item, index) => listImportItemToStrategyDto(item, baseId + index));
|
||||
setStrategies(prev => [...prev, ...imported]);
|
||||
showSnack(`${imported.length}개 전략을 가져왔습니다`);
|
||||
showSnack(`${imported.length}개 전략을 가져왔습니다 (로그인 시 DB에 저장됩니다)`, false);
|
||||
} catch (e) {
|
||||
showSnack(e instanceof Error ? e.message : '파일 읽기 실패', false);
|
||||
}
|
||||
@@ -1084,16 +1176,23 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
signal: t.signal,
|
||||
user: t,
|
||||
}));
|
||||
const builtinRows = templates.map((t, i) => ({
|
||||
key: `builtin-${i}`,
|
||||
source: 'builtin' as const,
|
||||
label: t.label,
|
||||
description: 'description' in t ? t.description : undefined,
|
||||
signal: t.signal,
|
||||
builtin: t,
|
||||
}));
|
||||
const hidden = new Set(hiddenBuiltinTemplateIds);
|
||||
const builtinRows = templates
|
||||
.filter(t => !hidden.has(getBuiltinTemplateId(t)))
|
||||
.map(t => {
|
||||
const builtinId = getBuiltinTemplateId(t);
|
||||
return {
|
||||
key: builtinId,
|
||||
source: 'builtin' as const,
|
||||
label: t.label,
|
||||
description: 'description' in t ? t.description : undefined,
|
||||
signal: t.signal,
|
||||
builtinId,
|
||||
builtin: t,
|
||||
};
|
||||
});
|
||||
return [...userRows, ...builtinRows];
|
||||
}, [userTemplates, templates]);
|
||||
}, [userTemplates, templates, hiddenBuiltinTemplateIds]);
|
||||
|
||||
const filteredTemplateRows = useMemo(() => {
|
||||
const q = templateSearch.trim().toLowerCase();
|
||||
@@ -1178,6 +1277,12 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
showSnack('템플릿을 삭제했습니다');
|
||||
}, []);
|
||||
|
||||
const handleDeleteBuiltinTemplate = useCallback((id: string, label: string) => {
|
||||
if (!window.confirm(`"${label}" 템플릿을 목록에서 삭제할까요?`)) return;
|
||||
setHiddenBuiltinTemplateIds(hideBuiltinTemplate(id));
|
||||
showSnack('템플릿을 목록에서 제거했습니다');
|
||||
}, []);
|
||||
|
||||
const openTemplateSaveModal = useCallback(() => {
|
||||
const hasContent = encodeConditionForSave(currentEditorState) != null || currentOrphans.length > 0;
|
||||
if (!hasContent) {
|
||||
@@ -1575,10 +1680,18 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
<button
|
||||
type="button"
|
||||
className="se-btn se-btn--gold se-btn--sm"
|
||||
onClick={() => { setSaveNameError(false); setSaveOpen(true); }}
|
||||
onClick={() => openSaveDialog(selectedId ? 'update' : 'create')}
|
||||
>
|
||||
저장하기
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="se-btn se-btn--ghost se-btn--sm"
|
||||
onClick={() => openSaveDialog('saveAs')}
|
||||
title="현재 조건을 다른 이름으로 새 전략으로 저장 (기존 전략은 유지)"
|
||||
>
|
||||
새로저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1887,25 +2000,27 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
{row.signal === 'buy' ? '매수' : '매도'}
|
||||
</span>
|
||||
</button>
|
||||
{row.source === 'user' && (
|
||||
<button
|
||||
type="button"
|
||||
className="se-template-delete"
|
||||
title="템플릿 삭제"
|
||||
aria-label={`${row.label} 템플릿 삭제`}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
<button
|
||||
type="button"
|
||||
className="se-template-delete"
|
||||
title="템플릿 삭제"
|
||||
aria-label={`${row.label} 템플릿 삭제`}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
if (row.source === 'user') {
|
||||
handleDeleteUserTemplate(row.user.id, row.label);
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.5 2a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v.5H12a.5.5 0 0 1 0 1h-.55l-.62 8.07A1.5 1.5 0 0 1 9.83 13H6.17a1.5 1.5 0 0 1-1.49-1.43L4.05 3.5H4a.5.5 0 0 1 0-1h1.5V2zm1.5 0v.5h2V2H7zm-2.38 1.5l.58 7.53a.5.5 0 0 0 .5.47h3.66a.5.5 0 0 0 .5-.47l.58-7.53H4.62z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
} else {
|
||||
handleDeleteBuiltinTemplate(row.builtinId, row.label);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.5 2a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v.5H12a.5.5 0 0 1 0 1h-.55l-.62 8.07A1.5 1.5 0 0 1 9.83 13H6.17a1.5 1.5 0 0 1-1.49-1.43L4.05 3.5H4a.5.5 0 0 1 0-1h1.5V2zm1.5 0v.5h2V2H7zm-2.38 1.5l.58 7.53a.5.5 0 0 0 .5.47h3.66a.5.5 0 0 0 .5-.47l.58-7.53H4.62z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
@@ -1953,23 +2068,51 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
)}
|
||||
|
||||
{saveOpen && (
|
||||
<DraggableModalFrame onClose={() => { setSaveNameError(false); setSaveOpen(false); }} title="전략 저장">
|
||||
<DraggableModalFrame
|
||||
onClose={() => { setSaveNameError(false); setSaveDuplicateError(false); setSaveOpen(false); }}
|
||||
title={saveMode === 'saveAs' ? '새 이름으로 저장' : '전략 저장'}
|
||||
>
|
||||
<div className="se-modal-body">
|
||||
{saveMode === 'saveAs' && selectedId && (
|
||||
<p className="se-template-save-hint">
|
||||
현재 편집 중인 조건을 <strong>새 전략</strong>으로 저장합니다.
|
||||
기존 전략「{strategies.find(s => s.id === selectedId)?.name ?? stratName}」은 변경되지 않습니다.
|
||||
</p>
|
||||
)}
|
||||
{saveMode === 'saveAs' && !selectedId && (
|
||||
<p className="se-template-save-hint">
|
||||
현재 조건을 <strong>새 전략</strong>으로 저장합니다.
|
||||
</p>
|
||||
)}
|
||||
<label className="se-field-lbl">전략명 *</label>
|
||||
<input
|
||||
className={`se-field-inp${saveNameError ? ' se-field-inp--error' : ''}`}
|
||||
value={stratName}
|
||||
onChange={e => { setStratName(e.target.value); setSaveNameError(false); }}
|
||||
className={`se-field-inp${saveNameError || saveDuplicateError ? ' se-field-inp--error' : ''}`}
|
||||
value={saveDraftName}
|
||||
onChange={e => {
|
||||
setSaveDraftName(e.target.value);
|
||||
setSaveNameError(false);
|
||||
setSaveDuplicateError(false);
|
||||
}}
|
||||
placeholder="예: Golden_RSI_V1"
|
||||
/>
|
||||
{saveNameError && (
|
||||
<p className="se-field-error" role="alert">전략 이름을 입력하세요</p>
|
||||
)}
|
||||
{saveDuplicateError && (
|
||||
<p className="se-field-error" role="alert">같은 이름의 전략이 이미 있습니다</p>
|
||||
)}
|
||||
<label className="se-field-lbl">설명</label>
|
||||
<textarea className="se-field-ta" value={stratDesc} onChange={e => setStratDesc(e.target.value)} rows={2} />
|
||||
<textarea
|
||||
className="se-field-ta"
|
||||
value={saveDraftDesc}
|
||||
onChange={e => setSaveDraftDesc(e.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
<div className="se-modal-actions">
|
||||
<button type="button" className="se-btn se-btn--ghost" onClick={() => setSaveOpen(false)}>취소</button>
|
||||
<button type="button" className="se-btn se-btn--gold" disabled={isSaving} onClick={handleSave}>{isSaving ? '저장 중…' : '저장'}</button>
|
||||
<button type="button" className="se-btn se-btn--gold" disabled={isSaving} onClick={handleSave}>
|
||||
{isSaving ? '저장 중…' : (saveMode === 'saveAs' ? '새로저장' : '저장')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DraggableModalFrame>
|
||||
|
||||
Reference in New Issue
Block a user