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:
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;
|
||||
Reference in New Issue
Block a user