Files
web-page/src/pages/stock/components/ReportTab.jsx
gahusb 2b463682d5 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>
2026-04-03 07:37:30 +09:00

385 lines
21 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;