5개 탭/드로어 컴포넌트를 components/ 디렉토리로 추출: - PortfolioTab: 포트폴리오 관리, 예수금, 자산추이 차트 - AiTradeTab: AI 모의투자 잔고, 수동주문, KIS 모달 - ReportTab: 차트, 리스크 분석, 수익률 랭킹, AI 코치 - AdvisorTab: 프롬프트 빌더, 클립보드 복사 - SellHistoryDrawer: 실현손익 드로어, 필터, 폼 StockTrade.jsx는 210줄 오케스트레이터로 축소 (hooks 호출 + lazy load + 헤더 + 탭 바 + 탭 렌더) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
385 lines
21 KiB
JavaScript
385 lines
21 KiB
JavaScript
import React from 'react';
|
||
import Loading from '../../../components/Loading';
|
||
import {
|
||
PieChart, Pie, Cell,
|
||
BarChart, Bar, XAxis, YAxis, CartesianGrid,
|
||
Tooltip as ChartTooltip, Legend, ResponsiveContainer,
|
||
} from 'recharts';
|
||
import {
|
||
formatNumber, formatPercent, toNumeric,
|
||
CHART_COLORS, profitColorClass, getVixLabel, getFgLabel,
|
||
} from '../stockUtils';
|
||
|
||
const ReportTab = ({ pf, report, ai, marketCtx }) => (
|
||
<>
|
||
{pf.portfolioLoading && (
|
||
<div style={{ display: 'flex', justifyContent: 'center', padding: 24 }}>
|
||
<Loading type="spinner" message="포트폴리오 로딩 중..." />
|
||
</div>
|
||
)}
|
||
{pf.portfolioError && <p className="stock-error">{pf.portfolioError}</p>}
|
||
|
||
{/* 자산 배분 + 수익률 차트 */}
|
||
{pf.portfolioHoldings.length > 0 && (
|
||
<section className="stock-panel stock-panel--wide">
|
||
<div className="stock-panel__head">
|
||
<div>
|
||
<p className="stock-panel__eyebrow">포트폴리오 분석</p>
|
||
<h3>자산 배분 현황</h3>
|
||
</div>
|
||
</div>
|
||
<div className="report-charts-row">
|
||
<div className="report-chart-box">
|
||
<p className="report-chart-title">증권사별 자산 배분</p>
|
||
<ResponsiveContainer width="100%" height={220}>
|
||
<PieChart>
|
||
<Pie
|
||
data={report.brokerPieData}
|
||
cx="50%"
|
||
cy="50%"
|
||
innerRadius={52}
|
||
outerRadius={84}
|
||
dataKey="value"
|
||
paddingAngle={2}
|
||
>
|
||
{report.brokerPieData.map((_, i) => (
|
||
<Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
|
||
))}
|
||
</Pie>
|
||
<ChartTooltip
|
||
formatter={(v) => [formatNumber(v) + '원', '평가금액']}
|
||
contentStyle={{ background: '#1e293b', border: 'none', borderRadius: 8, fontSize: 12 }}
|
||
/>
|
||
<Legend
|
||
iconType="circle"
|
||
iconSize={8}
|
||
formatter={(v) => <span style={{ color: '#9ca3af', fontSize: 12 }}>{v}</span>}
|
||
/>
|
||
</PieChart>
|
||
</ResponsiveContainer>
|
||
</div>
|
||
<div className="report-chart-box">
|
||
<p className="report-chart-title">종목별 수익률 (%)</p>
|
||
<ResponsiveContainer width="100%" height={220}>
|
||
<BarChart data={report.profitBarData} margin={{ top: 0, right: 8, left: -16, bottom: 48 }}>
|
||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
|
||
<XAxis
|
||
dataKey="name"
|
||
tick={{ fill: '#9ca3af', fontSize: 10 }}
|
||
angle={-40}
|
||
textAnchor="end"
|
||
interval={0}
|
||
/>
|
||
<YAxis
|
||
tick={{ fill: '#9ca3af', fontSize: 10 }}
|
||
tickFormatter={(v) => `${v}%`}
|
||
/>
|
||
<ChartTooltip
|
||
formatter={(v, _n, props) => [`${v.toFixed(2)}%`, props.payload.fullName]}
|
||
contentStyle={{ background: '#1e293b', border: 'none', borderRadius: 8, fontSize: 12 }}
|
||
/>
|
||
<Bar dataKey="rate" radius={[4, 4, 0, 0]}>
|
||
{report.profitBarData.map((entry, i) => (
|
||
<Cell key={i} fill={entry.rate >= 0 ? '#34d399' : '#f87171'} />
|
||
))}
|
||
</Bar>
|
||
</BarChart>
|
||
</ResponsiveContainer>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* 리스크 분산 분석 */}
|
||
{pf.portfolioHoldings.length > 0 && pf.portfolioSummary.total_eval != null && (
|
||
<section className="stock-panel stock-panel--wide">
|
||
<div className="stock-panel__head">
|
||
<div>
|
||
<p className="stock-panel__eyebrow">리스크 관리</p>
|
||
<h3>분산 분석</h3>
|
||
<p className="stock-panel__sub">증권사·종목 집중도를 확인합니다. 단일 비중 40% 초과 시 주의.</p>
|
||
</div>
|
||
</div>
|
||
<div className="risk-grid">
|
||
<div className="risk-card">
|
||
<p className="risk-card__title">증권사별 집중도</p>
|
||
{report.brokerConcentration.length === 0 ? (
|
||
<p className="stock-empty" style={{ fontSize: 13 }}>평가금액 데이터 없음</p>
|
||
) : (
|
||
<>
|
||
{report.brokerConcentration.some((b) => b.ratio > 40) && (
|
||
<div className="risk-warning">
|
||
⚠️ 단일 증권사 집중도가 40%를 초과합니다
|
||
</div>
|
||
)}
|
||
{report.brokerConcentration.map(({ broker, eval: evalAmt, ratio }) => {
|
||
const level = ratio >= 60 ? 'is-danger' : ratio >= 40 ? 'is-warn' : 'is-ok';
|
||
return (
|
||
<div key={broker} className="risk-item">
|
||
<div className="risk-item__head">
|
||
<span className="risk-item__name">{broker}</span>
|
||
<span className={`risk-item__ratio ${level}`}>{ratio.toFixed(1)}%</span>
|
||
</div>
|
||
<div className="risk-bar">
|
||
<div className={`risk-bar__fill ${level}`} style={{ width: `${Math.min(ratio, 100)}%` }} />
|
||
</div>
|
||
<span style={{ fontSize: 11, color: 'var(--muted)' }}>{formatNumber(evalAmt)}원</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</>
|
||
)}
|
||
</div>
|
||
<div className="risk-card">
|
||
<p className="risk-card__title">상위 5 종목 집중도</p>
|
||
{report.stockConcentration.length === 0 ? (
|
||
<p className="stock-empty" style={{ fontSize: 13 }}>현재가 데이터 없음</p>
|
||
) : (
|
||
<>
|
||
{report.stockConcentration.some((s) => s.ratio > 40) && (
|
||
<div className="risk-warning">
|
||
⚠️ 단일 종목 집중도가 40%를 초과합니다
|
||
</div>
|
||
)}
|
||
{report.stockConcentration.map(({ name, ticker, eval: evalAmt, ratio }) => {
|
||
const level = ratio >= 60 ? 'is-danger' : ratio >= 40 ? 'is-warn' : 'is-ok';
|
||
return (
|
||
<div key={ticker || name} className="risk-item">
|
||
<div className="risk-item__head">
|
||
<span className="risk-item__name">{name}</span>
|
||
<span className={`risk-item__ratio ${level}`}>{ratio.toFixed(1)}%</span>
|
||
</div>
|
||
<div className="risk-bar">
|
||
<div className={`risk-bar__fill ${level}`} style={{ width: `${Math.min(ratio, 100)}%` }} />
|
||
</div>
|
||
<span style={{ fontSize: 11, color: 'var(--muted)' }}>
|
||
{ticker && <span style={{ marginRight: 6 }}>{ticker}</span>}
|
||
{formatNumber(evalAmt)}원
|
||
</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* 수익률 랭킹 테이블 */}
|
||
{pf.portfolioHoldings.length > 0 && (
|
||
<section className="stock-panel stock-panel--wide">
|
||
<div className="stock-panel__head">
|
||
<div>
|
||
<p className="stock-panel__eyebrow">수익률 랭킹</p>
|
||
<h3>종목별 상세 현황</h3>
|
||
<p className="stock-panel__sub">헤더 클릭으로 정렬 · 비중은 총 평가금액 대비</p>
|
||
</div>
|
||
</div>
|
||
<div className="report-table-wrapper">
|
||
<table className="report-table">
|
||
<thead>
|
||
<tr>
|
||
{[
|
||
{ key: 'name', label: '종목명' },
|
||
{ key: 'broker', label: '증권사' },
|
||
{ key: 'profit_rate', label: '수익률' },
|
||
{ key: 'profit_amount', label: '평가손익' },
|
||
{ key: 'eval_amount', label: '평가금액' },
|
||
].map(({ key, label }) => (
|
||
<th key={key} onClick={() => report.handleReportSort(key)}>
|
||
{label}{' '}
|
||
<span className="report-sort-icon">
|
||
{report.reportSortField === key
|
||
? report.reportSortDir === 'asc' ? '↑' : '↓'
|
||
: '↕'}
|
||
</span>
|
||
</th>
|
||
))}
|
||
<th style={{ cursor: 'default' }}>비중</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{report.sortedHoldings.map((item) => {
|
||
const rateN = toNumeric(item.profit_rate);
|
||
const pnlN = toNumeric(item.profit_amount);
|
||
const evalAmt = item.eval_amount != null
|
||
? item.eval_amount
|
||
: item.current_price != null
|
||
? item.current_price * item.quantity
|
||
: null;
|
||
const totalEvalVal = toNumeric(pf.portfolioSummary.total_eval);
|
||
const weight = evalAmt != null && totalEvalVal
|
||
? Math.round((evalAmt / totalEvalVal) * 1000) / 10
|
||
: null;
|
||
return (
|
||
<tr key={item.id}>
|
||
<td>
|
||
<p className="report-table-name">{item.name ?? item.ticker ?? 'N/A'}</p>
|
||
<span className="report-table-code">{item.ticker ?? ''}</span>
|
||
</td>
|
||
<td className="report-td-muted">{item.broker ?? '-'}</td>
|
||
<td className={`stock-profit ${profitColorClass(rateN)}`}>
|
||
<div className="report-rate-cell">
|
||
<span>{item.profit_rate != null ? formatPercent(item.profit_rate) : '-'}</span>
|
||
{rateN != null && (
|
||
<div className="report-rate-bar">
|
||
<div
|
||
className={`report-rate-bar__fill ${rateN >= 0 ? 'is-up' : 'is-down'}`}
|
||
style={{ width: `${report.maxAbsRate > 0 ? Math.abs(rateN) / report.maxAbsRate * 100 : 0}%` }}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</td>
|
||
<td className={`stock-profit ${profitColorClass(pnlN)}`}>
|
||
{item.profit_amount != null ? formatNumber(item.profit_amount) : '-'}
|
||
</td>
|
||
<td className="report-td-muted">
|
||
{evalAmt != null ? formatNumber(evalAmt) : '-'}
|
||
</td>
|
||
<td className="report-td-muted">
|
||
{weight != null ? `${weight.toFixed(1)}%` : '-'}
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{pf.portfolioLoaded && pf.portfolioHoldings.length === 0 && !pf.portfolioError && (
|
||
<section className="stock-panel stock-panel--wide">
|
||
<p className="stock-empty" style={{ textAlign: 'center', padding: 24 }}>
|
||
등록된 종목이 없습니다. <strong>쟁승토리 계좌</strong> 탭에서 종목을 먼저 등록하세요.
|
||
</p>
|
||
</section>
|
||
)}
|
||
|
||
{/* AI 투자 코치 */}
|
||
<section className="stock-panel stock-panel--wide">
|
||
<div className="stock-panel__head">
|
||
<div>
|
||
<p className="stock-panel__eyebrow">AI 투자 코치</p>
|
||
<h3>오늘의 투자 평가</h3>
|
||
<p className="stock-panel__sub">
|
||
포트폴리오를 AI가 분석하여 성취도 등급과 내일을 위한 투자 조언을 드립니다.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 시장 컨텍스트 미니 패널 */}
|
||
{marketCtx && (
|
||
<div className="ai-market-ctx">
|
||
<span className="ai-market-ctx__label">시장 환경</span>
|
||
<div className="ai-market-ctx__chips">
|
||
{marketCtx.vix != null && (
|
||
<span className="ai-market-chip">
|
||
VIX <strong>{marketCtx.vix}</strong>
|
||
<em>{getVixLabel(marketCtx.vix)}</em>
|
||
</span>
|
||
)}
|
||
{marketCtx.fg != null && (
|
||
<span className="ai-market-chip">
|
||
F&G <strong>{marketCtx.fg}</strong>
|
||
<em>{getFgLabel(marketCtx.fg)}</em>
|
||
</span>
|
||
)}
|
||
{marketCtx.treasury != null && (
|
||
<span className="ai-market-chip">
|
||
10년물 <strong>{marketCtx.treasury}%</strong>
|
||
</span>
|
||
)}
|
||
{marketCtx.wti != null && (
|
||
<span className="ai-market-chip">
|
||
WTI <strong>${marketCtx.wti}</strong>
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 모델 선택 */}
|
||
<div className="ai-coach-settings">
|
||
<label>
|
||
AI 모델
|
||
<select
|
||
value={ai.aiModel}
|
||
onChange={(e) => {
|
||
ai.setAiModel(e.target.value);
|
||
localStorage.setItem('ai_coach_model', e.target.value);
|
||
}}
|
||
>
|
||
<option value="claude-haiku-4-5-20251001">Claude Haiku (빠름·저렴)</option>
|
||
<option value="claude-sonnet-4-6">Claude Sonnet (고성능)</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="ai-coach-actions">
|
||
<button
|
||
className="button primary"
|
||
type="button"
|
||
onClick={ai.handleAiCoach}
|
||
disabled={ai.aiLoading || pf.portfolioHoldings.length === 0}
|
||
>
|
||
{ai.aiLoading ? 'AI 분석 중...' : '오늘 투자 평가 받기'}
|
||
</button>
|
||
{pf.portfolioHoldings.length === 0 && (
|
||
<span className="ai-coach-note">종목 등록 후 이용 가능합니다.</span>
|
||
)}
|
||
{ai.aiResult?.generated_at && (
|
||
<span className="ai-coach-note">
|
||
{ai.aiResult.cached ? '오늘 캐시 결과 · ' : ''}
|
||
{new Date(ai.aiResult.generated_at).toLocaleTimeString('ko-KR')} 생성
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{ai.aiError && <p className="stock-error" style={{ marginTop: 8 }}>{ai.aiError}</p>}
|
||
|
||
{ai.aiResult && !ai.aiLoading && (
|
||
<div className="ai-coach-result">
|
||
<div className="ai-coach-header">
|
||
<div className={`ai-grade-badge grade-${(ai.aiResult.grade ?? 'c').toLowerCase()}`}>
|
||
{ai.aiResult.grade ?? '?'}
|
||
</div>
|
||
<div className="ai-score-wrap">
|
||
<span className="ai-score-num">{ai.aiResult.score ?? 0}</span>
|
||
<span className="ai-score-unit">/ 100</span>
|
||
</div>
|
||
<p className="ai-summary-text">{ai.aiResult.summary}</p>
|
||
</div>
|
||
<p className="ai-evaluation-text">{ai.aiResult.evaluation}</p>
|
||
{ai.aiResult.advice?.length > 0 && (
|
||
<div className="ai-advice-list">
|
||
{ai.aiResult.advice.map((a, i) => (
|
||
<div key={i} className="ai-advice-card">
|
||
<p className="ai-advice-title">{a.title}</p>
|
||
<p className="ai-advice-body">{a.body}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
<button
|
||
className="button ghost small"
|
||
type="button"
|
||
style={{ marginTop: 16, fontSize: 11 }}
|
||
onClick={() => {
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
localStorage.removeItem(`ai_coach_${today}`);
|
||
ai.setAiResult(null);
|
||
}}
|
||
>
|
||
다시 평가받기 (캐시 삭제)
|
||
</button>
|
||
</div>
|
||
)}
|
||
</section>
|
||
</>
|
||
);
|
||
|
||
export default ReportTab;
|