전체화면 모드 메뉴 상단으로 위치이동
This commit is contained in:
@@ -12174,6 +12174,25 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
|
||||
color: var(--accent, #8b5cf6);
|
||||
}
|
||||
|
||||
/* TopMenuBar 전체화면 버튼 */
|
||||
.tmb-fullscreen-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text2);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tmb-fullscreen-btn:hover {
|
||||
background: var(--bg4);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* TopMenuBar 알림 버튼 */
|
||||
.tmb-notify-group {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -1953,6 +1953,7 @@ function App() {
|
||||
onTradeAlertPopupPosition={v => saveAppDef({ tradeAlertPopupPosition: v })}
|
||||
onTradeAlertPopupLayout={v => saveAppDef({ tradeAlertPopupLayout: v })}
|
||||
onTradeAlertPopupGridCols={v => saveAppDef({ tradeAlertPopupGridCols: v })}
|
||||
onFullscreen={handleFullscreen}
|
||||
/>
|
||||
|
||||
<LoginModal
|
||||
@@ -2280,7 +2281,6 @@ function App() {
|
||||
onToggleAlert={() => setShowAlert(v => !v)}
|
||||
magnetMode={magnetMode}
|
||||
onToggleMagnet={() => setMagnetMode(m => m === 'off' ? 'strong' : 'off')}
|
||||
onFullscreen={handleFullscreen}
|
||||
onUndo={handleUndo}
|
||||
onRedo={handleRedo}
|
||||
canUndo={canUndo}
|
||||
|
||||
@@ -8,23 +8,19 @@ import React, { useState } from 'react';
|
||||
import type { BacktestAnalysis, BacktestSignal, BacktestStats } from '../utils/backendApi';
|
||||
import { useDraggablePanel } from '../hooks/useDraggablePanel';
|
||||
import { repairUtf8Mojibake } from '../utils/textEncoding';
|
||||
import {
|
||||
pct,
|
||||
pctAbs,
|
||||
num,
|
||||
wonFmt,
|
||||
colorCls,
|
||||
SectionTitle,
|
||||
KpiCard,
|
||||
MetricRow,
|
||||
WinRateCircle,
|
||||
CompareBar,
|
||||
} from './shared/analysisDashboardUi';
|
||||
|
||||
// ── 포맷 유틸 ────────────────────────────────────────────────────────────────
|
||||
|
||||
const pct = (v: number, dec = 2) =>
|
||||
isFinite(v) && v !== 0 ? `${v >= 0 ? '+' : ''}${(v * 100).toFixed(dec)}%` : '–';
|
||||
const pctAbs = (v: number, dec = 1) =>
|
||||
isFinite(v) && v !== 0 ? `${(v * 100).toFixed(dec)}%` : '–';
|
||||
const num = (v: number, dec = 2) =>
|
||||
isFinite(v) && v !== 0 ? v.toFixed(dec) : '–';
|
||||
const wonFmt = (v: number) => {
|
||||
if (!isFinite(v) || v === 0) return '–';
|
||||
const abs = Math.abs(Math.round(v));
|
||||
const s = abs >= 100_000_000 ? `${(abs/100_000_000).toFixed(2)}억`
|
||||
: abs >= 10_000 ? `${(abs/10_000).toFixed(1)}만`
|
||||
: abs.toLocaleString();
|
||||
return (v >= 0 ? '+' : '-') + s + '원';
|
||||
};
|
||||
const fmtDate = (ts: number) => {
|
||||
const d = new Date(ts * 1000);
|
||||
return `${d.getFullYear()}.${String(d.getMonth()+1).padStart(2,'0')}.${String(d.getDate()).padStart(2,'0')}`;
|
||||
@@ -34,11 +30,6 @@ const fmtDateStr = (iso: string) => {
|
||||
return `${d.getFullYear()}.${String(d.getMonth()+1).padStart(2,'0')}.${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;
|
||||
};
|
||||
|
||||
const colorCls = (v: number) => v > 0 ? 'brd-pos' : v < 0 ? 'brd-neg' : '';
|
||||
const colorStyle = (v: number): React.CSSProperties => ({
|
||||
color: v > 0 ? 'var(--brd-pos)' : v < 0 ? 'var(--brd-neg)' : undefined,
|
||||
});
|
||||
|
||||
// ── Props ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface BacktestResultModalProps {
|
||||
@@ -65,106 +56,6 @@ export interface BacktestDashboardProps {
|
||||
reportMode?: boolean;
|
||||
}
|
||||
|
||||
// ── 섹션 제목 ─────────────────────────────────────────────────────────────
|
||||
|
||||
function SectionTitle({ icon, title, right }: { icon: string; title: string; right?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="brd-section-title">
|
||||
<span className="brd-section-icon">{icon}</span>
|
||||
<span className="brd-section-text">{title}</span>
|
||||
{right && <span className="brd-section-right">{right}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── KPI 카드 ──────────────────────────────────────────────────────────────
|
||||
|
||||
function KpiCard({ icon, label, value, sub, valueColor, accent }:
|
||||
{ icon: string; label: string; value: string; sub: string; valueColor?: string; accent?: string }) {
|
||||
return (
|
||||
<div className={`brd-kpi-card ${accent ? `brd-kpi-card--${accent}` : ''}`}>
|
||||
<div className="brd-kpi-top">
|
||||
<span className="brd-kpi-icon">{icon}</span>
|
||||
<span className="brd-kpi-label">{label}</span>
|
||||
</div>
|
||||
<div className={`brd-kpi-value ${valueColor ?? ''}`}>{value}</div>
|
||||
<div className="brd-kpi-sub">{sub}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 미니 지표 행 (테이블 스타일) ─────────────────────────────────────────
|
||||
|
||||
function MetricRow({ label, value, cls, note }: { label: string; value: string; cls?: string; note?: string }) {
|
||||
return (
|
||||
<div className="brd-metric-row">
|
||||
<span className="brd-metric-label">
|
||||
{label}
|
||||
{note && <span className="brd-metric-note">{note}</span>}
|
||||
</span>
|
||||
<span className={`brd-metric-value ${cls ?? ''}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 승률 도넛 (CSS 원) ────────────────────────────────────────────────────
|
||||
|
||||
function WinRateCircle({ winRate, winning, total }: { winRate: number; winning: number; total: number }) {
|
||||
const r = 28;
|
||||
const circ = 2 * Math.PI * r;
|
||||
const pct2 = Math.max(0, Math.min(1, winRate));
|
||||
const dash = pct2 * circ;
|
||||
return (
|
||||
<div className="brd-donut-wrap">
|
||||
<svg width="72" height="72" viewBox="0 0 72 72">
|
||||
<circle cx="36" cy="36" r={r} fill="none" stroke="var(--bg4)" strokeWidth="8"/>
|
||||
<circle
|
||||
cx="36" cy="36" r={r} fill="none"
|
||||
stroke={pct2 >= 0.5 ? 'var(--brd-pos)' : 'var(--brd-neg)'}
|
||||
strokeWidth="8"
|
||||
strokeDasharray={`${dash} ${circ}`}
|
||||
strokeLinecap="round"
|
||||
strokeDashoffset={circ * 0.25}
|
||||
style={{ transform: 'rotate(-90deg)', transformOrigin: '50% 50%' }}
|
||||
/>
|
||||
<text x="36" y="33" textAnchor="middle" fontSize="11" fontWeight="800" fill="var(--text)">{pctAbs(winRate)}</text>
|
||||
<text x="36" y="45" textAnchor="middle" fontSize="8.5" fill="var(--text3)">승률</text>
|
||||
</svg>
|
||||
<div className="brd-donut-detail">
|
||||
<span className="brd-pos">▲ {winning}</span>
|
||||
<span className="brd-neg">▼ {total - winning}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 수평 비교 바 ─────────────────────────────────────────────────────────
|
||||
|
||||
function CompareBar({ labelA, valA, labelB, valB, colorA, colorB }:
|
||||
{ labelA: string; valA: number; labelB: string; valB: number; colorA: string; colorB: string }) {
|
||||
const max = Math.max(Math.abs(valA), Math.abs(valB), 0.01);
|
||||
const pctA2 = Math.min(100, Math.abs(valA) / max * 100);
|
||||
const pctB2 = Math.min(100, Math.abs(valB) / max * 100);
|
||||
return (
|
||||
<div className="brd-compare">
|
||||
<div className="brd-compare-row">
|
||||
<span className="brd-compare-label">{labelA}</span>
|
||||
<div className="brd-compare-track">
|
||||
<div className="brd-compare-bar" style={{ width: `${pctA2}%`, background: colorA }}/>
|
||||
</div>
|
||||
<span className="brd-compare-val" style={{ color: colorA }}>{pct(valA)}</span>
|
||||
</div>
|
||||
<div className="brd-compare-row">
|
||||
<span className="brd-compare-label">{labelB}</span>
|
||||
<div className="brd-compare-track">
|
||||
<div className="brd-compare-bar" style={{ width: `${pctB2}%`, background: colorB }}/>
|
||||
</div>
|
||||
<span className="brd-compare-val" style={{ color: colorB }}>{pct(valB)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 시그널 타입 ───────────────────────────────────────────────────────────
|
||||
|
||||
const SIG_LABEL: Record<string, string> = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* GoldenChart Command Center — Master Plan [01] Standard + [04] System/Tech
|
||||
* GoldenChart 대시보드 — 투자분석 레포트형 카드 UI
|
||||
*/
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { Theme } from '../types';
|
||||
@@ -8,13 +8,24 @@ import {
|
||||
type DashboardSummaryDto,
|
||||
type ProcessMonitorDto,
|
||||
type SystemMonitorDto,
|
||||
type TradeSignalDto,
|
||||
} from '../utils/backendApi';
|
||||
import DashboardGauge from './dashboard/DashboardGauge';
|
||||
import DashboardSegmentBar from './dashboard/DashboardSegmentBar';
|
||||
import DashboardSparkChart from './dashboard/DashboardSparkChart';
|
||||
import DashboardIndicatorBar from './dashboard/DashboardIndicatorBar';
|
||||
import '../styles/strategyEditorTheme.css';
|
||||
import '../styles/dashboardCommand.css';
|
||||
import {
|
||||
pct,
|
||||
pctAbs,
|
||||
num,
|
||||
wonFmt,
|
||||
fmtKrwShort,
|
||||
colorCls,
|
||||
SectionTitle,
|
||||
KpiCard,
|
||||
MetricRow,
|
||||
WinRateCircle,
|
||||
CompareBar,
|
||||
ResourceBar,
|
||||
} from './shared/analysisDashboardUi';
|
||||
import '../styles/dashboardReport.css';
|
||||
|
||||
interface Props {
|
||||
theme: Theme;
|
||||
@@ -28,11 +39,6 @@ const STATUS_LABEL: Record<string, string> = {
|
||||
disabled: '비활성',
|
||||
};
|
||||
|
||||
const fmtKrw = (n: number) =>
|
||||
n >= 1e8 ? `${(n / 1e8).toFixed(2)}억` : n >= 1e4 ? `${(n / 1e4).toFixed(0)}만` : n.toLocaleString('ko-KR');
|
||||
|
||||
const fmtPct = (v: number) => `${v >= 0 ? '+' : ''}${(v * 100).toFixed(1)}%`;
|
||||
|
||||
function formatAgo(ms: number): string {
|
||||
if (!ms) return '—';
|
||||
const sec = Math.floor((Date.now() - ms) / 1000);
|
||||
@@ -41,13 +47,6 @@ function formatAgo(ms: number): string {
|
||||
return `${Math.floor(sec / 3600)}시간 전`;
|
||||
}
|
||||
|
||||
function statusClass(s: string): string {
|
||||
if (s === 'healthy') return 'gc-proc--ok';
|
||||
if (s === 'degraded') return 'gc-proc--warn';
|
||||
if (s === 'down') return 'gc-proc--down';
|
||||
return 'gc-proc--off';
|
||||
}
|
||||
|
||||
function healthLevel(mon: SystemMonitorDto): 'green' | 'yellow' | 'red' {
|
||||
if (!mon.pipelineEnabled) return 'red';
|
||||
if (mon.jvm.heapPct > 85 || mon.queue.dropped > 0 || mon.staleMarkets.length > 5) return 'yellow';
|
||||
@@ -56,24 +55,31 @@ function healthLevel(mon: SystemMonitorDto): 'green' | 'yellow' | 'red' {
|
||||
return 'green';
|
||||
}
|
||||
|
||||
const ProcessCard: React.FC<{ proc: ProcessMonitorDto }> = ({ proc }) => (
|
||||
<article className={`gc-proc ${statusClass(proc.status)}`}>
|
||||
<header className="gc-proc-head">
|
||||
<span className="gc-proc-layer">{proc.layer}</span>
|
||||
<span className={`gc-proc-badge ${statusClass(proc.status)}`}>{STATUS_LABEL[proc.status] ?? proc.status}</span>
|
||||
</header>
|
||||
<h3 className="gc-proc-name">{proc.name}</h3>
|
||||
<ul className="gc-proc-metrics">
|
||||
{Object.entries(proc.metrics).slice(0, 4).map(([k, v]) => (
|
||||
<li key={k}><span>{k}</span><strong>{String(v ?? '—')}</strong></li>
|
||||
))}
|
||||
</ul>
|
||||
function healthScore(level: 'green' | 'yellow' | 'red'): number {
|
||||
if (level === 'green') return 1;
|
||||
if (level === 'yellow') return 0.55;
|
||||
return 0.2;
|
||||
}
|
||||
|
||||
function procMiniClass(status: string): string {
|
||||
if (status === 'healthy') return 'gc-dash-proc-mini--ok';
|
||||
if (status === 'degraded') return 'gc-dash-proc-mini--warn';
|
||||
if (status === 'down') return 'gc-dash-proc-mini--down';
|
||||
return 'gc-dash-proc-mini--off';
|
||||
}
|
||||
|
||||
const SIG_LABEL: Record<string, string> = { BUY: '매수', SELL: '매도' };
|
||||
|
||||
const ProcessMini: React.FC<{ proc: ProcessMonitorDto }> = ({ proc }) => (
|
||||
<article className={`gc-dash-proc-mini ${procMiniClass(proc.status)}`}>
|
||||
<p className="gc-dash-proc-mini-name">{proc.name}</p>
|
||||
<span className="gc-dash-proc-mini-badge">{STATUS_LABEL[proc.status] ?? proc.status}</span>
|
||||
</article>
|
||||
);
|
||||
|
||||
const HISTORY_LEN = 48;
|
||||
|
||||
const DashboardPage: React.FC<Props> = ({ theme, onGoChart }) => {
|
||||
const DashboardPage: React.FC<Props> = ({ onGoChart }) => {
|
||||
const [data, setData] = useState<DashboardSummaryDto | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -110,6 +116,7 @@ const DashboardPage: React.FC<Props> = ({ theme, onGoChart }) => {
|
||||
const mon = data?.systemMonitor;
|
||||
const app = data?.app as Record<string, unknown> | undefined;
|
||||
const health = mon ? healthLevel(mon) : 'yellow';
|
||||
const healthPct = healthScore(health);
|
||||
|
||||
const latencyMs = useMemo(() => {
|
||||
if (!mon) return 0;
|
||||
@@ -117,216 +124,371 @@ const DashboardPage: React.FC<Props> = ({ theme, onGoChart }) => {
|
||||
return tps > 0 ? Math.max(12, Math.round(1000 / Math.max(tps, 1))) : 45;
|
||||
}, [mon]);
|
||||
|
||||
const stompConnections = data?.monitoredMarkets ?? 0;
|
||||
|
||||
const ta4jPct = mon && mon.memory.maxBarsPerSeries > 0
|
||||
? Math.min(100, (mon.memory.totalBars / (mon.memory.series * mon.memory.maxBarsPerSeries || 1)) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className={`gc-dash-page se-page se-page--${theme}`}>
|
||||
<header className="gc-dash-header">
|
||||
<div className="gc-dash-header-left">
|
||||
<span className="gc-dash-badge">[01] Standard Dashboard</span>
|
||||
<h1 className="gc-dash-title">GoldenChart Command Center</h1>
|
||||
<p className="gc-dash-sub">실시간 투자 분석 · 파이프라인 모니터링 · 시스템 헬스체크</p>
|
||||
</div>
|
||||
<div className="gc-dash-header-right">
|
||||
<span className="gc-dash-stat">Global Latency: <strong>{latencyMs}ms</strong></span>
|
||||
<span className="gc-dash-stat">Active Markets: <strong>{stompConnections}</strong></span>
|
||||
{mon && (
|
||||
<span className="gc-dash-stat">
|
||||
Pipeline <strong className={mon.pipelineEnabled ? 'up' : 'down'}>{mon.pipelineEnabled ? 'ON' : 'OFF'}</strong>
|
||||
{mon.sampledAtMs ? ` · ${formatAgo(mon.sampledAtMs)}` : ''}
|
||||
</span>
|
||||
)}
|
||||
<button type="button" className="gc-dash-btn" onClick={refresh} disabled={loading}>
|
||||
{loading ? '갱신…' : '새로고침'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
const queuePct = (mon?.queue.fillRatio ?? 0) * 100;
|
||||
const heapPct = mon?.jvm.heapPct ?? 0;
|
||||
|
||||
{error && <p className="gc-dash-error">{error}</p>}
|
||||
const buySignals = useMemo(
|
||||
() => (data?.recentSignals ?? []).filter(s => s.signalType === 'BUY').length,
|
||||
[data?.recentSignals],
|
||||
);
|
||||
const sellSignals = (data?.recentSignals?.length ?? 0) - buySignals;
|
||||
|
||||
const printedAt = new Date().toLocaleString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
const tradingMode = String(app?.tradingMode ?? '—');
|
||||
|
||||
return (
|
||||
<div className="gc-dash-report-page">
|
||||
{error && <p className="gc-dash-report-error">{error}</p>}
|
||||
{loading && !data && <p className="gc-dash-report-loading">대시보드 불러오는 중…</p>}
|
||||
|
||||
{data && mon && (
|
||||
<div className="gc-dash-body">
|
||||
{/* ── LEFT: Portfolio + Indicators ── */}
|
||||
<aside className="gc-dash-col gc-dash-col--left">
|
||||
<section className="gc-dash-card gc-dash-card--glow">
|
||||
<h2 className="gc-dash-card-title">포트폴리오</h2>
|
||||
<div className="gc-dash-kpi-hero">
|
||||
<div className="gc-dash-kpi-block">
|
||||
<span className="gc-dash-kpi-label">모의 총자산</span>
|
||||
<strong className="gc-dash-kpi-val">{fmtKrw(data.paper.totalAsset)}</strong>
|
||||
<span className={`gc-dash-kpi-sub ${data.paper.totalReturnPct >= 0 ? 'up' : 'down'}`}>
|
||||
{fmtPct(data.paper.totalReturnPct)}
|
||||
<div className="gc-dash-report-sheet">
|
||||
<header className="gc-dash-report-header">
|
||||
<div className="gc-dash-report-brand">
|
||||
<h1>GoldenChart 대시보드</h1>
|
||||
<p className="gc-dash-report-meta">
|
||||
출력일시 {printedAt}
|
||||
{mon.sampledAtMs ? ` · 시스템 갱신 ${formatAgo(mon.sampledAtMs)}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="gc-dash-report-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="gc-dash-report-refresh"
|
||||
onClick={refresh}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '갱신…' : '새로고침'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="brd-dashboard brd-dashboard--report">
|
||||
{/* 요약 헤더 */}
|
||||
<div className="brd-dash-header">
|
||||
<div className="brd-dash-header-left">
|
||||
<div className="brd-dash-strategy">투자·시스템 현황 요약</div>
|
||||
<div className="brd-dash-meta">
|
||||
<span className="brd-dash-badge">{tradingMode}</span>
|
||||
<span className="brd-dash-badge">
|
||||
Pipeline {mon.pipelineEnabled ? 'ON' : 'OFF'}
|
||||
</span>
|
||||
<span className="brd-dash-badge">관심 {data.watchlistCount}</span>
|
||||
<span className="brd-dash-badge">감시 {data.monitoredMarkets}</span>
|
||||
<span className="brd-dash-badge brd-dash-badge--time">
|
||||
Latency {latencyMs}ms
|
||||
</span>
|
||||
</div>
|
||||
<div className="gc-dash-kpi-block">
|
||||
<span className="gc-dash-kpi-label">실거래 KRW</span>
|
||||
<strong className="gc-dash-kpi-val">{fmtKrw(data.live.krwBalance)}</strong>
|
||||
<span className="gc-dash-kpi-sub">{data.live.configured ? '연동됨' : '미설정'}</span>
|
||||
</div>
|
||||
<div className="brd-dash-header-kpi brd-dash-header-kpi--report">
|
||||
<div className="brd-dash-big-kpi brd-dash-big-kpi--report">
|
||||
<div className={`brd-dash-big-val ${colorCls(data.paper.totalReturnPct)}`}>
|
||||
{pct(data.paper.totalReturnPct)}
|
||||
</div>
|
||||
<div className="brd-dash-big-label">모의 총 수익률</div>
|
||||
</div>
|
||||
<div className="brd-dash-big-kpi brd-dash-big-kpi--report">
|
||||
<div className={`brd-dash-big-val ${colorCls(data.paper.totalReturnPct)}`}>
|
||||
{fmtKrwShort(data.paper.totalAsset)}
|
||||
</div>
|
||||
<div className="brd-dash-big-label">모의 총 자산</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="gc-dash-kv">
|
||||
<li><span>모의 현금</span><strong>{fmtKrw(data.paper.cashBalance)}</strong></li>
|
||||
<li><span>보유 종목</span><strong>{data.paper.positions?.length ?? 0}</strong></li>
|
||||
<li><span>관심종목</span><strong>{data.watchlistCount}</strong></li>
|
||||
<li><span>전략 감시</span><strong>{data.monitoredMarkets}</strong></li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="gc-dash-card">
|
||||
<h2 className="gc-dash-card-title">시스템 상태</h2>
|
||||
<div className={`gc-traffic-light gc-traffic-light--${health}`} aria-label="시스템 상태">
|
||||
<span className="gc-traffic-bulb gc-traffic-bulb--r" />
|
||||
<span className="gc-traffic-bulb gc-traffic-bulb--y" />
|
||||
<span className="gc-traffic-bulb gc-traffic-bulb--g" />
|
||||
</div>
|
||||
<p className="gc-traffic-caption">
|
||||
{health === 'green' ? '정상 운영' : health === 'yellow' ? '주의 필요' : '점검 필요'}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="gc-dash-card">
|
||||
<h2 className="gc-dash-card-title">리소스 인디케이터</h2>
|
||||
<div className="gc-ind-grid">
|
||||
<DashboardIndicatorBar label="JVM Heap" pct={mon.jvm.heapPct} />
|
||||
<DashboardIndicatorBar label="Event Queue" pct={(mon.queue.fillRatio ?? 0) * 100} />
|
||||
<DashboardIndicatorBar label="Ta4j Cache" pct={ta4jPct} />
|
||||
<DashboardIndicatorBar label="WS Load" pct={Math.min(100, (mon.traffic.wsMessagesPerSec ?? 0) * 2)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="gc-dash-card gc-dash-card--compact">
|
||||
<h2 className="gc-dash-card-title">매매 모드</h2>
|
||||
<ul className="gc-dash-kv">
|
||||
<li><span>운영 모드</span><strong>{String(app?.tradingMode ?? '—')}</strong></li>
|
||||
<li><span>실거래 자동</span><strong>{app?.liveAutoTradeEnabled ? 'ON' : 'OFF'}</strong></li>
|
||||
<li><span>모의 자동</span><strong>{app?.paperAutoTradeEnabled ? 'ON' : 'OFF'}</strong></li>
|
||||
<li><span>FCM</span><strong>{data.fcm.pushEnabled ? (data.fcm.available ? '활성' : '미연결') : 'OFF'}</strong></li>
|
||||
</ul>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
{/* ── CENTER: Infrastructure + Chart ── */}
|
||||
<main className="gc-dash-col gc-dash-col--center">
|
||||
<section className="gc-dash-card gc-dash-card--wide">
|
||||
<h2 className="gc-dash-card-title">[04] 데이터 파이프라인 · Ta4j 엔진</h2>
|
||||
<div className="gc-infra-grid">
|
||||
<DashboardSegmentBar
|
||||
label="Ta4jStorage"
|
||||
valueLabel={`${mon.memory.markets}종목 · ${mon.memory.totalBars.toLocaleString()}봉`}
|
||||
pct={ta4jPct}
|
||||
tone="green"
|
||||
{/* KPI 6 */}
|
||||
<div className="brd-row">
|
||||
<div className="brd-kpi-grid">
|
||||
<KpiCard
|
||||
icon="💼"
|
||||
label="모의 현금"
|
||||
value={fmtKrwShort(data.paper.cashBalance)}
|
||||
sub={`보유 ${data.paper.positions?.length ?? 0}종목`}
|
||||
accent="blue"
|
||||
/>
|
||||
<DashboardSegmentBar
|
||||
label="Event Queue"
|
||||
valueLabel={`${mon.queue.pending} / ${mon.queue.capacity?.toLocaleString() ?? '—'}`}
|
||||
pct={(mon.queue.fillRatio ?? 0) * 100}
|
||||
tone={mon.queue.fillRatio > 0.7 ? 'amber' : 'cyan'}
|
||||
<KpiCard
|
||||
icon="🏦"
|
||||
label="실거래 KRW"
|
||||
value={fmtKrwShort(data.live.krwBalance)}
|
||||
sub={data.live.configured ? '연동됨' : '미설정'}
|
||||
accent="teal"
|
||||
/>
|
||||
<DashboardSegmentBar
|
||||
label="Order Worker"
|
||||
valueLabel={`${mon.orders.executed} executed · ${mon.orders.failed} fail`}
|
||||
pct={Math.min(100, mon.orders.ratePerSecond * 10)}
|
||||
tone="cyan"
|
||||
<KpiCard
|
||||
icon="📉"
|
||||
label="JVM Heap"
|
||||
value={`${heapPct.toFixed(0)}%`}
|
||||
sub={`${mon.jvm.heapUsedMb} / ${mon.jvm.heapMaxMb} MB`}
|
||||
valueColor={heapPct > 85 ? 'brd-neg' : heapPct > 70 ? 'brd-warn' : 'brd-pos'}
|
||||
accent="red"
|
||||
/>
|
||||
<DashboardSegmentBar
|
||||
label="Tick Worker"
|
||||
valueLabel={`${mon.workers.processed.toLocaleString()} processed`}
|
||||
pct={mon.workers.active ? 85 : 20}
|
||||
tone={mon.workers.active ? 'green' : 'red'}
|
||||
<KpiCard
|
||||
icon="📬"
|
||||
label="이벤트 큐"
|
||||
value={`${queuePct.toFixed(0)}%`}
|
||||
sub={`${mon.queue.pending} / ${mon.queue.capacity?.toLocaleString() ?? '—'}`}
|
||||
valueColor={queuePct > 70 ? 'brd-neg' : ''}
|
||||
accent="yellow"
|
||||
/>
|
||||
<KpiCard
|
||||
icon="⚡"
|
||||
label="Tick 처리"
|
||||
value={String(mon.traffic.ticksProcessedPerSec ?? 0)}
|
||||
sub="ticks / sec"
|
||||
accent="purple"
|
||||
/>
|
||||
<KpiCard
|
||||
icon="📡"
|
||||
label="WebSocket"
|
||||
value={String(mon.traffic.wsMessagesPerSec ?? 0)}
|
||||
sub="msg / sec"
|
||||
accent="green"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="gc-dash-card gc-dash-card--chart">
|
||||
<DashboardSparkChart series={tickHistory.current} label="Tick 처리량 (실시간)" height={160} />
|
||||
</section>
|
||||
|
||||
<section className="gc-dash-card gc-dash-card--wide">
|
||||
<h2 className="gc-dash-card-title">멀티스레드 프로세스</h2>
|
||||
<div className="gc-proc-grid">
|
||||
{mon.processes.map(p => <ProcessCard key={p.id} proc={p} />)}
|
||||
{/* 3열 상세 */}
|
||||
<div className="brd-row brd-row--3col">
|
||||
<div className="brd-card">
|
||||
<SectionTitle icon="📊" title="포트폴리오" />
|
||||
<div className="brd-card-body">
|
||||
<MetricRow label="모의 총자산" value={fmtKrwShort(data.paper.totalAsset)} />
|
||||
<MetricRow
|
||||
label="총 수익률"
|
||||
value={pct(data.paper.totalReturnPct)}
|
||||
cls={colorCls(data.paper.totalReturnPct)}
|
||||
/>
|
||||
<MetricRow
|
||||
label="평가 손익"
|
||||
value={wonFmt(data.paper.unrealizedPnl)}
|
||||
cls={colorCls(data.paper.unrealizedPnl)}
|
||||
/>
|
||||
<MetricRow
|
||||
label="실현 손익"
|
||||
value={wonFmt(data.paper.realizedPnl)}
|
||||
cls={colorCls(data.paper.realizedPnl)}
|
||||
/>
|
||||
<MetricRow label="모의 현금" value={fmtKrwShort(data.paper.cashBalance)} />
|
||||
<MetricRow label="주식 평가" value={fmtKrwShort(data.paper.stockEvalAmount)} />
|
||||
<MetricRow label="초기 자본" value={fmtKrwShort(data.paper.initialCapital)} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="brd-card">
|
||||
<SectionTitle icon="🛡" title="시스템·리스크" />
|
||||
<div className="brd-card-body">
|
||||
<MetricRow
|
||||
label="시스템 상태"
|
||||
value={health === 'green' ? '정상' : health === 'yellow' ? '주의' : '점검'}
|
||||
cls={health === 'green' ? 'brd-pos' : health === 'yellow' ? 'brd-warn' : 'brd-neg'}
|
||||
/>
|
||||
<MetricRow label="JVM Heap" value={`${heapPct.toFixed(1)}%`} cls={heapPct > 85 ? 'brd-neg' : ''} note="≥85% 주의" />
|
||||
<MetricRow label="큐 적재" value={`${queuePct.toFixed(1)}%`} note="fill ratio" />
|
||||
<MetricRow label="큐 드롭" value={String(mon.queue.dropped)} cls={mon.queue.dropped > 0 ? 'brd-neg' : ''} />
|
||||
<MetricRow label="Ta4j 캐시" value={`${ta4jPct.toFixed(0)}%`} note={`${mon.memory.totalBars.toLocaleString()}봉`} />
|
||||
<MetricRow label="틱 정체" value={`${mon.staleMarkets.length}종목`} cls={mon.staleMarkets.length > 0 ? 'brd-warn' : ''} />
|
||||
<MetricRow label="주문 실패" value={String(mon.orders.failed)} cls={mon.orders.failed > 0 ? 'brd-neg' : ''} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="brd-card">
|
||||
<SectionTitle icon="🔢" title="운영 통계" />
|
||||
<div className="brd-card-body brd-card-body--center">
|
||||
<WinRateCircle
|
||||
winRate={healthPct}
|
||||
winning={mon.processes.filter(p => p.status === 'healthy').length}
|
||||
total={Math.max(mon.processes.length, 1)}
|
||||
centerLabel="헬스"
|
||||
/>
|
||||
<div className="brd-trade-stats">
|
||||
<MetricRow label="관심종목" value={`${data.watchlistCount}개`} />
|
||||
<MetricRow label="전략 감시" value={`${data.monitoredMarkets}개`} />
|
||||
<MetricRow label="실거래 자동" value={app?.liveAutoTradeEnabled ? 'ON' : 'OFF'} />
|
||||
<MetricRow label="모의 자동" value={app?.paperAutoTradeEnabled ? 'ON' : 'OFF'} />
|
||||
<MetricRow label="FCM 푸시" value={data.fcm.pushEnabled ? (data.fcm.available ? '활성' : '미연결') : 'OFF'} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2열: 수익 비교 + 리소스 */}
|
||||
<div className="brd-row brd-row--2col">
|
||||
<div className="brd-card">
|
||||
<SectionTitle icon="📐" title="자산·수익 비교" />
|
||||
<div className="brd-card-body">
|
||||
<CompareBar
|
||||
labelA="모의 수익률"
|
||||
valA={data.paper.totalReturnPct}
|
||||
labelB="실현 손익률"
|
||||
valB={data.paper.initialCapital > 0
|
||||
? data.paper.realizedPnl / data.paper.initialCapital
|
||||
: 0}
|
||||
colorA={data.paper.totalReturnPct >= 0 ? 'var(--brd-pos)' : 'var(--brd-neg)'}
|
||||
colorB="var(--text3)"
|
||||
/>
|
||||
<div className="brd-bench-summary">
|
||||
<div className="brd-bench-item">
|
||||
<span className="brd-bench-label">모의 자산</span>
|
||||
<span className="brd-bench-val">{fmtKrwShort(data.paper.totalAsset)}</span>
|
||||
</div>
|
||||
<div className="brd-bench-divider" />
|
||||
<div className="brd-bench-item">
|
||||
<span className="brd-bench-label">실거래 KRW</span>
|
||||
<span className="brd-bench-val">{fmtKrwShort(data.live.krwBalance)}</span>
|
||||
</div>
|
||||
<div className="brd-bench-divider" />
|
||||
<div className="brd-bench-item">
|
||||
<span className="brd-bench-label">평가 손익</span>
|
||||
<span className={`brd-bench-val ${colorCls(data.paper.unrealizedPnl)}`}>
|
||||
{wonFmt(data.paper.unrealizedPnl)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="brd-card">
|
||||
<SectionTitle icon="💻" title="리소스 사용" />
|
||||
<div className="brd-card-body">
|
||||
<div className="brd-pnl-bar-section">
|
||||
<ResourceBar label="Heap" pctFill={heapPct} cls={heapPct > 85 ? 'brd-neg' : 'brd-pos'} />
|
||||
<ResourceBar label="Queue" pctFill={queuePct} cls={queuePct > 70 ? 'brd-neg' : 'brd-pos'} />
|
||||
<ResourceBar label="Ta4j" pctFill={ta4jPct} cls={ta4jPct > 90 ? 'brd-neg' : 'brd-pos'} />
|
||||
<ResourceBar
|
||||
label="WS부하"
|
||||
pctFill={Math.min(100, (mon.traffic.wsMessagesPerSec ?? 0) * 3)}
|
||||
cls="brd-pos"
|
||||
/>
|
||||
</div>
|
||||
<div className="brd-pnl-net">
|
||||
<span className="brd-pnl-net-label">주문 처리</span>
|
||||
<span className="brd-pnl-net-val">
|
||||
{mon.orders.executed}건 · 실패 {mon.orders.failed}
|
||||
</span>
|
||||
</div>
|
||||
<MetricRow label="Tick Worker" value={mon.workers.active ? '가동' : '대기'} cls={mon.workers.active ? 'brd-pos' : 'brd-warn'} />
|
||||
<MetricRow label="처리 건수" value={mon.workers.processed.toLocaleString()} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 실시간 차트 */}
|
||||
<div className="brd-row">
|
||||
<div className="brd-card brd-card--full">
|
||||
<SectionTitle icon="📈" title="실시간 트래픽" />
|
||||
<div className="gc-dash-spark-row">
|
||||
<div className="gc-dash-spark-box">
|
||||
<DashboardSparkChart series={tickHistory.current} label="Tick 처리량" height={120} />
|
||||
</div>
|
||||
<div className="gc-dash-spark-box">
|
||||
<DashboardSparkChart series={wsHistory.current} label="WebSocket 수신" height={120} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 프로세스 */}
|
||||
<div className="brd-row">
|
||||
<div className="brd-card brd-card--full">
|
||||
<SectionTitle
|
||||
icon="⚙️"
|
||||
title="멀티스레드 프로세스"
|
||||
right={<span className="brd-section-count">{mon.processes.length}개</span>}
|
||||
/>
|
||||
<div className="gc-dash-proc-mini-grid">
|
||||
{mon.processes.map(p => <ProcessMini key={p.id} proc={p} />)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mon.staleMarkets.length > 0 && (
|
||||
<section className="gc-dash-card gc-dash-card--warn">
|
||||
<h2 className="gc-dash-card-title">틱 정체 종목 ({mon.staleMarkets.length})</h2>
|
||||
<div className="gc-stale-chips">
|
||||
{mon.staleMarkets.slice(0, 8).map(s => (
|
||||
<button key={s.market} type="button" className="gc-stale-chip" onClick={() => onGoChart?.(s.market)}>
|
||||
{s.market.replace('KRW-', '')} · {s.lastTickAgeSec}s
|
||||
</button>
|
||||
))}
|
||||
<div className="brd-row">
|
||||
<div className="brd-card brd-card--full">
|
||||
<SectionTitle icon="⚠️" title={`틱 정체 종목 (${mon.staleMarkets.length})`} />
|
||||
<div className="gc-dash-stale-chips">
|
||||
{mon.staleMarkets.slice(0, 12).map(s => (
|
||||
<button
|
||||
key={s.market}
|
||||
type="button"
|
||||
className="gc-dash-stale-chip"
|
||||
onClick={() => onGoChart?.(s.market)}
|
||||
>
|
||||
{s.market.replace('KRW-', '')} · {s.lastTickAgeSec}s
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* ── RIGHT: Gauges + Signals ── */}
|
||||
<aside className="gc-dash-col gc-dash-col--right">
|
||||
<section className="gc-dash-card gc-dash-card--glow">
|
||||
<h2 className="gc-dash-card-title">성과 · 리스크</h2>
|
||||
<div>
|
||||
<DashboardGauge
|
||||
label="JVM Heap"
|
||||
value={`${mon.jvm.heapPct}%`}
|
||||
pct={mon.jvm.heapPct}
|
||||
tone={mon.jvm.heapPct > 85 ? 'red' : 'green'}
|
||||
/>
|
||||
<DashboardGauge
|
||||
label="Queue Fill"
|
||||
value={`${Math.round((mon.queue.fillRatio ?? 0) * 100)}%`}
|
||||
pct={(mon.queue.fillRatio ?? 0) * 100}
|
||||
tone={(mon.queue.fillRatio ?? 0) > 0.7 ? 'amber' : 'cyan'}
|
||||
/>
|
||||
<DashboardGauge
|
||||
label="모의 수익률"
|
||||
value={fmtPct(data.paper.totalReturnPct)}
|
||||
pct={Math.min(100, Math.abs(data.paper.totalReturnPct) * 200)}
|
||||
max={100}
|
||||
tone={data.paper.totalReturnPct >= 0 ? 'green' : 'red'}
|
||||
/>
|
||||
<DashboardGauge
|
||||
label="WS msg/s"
|
||||
value={String(mon.traffic.wsMessagesPerSec ?? 0)}
|
||||
pct={Math.min(100, (mon.traffic.wsMessagesPerSec ?? 0) * 3)}
|
||||
tone="cyan"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="gc-dash-card gc-dash-card--chart-sm">
|
||||
<DashboardSparkChart series={wsHistory.current} label="WebSocket 수신" height={80} />
|
||||
</section>
|
||||
|
||||
<section className="gc-dash-card gc-dash-card--flex">
|
||||
<h2 className="gc-dash-card-title">최근 시그널 · 체결</h2>
|
||||
{data.recentSignals.length === 0 ? (
|
||||
<p className="gc-dash-muted">최근 시그널 없음</p>
|
||||
) : (
|
||||
<ul className="gc-signal-list">
|
||||
{data.recentSignals.slice(0, 12).map(s => {
|
||||
const buy = s.signalType === 'BUY';
|
||||
return (
|
||||
<li key={s.id} className={`gc-signal-item gc-signal-item--${buy ? 'buy' : 'sell'}`}>
|
||||
<span className="gc-signal-arrow">{buy ? '▲' : '▼'}</span>
|
||||
<button type="button" className="gc-signal-main" onClick={() => onGoChart?.(s.market)}>
|
||||
<strong>{buy ? 'BUY' : 'SELL'}</strong>
|
||||
<span>{s.market.replace('KRW-', '')}</span>
|
||||
<span className="gc-signal-price">@ {s.price?.toLocaleString('ko-KR')}</span>
|
||||
</button>
|
||||
<span className="gc-signal-time">{s.createdAt?.slice(11, 16) ?? ''}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</aside>
|
||||
{/* 최근 시그널 */}
|
||||
<div className="brd-row">
|
||||
<div className="brd-card brd-card--full">
|
||||
<SectionTitle
|
||||
icon="📋"
|
||||
title="최근 매매 시그널"
|
||||
right={<span className="brd-section-count">{data.recentSignals.length}건</span>}
|
||||
/>
|
||||
{data.recentSignals.length === 0 ? (
|
||||
<div className="brd-empty-msg">최근 시그널이 없습니다.</div>
|
||||
) : (
|
||||
<div className="brd-sig-scroll">
|
||||
<table className="brd-sig-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 36 }}>#</th>
|
||||
<th>시간</th>
|
||||
<th>종목</th>
|
||||
<th>구분</th>
|
||||
<th style={{ textAlign: 'right' }}>가격</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.recentSignals.slice(0, 20).map((s: TradeSignalDto, i: number) => {
|
||||
const buy = s.signalType === 'BUY';
|
||||
return (
|
||||
<tr key={s.id} className={i % 2 === 0 ? 'brd-sig-even' : ''}>
|
||||
<td style={{ textAlign: 'center', color: 'var(--text3)' }}>{i + 1}</td>
|
||||
<td style={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||
{s.createdAt?.slice(0, 16).replace('T', ' ') ?? '—'}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="brd-sig-market-btn"
|
||||
onClick={() => onGoChart?.(s.market)}
|
||||
>
|
||||
{s.market.replace('KRW-', '')}
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`brd-sig-badge ${buy ? 'brd-sig-buy' : 'brd-sig-sell'}`}>
|
||||
{SIG_LABEL[s.signalType] ?? s.signalType}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums', fontWeight: 600 }}>
|
||||
{Math.round(s.price).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,7 @@ interface NotifyTopMenuBarProps {
|
||||
onTradeAlertPopupPosition?: (v: TradeAlertPopupPosition) => void;
|
||||
onTradeAlertPopupLayout?: (v: TradeAlertPopupLayout) => void;
|
||||
onTradeAlertPopupGridCols?: (v: number) => void;
|
||||
onFullscreen?: () => void;
|
||||
}
|
||||
|
||||
export const NotifyTopMenuBar: React.FC<NotifyTopMenuBarProps> = props => {
|
||||
|
||||
@@ -97,7 +97,6 @@ export interface ToolbarProps {
|
||||
magnetMode?: 'off' | 'weak' | 'strong';
|
||||
/** 자석모드 토글 (off↔strong) */
|
||||
onToggleMagnet?: () => void;
|
||||
onFullscreen: () => void;
|
||||
onUndo?: () => void;
|
||||
onRedo?: () => void;
|
||||
canUndo?: boolean;
|
||||
@@ -220,12 +219,6 @@ const IcCamera = () => (
|
||||
<circle cx="8" cy="9" r="2.5"/>
|
||||
</svg>
|
||||
);
|
||||
const IcFullscreen = () => (
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<polyline points="1,5 1,1 5,1"/><polyline points="10,1 14,1 14,5"/>
|
||||
<polyline points="14,10 14,14 10,14"/><polyline points="5,14 1,14 1,10"/>
|
||||
</svg>
|
||||
);
|
||||
const IcBell = () => (
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M7.5 2 C5 2 3 4 3 6.5 L3 10 L2 11 L13 11 L12 10 L12 6.5 C12 4 10 2 7.5 2 Z"/>
|
||||
@@ -814,7 +807,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
onToggleStats, onToggleWatch, onToggleAlert,
|
||||
tradeNotifyUnread = 0, onOpenTradeNotifications, onOpenAppDownload,
|
||||
magnetMode = 'off', onToggleMagnet,
|
||||
onFullscreen, onUndo, onRedo, canUndo, canRedo, onClearDrawings,
|
||||
onUndo, onRedo, canUndo, canRedo, onClearDrawings,
|
||||
onToggleGrid,
|
||||
layoutId = '1', syncOptions = DEFAULT_SYNC, onLayoutSelect, onSyncChange,
|
||||
magnifierActive = false, onToggleMagnifier,
|
||||
@@ -1388,9 +1381,6 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
<button className="tv-icon-btn" onClick={onScreenshot} title="스크린샷 (Alt+S)">
|
||||
<IcCamera />
|
||||
</button>
|
||||
<button className="tv-icon-btn" onClick={onFullscreen} title="전체화면">
|
||||
<IcFullscreen />
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 레이아웃:
|
||||
* [로고] | [대시보드] [실시간차트] [전략편집기] … | [테마토글] [로그인]
|
||||
*/
|
||||
import React, { memo } from 'react';
|
||||
import React, { memo, useEffect, useState } from 'react';
|
||||
import type { Theme } from '../types';
|
||||
import type { AuthSession } from '../utils/auth';
|
||||
import TradeAlertPopupMenubarSelect from './TradeAlertPopupMenubarSelect';
|
||||
@@ -40,6 +40,8 @@ interface TopMenuBarProps {
|
||||
guestMode?: boolean;
|
||||
onLoginClick?: () => void;
|
||||
onLogout?: () => void;
|
||||
/** 브라우저 전체화면 (모든 화면에서 사용) */
|
||||
onFullscreen?: () => void;
|
||||
/** 메뉴별 접근 허용 (없으면 전체 표시) */
|
||||
menuPermissions?: Record<string, boolean>;
|
||||
}
|
||||
@@ -106,6 +108,24 @@ const IcDismissPopups = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IcFullscreen = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="1,6 1,1 6,1"/>
|
||||
<polyline points="10,1 15,1 15,6"/>
|
||||
<polyline points="15,10 15,15 10,15"/>
|
||||
<polyline points="6,15 1,15 1,10"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IcFullscreenExit = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="6,6 1,1"/>
|
||||
<polyline points="10,6 15,1"/>
|
||||
<polyline points="6,10 1,15"/>
|
||||
<polyline points="10,10 15,15"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IcLogin = () => (
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 2h3a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H9"/>
|
||||
@@ -193,8 +213,16 @@ export const TopMenuBar = memo(function TopMenuBar({
|
||||
guestMode = false,
|
||||
onLoginClick,
|
||||
onLogout,
|
||||
onFullscreen,
|
||||
menuPermissions,
|
||||
}: TopMenuBarProps) {
|
||||
const [isFullscreen, setIsFullscreen] = useState(() => !!document.fullscreenElement);
|
||||
useEffect(() => {
|
||||
const sync = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
document.addEventListener('fullscreenchange', sync);
|
||||
return () => document.removeEventListener('fullscreenchange', sync);
|
||||
}, []);
|
||||
|
||||
const tc = THEME_CONFIG[theme];
|
||||
const visibleItems = menuPermissions
|
||||
? MENU_ITEMS.filter(({ page }) => canAccessMenu(menuPermissions, page))
|
||||
@@ -297,6 +325,21 @@ export const TopMenuBar = memo(function TopMenuBar({
|
||||
</>
|
||||
)}
|
||||
|
||||
{onFullscreen && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="tmb-fullscreen-btn"
|
||||
onClick={onFullscreen}
|
||||
title={isFullscreen ? '전체화면 종료 (Esc)' : '전체화면'}
|
||||
aria-label={isFullscreen ? '전체화면 종료' : '전체화면'}
|
||||
>
|
||||
{isFullscreen ? <IcFullscreenExit /> : <IcFullscreen />}
|
||||
</button>
|
||||
<span className="tmb-divider" aria-hidden="true" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 테마 토글 버튼 */}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* 투자분석·대시보드 공통 카드 UI (brd-*)
|
||||
*/
|
||||
import React from 'react';
|
||||
|
||||
export const pct = (v: number, dec = 2) =>
|
||||
isFinite(v) && v !== 0 ? `${v >= 0 ? '+' : ''}${(v * 100).toFixed(dec)}%` : '–';
|
||||
export const pctAbs = (v: number, dec = 1) =>
|
||||
isFinite(v) && v !== 0 ? `${(v * 100).toFixed(dec)}%` : '–';
|
||||
export const num = (v: number, dec = 2) =>
|
||||
isFinite(v) && v !== 0 ? v.toFixed(dec) : '–';
|
||||
export const wonFmt = (v: number) => {
|
||||
if (!isFinite(v) || v === 0) return '–';
|
||||
const abs = Math.abs(Math.round(v));
|
||||
const s = abs >= 100_000_000 ? `${(abs / 100_000_000).toFixed(2)}억`
|
||||
: abs >= 10_000 ? `${(abs / 10_000).toFixed(1)}만`
|
||||
: abs.toLocaleString();
|
||||
return (v >= 0 ? '+' : '-') + s + '원';
|
||||
};
|
||||
export const fmtKrwShort = (n: number) =>
|
||||
n >= 1e8 ? `${(n / 1e8).toFixed(2)}억` : n >= 1e4 ? `${(n / 1e4).toFixed(0)}만` : n.toLocaleString('ko-KR');
|
||||
|
||||
export const colorCls = (v: number) => (v > 0 ? 'brd-pos' : v < 0 ? 'brd-neg' : '');
|
||||
|
||||
export function SectionTitle({ icon, title, right }: { icon: string; title: string; right?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="brd-section-title">
|
||||
<span className="brd-section-icon">{icon}</span>
|
||||
<span className="brd-section-text">{title}</span>
|
||||
{right && <span className="brd-section-right">{right}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function KpiCard({ icon, label, value, sub, valueColor, accent }: {
|
||||
icon: string;
|
||||
label: string;
|
||||
value: string;
|
||||
sub: string;
|
||||
valueColor?: string;
|
||||
accent?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={`brd-kpi-card ${accent ? `brd-kpi-card--${accent}` : ''}`}>
|
||||
<div className="brd-kpi-top">
|
||||
<span className="brd-kpi-icon">{icon}</span>
|
||||
<span className="brd-kpi-label">{label}</span>
|
||||
</div>
|
||||
<div className={`brd-kpi-value ${valueColor ?? ''}`}>{value}</div>
|
||||
<div className="brd-kpi-sub">{sub}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetricRow({ label, value, cls, note }: { label: string; value: string; cls?: string; note?: string }) {
|
||||
return (
|
||||
<div className="brd-metric-row">
|
||||
<span className="brd-metric-label">
|
||||
{label}
|
||||
{note && <span className="brd-metric-note">{note}</span>}
|
||||
</span>
|
||||
<span className={`brd-metric-value ${cls ?? ''}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WinRateCircle({ winRate, winning, total, centerLabel = '승률' }: {
|
||||
winRate: number;
|
||||
winning: number;
|
||||
total: number;
|
||||
centerLabel?: string;
|
||||
}) {
|
||||
const r = 28;
|
||||
const circ = 2 * Math.PI * r;
|
||||
const pct2 = Math.max(0, Math.min(1, winRate));
|
||||
const dash = pct2 * circ;
|
||||
return (
|
||||
<div className="brd-donut-wrap">
|
||||
<svg width="72" height="72" viewBox="0 0 72 72">
|
||||
<circle cx="36" cy="36" r={r} fill="none" stroke="var(--bg4)" strokeWidth="8" />
|
||||
<circle
|
||||
cx="36" cy="36" r={r} fill="none"
|
||||
stroke={pct2 >= 0.5 ? 'var(--brd-pos)' : 'var(--brd-neg)'}
|
||||
strokeWidth="8"
|
||||
strokeDasharray={`${dash} ${circ}`}
|
||||
strokeLinecap="round"
|
||||
strokeDashoffset={circ * 0.25}
|
||||
style={{ transform: 'rotate(-90deg)', transformOrigin: '50% 50%' }}
|
||||
/>
|
||||
<text x="36" y="33" textAnchor="middle" fontSize="11" fontWeight="800" fill="var(--text)">{pctAbs(winRate)}</text>
|
||||
<text x="36" y="45" textAnchor="middle" fontSize="8.5" fill="var(--text3)">{centerLabel}</text>
|
||||
</svg>
|
||||
<div className="brd-donut-detail">
|
||||
<span className="brd-pos">▲ {winning}</span>
|
||||
<span className="brd-neg">▼ {total - winning}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CompareBar({ labelA, valA, labelB, valB, colorA, colorB }: {
|
||||
labelA: string;
|
||||
valA: number;
|
||||
labelB: string;
|
||||
valB: number;
|
||||
colorA: string;
|
||||
colorB: string;
|
||||
}) {
|
||||
const max = Math.max(Math.abs(valA), Math.abs(valB), 0.01);
|
||||
const pctA2 = Math.min(100, Math.abs(valA) / max * 100);
|
||||
const pctB2 = Math.min(100, Math.abs(valB) / max * 100);
|
||||
return (
|
||||
<div className="brd-compare">
|
||||
<div className="brd-compare-row">
|
||||
<span className="brd-compare-label">{labelA}</span>
|
||||
<div className="brd-compare-track">
|
||||
<div className="brd-compare-bar" style={{ width: `${pctA2}%`, background: colorA }} />
|
||||
</div>
|
||||
<span className="brd-compare-val" style={{ color: colorA }}>{pct(valA)}</span>
|
||||
</div>
|
||||
<div className="brd-compare-row">
|
||||
<span className="brd-compare-label">{labelB}</span>
|
||||
<div className="brd-compare-track">
|
||||
<div className="brd-compare-bar" style={{ width: `${pctB2}%`, background: colorB }} />
|
||||
</div>
|
||||
<span className="brd-compare-val" style={{ color: colorB }}>{pct(valB)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResourceBar({ label, pctFill, cls }: { label: string; pctFill: number; cls: string }) {
|
||||
const w = Math.min(100, Math.max(0, pctFill));
|
||||
return (
|
||||
<div className="brd-pnl-row">
|
||||
<span className={`brd-pnl-label ${cls}`}>{label}</span>
|
||||
<div className="brd-pnl-track">
|
||||
<div className={`brd-pnl-bar ${cls === 'brd-pos' ? 'brd-pnl-bar--pos' : 'brd-pnl-bar--neg'}`} style={{ width: `${w}%` }} />
|
||||
</div>
|
||||
<span className={`brd-pnl-val ${cls}`}>{w.toFixed(0)}%</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
/* GoldenChart 대시보드 — 투자분석 레포트형 레이아웃 */
|
||||
|
||||
.gc-dash-report-page {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
background: linear-gradient(160deg, #a8adb5 0%, #8b919a 100%);
|
||||
padding: 16px 20px 28px;
|
||||
}
|
||||
|
||||
.gc-dash-report-sheet {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 14mm 12mm 16mm;
|
||||
background: #fff;
|
||||
color: #111827;
|
||||
border-radius: 2px;
|
||||
box-shadow:
|
||||
0 1px 3px rgba(0, 0, 0, 0.12),
|
||||
0 8px 24px rgba(0, 0, 0, 0.18);
|
||||
font-family: 'Apple SD Gothic Neo', 'Malgun Gothic', 'Noto Sans KR', sans-serif;
|
||||
letter-spacing: normal;
|
||||
word-break: keep-all;
|
||||
line-height: 1.45;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
|
||||
--text: #111827;
|
||||
--text2: #374151;
|
||||
--text3: #6b7280;
|
||||
--bg1: #ffffff;
|
||||
--bg2: #f9fafb;
|
||||
--bg3: #f3f4f6;
|
||||
--bg4: #e5e7eb;
|
||||
--border: #d1d5db;
|
||||
--brd-pos: #059669;
|
||||
--brd-neg: #dc2626;
|
||||
--brd-warn: #d97706;
|
||||
--brd-blue: #2563eb;
|
||||
--brd-purple: #7c3aed;
|
||||
--brd-teal: #0d9488;
|
||||
--brd-yellow: #ca8a04;
|
||||
--brd-green: #16a34a;
|
||||
}
|
||||
|
||||
.gc-dash-report-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid #c9a227;
|
||||
}
|
||||
|
||||
.gc-dash-report-brand h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 800;
|
||||
color: #111827;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.gc-dash-report-meta {
|
||||
margin: 0;
|
||||
font-size: 0.7rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.gc-dash-report-actions {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.gc-dash-report-refresh {
|
||||
border: 1px solid #d1d5db;
|
||||
background: #f9fafb;
|
||||
color: #374151;
|
||||
border-radius: 8px;
|
||||
padding: 6px 14px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.gc-dash-report-refresh:hover:not(:disabled) {
|
||||
background: #f3f4f6;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
.gc-dash-report-refresh:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.gc-dash-report-sheet .brd-dashboard {
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
min-height: 0;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.gc-dash-report-sheet .brd-dash-header {
|
||||
background: linear-gradient(135deg, #eff6ff 0%, #f5f3ff 100%);
|
||||
border: 1px solid #bfdbfe;
|
||||
}
|
||||
|
||||
.gc-dash-report-sheet .brd-dash-badge {
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
border-color: #93c5fd;
|
||||
}
|
||||
|
||||
.gc-dash-report-sheet .brd-dash-badge--time {
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.gc-dash-report-sheet .brd-kpi-card {
|
||||
background: #fff;
|
||||
border-color: #e5e7eb;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.gc-dash-report-sheet .brd-kpi-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.gc-dash-report-sheet .brd-card {
|
||||
background: #fff;
|
||||
border-color: #e5e7eb;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.gc-dash-report-sheet .brd-section-title {
|
||||
background: #f9fafb;
|
||||
border-bottom-color: #e5e7eb;
|
||||
}
|
||||
|
||||
.gc-dash-report-sheet .brd-sig-table th {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.gc-dash-report-sheet .brd-sig-even {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.gc-dash-report-error {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto 12px;
|
||||
padding: 10px 14px;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
color: #b91c1c;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.gc-dash-report-loading {
|
||||
max-width: 1100px;
|
||||
margin: 40px auto;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.gc-dash-proc-mini-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 8px;
|
||||
padding: 8px 12px 10px;
|
||||
}
|
||||
|
||||
.gc-dash-proc-mini {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.gc-dash-proc-mini--ok { border-top: 2px solid var(--brd-pos); }
|
||||
.gc-dash-proc-mini--warn { border-top: 2px solid var(--brd-yellow); }
|
||||
.gc-dash-proc-mini--down { border-top: 2px solid var(--brd-neg); }
|
||||
.gc-dash-proc-mini--off { border-top: 2px solid #9ca3af; }
|
||||
|
||||
.gc-dash-proc-mini-name {
|
||||
margin: 0 0 4px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.gc-dash-proc-mini-badge {
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
padding: 1px 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.gc-dash-spark-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
padding: 0 0 4px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.gc-dash-spark-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.gc-dash-spark-box {
|
||||
padding: 8px 10px 4px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.gc-dash-stale-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 8px 12px 10px;
|
||||
}
|
||||
|
||||
.gc-dash-stale-chip {
|
||||
border: 1px solid #fde68a;
|
||||
background: #fffbeb;
|
||||
color: #92400e;
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
font-size: 0.68rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.gc-dash-stale-chip:hover {
|
||||
background: #fef3c7;
|
||||
}
|
||||
Reference in New Issue
Block a user