백엔드 응답의 price_session에 따라 NXT 프리마켓/애프터마켓 거래 중인 종목에 작은 'NXT' / 'NXT 프리' 뱃지를 표시. 툴팁에 거래 시각 노출. 정규장 마감 후에도 평가금액이 자연스럽게 이어지는 흐름을 시각적으로 보강. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
672 lines
36 KiB
JavaScript
672 lines
36 KiB
JavaScript
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, numFitClass } from '../stockUtils';
|
||
|
||
const formatPriceTime = (iso) => {
|
||
if (!iso) return '';
|
||
const d = new Date(iso);
|
||
if (Number.isNaN(d.getTime())) return '';
|
||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||
};
|
||
|
||
const PriceSessionBadge = ({ session, asOf }) => {
|
||
if (session !== 'NXT_AFTER' && session !== 'NXT_PRE') return null;
|
||
const isPre = session === 'NXT_PRE';
|
||
const label = isPre ? 'NXT 프리' : 'NXT';
|
||
const desc = isPre ? 'NXT 프리마켓 거래가' : 'NXT 야간거래 (15:30~20:00)';
|
||
const time = formatPriceTime(asOf);
|
||
return (
|
||
<span className="pf-nxt-badge" title={time ? `${desc} · ${time}` : desc}>
|
||
{label}
|
||
</span>
|
||
);
|
||
};
|
||
|
||
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>
|
||
<label>
|
||
매입가 (원)
|
||
<input
|
||
type="number"
|
||
min={0}
|
||
step={1}
|
||
value={pf.addForm.purchase_price}
|
||
onChange={(e) =>
|
||
pf.setAddForm((p) => ({ ...p, purchase_price: e.target.value }))
|
||
}
|
||
placeholder="미입력 시 평균단가로 자동 설정"
|
||
/>
|
||
</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) => {
|
||
const display = s.isRate ? formatPercent(s.value) : formatNumber(s.value);
|
||
const profitCls = s.isProfit || s.isRate
|
||
? `stock-profit ${profitColorClass(toNumeric(s.value))}`
|
||
: '';
|
||
return (
|
||
<div key={s.label} className="pf-total-summary__card">
|
||
<span>{s.label}</span>
|
||
<strong className={`${profitCls} ${numFitClass(display)}`.trim()}>
|
||
{display}
|
||
</strong>
|
||
</div>
|
||
);
|
||
})}
|
||
{pf.totalCash != null && (() => {
|
||
const display = `${formatNumber(pf.totalCash)}원`;
|
||
return (
|
||
<div className="pf-total-summary__card is-cash">
|
||
<span>예수금 합계</span>
|
||
<strong className={numFitClass(display)}>{display}</strong>
|
||
</div>
|
||
);
|
||
})()}
|
||
{pf.totalAssets != null && (() => {
|
||
const display = `${formatNumber(pf.totalAssets)}원`;
|
||
return (
|
||
<div className="pf-total-summary__card is-assets">
|
||
<span>총 자산</span>
|
||
<strong className={numFitClass(display)}>{display}</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.totalBuy)} · 평가{' '}
|
||
{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>
|
||
<label>
|
||
매입가
|
||
<input
|
||
type="number"
|
||
min={0}
|
||
value={pf.editForm.purchase_price ?? ''}
|
||
onChange={(e) =>
|
||
pf.setEditForm((p) => ({
|
||
...p,
|
||
purchase_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>{formatNumber(item.purchase_price ?? 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)
|
||
: '조회 실패'}
|
||
<PriceSessionBadge
|
||
session={item.price_session}
|
||
asOf={item.price_as_of}
|
||
/>
|
||
</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;
|