993 lines
46 KiB
JavaScript
993 lines
46 KiB
JavaScript
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||
import { Link } from 'react-router-dom';
|
||
import {
|
||
createTradeOrder,
|
||
getTradeBalance,
|
||
getPortfolio,
|
||
addPortfolio,
|
||
updatePortfolio,
|
||
deletePortfolio,
|
||
} from '../../api';
|
||
import Loading from '../../components/Loading';
|
||
import './Stock.css';
|
||
|
||
/* ── helpers ─────────────────────────────────────────────────────── */
|
||
|
||
const formatNumber = (value) => {
|
||
if (value === null || value === undefined || value === '') return '-';
|
||
const numeric = Number(value);
|
||
if (Number.isNaN(numeric)) return value;
|
||
return new Intl.NumberFormat('ko-KR').format(numeric);
|
||
};
|
||
|
||
const formatPercent = (value) => {
|
||
if (value === null || value === undefined || value === '') return '-';
|
||
if (typeof value === 'string' && value.includes('%')) return value;
|
||
const numeric = Number(value);
|
||
if (Number.isNaN(numeric)) return value;
|
||
return `${numeric >= 0 ? '+' : ''}${numeric.toFixed(2)}%`;
|
||
};
|
||
|
||
const pickFirst = (...values) =>
|
||
values.find((value) => value !== undefined && value !== null && value !== '');
|
||
|
||
const getQty = (item) =>
|
||
pickFirst(item?.qty, item?.quantity, item?.holding, item?.hold_qty);
|
||
|
||
const getBuyPrice = (item) =>
|
||
pickFirst(
|
||
item?.buy_price,
|
||
item?.avg_price,
|
||
item?.avg,
|
||
item?.purchase_price,
|
||
item?.buyPrice,
|
||
item?.price
|
||
);
|
||
|
||
const getCurrentPrice = (item) =>
|
||
pickFirst(
|
||
item?.current_price,
|
||
item?.current,
|
||
item?.cur_price,
|
||
item?.now_price,
|
||
item?.market_price
|
||
);
|
||
|
||
const getProfitRate = (item) =>
|
||
pickFirst(
|
||
item?.profit_rate,
|
||
item?.profitRate,
|
||
item?.profit_pct,
|
||
item?.profitPercent,
|
||
item?.pnl_rate,
|
||
item?.return_rate,
|
||
item?.yield
|
||
);
|
||
|
||
const getProfitLoss = (item) =>
|
||
pickFirst(item?.profit_loss, item?.pnl, item?.profitLoss);
|
||
|
||
const toNumeric = (value) => {
|
||
if (value === null || value === undefined || value === '') return null;
|
||
const numeric = Number(String(value).replace(/[^0-9.-]/g, ''));
|
||
return Number.isNaN(numeric) ? null : numeric;
|
||
};
|
||
|
||
const profitColorClass = (numericValue) => {
|
||
if (numericValue > 0) return 'is-up';
|
||
if (numericValue < 0) return 'is-down';
|
||
if (numericValue === 0) return 'is-flat';
|
||
return '';
|
||
};
|
||
|
||
/* ── empty portfolio form ────────────────────────────────────────── */
|
||
|
||
const emptyPortfolioForm = {
|
||
broker: '',
|
||
ticker: '',
|
||
name: '',
|
||
quantity: '',
|
||
avg_price: '',
|
||
};
|
||
|
||
/* ── component ───────────────────────────────────────────────────── */
|
||
|
||
const StockTrade = () => {
|
||
/* Balance state */
|
||
const [balance, setBalance] = useState(null);
|
||
const [balanceLoading, setBalanceLoading] = useState(false);
|
||
const [balanceError, setBalanceError] = useState('');
|
||
|
||
/* Portfolio state */
|
||
const [portfolio, setPortfolio] = useState(null);
|
||
const [portfolioLoading, setPortfolioLoading] = useState(false);
|
||
const [portfolioError, setPortfolioError] = useState('');
|
||
|
||
/* Portfolio add form */
|
||
const [addForm, setAddForm] = useState({ ...emptyPortfolioForm });
|
||
const [addFormOpen, setAddFormOpen] = useState(false);
|
||
const [addLoading, setAddLoading] = useState(false);
|
||
const [addError, setAddError] = useState('');
|
||
|
||
/* Portfolio edit */
|
||
const [editingId, setEditingId] = useState(null);
|
||
const [editForm, setEditForm] = useState({});
|
||
const [editLoading, setEditLoading] = useState(false);
|
||
const editOrigRef = useRef({});
|
||
|
||
/* Portfolio delete */
|
||
const [deleteConfirmId, setDeleteConfirmId] = useState(null);
|
||
|
||
/* Manual order state */
|
||
const [manualForm, setManualForm] = useState({
|
||
code: '',
|
||
qty: 1,
|
||
price: 0,
|
||
type: 'buy',
|
||
});
|
||
const [manualLoading, setManualLoading] = useState(false);
|
||
const [manualError, setManualError] = useState('');
|
||
const [manualResult, setManualResult] = useState(null);
|
||
const [kisModal, setKisModal] = useState('');
|
||
|
||
/* ── loaders ─────────────────────────────────────────────────── */
|
||
|
||
const loadBalance = useCallback(async () => {
|
||
setBalanceLoading(true);
|
||
setBalanceError('');
|
||
try {
|
||
const data = await getTradeBalance();
|
||
setBalance(data);
|
||
} catch (err) {
|
||
setBalanceError(err?.message ?? String(err));
|
||
} finally {
|
||
setBalanceLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
const loadPortfolio = useCallback(async () => {
|
||
setPortfolioLoading(true);
|
||
setPortfolioError('');
|
||
try {
|
||
const data = await getPortfolio();
|
||
setPortfolio(data);
|
||
} catch (err) {
|
||
setPortfolioError(err?.message ?? String(err));
|
||
} finally {
|
||
setPortfolioLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
loadBalance();
|
||
loadPortfolio();
|
||
}, [loadBalance, loadPortfolio]);
|
||
|
||
/* Auto-refresh portfolio every 3 min */
|
||
useEffect(() => {
|
||
const timer = window.setInterval(loadPortfolio, 180000);
|
||
return () => window.clearInterval(timer);
|
||
}, [loadPortfolio]);
|
||
|
||
/* ── portfolio actions ───────────────────────────────────────── */
|
||
|
||
const handleAddSubmit = async (e) => {
|
||
e.preventDefault();
|
||
setAddLoading(true);
|
||
setAddError('');
|
||
try {
|
||
await addPortfolio({
|
||
broker: addForm.broker.trim(),
|
||
ticker: addForm.ticker.trim(),
|
||
name: addForm.name.trim(),
|
||
quantity: Number(addForm.quantity),
|
||
avg_price: Number(addForm.avg_price),
|
||
});
|
||
setAddForm({ ...emptyPortfolioForm });
|
||
setAddFormOpen(false);
|
||
await loadPortfolio();
|
||
} catch (err) {
|
||
setAddError(err?.message ?? String(err));
|
||
} finally {
|
||
setAddLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleEditStart = (item) => {
|
||
setEditingId(item.id);
|
||
setEditForm({
|
||
quantity: item.quantity,
|
||
avg_price: item.avg_price,
|
||
broker: item.broker,
|
||
name: item.name,
|
||
});
|
||
/* 원본 값을 기억해서 diff 비교용 */
|
||
editOrigRef.current = {
|
||
quantity: item.quantity,
|
||
avg_price: item.avg_price,
|
||
broker: item.broker,
|
||
name: item.name,
|
||
};
|
||
};
|
||
|
||
const handleEditSave = async (id) => {
|
||
setEditLoading(true);
|
||
try {
|
||
/* 변경된 필드만 추출하여 부분 수정 */
|
||
const orig = editOrigRef.current ?? {};
|
||
const diff = {};
|
||
for (const key of Object.keys(editForm)) {
|
||
if (editForm[key] !== orig[key]) {
|
||
diff[key] = editForm[key];
|
||
}
|
||
}
|
||
if (Object.keys(diff).length === 0) {
|
||
setEditingId(null);
|
||
return;
|
||
}
|
||
await updatePortfolio(id, diff);
|
||
setEditingId(null);
|
||
await loadPortfolio();
|
||
} catch (err) {
|
||
const msg = err?.message ?? String(err);
|
||
if (msg.includes('404') || msg.includes('not found')) {
|
||
alert('해당 종목을 찾을 수 없습니다. 이미 삭제되었을 수 있습니다.');
|
||
await loadPortfolio();
|
||
} else {
|
||
alert('수정 실패: ' + msg);
|
||
}
|
||
} finally {
|
||
setEditLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleDelete = async (id) => {
|
||
try {
|
||
await deletePortfolio(id);
|
||
setDeleteConfirmId(null);
|
||
await loadPortfolio();
|
||
} catch (err) {
|
||
const msg = err?.message ?? String(err);
|
||
if (msg.includes('404') || msg.includes('not found')) {
|
||
alert('해당 종목을 찾을 수 없습니다. 이미 삭제되었을 수 있습니다.');
|
||
setDeleteConfirmId(null);
|
||
await loadPortfolio();
|
||
} else {
|
||
alert('삭제 실패: ' + msg);
|
||
}
|
||
}
|
||
};
|
||
|
||
/* ── manual order ────────────────────────────────────────────── */
|
||
|
||
const submitManualOrder = async (event) => {
|
||
event.preventDefault();
|
||
setManualLoading(true);
|
||
setManualError('');
|
||
setManualResult(null);
|
||
try {
|
||
const payload = {
|
||
ticker: manualForm.code.trim(),
|
||
action: manualForm.type === 'sell' ? 'SELL' : 'BUY',
|
||
quantity: Number(manualForm.qty),
|
||
price: Number(manualForm.price),
|
||
};
|
||
const result = await createTradeOrder(payload);
|
||
setManualResult(result ?? { ok: true });
|
||
if (result?.kis_result !== undefined) {
|
||
const message =
|
||
typeof result.kis_result === 'string'
|
||
? result.kis_result
|
||
: JSON.stringify(result.kis_result, null, 2);
|
||
setKisModal(message);
|
||
}
|
||
await loadBalance();
|
||
} catch (err) {
|
||
setManualError(err?.message ?? String(err));
|
||
} finally {
|
||
setManualLoading(false);
|
||
}
|
||
};
|
||
|
||
/* ── derived data ────────────────────────────────────────────── */
|
||
|
||
const holdings = useMemo(() => {
|
||
if (!balance) return [];
|
||
if (Array.isArray(balance.holdings)) return balance.holdings;
|
||
if (Array.isArray(balance.positions)) return balance.positions;
|
||
if (Array.isArray(balance.items)) return balance.items;
|
||
return [];
|
||
}, [balance]);
|
||
const summary = balance?.summary ?? {};
|
||
const totalEval =
|
||
summary.total_eval ?? balance?.total_eval ?? balance?.total_value;
|
||
const deposit =
|
||
summary.deposit ?? balance?.deposit ?? balance?.available_cash;
|
||
|
||
/* Portfolio grouped by broker */
|
||
const portfolioHoldings = portfolio?.holdings ?? [];
|
||
const portfolioSummary = portfolio?.summary ?? {};
|
||
const brokerGroups = useMemo(() => {
|
||
const map = {};
|
||
for (const item of portfolioHoldings) {
|
||
const broker = item.broker || '기타';
|
||
if (!map[broker]) map[broker] = [];
|
||
map[broker].push(item);
|
||
}
|
||
return Object.entries(map).sort(([a], [b]) => a.localeCompare(b));
|
||
}, [portfolioHoldings]);
|
||
|
||
/* broker-level summary (eval_amount가 null인 종목은 안전하게 건너뜀) */
|
||
const getBrokerSummary = (items) => {
|
||
let totalBuy = 0;
|
||
let totalEvalAmt = 0;
|
||
let hasNullPrice = false;
|
||
for (const item of items) {
|
||
totalBuy += (item.avg_price ?? 0) * (item.quantity ?? 0);
|
||
if (item.eval_amount != null) {
|
||
totalEvalAmt += item.eval_amount;
|
||
} else {
|
||
hasNullPrice = true;
|
||
}
|
||
}
|
||
const totalProfit = totalEvalAmt - totalBuy;
|
||
const totalProfitRate = totalBuy > 0 ? (totalProfit / totalBuy) * 100 : 0;
|
||
return { totalBuy, totalEval: totalEvalAmt, totalProfit, totalProfitRate, hasNullPrice };
|
||
};
|
||
|
||
/* ── broker color accents (deterministic from name) ──────────── */
|
||
const brokerColors = useMemo(() => {
|
||
const palette = [
|
||
{ border: 'rgba(129,140,248,0.5)', bg: 'rgba(129,140,248,0.06)' },
|
||
{ border: 'rgba(251,191,36,0.5)', bg: 'rgba(251,191,36,0.06)' },
|
||
{ border: 'rgba(52,211,153,0.5)', bg: 'rgba(52,211,153,0.06)' },
|
||
{ border: 'rgba(244,114,182,0.5)', bg: 'rgba(244,114,182,0.06)' },
|
||
{ border: 'rgba(251,146,60,0.5)', bg: 'rgba(251,146,60,0.06)' },
|
||
{ border: 'rgba(139,92,246,0.5)', bg: 'rgba(139,92,246,0.06)' },
|
||
];
|
||
const map = {};
|
||
brokerGroups.forEach(([broker], i) => {
|
||
map[broker] = palette[i % palette.length];
|
||
});
|
||
return map;
|
||
}, [brokerGroups]);
|
||
|
||
/* ── render ───────────────────────────────────────────────────── */
|
||
|
||
return (
|
||
<div className="stock">
|
||
<header className="stock-header">
|
||
<div>
|
||
<p className="stock-kicker">거래 데스크</p>
|
||
<h1>주식 거래</h1>
|
||
<p className="stock-sub">
|
||
연결된 계좌 잔고를 확인하고 필요한 주문을 요청하세요.
|
||
</p>
|
||
<div className="stock-actions">
|
||
<Link className="button ghost" to="/stock">
|
||
주식 랩으로 돌아가기
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
<div className="stock-card">
|
||
<p className="stock-card__title">계좌 요약</p>
|
||
<div className="stock-status">
|
||
<div>
|
||
<span>총 평가금액</span>
|
||
<strong>{formatNumber(totalEval)}</strong>
|
||
</div>
|
||
<div>
|
||
<span>예수금</span>
|
||
<strong>{formatNumber(deposit)}</strong>
|
||
</div>
|
||
<div>
|
||
<span>보유 종목</span>
|
||
<strong>{holdings.length}</strong>
|
||
</div>
|
||
{portfolioHoldings.length > 0 && (
|
||
<>
|
||
<div style={{ borderTop: '1px solid var(--line)', paddingTop: 8 }}>
|
||
<span>포트폴리오 종목</span>
|
||
<strong>{portfolioHoldings.length}</strong>
|
||
</div>
|
||
<div>
|
||
<span>포트폴리오 평가</span>
|
||
<strong>{formatNumber(portfolioSummary.total_eval)}</strong>
|
||
</div>
|
||
<div>
|
||
<span>포트폴리오 손익</span>
|
||
<strong
|
||
className={`stock-profit ${profitColorClass(
|
||
toNumeric(portfolioSummary.total_profit)
|
||
)}`}
|
||
>
|
||
{formatNumber(portfolioSummary.total_profit)}
|
||
{portfolioSummary.total_profit_rate != null && (
|
||
<small style={{ marginLeft: 4, fontSize: 11 }}>
|
||
({formatPercent(portfolioSummary.total_profit_rate)})
|
||
</small>
|
||
)}
|
||
</strong>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
{summary.note ? (
|
||
<p className="stock-status__note">{summary.note}</p>
|
||
) : null}
|
||
</div>
|
||
</header>
|
||
|
||
{/* ── Portfolio sections (broker별) ─────────────────────── */}
|
||
{portfolioError ? (
|
||
<p className="stock-error">{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">
|
||
{portfolioLoading ? (
|
||
<Loading type="spinner" message="" />
|
||
) : null}
|
||
<button
|
||
className="button ghost small"
|
||
onClick={loadPortfolio}
|
||
disabled={portfolioLoading}
|
||
>
|
||
새로고침
|
||
</button>
|
||
<button
|
||
className="button primary small"
|
||
onClick={() => setAddFormOpen((v) => !v)}
|
||
>
|
||
{addFormOpen ? '취소' : '+ 종목 추가'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Add form */}
|
||
{addFormOpen && (
|
||
<form className="pf-add-form" onSubmit={handleAddSubmit}>
|
||
<label>
|
||
증권사
|
||
<input
|
||
type="text"
|
||
value={addForm.broker}
|
||
onChange={(e) =>
|
||
setAddForm((p) => ({ ...p, broker: e.target.value }))
|
||
}
|
||
placeholder="KB증권"
|
||
required
|
||
/>
|
||
</label>
|
||
<label>
|
||
종목코드
|
||
<input
|
||
type="text"
|
||
value={addForm.ticker}
|
||
onChange={(e) =>
|
||
setAddForm((p) => ({ ...p, ticker: e.target.value }))
|
||
}
|
||
placeholder="005930"
|
||
required
|
||
maxLength={6}
|
||
/>
|
||
</label>
|
||
<label>
|
||
종목명
|
||
<input
|
||
type="text"
|
||
value={addForm.name}
|
||
onChange={(e) =>
|
||
setAddForm((p) => ({ ...p, name: e.target.value }))
|
||
}
|
||
placeholder="삼성전자"
|
||
required
|
||
/>
|
||
</label>
|
||
<label>
|
||
수량
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
step={1}
|
||
value={addForm.quantity}
|
||
onChange={(e) =>
|
||
setAddForm((p) => ({ ...p, quantity: e.target.value }))
|
||
}
|
||
required
|
||
/>
|
||
</label>
|
||
<label>
|
||
평균 매입가 (원)
|
||
<input
|
||
type="number"
|
||
min={0}
|
||
step={1}
|
||
value={addForm.avg_price}
|
||
onChange={(e) =>
|
||
setAddForm((p) => ({ ...p, avg_price: e.target.value }))
|
||
}
|
||
required
|
||
/>
|
||
</label>
|
||
<button
|
||
className="button primary"
|
||
type="submit"
|
||
disabled={addLoading}
|
||
>
|
||
{addLoading ? '등록 중...' : '종목 등록'}
|
||
</button>
|
||
{addError && <p className="stock-error">{addError}</p>}
|
||
</form>
|
||
)}
|
||
|
||
{/* Portfolio total summary */}
|
||
{portfolioHoldings.length > 0 && (
|
||
<div className="pf-total-summary">
|
||
{[
|
||
{ label: '총 매입', value: portfolioSummary.total_buy },
|
||
{ label: '총 평가', value: portfolioSummary.total_eval },
|
||
{ label: '총 손익', value: portfolioSummary.total_profit, isProfit: true },
|
||
{ label: '수익률', value: 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>
|
||
))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
{/* Each broker gets a stacked card */}
|
||
{brokerGroups.map(([broker, items]) => {
|
||
const bSummary = getBrokerSummary(items);
|
||
const color = 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>
|
||
</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 = editingId === item.id;
|
||
const isDeleting = deleteConfirmId === item.id;
|
||
|
||
return (
|
||
<div
|
||
key={item.id}
|
||
className="stock-holdings__item pf-item"
|
||
>
|
||
{isEditing ? (
|
||
/* Edit mode */
|
||
<div className="pf-edit-row">
|
||
<div className="pf-edit-fields">
|
||
<label>
|
||
수량
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
value={editForm.quantity ?? ''}
|
||
onChange={(e) =>
|
||
setEditForm((p) => ({
|
||
...p,
|
||
quantity: Number(e.target.value),
|
||
}))
|
||
}
|
||
/>
|
||
</label>
|
||
<label>
|
||
평균매입가
|
||
<input
|
||
type="number"
|
||
min={0}
|
||
value={editForm.avg_price ?? ''}
|
||
onChange={(e) =>
|
||
setEditForm((p) => ({
|
||
...p,
|
||
avg_price: Number(e.target.value),
|
||
}))
|
||
}
|
||
/>
|
||
</label>
|
||
</div>
|
||
<div className="pf-edit-actions">
|
||
<button
|
||
className="button primary small"
|
||
onClick={() => handleEditSave(item.id)}
|
||
disabled={editLoading}
|
||
>
|
||
{editLoading ? '저장 중...' : '저장'}
|
||
</button>
|
||
<button
|
||
className="button ghost small"
|
||
onClick={() => setEditingId(null)}
|
||
>
|
||
취소
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
/* Normal display */
|
||
<>
|
||
<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
|
||
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>
|
||
{/* action buttons */}
|
||
<div className="pf-item-actions">
|
||
<button
|
||
className="button ghost small"
|
||
onClick={() => handleEditStart(item)}
|
||
title="수정"
|
||
>
|
||
✏️
|
||
</button>
|
||
{isDeleting ? (
|
||
<>
|
||
<button
|
||
className="button ghost small pf-btn-danger"
|
||
onClick={() => handleDelete(item.id)}
|
||
>
|
||
확인
|
||
</button>
|
||
<button
|
||
className="button ghost small"
|
||
onClick={() =>
|
||
setDeleteConfirmId(null)
|
||
}
|
||
>
|
||
취소
|
||
</button>
|
||
</>
|
||
) : (
|
||
<button
|
||
className="button ghost small"
|
||
onClick={() =>
|
||
setDeleteConfirmId(item.id)
|
||
}
|
||
title="삭제"
|
||
>
|
||
🗑️
|
||
</button>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
);
|
||
})}
|
||
|
||
{/* ── Existing balance section ─────────────────────────── */}
|
||
{balanceError ? <p className="stock-error">{balanceError}</p> : 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">
|
||
연결 계좌의 실시간 잔고와 보유 종목을 확인합니다.
|
||
</p>
|
||
</div>
|
||
<div className="stock-panel__actions">
|
||
{balanceLoading ? (
|
||
<span className="stock-chip">조회 중</span>
|
||
) : null}
|
||
<button
|
||
className="button ghost small"
|
||
onClick={loadBalance}
|
||
disabled={balanceLoading}
|
||
>
|
||
새로고침
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="stock-balance">
|
||
<div className="stock-balance__summary">
|
||
{[
|
||
{
|
||
label: '총 평가',
|
||
value: totalEval,
|
||
},
|
||
{
|
||
label: '예수금',
|
||
value: deposit,
|
||
},
|
||
].map((item) => (
|
||
<div
|
||
key={item.label}
|
||
className="stock-balance__card"
|
||
>
|
||
<span>{item.label}</span>
|
||
<strong>{formatNumber(item.value)}</strong>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{holdings.length ? (
|
||
<div className="stock-holdings">
|
||
{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
|
||
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={submitManualOrder}>
|
||
<label>
|
||
종목명/코드
|
||
<input
|
||
type="text"
|
||
value={manualForm.code}
|
||
onChange={(event) =>
|
||
setManualForm((prev) => ({
|
||
...prev,
|
||
code: event.target.value,
|
||
}))
|
||
}
|
||
placeholder="005930 또는 삼성전자"
|
||
required
|
||
/>
|
||
</label>
|
||
<label>
|
||
매수/매도
|
||
<select
|
||
value={manualForm.type}
|
||
onChange={(event) =>
|
||
setManualForm((prev) => ({
|
||
...prev,
|
||
type: event.target.value,
|
||
}))
|
||
}
|
||
>
|
||
<option value="buy">매수</option>
|
||
<option value="sell">매도</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
수량
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
step={1}
|
||
value={manualForm.qty}
|
||
onChange={(event) =>
|
||
setManualForm((prev) => ({
|
||
...prev,
|
||
qty: Number(event.target.value),
|
||
}))
|
||
}
|
||
required
|
||
/>
|
||
</label>
|
||
<label>
|
||
금액(원)
|
||
<input
|
||
type="number"
|
||
min={0}
|
||
step={1}
|
||
value={manualForm.price}
|
||
onChange={(event) =>
|
||
setManualForm((prev) => ({
|
||
...prev,
|
||
price: Number(event.target.value),
|
||
}))
|
||
}
|
||
/>
|
||
</label>
|
||
<button
|
||
className="button primary"
|
||
type="submit"
|
||
disabled={manualLoading}
|
||
>
|
||
{manualLoading ? '요청 중...' : '주문 요청'}
|
||
</button>
|
||
{manualError ? (
|
||
<p className="stock-error">{manualError}</p>
|
||
) : null}
|
||
{manualResult ? (
|
||
<div className="stock-result">
|
||
<p className="stock-result__title">요청 결과</p>
|
||
<pre>
|
||
{typeof manualResult === 'string'
|
||
? manualResult
|
||
: JSON.stringify(manualResult, null, 2)}
|
||
</pre>
|
||
</div>
|
||
) : null}
|
||
</form>
|
||
</section>
|
||
|
||
{/* KIS modal */}
|
||
{kisModal ? (
|
||
<div className="stock-modal" role="dialog" aria-modal="true">
|
||
<div
|
||
className="stock-modal__backdrop"
|
||
onClick={() => setKisModal('')}
|
||
/>
|
||
<div className="stock-modal__card">
|
||
<div className="stock-modal__head">
|
||
<h4>주문 결과</h4>
|
||
<button
|
||
type="button"
|
||
className="button ghost small"
|
||
onClick={() => setKisModal('')}
|
||
>
|
||
닫기
|
||
</button>
|
||
</div>
|
||
<pre>{kisModal}</pre>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default StockTrade;
|