계좌 페이지 분류

This commit is contained in:
2026-02-26 01:32:49 +09:00
parent c4abdbed3e
commit 3e9112c4c7
2 changed files with 734 additions and 574 deletions

View File

@@ -625,6 +625,101 @@
} }
} }
/* ── Main Tabs ─────────────────────────────────────────────────────── */
.stock-main-tabs {
display: flex;
gap: 4px;
margin: 0 0 20px;
border-bottom: 1px solid var(--line);
padding-bottom: 0;
}
.stock-main-tab {
position: relative;
display: flex;
align-items: center;
gap: 8px;
padding: 14px 24px;
background: none;
border: none;
color: var(--muted);
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: color 0.25s, background 0.25s;
border-radius: 12px 12px 0 0;
}
.stock-main-tab:hover {
color: var(--fg);
background: rgba(255, 255, 255, 0.04);
}
.stock-main-tab.is-active {
color: var(--fg);
background: rgba(255, 255, 255, 0.06);
}
.stock-main-tab.is-active::after {
content: '';
position: absolute;
bottom: -1px;
left: 12px;
right: 12px;
height: 2px;
background: linear-gradient(90deg, #818cf8, #a78bfa);
border-radius: 2px;
box-shadow: 0 0 8px rgba(129, 140, 248, 0.5);
}
.stock-main-tab__icon {
font-size: 18px;
}
.stock-main-tab__label {
font-weight: 600;
}
.stock-main-tab__badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 10px;
background: linear-gradient(135deg, #818cf8, #a78bfa);
color: #fff;
font-size: 11px;
font-weight: 700;
}
.stock-main-tab__sub {
font-size: 11px;
padding: 2px 8px;
border-radius: 8px;
background: rgba(251, 191, 36, 0.15);
color: rgba(251, 191, 36, 0.9);
font-weight: 600;
}
@media (max-width: 520px) {
.stock-main-tab {
padding: 10px 14px;
font-size: 13px;
gap: 4px;
}
.stock-main-tab__icon {
font-size: 15px;
}
.stock-main-tab__label {
font-size: 13px;
}
}
/* ── Portfolio ────────────────────────────────────────────────────── */ /* ── Portfolio ────────────────────────────────────────────────────── */
.pf-section { .pf-section {

View File

@@ -90,18 +90,24 @@ const emptyPortfolioForm = {
avg_price: '', avg_price: '',
}; };
/* ── TAB IDs ─────────────────────────────────────────────────────── */
const TAB_PORTFOLIO = 'portfolio';
const TAB_AI = 'ai';
/* ── component ───────────────────────────────────────────────────── */ /* ── component ───────────────────────────────────────────────────── */
const StockTrade = () => { const StockTrade = () => {
/* Balance state */ /* Active tab */
const [balance, setBalance] = useState(null); const [activeTab, setActiveTab] = useState(TAB_PORTFOLIO);
const [balanceLoading, setBalanceLoading] = useState(false);
const [balanceError, setBalanceError] = useState('');
/* Portfolio state */ /* ────────────────────────────────────────────────────────────── */
/* 쟁승토리 계좌 (Portfolio) state */
/* ────────────────────────────────────────────────────────────── */
const [portfolio, setPortfolio] = useState(null); const [portfolio, setPortfolio] = useState(null);
const [portfolioLoading, setPortfolioLoading] = useState(false); const [portfolioLoading, setPortfolioLoading] = useState(false);
const [portfolioError, setPortfolioError] = useState(''); const [portfolioError, setPortfolioError] = useState('');
const [portfolioLoaded, setPortfolioLoaded] = useState(false);
/* Portfolio add form */ /* Portfolio add form */
const [addForm, setAddForm] = useState({ ...emptyPortfolioForm }); const [addForm, setAddForm] = useState({ ...emptyPortfolioForm });
@@ -118,6 +124,14 @@ const StockTrade = () => {
/* Portfolio delete */ /* Portfolio delete */
const [deleteConfirmId, setDeleteConfirmId] = useState(null); const [deleteConfirmId, setDeleteConfirmId] = useState(null);
/* ────────────────────────────────────────────────────────────── */
/* AI 투자 (Balance) state */
/* ────────────────────────────────────────────────────────────── */
const [balance, setBalance] = useState(null);
const [balanceLoading, setBalanceLoading] = useState(false);
const [balanceError, setBalanceError] = useState('');
const [balanceLoaded, setBalanceLoaded] = useState(false);
/* Manual order state */ /* Manual order state */
const [manualForm, setManualForm] = useState({ const [manualForm, setManualForm] = useState({
code: '', code: '',
@@ -132,25 +146,13 @@ const StockTrade = () => {
/* ── loaders ─────────────────────────────────────────────────── */ /* ── 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 () => { const loadPortfolio = useCallback(async () => {
setPortfolioLoading(true); setPortfolioLoading(true);
setPortfolioError(''); setPortfolioError('');
try { try {
const data = await getPortfolio(); const data = await getPortfolio();
setPortfolio(data); setPortfolio(data);
setPortfolioLoaded(true);
} catch (err) { } catch (err) {
setPortfolioError(err?.message ?? String(err)); setPortfolioError(err?.message ?? String(err));
} finally { } finally {
@@ -158,16 +160,35 @@ const StockTrade = () => {
} }
}, []); }, []);
useEffect(() => { const loadBalance = useCallback(async () => {
loadBalance(); setBalanceLoading(true);
loadPortfolio(); setBalanceError('');
}, [loadBalance, loadPortfolio]); try {
const data = await getTradeBalance();
setBalance(data);
setBalanceLoaded(true);
} catch (err) {
setBalanceError(err?.message ?? String(err));
} finally {
setBalanceLoading(false);
}
}, []);
/* Auto-refresh portfolio every 3 min */ /* Lazy load: 탭 전환 시 해당 API만 호출 */
useEffect(() => { useEffect(() => {
if (activeTab === TAB_PORTFOLIO && !portfolioLoaded) {
loadPortfolio();
} else if (activeTab === TAB_AI && !balanceLoaded) {
loadBalance();
}
}, [activeTab, portfolioLoaded, balanceLoaded, loadPortfolio, loadBalance]);
/* Auto-refresh portfolio every 3 min (포트폴리오 탭 활성 시) */
useEffect(() => {
if (activeTab !== TAB_PORTFOLIO) return;
const timer = window.setInterval(loadPortfolio, 180000); const timer = window.setInterval(loadPortfolio, 180000);
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, [loadPortfolio]); }, [activeTab, loadPortfolio]);
/* ── portfolio actions ───────────────────────────────────────── */ /* ── portfolio actions ───────────────────────────────────────── */
@@ -201,7 +222,6 @@ const StockTrade = () => {
broker: item.broker, broker: item.broker,
name: item.name, name: item.name,
}); });
/* 원본 값을 기억해서 diff 비교용 */
editOrigRef.current = { editOrigRef.current = {
quantity: item.quantity, quantity: item.quantity,
avg_price: item.avg_price, avg_price: item.avg_price,
@@ -213,7 +233,6 @@ const StockTrade = () => {
const handleEditSave = async (id) => { const handleEditSave = async (id) => {
setEditLoading(true); setEditLoading(true);
try { try {
/* 변경된 필드만 추출하여 부분 수정 */
const orig = editOrigRef.current ?? {}; const orig = editOrigRef.current ?? {};
const diff = {}; const diff = {};
for (const key of Object.keys(editForm)) { for (const key of Object.keys(editForm)) {
@@ -289,7 +308,7 @@ const StockTrade = () => {
} }
}; };
/* ── derived data ────────────────────────────────────────────── */ /* ── derived: AI balance ──────────────────────────────────────── */
const holdings = useMemo(() => { const holdings = useMemo(() => {
if (!balance) return []; if (!balance) return [];
@@ -304,7 +323,8 @@ const StockTrade = () => {
const deposit = const deposit =
summary.deposit ?? balance?.deposit ?? balance?.available_cash; summary.deposit ?? balance?.deposit ?? balance?.available_cash;
/* Portfolio grouped by broker */ /* ── derived: Portfolio ───────────────────────────────────────── */
const portfolioHoldings = portfolio?.holdings ?? []; const portfolioHoldings = portfolio?.holdings ?? [];
const portfolioSummary = portfolio?.summary ?? {}; const portfolioSummary = portfolio?.summary ?? {};
const brokerGroups = useMemo(() => { const brokerGroups = useMemo(() => {
@@ -317,7 +337,6 @@ const StockTrade = () => {
return Object.entries(map).sort(([a], [b]) => a.localeCompare(b)); return Object.entries(map).sort(([a], [b]) => a.localeCompare(b));
}, [portfolioHoldings]); }, [portfolioHoldings]);
/* broker-level summary (eval_amount가 null인 종목은 안전하게 건너뜀) */
const getBrokerSummary = (items) => { const getBrokerSummary = (items) => {
let totalBuy = 0; let totalBuy = 0;
let totalEvalAmt = 0; let totalEvalAmt = 0;
@@ -335,7 +354,6 @@ const StockTrade = () => {
return { totalBuy, totalEval: totalEvalAmt, totalProfit, totalProfitRate, hasNullPrice }; return { totalBuy, totalEval: totalEvalAmt, totalProfit, totalProfitRate, hasNullPrice };
}; };
/* ── broker color accents (deterministic from name) ──────────── */
const brokerColors = useMemo(() => { const brokerColors = useMemo(() => {
const palette = [ const palette = [
{ border: 'rgba(129,140,248,0.5)', bg: 'rgba(129,140,248,0.06)' }, { border: 'rgba(129,140,248,0.5)', bg: 'rgba(129,140,248,0.06)' },
@@ -356,12 +374,13 @@ const StockTrade = () => {
return ( return (
<div className="stock"> <div className="stock">
{/* ── Header ──────────────────────────────────────────── */}
<header className="stock-header"> <header className="stock-header">
<div> <div>
<p className="stock-kicker">거래 데스크</p> <p className="stock-kicker">거래 데스크</p>
<h1>주식 거래</h1> <h1>거래 데스크</h1>
<p className="stock-sub"> <p className="stock-sub">
연결된 계좌 잔고를 확인하고 필요 주문을 요청하세요. 실제 계좌 AI 모의투자를 곳에서 관리하세요.
</p> </p>
<div className="stock-actions"> <div className="stock-actions">
<Link className="button ghost" to="/stock"> <Link className="button ghost" to="/stock">
@@ -370,32 +389,24 @@ const StockTrade = () => {
</div> </div>
</div> </div>
<div className="stock-card"> <div className="stock-card">
<p className="stock-card__title">계좌 요약</p> <p className="stock-card__title">
{activeTab === TAB_PORTFOLIO
? '쟁승토리 계좌 요약'
: 'AI 투자 요약'}
</p>
{activeTab === TAB_PORTFOLIO ? (
/* Portfolio summary */
<div className="stock-status"> <div className="stock-status">
<div> <div>
<span> 평가금액</span> <span> 매입</span>
<strong>{formatNumber(totalEval)}</strong> <strong>{formatNumber(portfolioSummary.total_buy)}</strong>
</div> </div>
<div> <div>
<span>예수금</span> <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> <strong>{formatNumber(portfolioSummary.total_eval)}</strong>
</div> </div>
<div> <div>
<span>포트폴리오 손익</span> <span> 손익</span>
<strong <strong
className={`stock-profit ${profitColorClass( className={`stock-profit ${profitColorClass(
toNumeric(portfolioSummary.total_profit) toNumeric(portfolioSummary.total_profit)
@@ -409,21 +420,70 @@ const StockTrade = () => {
)} )}
</strong> </strong>
</div> </div>
</> <div>
)} <span>보유 종목</span>
<strong>{portfolioHoldings.length}</strong>
</div> </div>
{summary.note ? ( </div>
) : (
/* AI balance summary */
<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>
</div>
)}
{activeTab === TAB_AI && summary.note ? (
<p className="stock-status__note">{summary.note}</p> <p className="stock-status__note">{summary.note}</p>
) : null} ) : null}
</div> </div>
</header> </header>
{/* ── Portfolio sections (broker별) ─────────────────────── */} {/* ── Main Tabs ───────────────────────────────────────── */}
<div className="stock-main-tabs">
<button
type="button"
className={`stock-main-tab ${activeTab === TAB_PORTFOLIO ? 'is-active' : ''}`}
onClick={() => setActiveTab(TAB_PORTFOLIO)}
>
<span className="stock-main-tab__icon">💼</span>
<span className="stock-main-tab__label">쟁승토리 계좌</span>
{portfolioHoldings.length > 0 && (
<span className="stock-main-tab__badge">
{portfolioHoldings.length}
</span>
)}
</button>
<button
type="button"
className={`stock-main-tab ${activeTab === TAB_AI ? 'is-active' : ''}`}
onClick={() => setActiveTab(TAB_AI)}
>
<span className="stock-main-tab__icon">🤖</span>
<span className="stock-main-tab__label">AI 투자</span>
<span className="stock-main-tab__sub">모의투자</span>
</button>
</div>
{/* ════════════════════════════════════════════════════════
TAB 1: 쟁승토리 계좌
════════════════════════════════════════════════════════ */}
{activeTab === TAB_PORTFOLIO && (
<>
{portfolioError ? ( {portfolioError ? (
<p className="stock-error">{portfolioError}</p> <p className="stock-error">{portfolioError}</p>
) : null} ) : null}
{/* 포트폴리오 요약 + 종목 추가 */} {/* 포트폴리오 관리 헤더 + 추가 폼 */}
<section className="stock-panel stock-panel--wide pf-section"> <section className="stock-panel stock-panel--wide pf-section">
<div className="stock-panel__head"> <div className="stock-panel__head">
<div> <div>
@@ -556,7 +616,7 @@ const StockTrade = () => {
)} )}
</section> </section>
{/* Each broker gets a stacked card */} {/* Broker cards stacked */}
{brokerGroups.map(([broker, items]) => { {brokerGroups.map(([broker, items]) => {
const bSummary = getBrokerSummary(items); const bSummary = getBrokerSummary(items);
const color = brokerColors[broker]; const color = brokerColors[broker];
@@ -607,7 +667,6 @@ const StockTrade = () => {
className="stock-holdings__item pf-item" className="stock-holdings__item pf-item"
> >
{isEditing ? ( {isEditing ? (
/* Edit mode */
<div className="pf-edit-row"> <div className="pf-edit-row">
<div className="pf-edit-fields"> <div className="pf-edit-fields">
<label> <label>
@@ -656,7 +715,6 @@ const StockTrade = () => {
</div> </div>
</div> </div>
) : ( ) : (
/* Normal display */
<> <>
<div> <div>
<p className="stock-holdings__name"> <p className="stock-holdings__name">
@@ -704,7 +762,6 @@ const StockTrade = () => {
: '-'} : '-'}
</strong> </strong>
</div> </div>
{/* action buttons */}
<div className="pf-item-actions"> <div className="pf-item-actions">
<button <button
className="button ghost small" className="button ghost small"
@@ -752,16 +809,31 @@ const StockTrade = () => {
); );
})} })}
{/* ── Existing balance section ─────────────────────────── */} {portfolioLoaded && portfolioHoldings.length === 0 && !portfolioError && (
<section className="stock-panel stock-panel--wide">
<p className="stock-empty" style={{ textAlign: 'center', padding: 24 }}>
등록된 종목이 없습니다. 상단의 <strong>+ 종목 추가</strong> 버튼으로 보유 종목을 등록하세요.
</p>
</section>
)}
</>
)}
{/* ════════════════════════════════════════════════════════
TAB 2: AI 투자 (모의투자)
════════════════════════════════════════════════════════ */}
{activeTab === TAB_AI && (
<>
{balanceError ? <p className="stock-error">{balanceError}</p> : null} {balanceError ? <p className="stock-error">{balanceError}</p> : null}
{/* AI Balance section */}
<section className="stock-panel stock-panel--wide"> <section className="stock-panel stock-panel--wide">
<div className="stock-panel__head"> <div className="stock-panel__head">
<div> <div>
<p className="stock-panel__eyebrow">잔고</p> <p className="stock-panel__eyebrow">AI 모의투자</p>
<h3>보유 현황</h3> <h3>보유 현황</h3>
<p className="stock-panel__sub"> <p className="stock-panel__sub">
연결 계좌의 실시간 잔고와 보유 종목을 확인합니다. AI가 운용 중인 모의투자 계좌의 잔고와 보유 종목을 확인합니다.
</p> </p>
</div> </div>
<div className="stock-panel__actions"> <div className="stock-panel__actions">
@@ -780,14 +852,8 @@ const StockTrade = () => {
<div className="stock-balance"> <div className="stock-balance">
<div className="stock-balance__summary"> <div className="stock-balance__summary">
{[ {[
{ { label: '총 평가', value: totalEval },
label: '총 평가', { label: '예수금', value: deposit },
value: totalEval,
},
{
label: '예수금',
value: deposit,
},
].map((item) => ( ].map((item) => (
<div <div
key={item.label} key={item.label}
@@ -835,9 +901,7 @@ const StockTrade = () => {
<div className="stock-holdings__metric"> <div className="stock-holdings__metric">
<span>현재가</span> <span>현재가</span>
<strong> <strong>
{formatNumber( {formatNumber(getCurrentPrice(item))}
getCurrentPrice(item)
)}
</strong> </strong>
</div> </div>
<div className="stock-holdings__metric"> <div className="stock-holdings__metric">
@@ -866,15 +930,14 @@ const StockTrade = () => {
</div> </div>
</section> </section>
{/* ── Manual order section ─────────────────────────────── */} {/* Manual order section */}
<section className="stock-panel stock-panel--wide"> <section className="stock-panel stock-panel--wide">
<div className="stock-panel__head"> <div className="stock-panel__head">
<div> <div>
<p className="stock-panel__eyebrow">수동 주문</p> <p className="stock-panel__eyebrow">수동 주문</p>
<h3>직접 매수/매도</h3> <h3>직접 매수/매도</h3>
<p className="stock-panel__sub"> <p className="stock-panel__sub">
종목명 또는 종목코드를 입력하고 매수/매도 주문을 종목명 또는 종목코드를 입력하고 매수/매도 주문을 요청합니다.
요청합니다.
</p> </p>
</div> </div>
</div> </div>
@@ -962,6 +1025,8 @@ const StockTrade = () => {
) : null} ) : null}
</form> </form>
</section> </section>
</>
)}
{/* KIS modal */} {/* KIS modal */}
{kisModal ? ( {kisModal ? (