Files
web-page/src/pages/stock/components/PortfolioTab.jsx
gahusb a20315ce34 feat(stock): 포트폴리오 현재가에 NXT 시간외 거래 뱃지
백엔드 응답의 price_session에 따라 NXT 프리마켓/애프터마켓 거래 중인
종목에 작은 'NXT' / 'NXT 프리' 뱃지를 표시. 툴팁에 거래 시각 노출.
정규장 마감 후에도 평가금액이 자연스럽게 이어지는 흐름을 시각적으로 보강.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:32:26 +09:00

672 lines
36 KiB
JavaScript
Raw Blame History

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