StockTrade 탭 컴포넌트 분리 (Phase 5+6): 1,932→210줄
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>
This commit is contained in:
384
src/pages/stock/components/ReportTab.jsx
Normal file
384
src/pages/stock/components/ReportTab.jsx
Normal file
@@ -0,0 +1,384 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user