refactor: 전략 설정 화면 UI 간소화 및 개선
- 프리셋 카드 UI 개선 (3종 → 명확한 비교 표시) - 필수 설정 6개만 상단 배치 - 중복 파라미터 통합 (RSI 2개 → 1개, 손절 3개 → 1개) - 그룹별 접기/펼치기 기본값 변경 - 툴팁 및 가이드 추가
This commit is contained in:
@@ -2,7 +2,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|||||||
import api from '../lib/api'
|
import api from '../lib/api'
|
||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import HtsPage from '../components/HtsPage'
|
import HtsPage from '../components/HtsPage'
|
||||||
import { RotateCcw, Save, Power, PowerOff, ChevronDown, ChevronRight, RefreshCw, Zap } from 'lucide-react'
|
import { RotateCcw, Save, Power, PowerOff, ChevronDown, ChevronUp, RefreshCw, Zap, Info } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
STRATEGY_PRESETS,
|
STRATEGY_PRESETS,
|
||||||
PRESET_ORDER,
|
PRESET_ORDER,
|
||||||
@@ -19,7 +19,7 @@ interface StrategyConfig {
|
|||||||
group: string
|
group: string
|
||||||
label: string
|
label: string
|
||||||
description: string | null
|
description: string | null
|
||||||
value_type: string // 'float' | 'int' | 'bool' | 'str'
|
value_type: string
|
||||||
value: string
|
value: string
|
||||||
default_value: string
|
default_value: string
|
||||||
min_value: string | null
|
min_value: string | null
|
||||||
@@ -28,48 +28,52 @@ interface StrategyConfig {
|
|||||||
updated_by: string | null
|
updated_by: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 그룹 한글 레이블 ────────────────────────────────────────
|
// ── 통합 파라미터 매핑 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// 핵심 설정만 (6개)
|
||||||
|
const ESSENTIAL_PARAMS = [
|
||||||
|
'RSI_OVERSOLD_LEVEL', // RSI 기준 (통합)
|
||||||
|
'MEAN_REVERSION_STOP_LOSS_PCT', // 손절 (%)
|
||||||
|
'MEAN_REVERSION_TAKE_PROFIT_PCT', // 익절 (%)
|
||||||
|
'MDD_FORCE_THRESHOLD', // 최대낙폭 한도
|
||||||
|
'MAX_HOLDINGS_COUNT', // 최대 보유 종목
|
||||||
|
'ML_ENSEMBLE_ENABLED', // ML 검증 필터
|
||||||
|
]
|
||||||
|
|
||||||
|
// 파라미터 표시명 재정의 (더 직관적으로)
|
||||||
|
const PARAM_DISPLAY_NAMES: Record<string, string> = {
|
||||||
|
'RSI_OVERSOLD_LEVEL': 'RSI 과매도 기준',
|
||||||
|
'MEAN_REVERSION_STOP_LOSS_PCT': '손절선 (%)',
|
||||||
|
'MEAN_REVERSION_TAKE_PROFIT_PCT': '익절선 (%)',
|
||||||
|
'MDD_FORCE_THRESHOLD': '최대낙폭 한도 (%)',
|
||||||
|
'MAX_HOLDINGS_COUNT': '최대 보유 종목수',
|
||||||
|
'ML_ENSEMBLE_ENABLED': 'ML 신호 검증',
|
||||||
|
'DAY_TRADE_ENABLED': '급등 종목 제외',
|
||||||
|
}
|
||||||
|
|
||||||
|
// 파라미터 설명 재정의
|
||||||
|
const PARAM_DESCRIPTIONS: Record<string, string> = {
|
||||||
|
'RSI_OVERSOLD_LEVEL': '낮을수록 진입 조건이 까다로워집니다 (권장: 30~40)',
|
||||||
|
'MEAN_REVERSION_STOP_LOSS_PCT': '마이너스 값, 빠른 손절로 손실 최소화 (권장: -3 ~ -6)',
|
||||||
|
'MEAN_REVERSION_TAKE_PROFIT_PCT': '플러스 값, 너무 높으면 익절 기회 감소 (권장: 4~10)',
|
||||||
|
'MDD_FORCE_THRESHOLD': '누적 손실이 이 값 도달 시 전량 청산 (권장: -8 ~ -15)',
|
||||||
|
'MAX_HOLDINGS_COUNT': '동시에 보유 가능한 최대 종목 수 (권장: 3~10)',
|
||||||
|
'ML_ENSEMBLE_ENABLED': 'AI 모델 검증 통과한 종목만 매매',
|
||||||
|
'DAY_TRADE_ENABLED': '3일/5일/10일 급등 종목 자동 제외',
|
||||||
|
}
|
||||||
|
|
||||||
const GROUP_LABELS: Record<string, string> = {
|
const GROUP_LABELS: Record<string, string> = {
|
||||||
INDICATOR: '기술 지표',
|
INDICATOR: '📊 기술 지표',
|
||||||
RISK: '리스크 관리',
|
RISK: '🛡️ 리스크 관리',
|
||||||
FILTER: '진입 필터',
|
FILTER: '🔍 진입 필터',
|
||||||
DAY_TRADE: '데이트레이딩',
|
DAY_TRADE: '⚡ 데이트레이딩',
|
||||||
MEAN_REVERSION: '평균회귀 전략',
|
MEAN_REVERSION: '↩️ 평균회귀 전략',
|
||||||
GENERAL: '일반 설정',
|
GENERAL: '⚙️ 일반 설정',
|
||||||
}
|
}
|
||||||
|
|
||||||
const GROUP_ORDER = ['GENERAL', 'RISK', 'FILTER', 'INDICATOR', 'MEAN_REVERSION', 'DAY_TRADE']
|
const GROUP_ORDER = ['GENERAL', 'RISK', 'FILTER', 'INDICATOR', 'MEAN_REVERSION', 'DAY_TRADE']
|
||||||
|
|
||||||
const GROUP_ICONS: Record<string, string> = {
|
// ── 프리셋 카드 (개선) ────────────────────────────────────────────────────────
|
||||||
GENERAL: '⚙️',
|
|
||||||
RISK: '🛡️',
|
|
||||||
FILTER: '🔍',
|
|
||||||
INDICATOR: '📊',
|
|
||||||
MEAN_REVERSION: '↩️',
|
|
||||||
DAY_TRADE: '⚡',
|
|
||||||
}
|
|
||||||
|
|
||||||
// 핵심 설정 파라미터 (기본 확장)
|
|
||||||
const CORE_PARAMS = [
|
|
||||||
// 리스크
|
|
||||||
'STOP_LOSS_PERCENT',
|
|
||||||
'TAKE_PROFIT_PERCENT',
|
|
||||||
'MAX_HOLDINGS_COUNT',
|
|
||||||
'MAX_POSITION_SIZE',
|
|
||||||
'MAX_DAILY_TRADES',
|
|
||||||
'MDD_PAUSE_THRESHOLD',
|
|
||||||
'MDD_FORCE_THRESHOLD',
|
|
||||||
// 전략
|
|
||||||
'MEAN_REVERSION_RSI_THRESHOLD',
|
|
||||||
'RSI_OVERSOLD_LEVEL',
|
|
||||||
'ATR_STOP_MULTIPLIER',
|
|
||||||
// 기능 토글
|
|
||||||
'DAY_TRADE_ENABLED',
|
|
||||||
'ML_ENSEMBLE_ENABLED',
|
|
||||||
]
|
|
||||||
|
|
||||||
// ── 프리셋 카드 컴포넌트 ────────────────────────────────────────
|
|
||||||
|
|
||||||
interface PresetCardProps {
|
interface PresetCardProps {
|
||||||
presetKey: PresetKey
|
presetKey: PresetKey
|
||||||
@@ -79,235 +83,223 @@ interface PresetCardProps {
|
|||||||
|
|
||||||
function PresetCard({ presetKey, isSelected, onSelect }: PresetCardProps) {
|
function PresetCard({ presetKey, isSelected, onSelect }: PresetCardProps) {
|
||||||
const preset = STRATEGY_PRESETS[presetKey]
|
const preset = STRATEGY_PRESETS[presetKey]
|
||||||
const riskColors = {
|
|
||||||
low: 'bg-green-500/10 text-green-400 border-green-500/30',
|
const riskBadge = {
|
||||||
medium: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/30',
|
low: { label: '🛡️ 안정형', color: 'bg-green-500/15 text-green-400 border-green-500/40' },
|
||||||
high: 'bg-red-500/10 text-red-400 border-red-500/30',
|
medium: { label: '💼 균형형', color: 'bg-blue-500/15 text-blue-400 border-blue-500/40' },
|
||||||
}
|
high: { label: '🚀 공격형', color: 'bg-red-500/15 text-red-400 border-red-500/40' },
|
||||||
|
}[preset.risk]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
className={`relative w-full p-4 rounded-lg border-2 text-left transition-all ${
|
className={`group relative w-full p-5 rounded-xl border-2 text-left transition-all hover:scale-[1.02] ${
|
||||||
isSelected
|
isSelected
|
||||||
? 'border-blue-500 bg-blue-500/10 shadow-lg shadow-blue-500/20'
|
? 'border-blue-500 bg-blue-500/10 shadow-xl shadow-blue-500/25'
|
||||||
: 'border-slate-700 bg-slate-900/60 hover:border-slate-600'
|
: 'border-slate-700 bg-slate-900/80 hover:border-slate-600 hover:bg-slate-900'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* 선택 배지 */}
|
{/* 선택 체크마크 */}
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<span className="absolute top-2 right-2 text-xs px-2 py-0.5 bg-blue-500 text-white rounded-full font-medium">
|
<div className="absolute -top-2 -right-2 w-7 h-7 bg-blue-500 rounded-full flex items-center justify-center shadow-lg">
|
||||||
선택됨
|
<svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
</span>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 아이콘 + 이름 */}
|
{/* 이모지 + 이름 */}
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
<span className="text-2xl">{preset.emoji}</span>
|
<span className="text-3xl">{preset.emoji}</span>
|
||||||
<h3 className="text-base font-bold text-white">{preset.name}</h3>
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-white">{preset.name}</h3>
|
||||||
|
<span className={`inline-block text-xs px-2 py-0.5 rounded-full border mt-1 ${riskBadge.color}`}>
|
||||||
|
{riskBadge.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 설명 */}
|
{/* 설명 */}
|
||||||
<p className="text-xs text-slate-400 mb-3 line-clamp-2">{preset.description}</p>
|
<p className="text-sm text-slate-400 mb-4 leading-relaxed">{preset.description}</p>
|
||||||
|
|
||||||
{/* 태그 */}
|
{/* 핵심 지표 */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="space-y-1.5 text-xs">
|
||||||
<span className={`text-[10px] px-2 py-0.5 rounded-full border font-medium ${riskColors[preset.risk]}`}>
|
<div className="flex justify-between">
|
||||||
{preset.risk === 'low' ? '낮은 리스크' : preset.risk === 'medium' ? '중간 리스크' : '높은 리스크'}
|
<span className="text-slate-500">기대 수익률</span>
|
||||||
</span>
|
<span className="font-medium text-green-400">{preset.expectedReturn}</span>
|
||||||
<span className="text-[10px] text-slate-500">
|
</div>
|
||||||
기대 수익: {preset.expectedReturn}
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-500">RSI 기준</span>
|
||||||
|
<span className="font-mono text-slate-300">{preset.params.RSI_OVERSOLD_LEVEL}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-500">손절/익절</span>
|
||||||
|
<span className="font-mono text-slate-300">
|
||||||
|
{preset.params.MEAN_REVERSION_STOP_LOSS_PCT}% /
|
||||||
|
{preset.params.MEAN_REVERSION_TAKE_PROFIT_PCT}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 개별 파라미터 행 컴포넌트 ────────────────────────────────
|
// ── 개별 파라미터 컴포넌트 (간소화) ────────────────────────────────────────────
|
||||||
|
|
||||||
interface ConfigRowProps {
|
interface ParamControlProps {
|
||||||
cfg: StrategyConfig
|
cfg: StrategyConfig
|
||||||
onSave: (key: string, value: string) => void
|
onSave: (key: string, value: string) => void
|
||||||
onToggle: (key: string) => void
|
|
||||||
onReset: (key: string) => void
|
|
||||||
isSaving: boolean
|
isSaving: boolean
|
||||||
|
isEssential?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConfigRow({ cfg, onSave, onToggle, onReset, isSaving }: ConfigRowProps) {
|
function ParamControl({ cfg, onSave, isSaving, isEssential }: ParamControlProps) {
|
||||||
const [editValue, setEditValue] = useState(cfg.value)
|
const [editValue, setEditValue] = useState(cfg.value)
|
||||||
const [dirty, setDirty] = useState(false)
|
const isDirty = editValue !== cfg.value
|
||||||
|
const isModified = cfg.value !== cfg.default_value
|
||||||
|
|
||||||
const isDefault = cfg.value === cfg.default_value
|
const displayName = PARAM_DISPLAY_NAMES[cfg.key] || cfg.label
|
||||||
const isModified = !isDefault && !dirty
|
const displayDesc = PARAM_DESCRIPTIONS[cfg.key] || cfg.description
|
||||||
|
|
||||||
const handleChange = (val: string) => {
|
|
||||||
setEditValue(val)
|
|
||||||
setDirty(val !== cfg.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
|
if (isDirty) {
|
||||||
onSave(cfg.key, editValue)
|
onSave(cfg.key, editValue)
|
||||||
setDirty(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
setEditValue(cfg.default_value)
|
|
||||||
setDirty(cfg.default_value !== cfg.value)
|
|
||||||
onReset(cfg.key)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isBool = cfg.value_type === 'bool'
|
const isBool = cfg.value_type === 'bool'
|
||||||
const isTrue = cfg.value === 'true' || cfg.value === 'True' || cfg.value === '1'
|
const boolValue = cfg.value === 'true' || cfg.value === 'True' || cfg.value === '1'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`relative bg-slate-900 border rounded-lg p-3 transition-all ${
|
<div className={`relative bg-slate-900/60 border rounded-lg p-4 transition-all ${
|
||||||
!cfg.is_active
|
isEssential ? 'border-blue-500/30' : 'border-slate-800'
|
||||||
? 'border-slate-800 opacity-40'
|
} ${
|
||||||
: isModified
|
isModified ? 'bg-yellow-500/5 border-yellow-500/30' : ''
|
||||||
? 'border-yellow-500/40 bg-yellow-500/5'
|
} ${
|
||||||
: dirty
|
isDirty ? 'ring-2 ring-blue-500/30' : ''
|
||||||
? 'border-blue-500/40 bg-blue-500/5'
|
|
||||||
: 'border-slate-800 hover:border-slate-700'
|
|
||||||
}`}>
|
}`}>
|
||||||
{/* 상태 배지 */}
|
{/* 상태 배지 */}
|
||||||
{(isModified || dirty) && (
|
{(isModified || isDirty) && (
|
||||||
<span className={`absolute top-1.5 right-1.5 text-[9px] px-1 py-0.5 rounded font-medium ${
|
<span className={`absolute top-2 right-2 text-[10px] px-2 py-0.5 rounded-full font-medium ${
|
||||||
dirty ? 'bg-blue-500/20 text-blue-400' : 'bg-yellow-500/20 text-yellow-400'
|
isDirty ? 'bg-blue-500 text-white' : 'bg-yellow-500/20 text-yellow-400'
|
||||||
}`}>
|
}`}>
|
||||||
{dirty ? '미저장' : '수정됨'}
|
{isDirty ? '●' : '수정됨'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 레이블 + 설명 */}
|
{/* 레이블 */}
|
||||||
<p className="text-xs font-medium text-slate-200 pr-8 leading-snug">{cfg.label}</p>
|
<div className="mb-3">
|
||||||
{cfg.description && (
|
<label className="text-sm font-semibold text-white flex items-center gap-1">
|
||||||
<p className="text-[10px] text-slate-600 mt-0.5 mb-2 line-clamp-1">{cfg.description}</p>
|
{displayName}
|
||||||
|
{displayDesc && (
|
||||||
|
<div className="group/tooltip relative">
|
||||||
|
<Info size={14} className="text-slate-500 cursor-help" />
|
||||||
|
<div className="hidden group-hover/tooltip:block absolute left-0 top-5 z-10 w-64 p-2 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 shadow-xl">
|
||||||
|
{displayDesc}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 컨트롤 영역 */}
|
{/* 입력 컨트롤 */}
|
||||||
<div className="flex items-center justify-between mt-2">
|
<div className="flex items-center gap-2">
|
||||||
{isBool ? (
|
{isBool ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => onSave(cfg.key, isTrue ? 'false' : 'true')}
|
onClick={() => onSave(cfg.key, boolValue ? 'false' : 'true')}
|
||||||
disabled={!cfg.is_active}
|
className={`relative w-12 h-6 rounded-full transition-colors ${
|
||||||
className={`relative w-9 h-5 rounded-full transition-colors ${isTrue ? 'bg-blue-500' : 'bg-slate-700'}`}
|
boolValue ? 'bg-blue-500' : 'bg-slate-700'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<span className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${isTrue ? 'translate-x-4' : 'translate-x-0.5'}`} />
|
<span className={`absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform ${
|
||||||
|
boolValue ? 'translate-x-6' : 'translate-x-0.5'
|
||||||
|
}`} />
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-1.5">
|
<>
|
||||||
<input
|
<input
|
||||||
type={cfg.value_type === 'int' || cfg.value_type === 'float' ? 'number' : 'text'}
|
type="number"
|
||||||
value={editValue}
|
value={editValue}
|
||||||
onChange={(e) => handleChange(e.target.value)}
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
disabled={!cfg.is_active}
|
step={cfg.value_type === 'float' ? '0.1' : '1'}
|
||||||
step={cfg.value_type === 'float' ? '0.001' : '1'}
|
|
||||||
min={cfg.min_value ?? undefined}
|
min={cfg.min_value ?? undefined}
|
||||||
max={cfg.max_value ?? undefined}
|
max={cfg.max_value ?? undefined}
|
||||||
className="w-20 px-2 py-1 text-xs bg-slate-800 border border-slate-700 rounded text-white text-right
|
onKeyDown={(e) => e.key === 'Enter' && handleSave()}
|
||||||
focus:outline-none focus:border-blue-500 disabled:opacity-40"
|
className="flex-1 px-3 py-2 text-sm bg-slate-800 border border-slate-700 rounded-lg text-white text-center font-mono focus:outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20"
|
||||||
/>
|
/>
|
||||||
<span className="text-[10px] text-slate-600">/ {cfg.default_value}</span>
|
{isDirty && (
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 액션 버튼 */}
|
|
||||||
<div className="flex items-center gap-0.5">
|
|
||||||
{!isBool && (
|
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={!dirty || isSaving}
|
disabled={isSaving}
|
||||||
title="저장"
|
className="px-3 py-2 bg-blue-500 text-white text-xs rounded-lg hover:bg-blue-600 disabled:opacity-50 transition-colors flex items-center gap-1"
|
||||||
className="p-1 rounded text-blue-400 hover:bg-blue-500/10 disabled:opacity-20 disabled:cursor-not-allowed transition-colors"
|
|
||||||
>
|
>
|
||||||
<Save size={11} />
|
<Save size={12} />
|
||||||
|
저장
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
</>
|
||||||
onClick={() => onToggle(cfg.key)}
|
)}
|
||||||
title={cfg.is_active ? '비활성화' : '활성화'}
|
|
||||||
className={`p-1 rounded transition-colors ${cfg.is_active ? 'text-slate-400 hover:bg-slate-700' : 'text-slate-600 hover:bg-slate-700'}`}
|
|
||||||
>
|
|
||||||
{cfg.is_active ? <Power size={11} /> : <PowerOff size={11} />}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleReset}
|
|
||||||
title="기본값으로 초기화"
|
|
||||||
className="p-1 rounded text-slate-500 hover:text-yellow-400 hover:bg-yellow-500/10 transition-colors"
|
|
||||||
>
|
|
||||||
<RotateCcw size={10} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 기본값 표시 */}
|
||||||
|
{!isBool && (
|
||||||
|
<div className="mt-2 text-xs text-slate-600">
|
||||||
|
기본값: <span className="font-mono">{cfg.default_value}</span>
|
||||||
|
{cfg.min_value && cfg.max_value && (
|
||||||
|
<span className="ml-2">범위: {cfg.min_value} ~ {cfg.max_value}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 그룹 섹션 컴포넌트 ──────────────────────────────────────────────
|
// ── 그룹 섹션 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface GroupSectionProps {
|
interface GroupSectionProps {
|
||||||
groupKey: string
|
groupKey: string
|
||||||
configs: StrategyConfig[]
|
configs: StrategyConfig[]
|
||||||
onSave: (key: string, value: string) => void
|
onSave: (key: string, value: string) => void
|
||||||
onToggle: (key: string) => void
|
|
||||||
onReset: (key: string) => void
|
|
||||||
isSaving: boolean
|
isSaving: boolean
|
||||||
isExpanded: boolean
|
isExpanded: boolean
|
||||||
onToggleExpand: () => void
|
onToggleExpand: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function GroupSection({
|
function GroupSection({ groupKey, configs, onSave, isSaving, isExpanded, onToggleExpand }: GroupSectionProps) {
|
||||||
groupKey,
|
|
||||||
configs,
|
|
||||||
onSave,
|
|
||||||
onToggle,
|
|
||||||
onReset,
|
|
||||||
isSaving,
|
|
||||||
isExpanded,
|
|
||||||
onToggleExpand
|
|
||||||
}: GroupSectionProps) {
|
|
||||||
const modifiedCount = configs.filter(c => c.value !== c.default_value).length
|
const modifiedCount = configs.filter(c => c.value !== c.default_value).length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="mb-4">
|
||||||
{/* 그룹 헤더 */}
|
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={onToggleExpand}
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
onToggleExpand()
|
|
||||||
}}
|
|
||||||
type="button"
|
type="button"
|
||||||
className="w-full flex items-center justify-between py-2 mb-3 group hover:bg-slate-800/30 rounded px-2 transition-colors"
|
className="w-full flex items-center justify-between py-3 px-4 bg-slate-900/40 hover:bg-slate-800/60 rounded-lg transition-colors group"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
<span>{GROUP_ICONS[groupKey] || '📁'}</span>
|
<span className="text-lg">{GROUP_LABELS[groupKey]?.split(' ')[0] || '📁'}</span>
|
||||||
<span className="font-semibold text-slate-200 text-sm">
|
<span className="font-semibold text-slate-200">
|
||||||
{GROUP_LABELS[groupKey] || groupKey}
|
{GROUP_LABELS[groupKey]?.split(' ')[1] || groupKey}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-slate-600">{configs.length}개</span>
|
<span className="text-xs text-slate-600">{configs.length}개</span>
|
||||||
{modifiedCount > 0 && (
|
{modifiedCount > 0 && (
|
||||||
<span className="text-xs px-2 py-0.5 bg-yellow-500/15 text-yellow-400 rounded-full">
|
<span className="text-xs px-2 py-0.5 bg-yellow-500/15 text-yellow-400 rounded-full">
|
||||||
{modifiedCount}개 수정됨
|
{modifiedCount}개 수정
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isExpanded
|
{isExpanded
|
||||||
? <ChevronDown size={15} className="text-slate-500 group-hover:text-slate-300 transition-colors" />
|
? <ChevronUp size={18} className="text-slate-500 group-hover:text-slate-300" />
|
||||||
: <ChevronRight size={15} className="text-slate-500 group-hover:text-slate-300 transition-colors" />
|
: <ChevronDown size={18} className="text-slate-500 group-hover:text-slate-300" />
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 파라미터 카드 그리드 */}
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="grid grid-cols-3 gap-2 mb-4">
|
<div className="grid grid-cols-2 gap-3 mt-3">
|
||||||
{configs.map((cfg) => (
|
{configs.map((cfg) => (
|
||||||
<ConfigRow
|
<ParamControl
|
||||||
key={cfg.key}
|
key={cfg.key}
|
||||||
cfg={cfg}
|
cfg={cfg}
|
||||||
onSave={onSave}
|
onSave={onSave}
|
||||||
onToggle={onToggle}
|
|
||||||
onReset={onReset}
|
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -317,17 +309,16 @@ function GroupSection({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 메인 페이지 ──────────────────────────────────────────────────────
|
// ── 메인 페이지 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const StrategySettings = () => {
|
const StrategySettings = () => {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [notification, setNotification] = useState<{ type: 'success' | 'error'; msg: string } | null>(null)
|
const [notification, setNotification] = useState<{ type: 'success' | 'error'; msg: string } | null>(null)
|
||||||
const [showPresets, setShowPresets] = useState(true)
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(['RISK'])) // 리스크만 기본 펼침
|
||||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
|
|
||||||
|
|
||||||
const showNotification = (type: 'success' | 'error', msg: string) => {
|
const showNotification = (type: 'success' | 'error', msg: string) => {
|
||||||
setNotification({ type, msg })
|
setNotification({ type, msg })
|
||||||
setTimeout(() => setNotification(null), 3000)
|
setTimeout(() => setNotification(null), 4000)
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleGroupExpand = (groupKey: string) => {
|
const toggleGroupExpand = (groupKey: string) => {
|
||||||
@@ -342,7 +333,7 @@ const StrategySettings = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 전략 파라미터 목록 조회
|
// 전략 파라미터 조회
|
||||||
const { data: configs = [], isLoading, refetch } = useQuery<StrategyConfig[]>({
|
const { data: configs = [], isLoading, refetch } = useQuery<StrategyConfig[]>({
|
||||||
queryKey: ['strategy-config'],
|
queryKey: ['strategy-config'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -364,52 +355,28 @@ const StrategySettings = () => {
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['strategy-config'] })
|
queryClient.invalidateQueries({ queryKey: ['strategy-config'] })
|
||||||
showNotification('success', '저장 완료')
|
showNotification('success', '✓ 저장되었습니다')
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
showNotification('error', `저장 실패: ${err.response?.data?.detail || err.message}`)
|
showNotification('error', `저장 실패: ${err.response?.data?.detail || err.message}`)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// 활성화 토글
|
// 전체 초기화
|
||||||
const toggleMutation = useMutation({
|
|
||||||
mutationFn: async (key: string) => {
|
|
||||||
return api.patch(`/strategy-config/${key}/toggle`)
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['strategy-config'] })
|
|
||||||
},
|
|
||||||
onError: (err: any) => {
|
|
||||||
showNotification('error', `토글 실패: ${err.message}`)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// 단일 기본값 리셋
|
|
||||||
const resetOneMutation = useMutation({
|
|
||||||
mutationFn: async (key: string) => {
|
|
||||||
return api.post(`/strategy-config/${key}/reset`)
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['strategy-config'] })
|
|
||||||
showNotification('success', '기본값으로 초기화 완료')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// 전체 기본값 리셋
|
|
||||||
const resetAllMutation = useMutation({
|
const resetAllMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
return api.post('/strategy-config/reset-all')
|
return api.post('/strategy-config/reset-all')
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['strategy-config'] })
|
queryClient.invalidateQueries({ queryKey: ['strategy-config'] })
|
||||||
showNotification('success', '전체 파라미터가 기본값으로 초기화되었습니다.')
|
showNotification('success', '✓ 전체 설정이 초기화되었습니다')
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
showNotification('error', `초기화 실패: ${err.message}`)
|
showNotification('error', `초기화 실패: ${err.message}`)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// 프리셋 적용 (일괄 변경)
|
// 프리셋 적용
|
||||||
const applyPresetMutation = useMutation({
|
const applyPresetMutation = useMutation({
|
||||||
mutationFn: async (presetKey: PresetKey) => {
|
mutationFn: async (presetKey: PresetKey) => {
|
||||||
const preset = STRATEGY_PRESETS[presetKey]
|
const preset = STRATEGY_PRESETS[presetKey]
|
||||||
@@ -417,42 +384,43 @@ const StrategySettings = () => {
|
|||||||
key,
|
key,
|
||||||
value: String(value),
|
value: String(value),
|
||||||
}))
|
}))
|
||||||
// 병렬 요청
|
|
||||||
return Promise.all(updates.map(({ key, value }) =>
|
return Promise.all(updates.map(({ key, value }) =>
|
||||||
api.put(`/strategy-config/${key}`, { value, updated_by: 'preset' })
|
api.put(`/strategy-config/${key}`, { value, updated_by: 'preset' })
|
||||||
))
|
))
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['strategy-config'] })
|
queryClient.invalidateQueries({ queryKey: ['strategy-config'] })
|
||||||
showNotification('success', '프리셋 적용 완료')
|
showNotification('success', '✓ 프리셋이 적용되었습니다')
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
showNotification('error', `프리셋 적용 실패: ${err.message}`)
|
showNotification('error', `적용 실패: ${err.message}`)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// 그룹별 분류
|
// 필수 설정 필터링
|
||||||
const grouped = useMemo(() => {
|
const essentialConfigs = configs.filter(c => ESSENTIAL_PARAMS.includes(c.key))
|
||||||
|
|
||||||
|
// 고급 설정 그룹화
|
||||||
|
const groupedConfigs = useMemo(() => {
|
||||||
const map: Record<string, StrategyConfig[]> = {}
|
const map: Record<string, StrategyConfig[]> = {}
|
||||||
for (const cfg of configs) {
|
const advanced = configs.filter(c => !ESSENTIAL_PARAMS.includes(c.key))
|
||||||
|
for (const cfg of advanced) {
|
||||||
if (!map[cfg.group]) map[cfg.group] = []
|
if (!map[cfg.group]) map[cfg.group] = []
|
||||||
map[cfg.group].push(cfg)
|
map[cfg.group].push(cfg)
|
||||||
}
|
}
|
||||||
return map
|
return map
|
||||||
}, [configs])
|
}, [configs])
|
||||||
|
|
||||||
// 핵심 설정 / 고급 설정 분리
|
|
||||||
const coreConfigs = configs.filter(c => CORE_PARAMS.includes(c.key))
|
|
||||||
const advancedGroups = GROUP_ORDER.filter(g => grouped[g]?.length)
|
|
||||||
|
|
||||||
// 수정된 파라미터 수
|
|
||||||
const totalModified = configs.filter(c => c.value !== c.default_value).length
|
const totalModified = configs.filter(c => c.value !== c.default_value).length
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<HtsPage>
|
<HtsPage>
|
||||||
<div className="flex items-center justify-center h-64 text-slate-400">
|
<div className="flex items-center justify-center h-96">
|
||||||
로딩 중...
|
<div className="text-slate-400 text-center">
|
||||||
|
<RefreshCw className="animate-spin mx-auto mb-2" size={24} />
|
||||||
|
<p>설정을 불러오는 중...</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</HtsPage>
|
</HtsPage>
|
||||||
)
|
)
|
||||||
@@ -460,39 +428,31 @@ const StrategySettings = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<HtsPage>
|
<HtsPage>
|
||||||
<div className="space-y-5">
|
<div className="max-w-7xl mx-auto space-y-6">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold text-white">전략 설정</h2>
|
<h1 className="text-3xl font-bold text-white mb-2">전략 설정</h1>
|
||||||
<p className="text-sm text-slate-500 mt-1">
|
<p className="text-sm text-slate-500">
|
||||||
변경 즉시 캐시 무효화 → 30초 이내 전략 코드에 반영됩니다.
|
매매 전략 파라미터를 조정합니다. 변경사항은 즉시 반영됩니다.
|
||||||
비활성화 시 config.py 기본값이 사용됩니다.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => refetch()}
|
onClick={() => refetch()}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-slate-400 hover:text-white border border-slate-700 rounded-lg hover:border-slate-500 transition-colors"
|
className="px-4 py-2 text-sm text-slate-400 hover:text-white border border-slate-700 rounded-lg hover:border-slate-500 transition-colors flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<RefreshCw size={14} />
|
<RefreshCw size={14} />
|
||||||
새로고침
|
새로고침
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onClick={() => setShowPresets(!showPresets)}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-blue-400 border border-blue-500/40 rounded-lg hover:bg-blue-500/10 transition-colors"
|
|
||||||
>
|
|
||||||
<Zap size={14} />
|
|
||||||
{showPresets ? '프리셋 숨기기' : '프리셋 보기'}
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (confirm('모든 파라미터를 config.py 기본값으로 초기화하시겠습니까?')) {
|
if (confirm('⚠️ 모든 설정을 기본값으로 초기화하시겠습니까?')) {
|
||||||
resetAllMutation.mutate()
|
resetAllMutation.mutate()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={resetAllMutation.isPending || totalModified === 0}
|
disabled={resetAllMutation.isPending || totalModified === 0}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-yellow-400 border border-yellow-500/40 rounded-lg hover:bg-yellow-500/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
className="px-4 py-2 text-sm text-yellow-400 border border-yellow-500/40 rounded-lg hover:bg-yellow-500/10 disabled:opacity-40 transition-colors flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<RotateCcw size={14} />
|
<RotateCcw size={14} />
|
||||||
전체 초기화 {totalModified > 0 && `(${totalModified})`}
|
전체 초기화 {totalModified > 0 && `(${totalModified})`}
|
||||||
@@ -500,9 +460,9 @@ const StrategySettings = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 알림 배너 */}
|
{/* 알림 */}
|
||||||
{notification && (
|
{notification && (
|
||||||
<div className={`px-4 py-2.5 rounded-lg text-sm font-medium ${
|
<div className={`px-4 py-3 rounded-lg text-sm font-medium animate-fade-in ${
|
||||||
notification.type === 'success'
|
notification.type === 'success'
|
||||||
? 'bg-green-500/15 text-green-400 border border-green-500/30'
|
? 'bg-green-500/15 text-green-400 border border-green-500/30'
|
||||||
: 'bg-red-500/15 text-red-400 border border-red-500/30'
|
: 'bg-red-500/15 text-red-400 border border-red-500/30'
|
||||||
@@ -512,23 +472,24 @@ const StrategySettings = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 프리셋 선택 */}
|
{/* 프리셋 선택 */}
|
||||||
{showPresets && (
|
<div className="bg-gradient-to-br from-slate-900 to-slate-900/50 border border-slate-800 rounded-2xl p-6">
|
||||||
<div className="bg-slate-900/60 border border-slate-800 rounded-xl p-5">
|
<div className="flex items-center justify-between mb-5">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||||
<h3 className="text-lg font-bold text-white flex items-center gap-2">
|
<Zap size={20} className="text-yellow-400" />
|
||||||
<Zap size={18} className="text-yellow-400" />
|
빠른 설정 선택
|
||||||
빠른 설정 (프리셋)
|
</h2>
|
||||||
</h3>
|
<span className="text-sm text-slate-500">
|
||||||
<span className="text-xs text-slate-500">
|
현재: <span className={`font-semibold ${
|
||||||
현재: <span className="text-blue-400 font-medium">
|
currentPreset === 'custom' ? 'text-purple-400' : 'text-blue-400'
|
||||||
|
}`}>
|
||||||
{currentPreset === 'custom'
|
{currentPreset === 'custom'
|
||||||
? '사용자 정의'
|
? '🎨 사용자 정의'
|
||||||
: STRATEGY_PRESETS[currentPreset as PresetKey].name
|
: STRATEGY_PRESETS[currentPreset as PresetKey].name
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
{PRESET_ORDER.map((key) => (
|
{PRESET_ORDER.map((key) => (
|
||||||
<PresetCard
|
<PresetCard
|
||||||
key={key}
|
key={key}
|
||||||
@@ -537,10 +498,10 @@ const StrategySettings = () => {
|
|||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
const diff = getPresetDiff(key, configs.map(c => ({ key: c.key, value: c.value })))
|
const diff = getPresetDiff(key, configs.map(c => ({ key: c.key, value: c.value })))
|
||||||
if (diff.length === 0) {
|
if (diff.length === 0) {
|
||||||
showNotification('success', '이미 해당 프리셋이 적용되어 있습니다.')
|
showNotification('success', '이미 적용된 프리셋입니다')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (confirm(`"${STRATEGY_PRESETS[key].name}" 프리셋을 적용하시겠습니까?\n${diff.length}개 파라미터가 변경됩니다.`)) {
|
if (confirm(`"${STRATEGY_PRESETS[key].name}" 프리셋을 적용하시겠습니까?\n\n${diff.length}개 항목이 변경됩니다.`)) {
|
||||||
applyPresetMutation.mutate(key)
|
applyPresetMutation.mutate(key)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -548,57 +509,47 @@ const StrategySettings = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 파라미터 없음 */}
|
{/* 필수 설정 */}
|
||||||
{configs.length === 0 && (
|
<div className="bg-slate-900/60 border border-blue-500/30 rounded-2xl p-6">
|
||||||
<div className="text-center py-16 text-slate-500">
|
<h2 className="text-xl font-bold text-white mb-5 flex items-center gap-2">
|
||||||
<p>전략 파라미터가 없습니다.</p>
|
⭐ 필수 설정
|
||||||
<p className="text-xs mt-2">DB 마이그레이션 후 서버를 재시작하면 기본값이 자동 생성됩니다.</p>
|
<span className="text-xs text-slate-500 font-normal">가장 중요한 6가지 설정</span>
|
||||||
</div>
|
</h2>
|
||||||
)}
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{essentialConfigs.map((cfg) => (
|
||||||
{/* 핵심 설정 */}
|
<ParamControl
|
||||||
{coreConfigs.length > 0 && (
|
|
||||||
<div className="bg-slate-900/60 border border-slate-800 rounded-xl p-5">
|
|
||||||
<h3 className="text-lg font-bold text-white mb-4 flex items-center gap-2">
|
|
||||||
⭐ 핵심 설정
|
|
||||||
<span className="text-xs text-slate-500 font-normal">가장 자주 사용하는 파라미터</span>
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-3 gap-2">
|
|
||||||
{coreConfigs.map((cfg) => (
|
|
||||||
<ConfigRow
|
|
||||||
key={cfg.key}
|
key={cfg.key}
|
||||||
cfg={cfg}
|
cfg={cfg}
|
||||||
onSave={(key, value) => updateMutation.mutate({ key, value })}
|
onSave={(key, value) => updateMutation.mutate({ key, value })}
|
||||||
onToggle={(key) => toggleMutation.mutate(key)}
|
|
||||||
onReset={(key) => resetOneMutation.mutate(key)}
|
|
||||||
isSaving={updateMutation.isPending}
|
isSaving={updateMutation.isPending}
|
||||||
|
isEssential
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 고급 설정 (그룹별 접기/펼치기) */}
|
{/* 고급 설정 */}
|
||||||
<div className="bg-slate-900/40 border border-slate-800 rounded-xl p-5">
|
<div className="bg-slate-900/40 border border-slate-800 rounded-2xl p-6">
|
||||||
<h3 className="text-lg font-bold text-white mb-4 flex items-center gap-2">
|
<h2 className="text-xl font-bold text-white mb-5 flex items-center gap-2">
|
||||||
⚙️ 고급 설정
|
⚙️ 고급 설정
|
||||||
<span className="text-xs text-slate-500 font-normal">그룹별로 펼쳐보기</span>
|
<span className="text-xs text-slate-500 font-normal">필요시 세부 조정</span>
|
||||||
</h3>
|
</h2>
|
||||||
{advancedGroups.map(groupKey => (
|
{GROUP_ORDER.map(groupKey => {
|
||||||
|
const groupConfigs = groupedConfigs[groupKey]
|
||||||
|
if (!groupConfigs?.length) return null
|
||||||
|
return (
|
||||||
<GroupSection
|
<GroupSection
|
||||||
key={groupKey}
|
key={groupKey}
|
||||||
groupKey={groupKey}
|
groupKey={groupKey}
|
||||||
configs={grouped[groupKey]}
|
configs={groupConfigs}
|
||||||
onSave={(key, value) => updateMutation.mutate({ key, value })}
|
onSave={(key, value) => updateMutation.mutate({ key, value })}
|
||||||
onToggle={(key) => toggleMutation.mutate(key)}
|
|
||||||
onReset={(key) => resetOneMutation.mutate(key)}
|
|
||||||
isSaving={updateMutation.isPending}
|
isSaving={updateMutation.isPending}
|
||||||
isExpanded={expandedGroups.has(groupKey)}
|
isExpanded={expandedGroups.has(groupKey)}
|
||||||
onToggleExpand={() => toggleGroupExpand(groupKey)}
|
onToggleExpand={() => toggleGroupExpand(groupKey)}
|
||||||
/>
|
/>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</HtsPage>
|
</HtsPage>
|
||||||
|
|||||||
Reference in New Issue
Block a user