Files
goldenChart/frontend/src/components/SettingsPage.tsx
T
2026-05-29 02:25:03 +09:00

1873 lines
78 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 설정 페이지
* macOS 시스템 환경설정 스타일 — 좌측 카테고리 목록 + 우측 설정 패널
*/
import React, { useState, useEffect, useCallback } from 'react';
import type { Theme } from '../types';
import {
saveLiveStrategySettings,
type LiveStrategySettingsDto,
} from '../utils/backendApi';
import { syncStrategyTimeframesFromLayoutIfNeeded } from '../utils/strategyTimeframeSync';
import { BacktestSettingsPanel } from './BacktestSettingsPanel';
import IndicatorMainDefaultsPanel, {
type IndicatorSaveUiState,
} from './IndicatorMainDefaultsPanel';
import type { IndicatorConfig } from '../types';
import type { PlotDef, HLineDef } from '../utils/indicatorRegistry';
import type { IchimokuCloudColors } from '../utils/ichimokuConfig';
import ChartTimeFormatPicker from './ChartTimeFormatPicker';
import {
TRADE_ALERT_SOUND_OPTIONS,
normalizeTradeAlertSoundId,
previewTradeAlertSound,
} from '../utils/tradeAlertSound';
import {
TRADE_ALERT_GRID_COL_OPTIONS,
TRADE_ALERT_POPUP_LAYOUTS,
TRADE_ALERT_POPUP_POSITIONS,
normalizeTradeAlertGridCols,
normalizeTradeAlertPopupLayout,
normalizeTradeAlertPopupPosition,
} from '../utils/tradeAlertPopupLayout';
import TradeAlertPopupPreview from './TradeAlertPopupPreview';
import TrendSearchSettingsPanel from './trendSearch/TrendSearchSettingsPanel';
import type { TrendSearchAppSettings } from '../utils/trendSearchAppSettings';
import {
CHART_LEGEND_SETTING_ITEMS,
type ChartLegendVisibility,
} from '../types/chartLegend';
import type { ChartPaneSeparatorOptions } from '../types/chartPaneSeparator';
import {
LINE_STYLE_LABELS,
LINE_STYLE_OPTIONS,
LINE_WIDTH_OPTIONS,
} from '../utils/plotColorUtils';
import TimezonePicker from './TimezonePicker';
import AdminPasswordGate from './AdminPasswordGate';
import AdminSettingsPanel from './AdminSettingsPanel';
import {
type SettingsCategoryId,
settingsCategoryToMenuId,
canAccessMenu,
} from '../utils/permissions';
interface SettingsPageProps {
theme: Theme;
magnetMode?: 'off' | 'weak' | 'strong';
onMagnetMode?: (m: 'off' | 'weak' | 'strong') => void;
btAutoPopup?: boolean;
onBtAutoPopup?: (v: boolean) => void;
btShowPrice?: boolean;
onBtShowPrice?: (v: boolean) => void;
/** 실시간 전략 체크 — 현재 차트 종목 */
liveMarket?: string;
/** 실시간 전략 체크 — 선택 가능한 전략 목록 */
liveStrategies?: { id: number; name: string }[];
/** 마커 초기화 콜백 */
onClearLiveMarkers?: () => void;
/** 매매 알림 팝업 ON/OFF */
tradeAlertPopup?: boolean;
onTradeAlertPopup?: (v: boolean) => void;
/** 알림 설정 탭 */
tradeAlertSoundEnabled?: boolean;
onTradeAlertSoundEnabled?: (v: boolean) => void;
tradeAlertSound?: string;
onTradeAlertSound?: (v: string) => void;
tradeAlertPopupPosition?: string;
onTradeAlertPopupPosition?: (v: string) => void;
tradeAlertPopupLayout?: string;
onTradeAlertPopupLayout?: (v: string) => void;
tradeAlertPopupGridCols?: number;
onTradeAlertPopupGridCols?: (v: number) => void;
liveSettings?: LiveStrategySettingsDto;
onLiveSettingsChange?: (settings: LiveStrategySettingsDto) => void;
watchlistCount?: number;
monitoredMarkets?: string[];
/** 가상투자 세션 실행 중 — 전략 설정은 가상투자 화면에서 */
virtualDriven?: boolean;
/** Main 탭 보조지표 기본 파라미터 (DB) */
getIndicatorParams?: (type: string, defaults: Record<string, number | string | boolean>) => Record<string, number | string | boolean>;
saveIndicatorParams?: (type: string, params: Record<string, number | string | boolean>) => void;
getIndicatorVisual?: (type: string, defaultPlots?: PlotDef[], defaultHlines?: HLineDef[]) => {
plots: PlotDef[];
hlines: HLineDef[];
cloudColors?: IchimokuCloudColors;
};
saveIndicatorVisual?: (
type: string,
plots?: PlotDef[],
hlines?: HLineDef[],
cloudColors?: IchimokuCloudColors,
) => void;
/** 설정 화면 — 보조지표 기본값 일괄 DB 저장 */
saveAllIndicatorDefaults?: (configs: Record<string, IndicatorConfig>) => Promise<void>;
/** 보조지표 DB 캐시 갱신 revision */
indicatorSettingsRevision?: number;
/** 설정 화면에서 차트 on/off 연동 */
chartIndicators?: IndicatorConfig[];
onAddIndicatorToChart?: (type: string, config?: IndicatorConfig) => void;
onRemoveIndicatorFromChart?: (type: string) => void;
/** 보조지표 설정 목록 순서 변경 → 차트 pane 순서 */
onIndicatorListOrderChange?: (orderedTypes: string[]) => void;
chartSeriesPriceLabels?: boolean;
onChartSeriesPriceLabels?: (v: boolean) => void;
chartVolumeVisible?: boolean;
onChartVolumeVisible?: (v: boolean) => void;
chartLiveReceiveHighlight?: boolean;
onChartLiveReceiveHighlight?: (v: boolean) => void;
chartLegendOptions?: ChartLegendVisibility;
onChartLegendOptionsChange?: (patch: Partial<ChartLegendVisibility>) => void;
chartPaneSeparator?: ChartPaneSeparatorOptions;
onChartPaneSeparatorChange?: (opts: ChartPaneSeparatorOptions) => void;
paperTradingEnabled?: boolean;
onPaperTradingEnabled?: (v: boolean) => void;
paperInitialCapital?: number;
onPaperInitialCapital?: (v: number) => void;
paperFeeRatePct?: number;
onPaperFeeRatePct?: (v: number) => void;
paperSlippagePct?: number;
onPaperSlippagePct?: (v: number) => void;
paperMinOrderKrw?: number;
onPaperMinOrderKrw?: (v: number) => void;
paperAutoTradeEnabled?: boolean;
onPaperAutoTradeEnabled?: (v: boolean) => void;
paperAutoTradeBudgetPct?: number;
onPaperAutoTradeBudgetPct?: (v: number) => void;
virtualTargetMaxCount?: number;
onVirtualTargetMaxCount?: (v: number) => void;
onPaperAccountReset?: () => void;
tradingMode?: string;
onTradingMode?: (v: string) => void;
liveAutoTradeEnabled?: boolean;
onLiveAutoTradeEnabled?: (v: boolean) => void;
hasUpbitKeys?: boolean;
upbitAccessKeyMasked?: string | null;
onUpbitKeys?: (access: string, secret: string) => void;
chartRealtimeSource?: string;
onChartRealtimeSource?: (v: string) => void;
liveAutoTradeBudgetPct?: number;
onLiveAutoTradeBudgetPct?: (v: number) => void;
fcmPushEnabled?: boolean;
onFcmPushEnabled?: (v: boolean) => void;
onFcmTest?: () => void;
displayTimezone?: string;
onDisplayTimezoneChange?: (tz: string) => void;
chartTimeFormat?: string;
onChartTimeFormatChange?: (format: string) => void;
tradeAlertTimeFormat?: string;
onTradeAlertTimeFormatChange?: (format: string) => void;
menuPermissions?: Record<string, boolean>;
verificationIssueNotify?: boolean;
onVerificationIssueNotify?: (v: boolean) => void;
trendSearchSettings?: TrendSearchAppSettings;
onTrendSearchSettingsChange?: (s: TrendSearchAppSettings) => void;
}
// ── 카테고리 정의 ────────────────────────────────────────────────────────────
type CategoryId = SettingsCategoryId;
interface Category {
id: CategoryId;
label: string;
icon: React.ReactNode;
desc: string;
}
const IcGeneral = () => (
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="3"/>
<path d="M11 2v2M11 18v2M2 11h2M18 11h2M4.93 4.93l1.41 1.41M15.66 15.66l1.41 1.41M4.93 17.07l1.41-1.41M15.66 6.34l1.41-1.41"/>
</svg>
);
const IcChart = () => (
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="2,16 7,10 11,13 16,6 20,6"/>
<line x1="4" y1="19" x2="4" y2="13"/>
<line x1="8.5" y1="19" x2="8.5" y2="15.5"/>
<line x1="13" y1="19" x2="13" y2="10"/>
<line x1="17.5" y1="19" x2="17.5" y2="6"/>
</svg>
);
const IcStrategy = () => (
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="9"/>
<circle cx="11" cy="11" r="3.5"/>
<line x1="11" y1="2" x2="11" y2="7.5"/>
<line x1="11" y1="14.5" x2="11" y2="20"/>
<line x1="2" y1="11" x2="7.5" y2="11"/>
<line x1="14.5" y1="11" x2="20" y2="11"/>
</svg>
);
const IcAlert = () => (
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 8A7 7 0 0 0 4 8c0 7-3 9-3 9h20s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
</svg>
);
const IcNetwork = () => (
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="9"/>
<path d="M11 2C11 2 7 6 7 11s4 9 4 9M11 2c0 0 4 4 4 9s-4 9-4 9"/>
<line x1="2" y1="11" x2="20" y2="11"/>
</svg>
);
const IcIndicators = () => (
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="2,15 6,9 10,11 14,6 20,4"/>
<line x1="4" y1="19" x2="4" y2="13"/>
<line x1="8" y1="19" x2="8" y2="15"/>
<line x1="12" y1="19" x2="12" y2="10"/>
<line x1="16" y1="19" x2="16" y2="7"/>
</svg>
);
const IcPaper = () => (
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="5" width="16" height="12" rx="2"/>
<line x1="7" y1="9" x2="15" y2="9"/>
<line x1="7" y1="12" x2="12" y2="12"/>
<circle cx="16" cy="6" r="2" fill="currentColor" stroke="none"/>
</svg>
);
const IcTrendSearch = () => (
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="4,17 9,11 13,14 18,6"/>
<line x1="4" y1="19" x2="18" y2="19"/>
<circle cx="18" cy="6" r="2" fill="currentColor" stroke="none"/>
</svg>
);
const IcBacktest = () => (
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="2,17 6,11 9,14 13,7 16,10 20,4"/>
<circle cx="6" cy="11" r="1.5" fill="currentColor" stroke="none"/>
<circle cx="13" cy="7" r="1.5" fill="currentColor" stroke="none"/>
<line x1="6" y1="11" x2="6" y2="19"/>
<line x1="13" y1="7" x2="13" y2="19"/>
</svg>
);
const IcAdmin = () => (
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8z"/>
<path d="M4 19c0-3.5 3-6 7-6s7 2.5 7 6"/>
<path d="M16 4l2 2M18 4l-2 2"/>
</svg>
);
const ALL_CATEGORIES: Category[] = [
{ id: 'general', label: '일반 설정', icon: <IcGeneral />, desc: '언어, 테마, 날짜 형식 등 기본 설정' },
{ id: 'chart', label: '차트 설정', icon: <IcChart />, desc: '캔들 색상, 격자선, 크로스헤어, 가격축 등' },
{ id: 'indicators', label: '보조지표 설정', icon: <IcIndicators />, desc: '지표 추가 Main 탭 보조지표 기본 파라미터·색상·기준선' },
{ id: 'backtest', label: '백테스팅', icon: <IcBacktest />, desc: '백테스팅 실행 옵션, 자본·비용·리스크 관리 설정' },
{ id: 'strategy', label: '전략 설정', icon: <IcStrategy />, desc: '투자 전략 기본 파라미터 및 조건 설정' },
{ id: 'paper', label: '가상매매', icon: <IcPaper />, desc: '가상 자본, 수수료, 투자대상 한도, 자동매매 ON/OFF 등 가상매매 설정' },
{ id: 'trend-search', label: '추세검색', icon: <IcTrendSearch />, desc: '상승추세 배점, 합격 점수, 결과 수, 자동갱신·투자대상 자동추가' },
{ id: 'alert', label: '알림 설정', icon: <IcAlert />, desc: '가격 알림, 신호 알림, 알림 방식 설정' },
{ id: 'network', label: '네트워크', icon: <IcNetwork />, desc: 'API 서버 주소, WebSocket, 연결 상태' },
{ id: 'admin', label: '관리자 설정', icon: <IcAdmin />, desc: '사용자·역할·메뉴 접근 권한 관리 (관리자 비밀번호 필요)' },
];
function filterCategories(permissions?: Record<string, boolean>): Category[] {
return ALL_CATEGORIES.filter(c =>
canAccessMenu(permissions, settingsCategoryToMenuId(c.id)),
);
}
// ── 설정 행 공통 컴포넌트 ────────────────────────────────────────────────────
const SettingRow: React.FC<{
label: string;
desc?: string;
className?: string;
children: React.ReactNode;
}> = ({ label, desc, className, children }) => (
<div className={`stg-row${className ? ` ${className}` : ''}`}>
<div className="stg-row-label">
<span className="stg-row-title">{label}</span>
{desc && <span className="stg-row-desc">{desc}</span>}
</div>
<div className="stg-row-ctrl">{children}</div>
</div>
);
const SettingSection: React.FC<{ title: string; children: React.ReactNode; grid?: boolean }> = ({
title, children, grid,
}) => (
<div className="stg-section">
<div className="stg-section-title">{title}</div>
<div className={`stg-section-body${grid ? ' stg-section-body--grid' : ''}`}>{children}</div>
</div>
);
const StgCard: React.FC<{
label: string;
desc?: string;
full?: boolean;
children: React.ReactNode;
}> = ({ label, desc, full, children }) => (
<div className={`stg-card${full ? ' stg-card--full' : ''}`}>
<div className="stg-card-head">
<span className="stg-card-label">{label}</span>
{desc && <span className="stg-card-desc">{desc}</span>}
</div>
<div className="stg-card-ctrl">{children}</div>
</div>
);
// ── 각 카테고리 패널 ─────────────────────────────────────────────────────────
interface PaperPanelProps {
paperTradingEnabled?: boolean;
onPaperTradingEnabled?: (v: boolean) => void;
paperInitialCapital?: number;
onPaperInitialCapital?: (v: number) => void;
paperFeeRatePct?: number;
onPaperFeeRatePct?: (v: number) => void;
paperSlippagePct?: number;
onPaperSlippagePct?: (v: number) => void;
paperMinOrderKrw?: number;
onPaperMinOrderKrw?: (v: number) => void;
paperAutoTradeEnabled?: boolean;
onPaperAutoTradeEnabled?: (v: boolean) => void;
paperAutoTradeBudgetPct?: number;
onPaperAutoTradeBudgetPct?: (v: number) => void;
virtualTargetMaxCount?: number;
onVirtualTargetMaxCount?: (v: number) => void;
onPaperAccountReset?: () => void;
tradingMode?: string;
onTradingMode?: (v: string) => void;
liveAutoTradeEnabled?: boolean;
onLiveAutoTradeEnabled?: (v: boolean) => void;
hasUpbitKeys?: boolean;
upbitAccessKeyMasked?: string | null;
onUpbitKeys?: (access: string, secret: string) => void;
liveAutoTradeBudgetPct?: number;
onLiveAutoTradeBudgetPct?: (v: number) => void;
}
const PaperPanel: React.FC<PaperPanelProps> = ({
paperTradingEnabled = true,
onPaperTradingEnabled,
paperInitialCapital = 10_000_000,
onPaperInitialCapital,
paperFeeRatePct = 0.05,
onPaperFeeRatePct,
paperSlippagePct = 0,
onPaperSlippagePct,
paperMinOrderKrw = 5000,
onPaperMinOrderKrw,
paperAutoTradeEnabled = false,
onPaperAutoTradeEnabled,
paperAutoTradeBudgetPct = 95,
onPaperAutoTradeBudgetPct,
virtualTargetMaxCount = 20,
onVirtualTargetMaxCount,
onPaperAccountReset,
tradingMode = 'PAPER',
onTradingMode,
liveAutoTradeEnabled = false,
onLiveAutoTradeEnabled,
hasUpbitKeys = false,
upbitAccessKeyMasked = null,
onUpbitKeys,
liveAutoTradeBudgetPct = 95,
onLiveAutoTradeBudgetPct,
}) => {
const [accessKey, setAccessKey] = useState('');
const [secretKey, setSecretKey] = useState('');
return (
<>
<SettingSection title="매매 운영 모드">
<SettingRow label="실행 대상" desc="가상투자만, 실거래(업비트 API)만, 또는 둘 다 병행할 수 있습니다.">
<select className="stg-select" value={tradingMode} onChange={e => onTradingMode?.(e.target.value)}>
<option value="PAPER">가상투자만</option>
<option value="LIVE">실거래만</option>
<option value="BOTH">모의 + 실거래 병행</option>
</select>
</SettingRow>
</SettingSection>
<SettingSection title="가상매매">
<SettingRow label="가상매매 활성화" desc="켜면 가상매매·실시간 차트 매매 패널·전략 Match 자동매매가 가상 계좌로 체결됩니다.">
<label className="stg-toggle">
<input type="checkbox" checked={paperTradingEnabled} onChange={e => onPaperTradingEnabled?.(e.target.checked)} />
<span className="stg-toggle-slider" />
</label>
</SettingRow>
<SettingRow
label="투자대상 목록 최대 개수"
desc="가상매매 화면·추세검색에서 등록할 수 있는 투자대상 종목 수 상한입니다."
>
<input
type="number"
className="stg-input stg-input--num"
value={virtualTargetMaxCount}
min={1}
max={100}
step={1}
onChange={e => onVirtualTargetMaxCount?.(Number(e.target.value))}
/>
</SettingRow>
</SettingSection>
<SettingSection title="자동매매">
<SettingRow
label="자동매매 ON/OFF"
desc="가상투자 화면 타이틀바에서 변경합니다. Match 충족 시 자동 매수·매도 여부를 제어합니다."
>
<span className={`stg-badge ${paperAutoTradeEnabled ? 'stg-badge--on' : 'stg-badge--off'}`}>
{paperAutoTradeEnabled ? 'ON (타이틀바)' : 'OFF (타이틀바)'}
</span>
</SettingRow>
<SettingRow
label="자동 매수 예산 (%)"
desc="Match 매수 시 주문가능 현금 중 사용 비율. Match 매도는 해당 종목 보유 수량 전량 매도."
>
<input
type="number"
className="stg-input stg-input--num"
value={paperAutoTradeBudgetPct}
min={1}
max={100}
step={1}
disabled={!paperAutoTradeEnabled}
onChange={e => onPaperAutoTradeBudgetPct?.(Number(e.target.value))}
/>
</SettingRow>
{!paperAutoTradeEnabled && (
<p className="stg-hint">자동매매 ON/OFF는 가상투자 화면 타이틀바에서 변경하세요. OFF 상태에서는 Match·시그널로 주문되지 않습니다.</p>
)}
</SettingSection>
<SettingSection title="계좌·비용">
<SettingRow label="초기 자본 (KRW)" desc="계좌 초기화 시 적용되는 시작 현금입니다.">
<input
type="number"
className="stg-input stg-input--num"
value={paperInitialCapital}
min={100000}
step={100000}
onChange={e => onPaperInitialCapital?.(Number(e.target.value))}
/>
</SettingRow>
<SettingRow label="수수료율 (%)" desc="체결 금액 기준 수수료 (업비트 KRW 마켓 기본 0.05% 참고).">
<input
type="number"
className="stg-input stg-input--num"
value={paperFeeRatePct}
min={0}
max={1}
step={0.01}
onChange={e => onPaperFeeRatePct?.(Number(e.target.value))}
/>
</SettingRow>
<SettingRow label="슬리피지 (%)" desc="매수는 가격 상승, 매도는 가격 하락으로 체결가를 조정합니다.">
<input
type="number"
className="stg-input stg-input--num"
value={paperSlippagePct}
min={0}
max={5}
step={0.01}
onChange={e => onPaperSlippagePct?.(Number(e.target.value))}
/>
</SettingRow>
<SettingRow label="최소 주문 금액 (KRW)" desc="이 금액 미만 주문은 거부됩니다.">
<input
type="number"
className="stg-input stg-input--num"
value={paperMinOrderKrw}
min={1000}
step={1000}
onChange={e => onPaperMinOrderKrw?.(Number(e.target.value))}
/>
</SettingRow>
</SettingSection>
<SettingSection title="계좌 관리">
<SettingRow label="계좌 초기화" desc="보유 종목·체결 이력을 삭제하고 초기 자본으로 현금을 되돌립니다.">
<button type="button" className="stg-btn stg-btn--danger" onClick={onPaperAccountReset}>
모의 계좌 초기화
</button>
</SettingRow>
</SettingSection>
<SettingSection title="실거래 (업비트 Open API)">
<SettingRow label="실거래 자동매매" desc="ON이면 전략 시그널·손절/익절 시 업비트에 실제 주문합니다. API 키에 주문 권한이 필요합니다.">
<label className="stg-toggle">
<input type="checkbox" checked={liveAutoTradeEnabled} onChange={e => onLiveAutoTradeEnabled?.(e.target.checked)} />
<span className="stg-toggle-slider" />
</label>
</SettingRow>
<SettingRow label="API 키 상태" desc={hasUpbitKeys ? `등록됨 (${upbitAccessKeyMasked})` : 'Access / Secret 키를 입력 후 저장하세요.'}>
<span style={{ fontSize: 12, color: hasUpbitKeys ? 'var(--accent)' : 'var(--text3)' }}>
{hasUpbitKeys ? '연결됨' : '미등록'}
</span>
</SettingRow>
<SettingRow label="Access Key" desc="입력 내용이 그대로 표시됩니다. 저장 시 DB에 AES 암호화되어 보관됩니다.">
<input
type="text"
className="stg-input stg-input--sensitive"
placeholder={hasUpbitKeys ? '변경 시 새 Access Key 입력' : 'Access Key'}
value={accessKey}
onChange={e => setAccessKey(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
</SettingRow>
<SettingRow label="Secret Key" desc="입력 내용이 그대로 표시됩니다. 저장 시 암호화되며 API로 전체 키는 반환되지 않습니다.">
<input
type="text"
className="stg-input stg-input--sensitive"
placeholder={hasUpbitKeys ? '변경 시 새 Secret Key 입력' : 'Secret Key'}
value={secretKey}
onChange={e => setSecretKey(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
</SettingRow>
{(accessKey.trim() || secretKey.trim()) && (
<SettingRow label="">
<button
type="button"
className="stg-btn stg-btn--primary"
onClick={() => {
onUpbitKeys?.(accessKey.trim(), secretKey.trim());
setAccessKey('');
setSecretKey('');
}}
>
API 저장
</button>
</SettingRow>
)}
<SettingRow label="실거래 자동매수 예산 (%)" desc="시장가 매수 시 KRW 잔고 대비 사용 비율 (모의투자 예산과 별도)">
<input type="number" className="stg-input stg-input--narrow" min={1} max={100} step={1}
value={liveAutoTradeBudgetPct}
onChange={e => onLiveAutoTradeBudgetPct?.(Number(e.target.value))} />
<span className="stg-unit">%</span>
</SettingRow>
</SettingSection>
</>
);
};
// ── 일반 설정 패널 ────────────────────────────────────────────────────────────
const GeneralPanel: React.FC<{
displayTimezone: string;
onDisplayTimezoneChange: (tz: string) => void;
chartTimeFormat?: string;
onChartTimeFormatChange?: (format: string) => void;
tradeAlertTimeFormat?: string;
onTradeAlertTimeFormatChange?: (format: string) => void;
verificationIssueNotify?: boolean;
onVerificationIssueNotify?: (v: boolean) => void;
showVerificationNotify?: boolean;
}> = ({
displayTimezone,
onDisplayTimezoneChange,
chartTimeFormat = 'MM-dd HH:mm',
onChartTimeFormatChange,
tradeAlertTimeFormat = 'MM-dd HH:mm',
onTradeAlertTimeFormatChange,
verificationIssueNotify = true,
onVerificationIssueNotify,
showVerificationNotify = true,
}) => {
const [lang, setLang] = useState('ko');
const [startPage, setStart] = useState('chart');
const [animations, setAnim] = useState(true);
const [tooltips, setTool] = useState(true);
return (
<>
<SettingSection title="언어 및 지역">
<SettingRow label="언어" desc="UI 표시 언어를 선택합니다.">
<select className="stg-select" value={lang} onChange={e => setLang(e.target.value)}>
<option value="ko">한국어</option>
<option value="en">English</option>
<option value="ja">日本語</option>
</select>
</SettingRow>
<SettingRow
label="날짜·시간 형식"
desc="캔들·보조지표 차트 하단 시간축·크로스헤어에 적용됩니다. 프리셋 선택 또는 직접 입력(토큰: yyyy, yy, MM, dd, HH, mm, ss)이 가능합니다."
>
<ChartTimeFormatPicker
value={chartTimeFormat}
onChange={fmt => onChartTimeFormatChange?.(fmt)}
/>
</SettingRow>
<SettingRow
label="매매 시그널 알림 시간 형식"
desc="매매 시그널 알림 팝업·상세에 표시되는 캔들·수신 시각 형식입니다. 프리셋 선택 또는 직접 입력이 가능합니다."
>
<ChartTimeFormatPicker
value={tradeAlertTimeFormat}
onChange={fmt => onTradeAlertTimeFormatChange?.(fmt)}
/>
</SettingRow>
<SettingRow label="시간대" desc="차트·하단 시계·알림에 사용되는 표시 시간대입니다.">
<TimezonePicker
variant="select"
value={displayTimezone}
onChange={onDisplayTimezoneChange}
/>
</SettingRow>
</SettingSection>
{showVerificationNotify && (
<SettingSection title="검증게시판">
<SettingRow
label="검증 이슈 등록 알림"
desc="검증 이슈가 등록되거나 단계가 변경될 때 팝업 알림을 표시합니다."
>
<label className="stg-toggle">
<input
type="checkbox"
checked={verificationIssueNotify}
onChange={e => onVerificationIssueNotify?.(e.target.checked)}
/>
<span className="stg-toggle-slider" />
</label>
</SettingRow>
</SettingSection>
)}
<SettingSection title="시작 화면">
<SettingRow label="시작 페이지" desc="앱 시작 시 표시될 기본 화면입니다.">
<select className="stg-select" value={startPage} onChange={e => setStart(e.target.value)}>
<option value="dashboard">대시보드</option>
<option value="chart">실시간 차트</option>
<option value="strategy">투자 전략</option>
<option value="backtest">백테스팅</option>
</select>
</SettingRow>
</SettingSection>
<SettingSection title="UI 동작">
<SettingRow label="애니메이션" desc="화면 전환 및 차트 업데이트 시 애니메이션을 사용합니다.">
<label className="stg-toggle">
<input type="checkbox" checked={animations} onChange={e => setAnim(e.target.checked)} />
<span className="stg-toggle-slider" />
</label>
</SettingRow>
<SettingRow label="툴팁 표시" desc="버튼 및 지표에 마우스를 올렸을 때 설명을 표시합니다.">
<label className="stg-toggle">
<input type="checkbox" checked={tooltips} onChange={e => setTool(e.target.checked)} />
<span className="stg-toggle-slider" />
</label>
</SettingRow>
</SettingSection>
</>
);
};
interface ChartPanelProps {
chartRealtimeSource?: string;
onChartRealtimeSource?: (v: string) => void;
magnetMode?: 'off' | 'weak' | 'strong';
onMagnetMode?: (m: 'off' | 'weak' | 'strong') => void;
chartSeriesPriceLabels?: boolean;
onChartSeriesPriceLabels?: (v: boolean) => void;
chartVolumeVisible?: boolean;
onChartVolumeVisible?: (v: boolean) => void;
chartLiveReceiveHighlight?: boolean;
onChartLiveReceiveHighlight?: (v: boolean) => void;
chartLegendOptions?: ChartLegendVisibility;
onChartLegendOptionsChange?: (patch: Partial<ChartLegendVisibility>) => void;
chartPaneSeparator?: ChartPaneSeparatorOptions;
onChartPaneSeparatorChange?: (opts: ChartPaneSeparatorOptions) => void;
chartTimeFormat?: string;
onChartTimeFormatChange?: (format: string) => void;
}
const ChartPanel: React.FC<ChartPanelProps> = ({
chartRealtimeSource = 'BACKEND_STOMP',
onChartRealtimeSource,
magnetMode = 'off', onMagnetMode,
chartSeriesPriceLabels = true,
onChartSeriesPriceLabels,
chartVolumeVisible = true,
onChartVolumeVisible,
chartLiveReceiveHighlight = true,
onChartLiveReceiveHighlight,
chartLegendOptions,
onChartLegendOptionsChange,
chartPaneSeparator,
onChartPaneSeparatorChange,
chartTimeFormat = 'MM-dd HH:mm',
onChartTimeFormatChange,
}) => {
const [upColor, setUp] = useState('#26a69a');
const [downColor, setDown] = useState('#ef5350');
const [bgColor, setBg] = useState('#131722');
const [gridLines, setGrid] = useState(true);
const [crosshair, setCh] = useState('normal');
const [priceScale, setPs] = useState('right');
const [precision, setPrec] = useState('2');
return (
<>
<SettingSection title="시간축 표시">
<SettingRow
label="시간 포맷"
desc="차트 하단 시간축·크로스헤어에 표시되는 날짜·시간 형식입니다. 프리셋을 선택하거나 직접 입력할 수 있습니다."
>
<ChartTimeFormatPicker
value={chartTimeFormat}
onChange={fmt => onChartTimeFormatChange?.(fmt)}
/>
</SettingRow>
</SettingSection>
<SettingSection title="실시간 차트 데이터">
<SettingRow label="데이터 소스" desc="Blueprint 권장: 백엔드 STOMP. 직연결은 업비트 WebSocket을 프론트가 직접 수신합니다.">
<select className="stg-select" value={chartRealtimeSource}
onChange={e => onChartRealtimeSource?.(e.target.value)}>
<option value="BACKEND_STOMP">백엔드 STOMP (권장)</option>
<option value="UPBIT_DIRECT">업비트 직연결 (기존)</option>
</select>
</SettingRow>
<SettingRow
label="실시간 수신 음영처리"
desc="켜면 실시간 데이터 수신 순간 멀티차트·가상투자 카드 아웃라인에 형광 연두색 glow가 표시됩니다. 끄면 아웃라인 음영 효과가 비활성화됩니다."
>
<label className="stg-toggle">
<input
type="checkbox"
checked={chartLiveReceiveHighlight}
onChange={e => onChartLiveReceiveHighlight?.(e.target.checked)}
/>
<span className="stg-toggle-slider" />
</label>
</SettingRow>
</SettingSection>
<SettingSection title="캔들 색상">
<SettingRow label="상승 캔들 색상" desc="양봉(상승) 캔들의 기본 색상입니다.">
<div className="stg-color-row">
<input type="color" className="stg-color" value={upColor} onChange={e => setUp(e.target.value)} />
<span className="stg-color-label">{upColor}</span>
</div>
</SettingRow>
<SettingRow label="하락 캔들 색상" desc="음봉(하락) 캔들의 기본 색상입니다.">
<div className="stg-color-row">
<input type="color" className="stg-color" value={downColor} onChange={e => setDown(e.target.value)} />
<span className="stg-color-label">{downColor}</span>
</div>
</SettingRow>
<SettingRow label="배경 색상" desc="차트 영역의 배경 색상입니다.">
<div className="stg-color-row">
<input type="color" className="stg-color" value={bgColor} onChange={e => setBg(e.target.value)} />
<span className="stg-color-label">{bgColor}</span>
</div>
</SettingRow>
</SettingSection>
<SettingSection title="차트 표시 옵션">
<SettingRow label="격자선 표시" desc="차트 배경에 격자선을 표시합니다.">
<label className="stg-toggle">
<input type="checkbox" checked={gridLines} onChange={e => setGrid(e.target.checked)} />
<span className="stg-toggle-slider" />
</label>
</SettingRow>
<SettingRow label="거래량 표시" desc="차트 하단에 거래량 바를 표시합니다.">
<label className="stg-toggle">
<input
type="checkbox"
checked={chartVolumeVisible}
onChange={e => onChartVolumeVisible?.(e.target.checked)}
/>
<span className="stg-toggle-slider" />
</label>
</SettingRow>
<SettingRow label="크로스헤어 모드" desc="마우스 이동 시 표시되는 십자선 형식입니다.">
<select className="stg-select" value={crosshair} onChange={e => setCh(e.target.value)}>
<option value="normal">기본</option>
<option value="magnet">마그넷</option>
<option value="hidden">숨기기</option>
</select>
</SettingRow>
<SettingRow
label="자석모드 (Magnet Mode)"
desc="켜면 십자선 커서가 가장 가까운 캔들 고점·저점·시가·종가에 자동으로 달라붙습니다. 정확한 지지/저항 선 작도에 유용합니다."
>
<label className="stg-toggle">
<input
type="checkbox"
checked={magnetMode !== 'off'}
onChange={e => onMagnetMode?.(e.target.checked ? 'strong' : 'off')}
/>
<span className="stg-toggle-slider" />
</label>
</SettingRow>
<SettingRow
label="지표 가격축 라벨·설명"
desc="켜면 보조지표 선마다 우측 가격축에 금액 하이라이트와 설명(전환선, 기준선 등)이 표시됩니다. 끄면 선만 표시됩니다."
>
<label className="stg-toggle">
<input
type="checkbox"
checked={chartSeriesPriceLabels}
onChange={e => onChartSeriesPriceLabels?.(e.target.checked)}
/>
<span className="stg-toggle-slider" />
</label>
</SettingRow>
</SettingSection>
{chartPaneSeparator && onChartPaneSeparatorChange && (
<SettingSection title="차트 영역 구분선">
<SettingRow
label="구분선 표시"
desc="캔들·거래량·보조지표 pane 사이에 구분선을 표시합니다."
>
<label className="stg-toggle">
<input
type="checkbox"
checked={chartPaneSeparator.visible}
onChange={e => onChartPaneSeparatorChange({
...chartPaneSeparator,
visible: e.target.checked,
})}
/>
<span className="stg-toggle-slider" />
</label>
</SettingRow>
{chartPaneSeparator.visible && (
<>
<SettingRow label="선 색상" desc="pane 사이 구분선 색상입니다.">
<div className="stg-color-row">
<input
type="color"
className="stg-color"
value={chartPaneSeparator.color.startsWith('#')
? chartPaneSeparator.color.slice(0, 7)
: '#2a2e3a'}
onChange={e => onChartPaneSeparatorChange({
...chartPaneSeparator,
color: e.target.value,
})}
/>
<span className="stg-color-label">{chartPaneSeparator.color}</span>
</div>
</SettingRow>
<SettingRow label="선 굵기" desc="구분선 두께(px)입니다.">
<select
className="stg-select"
value={chartPaneSeparator.width}
onChange={e => onChartPaneSeparatorChange({
...chartPaneSeparator,
width: Number(e.target.value) as ChartPaneSeparatorOptions['width'],
})}
>
{LINE_WIDTH_OPTIONS.map(w => (
<option key={w} value={w}>{w}px</option>
))}
</select>
</SettingRow>
<SettingRow label="선 스타일" desc="실선·점선·도트 중 선택합니다.">
<select
className="stg-select"
value={chartPaneSeparator.lineStyle}
onChange={e => onChartPaneSeparatorChange({
...chartPaneSeparator,
lineStyle: e.target.value as ChartPaneSeparatorOptions['lineStyle'],
})}
>
{LINE_STYLE_OPTIONS.map(s => (
<option key={s} value={s}>{LINE_STYLE_LABELS[s]}</option>
))}
</select>
</SettingRow>
</>
)}
</SettingSection>
)}
{chartLegendOptions && onChartLegendOptionsChange && (
<SettingSection title="상단 정보 표시">
{CHART_LEGEND_SETTING_ITEMS.map(({ key, label, desc }) => (
<SettingRow key={key} label={label} desc={desc}>
<label className="stg-toggle">
<input
type="checkbox"
checked={chartLegendOptions[key]}
onChange={e => onChartLegendOptionsChange({ [key]: e.target.checked })}
/>
<span className="stg-toggle-slider" />
</label>
</SettingRow>
))}
</SettingSection>
)}
<SettingSection title="가격축">
<SettingRow label="가격축 위치" desc="가격 눈금자의 표시 위치입니다.">
<select className="stg-select" value={priceScale} onChange={e => setPs(e.target.value)}>
<option value="right">우측</option>
<option value="left">좌측</option>
<option value="both">양쪽</option>
</select>
</SettingRow>
<SettingRow label="소수점 자리수" desc="가격 표시 시 소수점 이하 자리수입니다.">
<select className="stg-select" value={precision} onChange={e => setPrec(e.target.value)}>
<option value="0">0자리</option>
<option value="2">2자리</option>
<option value="4">4자리</option>
<option value="8">8자리</option>
</select>
</SettingRow>
</SettingSection>
</>
);
};
interface StrategyPanelProps {
liveMarket?: string;
liveStrategies?: { id: number; name: string }[];
liveSettings?: LiveStrategySettingsDto;
watchlistCount?: number;
monitoredMarkets?: string[];
virtualDriven?: boolean;
onClearLiveMarkers?: () => void;
tradeAlertPopup?: boolean;
onTradeAlertPopup?: (v: boolean) => void;
onLiveSettingsChange?: (settings: LiveStrategySettingsDto) => void;
}
const StrategyPanel: React.FC<StrategyPanelProps> = ({
liveMarket = 'KRW-BTC',
liveStrategies = [],
liveSettings: liveSettingsProp,
watchlistCount = 0,
monitoredMarkets = [],
virtualDriven = false,
onClearLiveMarkers,
tradeAlertPopup = true,
onTradeAlertPopup,
onLiveSettingsChange,
}) => {
const [defaultRisk, setRisk] = useState('1');
const [maxPos, setMax] = useState('5');
const [trailingStop, setTrail] = useState(false);
const [reentry, setRe] = useState(false);
const [backtestFee, setFee] = useState('0.05');
const [slippage, setSlip] = useState('0.01');
const [evalMetric, setEval] = useState('sharpe');
// ── 실시간 전략 체크 설정 상태 ──────────────────────────────────────────────
const [liveSettings, setLiveSettings] = useState<LiveStrategySettingsDto>(
liveSettingsProp ?? {
market: liveMarket,
strategyId: null,
isLiveCheck: false,
executionType: 'CANDLE_CLOSE',
},
);
const [liveSaving, setLiveSaving] = useState(false);
useEffect(() => {
if (liveSettingsProp) setLiveSettings(liveSettingsProp);
}, [liveSettingsProp]);
const persistLive = useCallback(async (patch: Partial<LiveStrategySettingsDto>) => {
if (virtualDriven) return;
const next: LiveStrategySettingsDto = { ...liveSettings, ...patch, market: liveMarket };
if (next.isLiveCheck && next.strategyId != null) {
const synced = await syncStrategyTimeframesFromLayoutIfNeeded(next.strategyId);
if (!synced) return;
}
setLiveSaving(true);
try {
const saved = await saveLiveStrategySettings(next);
if (saved) {
setLiveSettings(saved);
onLiveSettingsChange?.(saved);
}
onClearLiveMarkers?.();
} finally {
setLiveSaving(false);
}
}, [liveSettings, liveMarket, onClearLiveMarkers, onLiveSettingsChange, virtualDriven]);
const isOn = liveSettings.isLiveCheck;
const execType = liveSettings.executionType;
const stratId = liveSettings.strategyId;
const monCount = monitoredMarkets.length;
return (
<>
{/* ── 실시간 전략 체크 ─────────────────────────────────────────────── */}
<SettingSection title="실시간 전략 체크">
{virtualDriven && (
<p className="stg-hint" style={{ marginBottom: 12 }}>
가상투자 세션이 실행 중입니다. 전략·실행방식·시그널 모드는 <strong>가상투자</strong> 화면에서 변경하세요.
</p>
)}
<SettingRow
label="모니터링 현황"
desc={virtualDriven
? "가상투자 투자대상 종목이 백엔드에서 실시간 전략 체크 대상입니다."
: "★ 관심종목으로 등록한 종목이 자동으로 전략 체크 대상입니다. 실시간 체크를 켜고 전략을 선택하세요."}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'flex-end' }}>
<span style={{ fontSize: 12, fontWeight: 600 }}>
관심 <span style={{ color: 'var(--accent)' }}>{watchlistCount}</span>
{' · '}체크 대상 <span style={{ color: monCount > 0 ? '#00BCD4' : 'var(--text3)' }}>{monCount}</span>
</span>
{monCount > 0 && (
<span style={{ fontSize: 10, color: 'var(--text3)', maxWidth: 220, textAlign: 'right' }}>
{monitoredMarkets.map(m => m.replace('KRW-', '')).join(', ')}
</span>
)}
</div>
</SettingRow>
<SettingRow
label="실시간 체크"
desc="캔들 차트 위에 매수·매도 시그널을 실시간으로 표시합니다. 설정한 전략 조건을 백엔드에서 지속적으로 평가합니다."
>
<label className="stg-toggle">
<input
type="checkbox"
checked={isOn}
disabled={virtualDriven}
onChange={e => persistLive({ isLiveCheck: e.target.checked })}
/>
<span className="stg-toggle-slider" />
</label>
{liveSaving && <span style={{ fontSize: 10, color: 'var(--text3)', marginLeft: 6 }}>저장 중…</span>}
</SettingRow>
<SettingRow
label="모니터링 종목"
desc="현재 실시간 차트에서 선택된 종목입니다."
>
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text)' }}>{liveMarket}</span>
</SettingRow>
<SettingRow
label="전략 선택"
desc="실시간으로 체크할 투자 전략을 선택합니다. 선택된 전략의 매수·매도 조건을 백엔드에서 실시간으로 평가합니다."
>
<select
className="stg-select"
disabled={virtualDriven || !isOn}
value={stratId ?? ''}
onChange={e => persistLive({ strategyId: e.target.value ? Number(e.target.value) : null })}
style={{ opacity: isOn ? 1 : 0.45 }}
>
<option value="">전략 선택…</option>
{liveStrategies.map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</SettingRow>
<SettingRow
className="stg-row--block"
label="체크 방식"
desc="봉 마감 직후: 완성된 캔들 기준으로 1회 판정합니다. 실시간 틱: 3초 주기로 진행 중인 캔들을 기준으로 판정합니다."
>
<div className={`stg-radio-row${!isOn ? ' stg-radio-row--disabled' : ''}`}>
<label className="stg-radio-option stg-radio-option--cyan">
<input
type="radio"
name="stg-exec-type"
value="CANDLE_CLOSE"
checked={execType === 'CANDLE_CLOSE'}
disabled={virtualDriven || !isOn}
onChange={() => persistLive({ executionType: 'CANDLE_CLOSE' })}
/>
<span className="stg-radio-option-text">
<strong> 마감 직후</strong>
<span>완성된 캔들 기준으로 1 판정</span>
</span>
</label>
<label className="stg-radio-option stg-radio-option--cyan">
<input
type="radio"
name="stg-exec-type"
value="REALTIME_TICK"
checked={execType === 'REALTIME_TICK'}
disabled={virtualDriven || !isOn}
onChange={() => persistLive({ executionType: 'REALTIME_TICK' })}
/>
<span className="stg-radio-option-text">
<strong>실시간 (3)</strong>
<span>진행 중인 캔들 기준, 3 주기 판정</span>
</span>
</label>
</div>
</SettingRow>
{isOn && (
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '8px 10px', borderRadius: 6,
background: 'rgba(0,188,212,0.1)', border: '1px solid rgba(0,188,212,0.25)',
fontSize: 11.5, color: 'var(--text2)',
}}>
<span style={{
width: 7, height: 7, borderRadius: '50%', flexShrink: 0,
background: '#00BCD4', boxShadow: '0 0 5px rgba(0,188,212,0.6)',
animation: 'lsp-pulse 1.5s ease-in-out infinite',
}} />
{stratId
? `${liveStrategies.find(s => s.id === stratId)?.name ?? '전략'} 모니터링 중`
: '전략을 선택하면 모니터링이 시작됩니다'}
</div>
)}
<SettingRow
className="stg-row--block"
label="포지션 종속성 모드"
desc="매도 시그널 발생 조건을 선택합니다. LONG_ONLY: 매수 이력이 있을 때만 매도 허용. SIGNAL_ONLY: 포지션 없이도 지표 규칙 충족 시 즉시 매도 시그널."
>
<div className={`stg-radio-row${!isOn ? ' stg-radio-row--disabled' : ''}`}>
<label className="stg-radio-option stg-radio-option--blue">
<input
type="radio"
name="stg-pos-mode"
value="LONG_ONLY"
checked={(liveSettings.positionMode ?? 'LONG_ONLY') === 'LONG_ONLY'}
disabled={!isOn}
onChange={() => persistLive({ positionMode: 'LONG_ONLY' })}
/>
<span className="stg-radio-option-text">
<strong>보유 자산 기준 (Long-Only)</strong>
<span>매수 이력 있을 때만 매도 허용</span>
</span>
</label>
<label className="stg-radio-option stg-radio-option--orange">
<input
type="radio"
name="stg-pos-mode"
value="SIGNAL_ONLY"
checked={liveSettings.positionMode === 'SIGNAL_ONLY'}
disabled={!isOn}
onChange={() => persistLive({ positionMode: 'SIGNAL_ONLY' })}
/>
<span className="stg-radio-option-text">
<strong>순수 지표 기준 (Signal-Only)</strong>
<span>포지션 무관, 지표 충족 즉시 매도</span>
</span>
</label>
</div>
</SettingRow>
</SettingSection>
{/* ── 매매 알림 팝업 ────────────────────────────────────────────────── */}
<SettingSection title="매매 알림 팝업">
<SettingRow
label="알림 팝업"
desc="실시간 전략 체크에서 매수·매도 시그널 발생 시 우측 상단 알림 카드를 표시합니다. 차트·전략·설정 등 모든 화면에서 동일하게 표시됩니다."
>
<label className="stg-toggle">
<input
type="checkbox"
checked={tradeAlertPopup}
onChange={e => onTradeAlertPopup?.(e.target.checked)}
/>
<span className="stg-toggle-slider" />
</label>
</SettingRow>
{tradeAlertPopup && (
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '8px 10px', borderRadius: 6,
background: 'rgba(255,193,7,0.1)', border: '1px solid rgba(255,193,7,0.25)',
fontSize: 11.5, color: 'var(--text2)',
}}>
<span style={{
width: 7, height: 7, borderRadius: '50%', flexShrink: 0,
background: '#FFC107', boxShadow: '0 0 5px rgba(255,193,7,0.6)',
animation: 'lsp-pulse 1.5s ease-in-out infinite',
}} />
시그널 발생 알림 팝업이 자동으로 표시됩니다
</div>
)}
</SettingSection>
{/* ── 리스크 관리 ──────────────────────────────────────────────────── */}
<SettingSection title="리스크 관리" grid>
<StgCard label="기본 리스크 비율 (%)" desc="진입 시 포지션당 기본 리스크 비율 (자본 대비).">
<input className="stg-input stg-input--wide" type="number" min="0" max="100" step="0.1"
value={defaultRisk} onChange={e => setRisk(e.target.value)} />
</StgCard>
<StgCard label="최대 동시 포지션" desc="동시에 보유할 수 있는 최대 포지션 수입니다.">
<input className="stg-input stg-input--wide" type="number" min="1" max="100"
value={maxPos} onChange={e => setMax(e.target.value)} />
</StgCard>
<StgCard label="트레일링 스탑" desc="수익 발생 시 손절선을 자동으로 상향 조정합니다.">
<label className="stg-toggle">
<input type="checkbox" checked={trailingStop} onChange={e => setTrail(e.target.checked)} />
<span className="stg-toggle-slider" />
</label>
</StgCard>
<StgCard label="재진입 허용" desc="청산 후 동일 조건으로 재진입을 허용합니다.">
<label className="stg-toggle">
<input type="checkbox" checked={reentry} onChange={e => setRe(e.target.checked)} />
<span className="stg-toggle-slider" />
</label>
</StgCard>
</SettingSection>
{/* ── 백테스팅 기본값 ──────────────────────────────────────────────── */}
<SettingSection title="백테스팅 기본값" grid>
<StgCard label="수수료율 (%)" desc="백테스팅 시 적용할 매수/매도 수수료율입니다.">
<input className="stg-input stg-input--wide" type="number" min="0" step="0.001"
value={backtestFee} onChange={e => setFee(e.target.value)} />
</StgCard>
<StgCard label="슬리피지 (%)" desc="백테스팅 시 적용할 슬리피지 비율입니다.">
<input className="stg-input stg-input--wide" type="number" min="0" step="0.001"
value={slippage} onChange={e => setSlip(e.target.value)} />
</StgCard>
<StgCard label="성과 지표 기준" desc="전략 비교 시 사용할 기본 성과 지표입니다.">
<select className="stg-select stg-select--wide" value={evalMetric} onChange={e => setEval(e.target.value)}>
<option value="sharpe">샤프 비율</option>
<option value="pnl"> 손익률</option>
<option value="winrate">승률</option>
<option value="maxdd">최대 낙폭</option>
</select>
</StgCard>
</SettingSection>
</>
);
};
interface AlertPanelProps {
tradeAlertPopup?: boolean;
onTradeAlertPopup?: (v: boolean) => void;
tradeAlertSoundEnabled?: boolean;
onTradeAlertSoundEnabled?: (v: boolean) => void;
tradeAlertSound?: string;
onTradeAlertSound?: (v: string) => void;
tradeAlertPopupPosition?: string;
onTradeAlertPopupPosition?: (v: string) => void;
tradeAlertPopupLayout?: string;
onTradeAlertPopupLayout?: (v: string) => void;
tradeAlertPopupGridCols?: number;
onTradeAlertPopupGridCols?: (v: number) => void;
fcmPushEnabled?: boolean;
onFcmPushEnabled?: (v: boolean) => void;
onFcmTest?: () => void;
}
const AlertPanel: React.FC<AlertPanelProps> = ({
tradeAlertPopup = true,
onTradeAlertPopup,
tradeAlertSoundEnabled = true,
onTradeAlertSoundEnabled,
tradeAlertSound = 'bell',
onTradeAlertSound,
tradeAlertPopupPosition = 'right',
onTradeAlertPopupPosition,
tradeAlertPopupLayout = 'stack',
onTradeAlertPopupLayout,
tradeAlertPopupGridCols = 2,
onTradeAlertPopupGridCols,
fcmPushEnabled = false,
onFcmPushEnabled,
onFcmTest,
}) => {
const [browserNotif, setBn] = useState(false);
const [emailAlert, setEmail] = useState(false);
const [emailAddr, setAddr] = useState('');
const [priceAlert, setPa] = useState(true);
const [signalAlert, setSig] = useState(true);
const [cooldown, setCool] = useState('60');
const soundId = normalizeTradeAlertSoundId(tradeAlertSound);
const popupPosition = normalizeTradeAlertPopupPosition(tradeAlertPopupPosition);
const popupLayout = normalizeTradeAlertPopupLayout(tradeAlertPopupLayout);
const gridCols = normalizeTradeAlertGridCols(tradeAlertPopupGridCols);
const handlePreviewSound = () => {
if (!tradeAlertSoundEnabled) {
window.alert('알림 사운드가 꺼져 있습니다. 먼저 사운드를 켜 주세요.');
return;
}
if (soundId === 'silent') {
window.alert('무음이 선택되었습니다. 알림 발생 시 소리가 재생되지 않습니다.');
return;
}
previewTradeAlertSound(soundId);
};
return (
<>
<SettingSection title="매매 시그널 알림">
<SettingRow
label="알림 팝업"
desc="시그널 발생 시 알림 카드가 표시됩니다. 차트·전략·설정 등 어떤 화면에 있어도 동일하게 나타납니다."
>
<label className="stg-toggle">
<input
type="checkbox"
checked={tradeAlertPopup}
onChange={e => onTradeAlertPopup?.(e.target.checked)}
/>
<span className="stg-toggle-slider" />
</label>
</SettingRow>
{tradeAlertPopup && (
<>
<SettingRow
label="표시 위치"
desc="알림 팝업 목록이 나타날 화면 위치입니다. 변경 즉시 현재 알림에 반영됩니다."
>
<div className="stg-alert-layout-options">
{TRADE_ALERT_POPUP_POSITIONS.map(opt => (
<button
key={opt.id}
type="button"
className={`stg-alert-layout-option${popupPosition === opt.id ? ' stg-alert-layout-option--active' : ''}`}
onClick={() => onTradeAlertPopupPosition?.(opt.id)}
>
<strong>{opt.label}</strong>
<span>{opt.desc}</span>
</button>
))}
</div>
</SettingRow>
<SettingRow
label="표시 방식"
desc="알림 목록의 배치 방식입니다. 스택·그리드·가로 나열·최신 1건 중 선택할 수 있습니다."
>
<div className="stg-alert-layout-options">
{TRADE_ALERT_POPUP_LAYOUTS.map(opt => (
<button
key={opt.id}
type="button"
className={`stg-alert-layout-option${popupLayout === opt.id ? ' stg-alert-layout-option--active' : ''}`}
onClick={() => onTradeAlertPopupLayout?.(opt.id)}
>
<strong>{opt.label}</strong>
<span>{opt.desc}</span>
</button>
))}
</div>
</SettingRow>
{popupLayout === 'grid' && (
<SettingRow
label="그리드 열 개수"
desc="N×N 그리드의 열(가로) 개수입니다. 2~4열까지 설정할 수 있습니다."
>
<select
className="stg-select stg-select--wide"
value={gridCols}
onChange={e => onTradeAlertPopupGridCols?.(Number(e.target.value))}
>
{TRADE_ALERT_GRID_COL_OPTIONS.map(n => (
<option key={n} value={n}>{n} ({n}×{n} 격자)</option>
))}
</select>
</SettingRow>
)}
<SettingRow
label="배치 미리보기"
desc="선택한 위치·방식으로 알림이 표시되는 모습입니다. 현재 표시 중인 알림에도 즉시 적용됩니다."
>
<TradeAlertPopupPreview
position={popupPosition}
layout={popupLayout}
gridCols={gridCols}
enabled={tradeAlertPopup}
/>
</SettingRow>
</>
)}
<SettingRow
label="알림 사운드"
desc="실시간 전략에서 매수·매도 시그널이 발생하면 선택한 알림음을 재생합니다. 팝업 표시 여부와 별도로 동작합니다."
>
<label className="stg-toggle">
<input
type="checkbox"
checked={tradeAlertSoundEnabled}
onChange={e => onTradeAlertSoundEnabled?.(e.target.checked)}
/>
<span className="stg-toggle-slider" />
</label>
</SettingRow>
<SettingRow
label="알림음 선택"
desc="시그널 발생 시 재생할 알림음을 선택합니다."
>
<div className="stg-sound-row">
<select
className="stg-select stg-select--wide"
value={soundId}
disabled={!tradeAlertSoundEnabled}
onChange={e => onTradeAlertSound?.(e.target.value)}
>
{TRADE_ALERT_SOUND_OPTIONS.map(opt => (
<option key={opt.id} value={opt.id}>{opt.label}</option>
))}
</select>
<button
type="button"
className="stg-btn-secondary"
disabled={!tradeAlertSoundEnabled}
onClick={handlePreviewSound}
>
미리듣기
</button>
</div>
</SettingRow>
</SettingSection>
<SettingSection title="FCM 푸시 (모바일·브라우저)">
<SettingRow label="FCM 푸시" desc="Firebase 설정(VITE_FIREBASE_*) 및 서버 firebase-service-account.json 필요">
<label className="stg-toggle">
<input type="checkbox" checked={fcmPushEnabled}
onChange={e => onFcmPushEnabled?.(e.target.checked)} />
<span className="stg-toggle-slider" />
</label>
</SettingRow>
<SettingRow label="테스트 발송" desc="등록된 FCM 토큰으로 테스트 알림을 보냅니다.">
<button type="button" className="stg-btn-secondary" onClick={onFcmTest}>FCM 테스트</button>
</SettingRow>
</SettingSection>
<SettingSection title="알림 방식">
<SettingRow label="브라우저 알림 (예정)" desc="OS 푸시 알림을 통해 백그라운드에서도 알림을 받습니다.">
<label className="stg-toggle">
<input type="checkbox" checked={browserNotif} onChange={e => setBn(e.target.checked)} disabled />
<span className="stg-toggle-slider" />
</label>
</SettingRow>
<SettingRow label="이메일 알림" desc="지정한 이메일 주소로 알림 메시지를 발송합니다.">
<label className="stg-toggle">
<input type="checkbox" checked={emailAlert} onChange={e => setEmail(e.target.checked)} />
<span className="stg-toggle-slider" />
</label>
</SettingRow>
{emailAlert && (
<SettingRow label="이메일 주소" desc="알림을 수신할 이메일 주소를 입력하세요.">
<input className="stg-input stg-input--wide" type="email" placeholder="user@example.com"
value={emailAddr} onChange={e => setAddr(e.target.value)} />
</SettingRow>
)}
</SettingSection>
<SettingSection title="알림 조건">
<SettingRow label="가격 알림" desc="설정된 가격 도달 시 알림을 발생시킵니다.">
<label className="stg-toggle">
<input type="checkbox" checked={priceAlert} onChange={e => setPa(e.target.checked)} />
<span className="stg-toggle-slider" />
</label>
</SettingRow>
<SettingRow label="매매 신호 알림" desc="전략 조건에 의한 매수/매도 신호 발생 시 알림을 줍니다.">
<label className="stg-toggle">
<input type="checkbox" checked={signalAlert} onChange={e => setSig(e.target.checked)} />
<span className="stg-toggle-slider" />
</label>
</SettingRow>
<SettingRow label="알림 재발송 대기 (초)" desc="동일 조건의 알림이 반복 발송되지 않도록 대기하는 시간입니다.">
<input className="stg-input" type="number" min="0" max="3600"
value={cooldown} onChange={e => setCool(e.target.value)} />
</SettingRow>
</SettingSection>
</>
);
};
const NetworkPanel: React.FC = () => {
const [apiHost, setApi] = useState('http://localhost:8080');
const [wsHost, setWs] = useState('ws://localhost:8080/ws');
const [timeout, setTo] = useState('10000');
const [retries, setRt] = useState('3');
const [proxyUrl, setProxy] = useState('');
const [connected, _] = useState(true);
const handleTest = () => {
fetch(apiHost + '/actuator/health')
.then(() => alert('✅ 서버 연결 성공'))
.catch(() => alert('❌ 서버 연결 실패'));
};
return (
<>
<SettingSection title="API 서버">
<SettingRow label="서버 주소" desc="백엔드 REST API 서버의 호스트 주소입니다. 포트 포함.">
<input className="stg-input stg-input--wide" type="text"
value={apiHost} onChange={e => setApi(e.target.value)}
placeholder="http://localhost:8080" />
</SettingRow>
<SettingRow label="연결 상태" desc="현재 API 서버와의 연결 상태입니다.">
<span className={`stg-badge ${connected ? 'stg-badge--ok' : 'stg-badge--err'}`}>
{connected ? '● 연결됨' : '● 연결 끊김'}
</span>
</SettingRow>
<SettingRow label="" desc="">
<button className="stg-btn-test" onClick={handleTest}>연결 테스트</button>
</SettingRow>
</SettingSection>
<SettingSection title="WebSocket">
<SettingRow label="WebSocket 주소" desc="실시간 시세 수신에 사용되는 WebSocket 서버 주소입니다.">
<input className="stg-input stg-input--wide" type="text"
value={wsHost} onChange={e => setWs(e.target.value)}
placeholder="ws://localhost:8080/ws" />
</SettingRow>
</SettingSection>
<SettingSection title="연결 설정">
<SettingRow label="연결 타임아웃 (ms)" desc="요청 후 응답이 없을 때 연결을 종료하는 시간(밀리초)입니다.">
<input className="stg-input" type="number" min="1000" step="1000"
value={timeout} onChange={e => setTo(e.target.value)} />
</SettingRow>
<SettingRow label="재시도 횟수" desc="연결 실패 시 자동 재시도 횟수입니다.">
<input className="stg-input" type="number" min="0" max="10"
value={retries} onChange={e => setRt(e.target.value)} />
</SettingRow>
<SettingRow label="프록시 URL" desc="프록시 서버를 통해 API 요청을 라우팅합니다. 비어 있으면 직접 연결합니다.">
<input className="stg-input stg-input--wide" type="text"
value={proxyUrl} onChange={e => setProxy(e.target.value)}
placeholder="http://proxy:8888 (선택)" />
</SettingRow>
</SettingSection>
</>
);
};
// ── 메인 컴포넌트 ─────────────────────────────────────────────────────────────
const SettingsPage: React.FC<SettingsPageProps> = ({
theme,
magnetMode = 'off',
onMagnetMode,
btAutoPopup = true,
onBtAutoPopup,
btShowPrice = true,
onBtShowPrice,
liveMarket = 'KRW-BTC',
liveStrategies = [],
onClearLiveMarkers,
tradeAlertPopup = true,
onTradeAlertPopup,
liveSettings,
onLiveSettingsChange,
watchlistCount = 0,
monitoredMarkets = [],
virtualDriven = false,
getIndicatorParams,
saveIndicatorParams,
getIndicatorVisual,
saveIndicatorVisual,
saveAllIndicatorDefaults,
indicatorSettingsRevision = 0,
chartIndicators = [],
onAddIndicatorToChart,
onRemoveIndicatorFromChart,
onIndicatorListOrderChange,
chartSeriesPriceLabels = true,
onChartSeriesPriceLabels,
chartVolumeVisible = true,
onChartVolumeVisible,
chartLiveReceiveHighlight = true,
onChartLiveReceiveHighlight,
chartLegendOptions,
onChartLegendOptionsChange,
chartPaneSeparator,
onChartPaneSeparatorChange,
tradeAlertSoundEnabled = true,
onTradeAlertSoundEnabled,
tradeAlertSound = 'bell',
onTradeAlertSound,
tradeAlertPopupPosition = 'right',
onTradeAlertPopupPosition,
tradeAlertPopupLayout = 'stack',
onTradeAlertPopupLayout,
tradeAlertPopupGridCols = 2,
onTradeAlertPopupGridCols,
paperTradingEnabled = true,
onPaperTradingEnabled,
paperInitialCapital = 10_000_000,
onPaperInitialCapital,
paperFeeRatePct = 0.05,
onPaperFeeRatePct,
paperSlippagePct = 0,
onPaperSlippagePct,
paperMinOrderKrw = 5000,
onPaperMinOrderKrw,
paperAutoTradeEnabled = false,
onPaperAutoTradeEnabled,
paperAutoTradeBudgetPct = 95,
onPaperAutoTradeBudgetPct,
virtualTargetMaxCount = 20,
onVirtualTargetMaxCount,
onPaperAccountReset,
tradingMode = 'PAPER',
onTradingMode,
liveAutoTradeEnabled = false,
onLiveAutoTradeEnabled,
hasUpbitKeys = false,
upbitAccessKeyMasked = null,
onUpbitKeys,
chartRealtimeSource = 'BACKEND_STOMP',
onChartRealtimeSource,
liveAutoTradeBudgetPct = 95,
onLiveAutoTradeBudgetPct,
fcmPushEnabled = false,
onFcmPushEnabled,
onFcmTest,
displayTimezone = 'Asia/Seoul',
onDisplayTimezoneChange,
chartTimeFormat = 'MM-dd HH:mm',
onChartTimeFormatChange,
tradeAlertTimeFormat = 'MM-dd HH:mm',
onTradeAlertTimeFormatChange,
menuPermissions,
verificationIssueNotify = true,
onVerificationIssueNotify,
trendSearchSettings,
onTrendSearchSettingsChange,
}) => {
const categories = filterCategories(menuPermissions);
const [active, setActive] = useState<CategoryId>('general');
const [indicatorSaveUi, setIndicatorSaveUi] = useState<IndicatorSaveUiState | null>(null);
useEffect(() => {
if (categories.length === 0) return;
if (!categories.some(c => c.id === active)) {
setActive(categories[0].id);
}
}, [categories, active]);
useEffect(() => {
if (active !== 'indicators') setIndicatorSaveUi(null);
}, [active]);
const activeCat = categories.find(c => c.id === active) ?? categories[0];
if (!activeCat) {
return (
<div className="settings-page">
<p className="stg-ind-unavailable">접근 가능한 설정 메뉴가 없습니다.</p>
</div>
);
}
const renderPanel = () => {
switch (active) {
case 'chart': return (
<ChartPanel
chartRealtimeSource={chartRealtimeSource}
onChartRealtimeSource={onChartRealtimeSource}
magnetMode={magnetMode}
onMagnetMode={onMagnetMode}
chartSeriesPriceLabels={chartSeriesPriceLabels}
onChartSeriesPriceLabels={onChartSeriesPriceLabels}
chartVolumeVisible={chartVolumeVisible}
onChartVolumeVisible={onChartVolumeVisible}
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
onChartLiveReceiveHighlight={onChartLiveReceiveHighlight}
chartLegendOptions={chartLegendOptions}
onChartLegendOptionsChange={onChartLegendOptionsChange}
chartPaneSeparator={chartPaneSeparator}
onChartPaneSeparatorChange={onChartPaneSeparatorChange}
chartTimeFormat={chartTimeFormat}
onChartTimeFormatChange={onChartTimeFormatChange}
/>
);
case 'indicators':
if (
!getIndicatorParams || !saveIndicatorParams || !getIndicatorVisual || !saveIndicatorVisual
|| !saveAllIndicatorDefaults
|| !onAddIndicatorToChart || !onRemoveIndicatorFromChart
) {
return (
<p className="stg-ind-unavailable">보조지표 설정을 불러올 없습니다. 차트 화면에서 다시 시도해 주세요.</p>
);
}
return (
<IndicatorMainDefaultsPanel
chartIndicators={chartIndicators}
onAddToChart={onAddIndicatorToChart}
onRemoveFromChart={onRemoveIndicatorFromChart}
getParams={getIndicatorParams}
saveParams={saveIndicatorParams}
getVisualConfig={getIndicatorVisual}
saveVisual={saveIndicatorVisual}
saveAllDefaults={saveAllIndicatorDefaults}
onSaveUiState={setIndicatorSaveUi}
settingsRevision={indicatorSettingsRevision}
onListOrderChange={onIndicatorListOrderChange}
/>
);
case 'backtest': return (
<BacktestSettingsPanel
btAutoPopup={btAutoPopup}
onBtAutoPopup={onBtAutoPopup}
btShowPrice={btShowPrice}
onBtShowPrice={onBtShowPrice}
/>
);
case 'general': return (
<GeneralPanel
displayTimezone={displayTimezone}
onDisplayTimezoneChange={onDisplayTimezoneChange ?? (() => {})}
chartTimeFormat={chartTimeFormat}
onChartTimeFormatChange={onChartTimeFormatChange}
tradeAlertTimeFormat={tradeAlertTimeFormat}
onTradeAlertTimeFormatChange={onTradeAlertTimeFormatChange}
verificationIssueNotify={verificationIssueNotify}
onVerificationIssueNotify={onVerificationIssueNotify}
showVerificationNotify={menuPermissions == null || menuPermissions['verification-board'] === true}
/>
);
case 'admin': return (
<AdminPasswordGate>
<AdminSettingsPanel />
</AdminPasswordGate>
);
case 'paper': return (
<PaperPanel
paperTradingEnabled={paperTradingEnabled}
onPaperTradingEnabled={onPaperTradingEnabled}
paperInitialCapital={paperInitialCapital}
onPaperInitialCapital={onPaperInitialCapital}
paperFeeRatePct={paperFeeRatePct}
onPaperFeeRatePct={onPaperFeeRatePct}
paperSlippagePct={paperSlippagePct}
onPaperSlippagePct={onPaperSlippagePct}
paperMinOrderKrw={paperMinOrderKrw}
onPaperMinOrderKrw={onPaperMinOrderKrw}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperAutoTradeEnabled={onPaperAutoTradeEnabled}
paperAutoTradeBudgetPct={paperAutoTradeBudgetPct}
onPaperAutoTradeBudgetPct={onPaperAutoTradeBudgetPct}
virtualTargetMaxCount={virtualTargetMaxCount}
onVirtualTargetMaxCount={onVirtualTargetMaxCount}
onPaperAccountReset={onPaperAccountReset}
tradingMode={tradingMode}
onTradingMode={onTradingMode}
liveAutoTradeEnabled={liveAutoTradeEnabled}
onLiveAutoTradeEnabled={onLiveAutoTradeEnabled}
hasUpbitKeys={hasUpbitKeys}
upbitAccessKeyMasked={upbitAccessKeyMasked}
onUpbitKeys={onUpbitKeys}
liveAutoTradeBudgetPct={liveAutoTradeBudgetPct}
onLiveAutoTradeBudgetPct={onLiveAutoTradeBudgetPct}
/>
);
case 'trend-search': return (
trendSearchSettings && onTrendSearchSettingsChange ? (
<TrendSearchSettingsPanel
settings={trendSearchSettings}
onChange={onTrendSearchSettingsChange}
/>
) : (
<p className="stg-ind-unavailable">추세검색 설정을 불러올 없습니다.</p>
)
);
case 'strategy': return (
<StrategyPanel
liveMarket={liveMarket}
liveStrategies={liveStrategies}
liveSettings={liveSettings}
watchlistCount={watchlistCount}
monitoredMarkets={monitoredMarkets}
virtualDriven={virtualDriven}
onClearLiveMarkers={onClearLiveMarkers}
tradeAlertPopup={tradeAlertPopup}
onTradeAlertPopup={onTradeAlertPopup}
onLiveSettingsChange={onLiveSettingsChange}
/>
);
case 'alert': return (
<AlertPanel
tradeAlertPopup={tradeAlertPopup}
onTradeAlertPopup={onTradeAlertPopup}
tradeAlertSoundEnabled={tradeAlertSoundEnabled}
onTradeAlertSoundEnabled={onTradeAlertSoundEnabled}
tradeAlertSound={tradeAlertSound}
onTradeAlertSound={onTradeAlertSound}
tradeAlertPopupPosition={tradeAlertPopupPosition}
onTradeAlertPopupPosition={onTradeAlertPopupPosition}
tradeAlertPopupLayout={tradeAlertPopupLayout}
onTradeAlertPopupLayout={onTradeAlertPopupLayout}
tradeAlertPopupGridCols={tradeAlertPopupGridCols}
onTradeAlertPopupGridCols={onTradeAlertPopupGridCols}
fcmPushEnabled={fcmPushEnabled}
onFcmPushEnabled={onFcmPushEnabled}
onFcmTest={onFcmTest}
/>
);
case 'network': return <NetworkPanel />;
}
};
return (
<div className={`stg-page stg-page--${theme}`}>
{/* ── 좌측 사이드바 ── */}
<aside className="stg-sidebar">
<div className="stg-sidebar-title">설정</div>
<nav className="stg-sidebar-nav">
{categories.map(cat => (
<button
key={cat.id}
className={`stg-sidebar-item ${active === cat.id ? 'stg-sidebar-item--active' : ''}`}
onClick={() => setActive(cat.id)}
>
<span className="stg-sidebar-icon">{cat.icon}</span>
<span className="stg-sidebar-label" style={{ fontSize: 13.5, fontWeight: 'inherit' }}>{cat.label}</span>
</button>
))}
</nav>
</aside>
{/* ── 우측 콘텐츠 ── */}
<main className="stg-content">
{/* 헤더 */}
<div className="stg-content-header">
<div className="stg-content-icon">{activeCat.icon}</div>
<div className="stg-content-header-text">
<h2 className="stg-content-title">{activeCat.label}</h2>
<p className="stg-content-desc">{activeCat.desc}</p>
</div>
{active === 'indicators' && indicatorSaveUi && (
<div className="stg-content-header-actions">
{indicatorSaveUi.saveMessage && (
<span
className={`stg-ind-save-msg stg-ind-save-msg--inline${
indicatorSaveUi.saveMessage.includes('실패') ? ' stg-ind-save-msg--err' : ''
}`}
>
{indicatorSaveUi.saveMessage}
</span>
)}
<button
type="button"
className="stg-btn-save"
disabled={!indicatorSaveUi.dirty || indicatorSaveUi.saving}
onClick={() => void indicatorSaveUi.save()}
>
{indicatorSaveUi.saving ? '저장 중…' : '저장'}
</button>
</div>
)}
</div>
{/* 설정 패널 */}
<div className="stg-content-body">
{renderPanel()}
</div>
{/* 저장 버튼 (보조지표 설정은 변경 시 DB 자동 저장) */}
{active !== 'indicators' && (
<div className="stg-footer">
<button className="stg-btn-reset">초기화</button>
<button className="stg-btn-save">저장</button>
</div>
)}
</main>
</div>
);
};
export default SettingsPage;