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:
2026-04-03 07:37:30 +09:00
parent 1b16b40251
commit 2b463682d5
6 changed files with 1698 additions and 1781 deletions

View 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&nbsp;<strong>{marketCtx.vix}</strong>
<em>{getVixLabel(marketCtx.vix)}</em>
</span>
)}
{marketCtx.fg != null && (
<span className="ai-market-chip">
F&amp;G&nbsp;<strong>{marketCtx.fg}</strong>
<em>{getFgLabel(marketCtx.fg)}</em>
</span>
)}
{marketCtx.treasury != null && (
<span className="ai-market-chip">
10년물&nbsp;<strong>{marketCtx.treasury}%</strong>
</span>
)}
{marketCtx.wti != null && (
<span className="ai-market-chip">
WTI&nbsp;<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;