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