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:
File diff suppressed because it is too large
Load Diff
72
src/pages/stock/components/AdvisorTab.jsx
Normal file
72
src/pages/stock/components/AdvisorTab.jsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import Loading from '../../../components/Loading';
|
||||
import { formatNumber } from '../stockUtils';
|
||||
|
||||
const AdvisorTab = ({ pf, advisor }) => (
|
||||
<section className="stock-panel stock-panel--wide advisor-panel">
|
||||
<div className="advisor-panel__head">
|
||||
<div className="advisor-panel__title-block">
|
||||
<span className="advisor-panel__badge">AI 어드바이저</span>
|
||||
<h3 className="advisor-panel__title">포트폴리오 분석 프롬프트</h3>
|
||||
<p className="advisor-panel__sub">
|
||||
보유 종목 정보를 담은 전문가용 프롬프트를 생성합니다.
|
||||
복사 후 Gemini, ChatGPT 등에 붙여넣어 분석을 받아보세요.
|
||||
</p>
|
||||
</div>
|
||||
<div className="advisor-panel__actions">
|
||||
<a
|
||||
href="https://gemini.google.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="button ghost small"
|
||||
>
|
||||
Gemini 열기 ↗
|
||||
</a>
|
||||
<a
|
||||
href="https://chatgpt.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="button ghost small"
|
||||
>
|
||||
ChatGPT 열기 ↗
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pf.portfolioLoading && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 24 }}>
|
||||
<Loading type="spinner" message="포트폴리오 로딩 중..." />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!pf.portfolioLoading && pf.portfolioHoldings.length === 0 && (
|
||||
<div className="advisor-panel__empty">
|
||||
<span className="advisor-panel__empty-icon">📋</span>
|
||||
<p>포트폴리오 탭에서 보유 종목을 먼저 등록해주세요.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!pf.portfolioLoading && pf.portfolioHoldings.length > 0 && (
|
||||
<div className="advisor-panel__body">
|
||||
<div className="advisor-prompt__toolbar">
|
||||
<span className="advisor-prompt__info">
|
||||
종목 {pf.portfolioHoldings.length}개 · 총 자산 {pf.totalAssets != null ? formatNumber(pf.totalAssets) + '원' : '미집계'}
|
||||
</span>
|
||||
<button
|
||||
className={`button primary small ${advisor.advisorCopied ? 'is-copied' : ''}`}
|
||||
onClick={advisor.handleCopyPrompt}
|
||||
>
|
||||
{advisor.advisorCopied ? '✅ 복사됨' : '📋 프롬프트 복사'}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="advisor-prompt__preview">{advisor.buildAdvisorPrompt()}</pre>
|
||||
<p className="advisor-panel__disclaimer">
|
||||
※ 이 프롬프트를 AI에 붙여넣으면 전문가 관점의 매매 조언을 받을 수 있습니다.
|
||||
투자 결정은 최종적으로 본인의 판단과 책임 하에 이루어져야 합니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
export default AdvisorTab;
|
||||
220
src/pages/stock/components/AiTradeTab.jsx
Normal file
220
src/pages/stock/components/AiTradeTab.jsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
formatNumber, formatPercent,
|
||||
getQty, getBuyPrice, getCurrentPrice, getProfitRate, getProfitLoss,
|
||||
toNumeric, profitColorClass,
|
||||
} from '../stockUtils';
|
||||
|
||||
const AiTradeTab = ({ aib }) => (
|
||||
<>
|
||||
{aib.balanceError ? <p className="stock-error">{aib.balanceError}</p> : null}
|
||||
|
||||
{/* AI Balance section */}
|
||||
<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 className="stock-panel__actions">
|
||||
{aib.balanceLoading ? (
|
||||
<span className="stock-chip">조회 중</span>
|
||||
) : null}
|
||||
<button
|
||||
className="button ghost small"
|
||||
onClick={aib.loadBalance}
|
||||
disabled={aib.balanceLoading}
|
||||
>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stock-balance">
|
||||
<div className="stock-balance__summary">
|
||||
{[
|
||||
{ label: '총 평가', value: aib.totalEval },
|
||||
{ label: '예수금', value: aib.deposit },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="stock-balance__card">
|
||||
<span>{item.label}</span>
|
||||
<strong>{formatNumber(item.value)}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{aib.holdings.length ? (
|
||||
<div className="stock-holdings">
|
||||
{aib.holdings.map((item, idx) => {
|
||||
const profitLoss = getProfitLoss(item);
|
||||
const profitLossNumeric = toNumeric(profitLoss);
|
||||
const profitClass = profitColorClass(profitLossNumeric);
|
||||
const profitRate = getProfitRate(item);
|
||||
const profitRateNumeric = toNumeric(profitRate);
|
||||
const profitRateClass = profitColorClass(profitRateNumeric);
|
||||
return (
|
||||
<div
|
||||
key={item.code ?? `${item.name}-${idx}`}
|
||||
className="stock-holdings__item"
|
||||
>
|
||||
<div>
|
||||
<p className="stock-holdings__name">
|
||||
{item.name ?? item.code ?? 'N/A'}
|
||||
</p>
|
||||
<span className="stock-holdings__code">
|
||||
{item.code ?? ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="stock-holdings__metric">
|
||||
<span>수량</span>
|
||||
<strong>{formatNumber(getQty(item))}</strong>
|
||||
</div>
|
||||
<div className="stock-holdings__metric">
|
||||
<span>매입가</span>
|
||||
<strong>{formatNumber(getBuyPrice(item))}</strong>
|
||||
</div>
|
||||
<div className="stock-holdings__metric">
|
||||
<span>현재가</span>
|
||||
<strong>{formatNumber(getCurrentPrice(item))}</strong>
|
||||
</div>
|
||||
<div className="stock-holdings__metric">
|
||||
<span>평가금액</span>
|
||||
<strong>
|
||||
{getCurrentPrice(item) != null && getQty(item) != null
|
||||
? formatNumber(toNumeric(getCurrentPrice(item)) * toNumeric(getQty(item)))
|
||||
: '-'}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="stock-holdings__metric">
|
||||
<span>수익률</span>
|
||||
<strong className={`stock-profit ${profitRateClass}`}>
|
||||
{formatPercent(profitRate)}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="stock-holdings__metric">
|
||||
<span>평가손익</span>
|
||||
<strong className={`stock-profit ${profitClass}`}>
|
||||
{formatNumber(profitLoss)}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="stock-empty">보유 종목이 없습니다.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Manual order section */}
|
||||
<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>
|
||||
<form className="stock-order" onSubmit={aib.submitManualOrder}>
|
||||
<label>
|
||||
종목명/코드
|
||||
<input
|
||||
type="text"
|
||||
value={aib.manualForm.code}
|
||||
onChange={(e) =>
|
||||
aib.setManualForm((prev) => ({ ...prev, code: e.target.value }))
|
||||
}
|
||||
placeholder="005930 또는 삼성전자"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
매수/매도
|
||||
<select
|
||||
value={aib.manualForm.type}
|
||||
onChange={(e) =>
|
||||
aib.setManualForm((prev) => ({ ...prev, type: e.target.value }))
|
||||
}
|
||||
>
|
||||
<option value="buy">매수</option>
|
||||
<option value="sell">매도</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
수량
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={aib.manualForm.qty}
|
||||
onChange={(e) =>
|
||||
aib.setManualForm((prev) => ({ ...prev, qty: Number(e.target.value) }))
|
||||
}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
금액(원)
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={aib.manualForm.price}
|
||||
onChange={(e) =>
|
||||
aib.setManualForm((prev) => ({ ...prev, price: Number(e.target.value) }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="button primary"
|
||||
type="submit"
|
||||
disabled={aib.manualLoading}
|
||||
>
|
||||
{aib.manualLoading ? '요청 중...' : '주문 요청'}
|
||||
</button>
|
||||
{aib.manualError ? (
|
||||
<p className="stock-error">{aib.manualError}</p>
|
||||
) : null}
|
||||
{aib.manualResult ? (
|
||||
<div className="stock-result">
|
||||
<p className="stock-result__title">요청 결과</p>
|
||||
<pre>
|
||||
{typeof aib.manualResult === 'string'
|
||||
? aib.manualResult
|
||||
: JSON.stringify(aib.manualResult, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* KIS modal */}
|
||||
{aib.kisModal ? (
|
||||
<div className="stock-modal" role="dialog" aria-modal="true">
|
||||
<div
|
||||
className="stock-modal__backdrop"
|
||||
onClick={() => aib.setKisModal('')}
|
||||
/>
|
||||
<div className="stock-modal__card">
|
||||
<div className="stock-modal__head">
|
||||
<h4>주문 결과</h4>
|
||||
<button
|
||||
type="button"
|
||||
className="button ghost small"
|
||||
onClick={() => aib.setKisModal('')}
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
<pre>{aib.kisModal}</pre>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
export default AiTradeTab;
|
||||
609
src/pages/stock/components/PortfolioTab.jsx
Normal file
609
src/pages/stock/components/PortfolioTab.jsx
Normal file
@@ -0,0 +1,609 @@
|
||||
import React from 'react';
|
||||
import Loading from '../../../components/Loading';
|
||||
import {
|
||||
ResponsiveContainer, AreaChart, Area, XAxis, YAxis,
|
||||
Tooltip as ChartTooltip,
|
||||
} from 'recharts';
|
||||
import { formatNumber, formatPercent, toNumeric, profitColorClass } from '../stockUtils';
|
||||
|
||||
const PortfolioTab = ({ pf, asset, handleSell, handleSaveSnapshot }) => (
|
||||
<>
|
||||
{pf.portfolioError ? (
|
||||
<p className="stock-error">{pf.portfolioError}</p>
|
||||
) : null}
|
||||
|
||||
{/* 포트폴리오 관리 헤더 + 추가 폼 */}
|
||||
<section className="stock-panel stock-panel--wide pf-section">
|
||||
<div className="stock-panel__head">
|
||||
<div>
|
||||
<p className="stock-panel__eyebrow">포트폴리오</p>
|
||||
<h3>수동 입력 종목 관리</h3>
|
||||
<p className="stock-panel__sub">
|
||||
증권사별 보유 종목을 수동 등록하면 현재가를 자동 조회합니다. (3분 캐시)
|
||||
</p>
|
||||
</div>
|
||||
<div className="stock-panel__actions">
|
||||
{pf.portfolioLoading ? (
|
||||
<Loading type="spinner" message="" />
|
||||
) : null}
|
||||
<button
|
||||
className="button ghost small"
|
||||
onClick={pf.loadPortfolio}
|
||||
disabled={pf.portfolioLoading}
|
||||
>
|
||||
새로고침
|
||||
</button>
|
||||
<button
|
||||
className="button primary small"
|
||||
onClick={() => pf.setAddFormOpen((v) => !v)}
|
||||
>
|
||||
{pf.addFormOpen ? '취소' : '+ 종목 추가'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add form */}
|
||||
{pf.addFormOpen && (
|
||||
<form className="pf-add-form" onSubmit={pf.handleAddSubmit}>
|
||||
<label>
|
||||
증권사
|
||||
<input
|
||||
type="text"
|
||||
value={pf.addForm.broker}
|
||||
onChange={(e) =>
|
||||
pf.setAddForm((p) => ({ ...p, broker: e.target.value }))
|
||||
}
|
||||
placeholder="KB증권"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
종목코드
|
||||
<input
|
||||
type="text"
|
||||
value={pf.addForm.ticker}
|
||||
onChange={(e) =>
|
||||
pf.setAddForm((p) => ({ ...p, ticker: e.target.value }))
|
||||
}
|
||||
placeholder="005930"
|
||||
required
|
||||
maxLength={6}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
종목명
|
||||
<input
|
||||
type="text"
|
||||
value={pf.addForm.name}
|
||||
onChange={(e) =>
|
||||
pf.setAddForm((p) => ({ ...p, name: e.target.value }))
|
||||
}
|
||||
placeholder="삼성전자"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
수량
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={pf.addForm.quantity}
|
||||
onChange={(e) =>
|
||||
pf.setAddForm((p) => ({ ...p, quantity: e.target.value }))
|
||||
}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
평균 매입가 (원)
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={pf.addForm.avg_price}
|
||||
onChange={(e) =>
|
||||
pf.setAddForm((p) => ({ ...p, avg_price: e.target.value }))
|
||||
}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="button primary"
|
||||
type="submit"
|
||||
disabled={pf.addLoading}
|
||||
>
|
||||
{pf.addLoading ? '등록 중...' : '종목 등록'}
|
||||
</button>
|
||||
{pf.addError && <p className="stock-error">{pf.addError}</p>}
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Portfolio total summary */}
|
||||
{pf.portfolioHoldings.length > 0 && (
|
||||
<div className="pf-total-summary">
|
||||
{[
|
||||
{ label: '총 매입', value: pf.portfolioSummary.total_buy },
|
||||
{ label: '총 평가', value: pf.portfolioSummary.total_eval },
|
||||
{ label: '총 손익', value: pf.portfolioSummary.total_profit, isProfit: true },
|
||||
{ label: '수익률', value: pf.portfolioSummary.total_profit_rate, isRate: true },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="pf-total-summary__card">
|
||||
<span>{s.label}</span>
|
||||
<strong
|
||||
className={
|
||||
s.isProfit || s.isRate
|
||||
? `stock-profit ${profitColorClass(toNumeric(s.value))}`
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{s.isRate ? formatPercent(s.value) : formatNumber(s.value)}
|
||||
</strong>
|
||||
</div>
|
||||
))}
|
||||
{pf.totalCash != null && (
|
||||
<div className="pf-total-summary__card is-cash">
|
||||
<span>예수금 합계</span>
|
||||
<strong>{formatNumber(pf.totalCash)}원</strong>
|
||||
</div>
|
||||
)}
|
||||
{pf.totalAssets != null && (
|
||||
<div className="pf-total-summary__card is-assets">
|
||||
<span>총 자산</span>
|
||||
<strong>{formatNumber(pf.totalAssets)}원</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 자산 추이 차트 */}
|
||||
<div className="pf-asset-history">
|
||||
<div className="pf-asset-history__head">
|
||||
<p className="pf-asset-history__title">총 자산 추이</p>
|
||||
<div className="pf-asset-history__controls">
|
||||
{[
|
||||
{ label: '7일', value: 7 },
|
||||
{ label: '30일', value: 30 },
|
||||
{ label: '90일', value: 90 },
|
||||
{ label: '전체', value: 0 },
|
||||
].map(({ label, value }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className={`pf-asset-period-btn ${asset.assetHistoryDays === value ? 'is-active' : ''}`}
|
||||
onClick={() => asset.setAssetHistoryDays(value)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="button ghost small"
|
||||
onClick={handleSaveSnapshot}
|
||||
disabled={asset.snapshotSaving || pf.totalAssets == null}
|
||||
title="현재 총 자산을 오늘 날짜로 저장"
|
||||
>
|
||||
{asset.snapshotSaving ? '저장 중...' : '📸 스냅샷'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{asset.assetHistoryLoading ? (
|
||||
<div className="pf-asset-history__empty">
|
||||
<Loading type="spinner" message="" />
|
||||
</div>
|
||||
) : Array.isArray(asset.assetHistory) && asset.assetHistory.length >= 1 ? (
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<AreaChart
|
||||
data={asset.assetHistory}
|
||||
margin={{ top: 8, right: 12, left: 0, bottom: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="assetGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#38bdf8" stopOpacity={0.25} />
|
||||
<stop offset="95%" stopColor="#38bdf8" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fill: 'var(--text-muted)', fontSize: 10 }}
|
||||
tickFormatter={(v) => v?.slice(5)}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis hide domain={['auto', 'auto']} />
|
||||
<ChartTooltip
|
||||
contentStyle={{
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
}}
|
||||
labelStyle={{ color: 'var(--text-dim)', marginBottom: 4 }}
|
||||
formatter={(v) => [`${new Intl.NumberFormat('ko-KR').format(v)}원`, '총 자산']}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="total_assets"
|
||||
stroke="#38bdf8"
|
||||
strokeWidth={2}
|
||||
fill="url(#assetGrad)"
|
||||
dot={false}
|
||||
activeDot={{ r: 4, fill: '#38bdf8' }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="pf-asset-history__empty">
|
||||
저장된 자산 추이 데이터가 없습니다. 📸 스냅샷 버튼으로 오늘 자산을 기록하세요.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 예수금 패널 */}
|
||||
<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>
|
||||
|
||||
{pf.cashList.length > 0 && (
|
||||
<div className="pf-cash-table">
|
||||
{pf.cashList.map((item) => {
|
||||
const isEditing = pf.cashEditingBroker === item.broker;
|
||||
return (
|
||||
<div key={item.id ?? item.broker} className="pf-cash-row">
|
||||
<span className="pf-cash-broker">{item.broker}</span>
|
||||
{isEditing ? (
|
||||
<input
|
||||
className="pf-cash-edit-input"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={pf.cashEditingValue}
|
||||
onChange={(e) => pf.setCashEditingValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') pf.handleCashInlineSave(item.broker);
|
||||
if (e.key === 'Escape') pf.handleCashInlineCancel();
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<strong className="pf-cash-amount">
|
||||
{formatNumber(item.cash)}원
|
||||
</strong>
|
||||
)}
|
||||
<span className="pf-cash-date">
|
||||
{item.updated_at
|
||||
? new Date(item.updated_at).toLocaleDateString('ko-KR')
|
||||
: ''}
|
||||
</span>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<button
|
||||
className="button primary small"
|
||||
onClick={() => pf.handleCashInlineSave(item.broker)}
|
||||
disabled={pf.cashEditSaving}
|
||||
>
|
||||
{pf.cashEditSaving ? '저장 중' : '저장'}
|
||||
</button>
|
||||
<button
|
||||
className="button ghost small"
|
||||
onClick={pf.handleCashInlineCancel}
|
||||
disabled={pf.cashEditSaving}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className="button ghost small"
|
||||
onClick={() => pf.handleCashInlineEdit(item)}
|
||||
title="수정"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
className="button ghost small pf-btn-danger"
|
||||
onClick={() => pf.handleCashDelete(item.broker)}
|
||||
title="삭제"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{pf.cashList.length === 0 && (
|
||||
<p className="stock-empty" style={{ fontSize: 13 }}>
|
||||
등록된 예수금이 없습니다.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form className="pf-cash-form" onSubmit={pf.handleCashSave}>
|
||||
<label>
|
||||
증권사명
|
||||
<input
|
||||
type="text"
|
||||
value={pf.cashForm.broker}
|
||||
onChange={(e) =>
|
||||
pf.setCashForm((p) => ({ ...p, broker: e.target.value }))
|
||||
}
|
||||
placeholder="KB증권"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
예수금 (원)
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={pf.cashForm.cash}
|
||||
onChange={(e) =>
|
||||
pf.setCashForm((p) => ({ ...p, cash: e.target.value }))
|
||||
}
|
||||
placeholder="1500000"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="button primary"
|
||||
type="submit"
|
||||
disabled={pf.cashSaving}
|
||||
>
|
||||
{pf.cashSaving ? '저장 중...' : '저장'}
|
||||
</button>
|
||||
{pf.cashError && <p className="stock-error">{pf.cashError}</p>}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* Broker cards stacked */}
|
||||
{pf.brokerGroups.map(([broker, items]) => {
|
||||
const bSummary = pf.getBrokerSummary(items);
|
||||
const color = pf.brokerColors[broker];
|
||||
return (
|
||||
<section
|
||||
key={broker}
|
||||
className="stock-panel stock-panel--wide pf-broker-section"
|
||||
style={{ borderColor: color?.border, background: color?.bg }}
|
||||
>
|
||||
<div className="stock-panel__head">
|
||||
<div>
|
||||
<p className="stock-panel__eyebrow" style={{ color: color?.border }}>
|
||||
{broker}
|
||||
</p>
|
||||
<h3>{broker} 보유 현황</h3>
|
||||
<p className="stock-panel__sub">
|
||||
{items.length}종목 · 평가{' '}
|
||||
{formatNumber(bSummary.totalEval)} · 손익{' '}
|
||||
<span className={`stock-profit ${profitColorClass(bSummary.totalProfit)}`}>
|
||||
{formatNumber(bSummary.totalProfit)} (
|
||||
{formatPercent(bSummary.totalProfitRate)})
|
||||
</span>
|
||||
{(() => {
|
||||
const bc = pf.cashList.find((c) => c.broker === broker);
|
||||
return bc ? (
|
||||
<span className="pf-cash-badge">
|
||||
예수금 {formatNumber(bc.cash)}원
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stock-holdings">
|
||||
{items.map((item) => {
|
||||
const profitAmt = item.profit_amount;
|
||||
const profitRate = item.profit_rate;
|
||||
const profitAmtN = toNumeric(profitAmt);
|
||||
const profitRateN = toNumeric(profitRate);
|
||||
const isEditing = pf.editingId === item.id;
|
||||
const isDeleting = pf.deleteConfirmId === item.id;
|
||||
const isSelling = pf.sellConfirmId === item.id;
|
||||
const sellPrice = item.current_price ?? item.avg_price;
|
||||
const saleAmount = sellPrice != null ? sellPrice * (item.quantity ?? 0) : null;
|
||||
|
||||
return (
|
||||
<div key={item.id} className="stock-holdings__item pf-item">
|
||||
{isEditing ? (
|
||||
<div className="pf-edit-row">
|
||||
<div className="pf-edit-fields">
|
||||
<label>
|
||||
수량
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={pf.editForm.quantity ?? ''}
|
||||
onChange={(e) =>
|
||||
pf.setEditForm((p) => ({
|
||||
...p,
|
||||
quantity: Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
평균매입가
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={pf.editForm.avg_price ?? ''}
|
||||
onChange={(e) =>
|
||||
pf.setEditForm((p) => ({
|
||||
...p,
|
||||
avg_price: Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="pf-edit-actions">
|
||||
<button
|
||||
className="button primary small"
|
||||
onClick={() => pf.handleEditSave(item.id)}
|
||||
disabled={pf.editLoading}
|
||||
>
|
||||
{pf.editLoading ? '저장 중...' : '저장'}
|
||||
</button>
|
||||
<button
|
||||
className="button ghost small"
|
||||
onClick={() => pf.setEditingId(null)}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<p className="stock-holdings__name">
|
||||
{item.name ?? item.ticker ?? 'N/A'}
|
||||
</p>
|
||||
<span className="stock-holdings__code">
|
||||
{item.ticker ?? ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="stock-holdings__metric">
|
||||
<span>수량</span>
|
||||
<strong>{formatNumber(item.quantity)}</strong>
|
||||
</div>
|
||||
<div className="stock-holdings__metric">
|
||||
<span>매입가</span>
|
||||
<strong>{formatNumber(item.avg_price)}</strong>
|
||||
</div>
|
||||
<div className="stock-holdings__metric">
|
||||
<span>현재가</span>
|
||||
<strong className={item.current_price == null ? 'pf-null-price' : ''}>
|
||||
{item.current_price != null
|
||||
? formatNumber(item.current_price)
|
||||
: '조회 실패'}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="stock-holdings__metric">
|
||||
<span>평가금액</span>
|
||||
<strong>
|
||||
{item.current_price != null && item.quantity != null
|
||||
? formatNumber(item.current_price * item.quantity)
|
||||
: '-'}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="stock-holdings__metric">
|
||||
<span>수익률</span>
|
||||
<strong className={`stock-profit ${profitColorClass(profitRateN)}`}>
|
||||
{profitRate != null ? formatPercent(profitRate) : '-'}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="stock-holdings__metric">
|
||||
<span>평가손익</span>
|
||||
<strong className={`stock-profit ${profitColorClass(profitAmtN)}`}>
|
||||
{profitAmt != null ? formatNumber(profitAmt) : '-'}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="pf-item-actions">
|
||||
{!isSelling && !isDeleting && (
|
||||
<button
|
||||
className="button ghost small"
|
||||
onClick={() => pf.handleEditStart(item)}
|
||||
title="수정"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
)}
|
||||
{isSelling ? (
|
||||
<div className="pf-sell-confirm">
|
||||
<span className="pf-sell-confirm__msg">
|
||||
{item.current_price == null && (
|
||||
<small className="pf-sell-confirm__warn">현재가 미조회 — 매입가 기준</small>
|
||||
)}
|
||||
{saleAmount != null
|
||||
? `${formatNumber(saleAmount)}원 매도 후 예수금 반영`
|
||||
: '매도 처리'}
|
||||
</span>
|
||||
<button
|
||||
className="button small pf-btn-sell"
|
||||
onClick={() => handleSell(item)}
|
||||
disabled={pf.sellLoading}
|
||||
>
|
||||
{pf.sellLoading ? '처리 중...' : '매도 확인'}
|
||||
</button>
|
||||
<button
|
||||
className="button ghost small"
|
||||
onClick={() => pf.setSellConfirmId(null)}
|
||||
disabled={pf.sellLoading}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
) : isDeleting ? (
|
||||
<>
|
||||
<button
|
||||
className="button ghost small pf-btn-danger"
|
||||
onClick={() => pf.handleDelete(item.id)}
|
||||
>
|
||||
확인
|
||||
</button>
|
||||
<button
|
||||
className="button ghost small"
|
||||
onClick={() => pf.setDeleteConfirmId(null)}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className="button ghost small pf-btn-sell"
|
||||
onClick={() => {
|
||||
pf.setSellConfirmId(item.id);
|
||||
pf.setDeleteConfirmId(null);
|
||||
}}
|
||||
title="매도"
|
||||
>
|
||||
매도
|
||||
</button>
|
||||
<button
|
||||
className="button ghost small"
|
||||
onClick={() => {
|
||||
pf.setDeleteConfirmId(item.id);
|
||||
pf.setSellConfirmId(null);
|
||||
}}
|
||||
title="삭제"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
export default PortfolioTab;
|
||||
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;
|
||||
354
src/pages/stock/components/SellHistoryDrawer.jsx
Normal file
354
src/pages/stock/components/SellHistoryDrawer.jsx
Normal file
@@ -0,0 +1,354 @@
|
||||
import React from 'react';
|
||||
import Loading from '../../../components/Loading';
|
||||
import { formatNumber, formatPercent, profitColorClass } from '../stockUtils';
|
||||
|
||||
const SellHistoryDrawer = ({
|
||||
sell, sellHistoryBrokers, filteredSellHistory, sellHistorySummary,
|
||||
}) => (
|
||||
<>
|
||||
{/* Floating 토글 버튼 */}
|
||||
{!sell.sellDrawerOpen && (
|
||||
<button
|
||||
type="button"
|
||||
className="sh-floating-toggle"
|
||||
onClick={() => {
|
||||
sell.setSellDrawerOpen(true);
|
||||
sell.loadSellHistory();
|
||||
}}
|
||||
title="실현손익 내역"
|
||||
>
|
||||
<span className="sh-floating-toggle__icon">💹</span>
|
||||
<span className="sh-floating-toggle__label">실현손익</span>
|
||||
{sell.sellHistory.length > 0 && (
|
||||
<span className="sh-floating-toggle__badge">{sell.sellHistory.length}</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Backdrop */}
|
||||
{sell.sellDrawerOpen && (
|
||||
<div
|
||||
className="sh-backdrop"
|
||||
onClick={() => { sell.setSellDrawerOpen(false); sell.handleSellFormClose(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Drawer */}
|
||||
<aside className={`sh-drawer ${sell.sellDrawerOpen ? 'is-open' : ''}`}>
|
||||
<div className="sh-drawer__header">
|
||||
<div>
|
||||
<p className="sh-drawer__eyebrow">실현손익</p>
|
||||
<h3 className="sh-drawer__title">매도 거래 내역</h3>
|
||||
</div>
|
||||
<div className="sh-drawer__header-actions">
|
||||
{sell.sellHistoryLoading && <Loading type="spinner" message="" />}
|
||||
<button
|
||||
className="button ghost small"
|
||||
onClick={sell.loadSellHistory}
|
||||
disabled={sell.sellHistoryLoading}
|
||||
>
|
||||
새로고침
|
||||
</button>
|
||||
<button
|
||||
className="button primary small"
|
||||
onClick={sell.sellFormOpen && sell.sellEditId == null ? sell.handleSellFormClose : sell.handleSellFormOpen}
|
||||
>
|
||||
{sell.sellFormOpen && sell.sellEditId == null ? '취소' : '+ 추가'}
|
||||
</button>
|
||||
<button
|
||||
className="sh-drawer__close"
|
||||
type="button"
|
||||
onClick={() => { sell.setSellDrawerOpen(false); sell.handleSellFormClose(); }}
|
||||
aria-label="닫기"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 수동 추가 / 수정 폼 */}
|
||||
{sell.sellFormOpen && (
|
||||
<form className="sh-form" onSubmit={sell.handleSellFormSubmit}>
|
||||
<div className="sh-form__title">
|
||||
{sell.sellEditId != null ? '거래 내역 수정' : '매도 내역 수동 추가'}
|
||||
</div>
|
||||
<div className="sh-form__grid">
|
||||
<label>
|
||||
증권사
|
||||
<input
|
||||
type="text"
|
||||
value={sell.sellForm.broker}
|
||||
onChange={(e) => sell.setSellForm((p) => ({ ...p, broker: e.target.value }))}
|
||||
placeholder="KB증권"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
종목코드
|
||||
<input
|
||||
type="text"
|
||||
value={sell.sellForm.ticker}
|
||||
onChange={(e) => sell.setSellForm((p) => ({ ...p, ticker: e.target.value }))}
|
||||
placeholder="005930"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
종목명
|
||||
<input
|
||||
type="text"
|
||||
value={sell.sellForm.name}
|
||||
onChange={(e) => sell.setSellForm((p) => ({ ...p, name: e.target.value }))}
|
||||
placeholder="삼성전자"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
수량
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={sell.sellForm.quantity}
|
||||
onChange={(e) => sell.setSellForm((p) => ({ ...p, quantity: e.target.value }))}
|
||||
placeholder="10"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
평균 매입가 (원)
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={sell.sellForm.avg_price}
|
||||
onChange={(e) => sell.setSellForm((p) => ({ ...p, avg_price: e.target.value }))}
|
||||
placeholder="58000"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
매도가 (원)
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={sell.sellForm.sell_price}
|
||||
onChange={(e) => sell.setSellForm((p) => ({ ...p, sell_price: e.target.value }))}
|
||||
placeholder="62000"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
수수료 & 세금 (원)
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={sell.sellForm.commission}
|
||||
onChange={(e) => sell.setSellForm((p) => ({ ...p, commission: e.target.value }))}
|
||||
placeholder="0"
|
||||
/>
|
||||
</label>
|
||||
<label className="sh-form__datetime">
|
||||
매도 일시
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={sell.sellForm.sold_at}
|
||||
onChange={(e) => sell.setSellForm((p) => ({ ...p, sold_at: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{sell.sellForm.quantity && sell.sellForm.avg_price && sell.sellForm.sell_price && (() => {
|
||||
const qty = Number(sell.sellForm.quantity);
|
||||
const buy = Number(sell.sellForm.avg_price) * qty;
|
||||
const sellAmt = Number(sell.sellForm.sell_price) * qty;
|
||||
const commission = Number(sell.sellForm.commission) || 0;
|
||||
const profit = sellAmt - buy - commission;
|
||||
const rate = buy > 0 ? (profit / buy) * 100 : 0;
|
||||
return (
|
||||
<div className="sh-form__preview">
|
||||
<span>매도금액 <strong>{formatNumber(Math.round(sellAmt))}원</strong></span>
|
||||
{commission > 0 && (
|
||||
<span>수수료 & 세금 <strong className="stock-profit is-negative">-{formatNumber(Math.round(commission))}원</strong></span>
|
||||
)}
|
||||
<span>실현손익 <strong className={`stock-profit ${profitColorClass(profit)}`}>{formatNumber(Math.round(profit))}원</strong></span>
|
||||
<span>수익률 <strong className={`stock-profit ${profitColorClass(rate)}`}>{formatPercent(rate)}</strong></span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div className="sh-form__actions">
|
||||
<button className="button primary" type="submit" disabled={sell.sellFormSaving}>
|
||||
{sell.sellFormSaving ? '저장 중...' : (sell.sellEditId != null ? '수정 저장' : '추가')}
|
||||
</button>
|
||||
<button className="button ghost" type="button" onClick={sell.handleSellFormClose} disabled={sell.sellFormSaving}>
|
||||
취소
|
||||
</button>
|
||||
{sell.sellFormError && <p className="stock-error">{sell.sellFormError}</p>}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* 필터 바 */}
|
||||
<div className="sell-history__filters">
|
||||
<div className="sell-history__filter-group">
|
||||
<span className="sell-history__filter-label">계좌</span>
|
||||
{sellHistoryBrokers.map((b) => (
|
||||
<button
|
||||
key={b}
|
||||
type="button"
|
||||
className={`sell-history__filter-btn ${sell.sellHistoryBroker === b ? 'is-active' : ''}`}
|
||||
onClick={() => sell.setSellHistoryBroker(b)}
|
||||
>
|
||||
{b === 'ALL' ? '전체' : b}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="sell-history__filter-group">
|
||||
<span className="sell-history__filter-label">기간</span>
|
||||
{[
|
||||
{ label: '1개월', value: '1M' },
|
||||
{ label: '3개월', value: '3M' },
|
||||
{ label: '6개월', value: '6M' },
|
||||
{ label: '1년', value: '1Y' },
|
||||
{ label: '전체', value: 'ALL' },
|
||||
].map(({ label, value }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className={`sell-history__filter-btn ${sell.sellHistoryPeriod === value ? 'is-active' : ''}`}
|
||||
onClick={() => sell.setSellHistoryPeriod(value)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 요약 카드 */}
|
||||
{filteredSellHistory.length > 0 && (
|
||||
<div className="sell-history__summary">
|
||||
<div className="sell-history__summary-card">
|
||||
<span>거래 횟수</span>
|
||||
<strong>{sellHistorySummary.count}건</strong>
|
||||
</div>
|
||||
<div className="sell-history__summary-card">
|
||||
<span>총 매도금액</span>
|
||||
<strong>{formatNumber(sellHistorySummary.totalSell)}원</strong>
|
||||
</div>
|
||||
<div className="sell-history__summary-card">
|
||||
<span>총 수수료 & 세금</span>
|
||||
<strong className="stock-profit is-negative">
|
||||
-{formatNumber(Math.round(sellHistorySummary.totalCommission))}원
|
||||
</strong>
|
||||
</div>
|
||||
<div className="sell-history__summary-card">
|
||||
<span>실현손익 합계</span>
|
||||
<strong className={`stock-profit ${profitColorClass(sellHistorySummary.totalProfit)}`}>
|
||||
{formatNumber(Math.round(sellHistorySummary.totalProfit))}원
|
||||
</strong>
|
||||
</div>
|
||||
<div className="sell-history__summary-card">
|
||||
<span>평균 수익률</span>
|
||||
<strong className={`stock-profit ${profitColorClass(sellHistorySummary.rate)}`}>
|
||||
{formatPercent(sellHistorySummary.rate)}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 거래 내역 목록 */}
|
||||
{filteredSellHistory.length > 0 ? (
|
||||
<div className="sh-drawer__list">
|
||||
{filteredSellHistory.map((r) => {
|
||||
const profitN = r.realized_profit ?? 0;
|
||||
const rateN = r.realized_rate ?? 0;
|
||||
return (
|
||||
<div key={r.id} className="sh-drawer__item">
|
||||
<div className="sh-drawer__item-top">
|
||||
<div className="sh-drawer__item-name">
|
||||
<span>{r.name}</span>
|
||||
{r.ticker && <code>{r.ticker}</code>}
|
||||
</div>
|
||||
<div className="sh-drawer__item-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="button ghost small"
|
||||
onClick={() => sell.handleSellEditStart(r)}
|
||||
title="수정"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button ghost small pf-btn-danger"
|
||||
onClick={() => sell.handleDeleteSellRecord(r.id)}
|
||||
title="삭제"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sh-drawer__item-meta">
|
||||
<span className="sell-history__broker">{r.broker}</span>
|
||||
<span className="sell-history__date">
|
||||
{new Date(r.sold_at).toLocaleString('ko-KR', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="sh-drawer__item-metrics">
|
||||
<div>
|
||||
<span>수량</span>
|
||||
<strong>{formatNumber(r.quantity)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>매입가</span>
|
||||
<strong>{formatNumber(r.avg_price)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>매도가</span>
|
||||
<strong>{formatNumber(r.sell_price)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>매도금액</span>
|
||||
<strong>{formatNumber(Math.round(r.sell_amount))}</strong>
|
||||
</div>
|
||||
{(r.commission > 0) && (
|
||||
<div>
|
||||
<span>수수료 & 세금</span>
|
||||
<strong className="stock-profit is-negative">
|
||||
-{formatNumber(Math.round(r.commission))}
|
||||
</strong>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span>실현손익</span>
|
||||
<strong className={`stock-profit ${profitColorClass(profitN)}`}>
|
||||
{formatNumber(Math.round(profitN))}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>수익률</span>
|
||||
<strong className={`stock-profit ${profitColorClass(rateN)}`}>
|
||||
{formatPercent(rateN)}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="stock-empty sh-drawer__empty">
|
||||
{sell.sellHistory.length === 0
|
||||
? '아직 매도 기록이 없습니다.'
|
||||
: '필터 조건에 맞는 기록이 없습니다.'}
|
||||
</p>
|
||||
)}
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
|
||||
export default SellHistoryDrawer;
|
||||
Reference in New Issue
Block a user