계좌 페이지 분류

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 ────────────────────────────────────────────────────── */
.pf-section {

View File

@@ -90,18 +90,24 @@ const emptyPortfolioForm = {
avg_price: '',
};
/* ── TAB IDs ─────────────────────────────────────────────────────── */
const TAB_PORTFOLIO = 'portfolio';
const TAB_AI = 'ai';
/* ── component ───────────────────────────────────────────────────── */
const StockTrade = () => {
/* Balance state */
const [balance, setBalance] = useState(null);
const [balanceLoading, setBalanceLoading] = useState(false);
const [balanceError, setBalanceError] = useState('');
/* Active tab */
const [activeTab, setActiveTab] = useState(TAB_PORTFOLIO);
/* Portfolio state */
/* ────────────────────────────────────────────────────────────── */
/* 쟁승토리 계좌 (Portfolio) state */
/* ────────────────────────────────────────────────────────────── */
const [portfolio, setPortfolio] = useState(null);
const [portfolioLoading, setPortfolioLoading] = useState(false);
const [portfolioError, setPortfolioError] = useState('');
const [portfolioLoaded, setPortfolioLoaded] = useState(false);
/* Portfolio add form */
const [addForm, setAddForm] = useState({ ...emptyPortfolioForm });
@@ -118,6 +124,14 @@ const StockTrade = () => {
/* Portfolio delete */
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 */
const [manualForm, setManualForm] = useState({
code: '',
@@ -132,25 +146,13 @@ const StockTrade = () => {
/* ── 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);
setPortfolioLoaded(true);
} catch (err) {
setPortfolioError(err?.message ?? String(err));
} finally {
@@ -158,16 +160,35 @@ const StockTrade = () => {
}
}, []);
useEffect(() => {
loadBalance();
loadPortfolio();
}, [loadBalance, loadPortfolio]);
const loadBalance = useCallback(async () => {
setBalanceLoading(true);
setBalanceError('');
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(() => {
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);
return () => window.clearInterval(timer);
}, [loadPortfolio]);
}, [activeTab, loadPortfolio]);
/* ── portfolio actions ───────────────────────────────────────── */
@@ -201,7 +222,6 @@ const StockTrade = () => {
broker: item.broker,
name: item.name,
});
/* 원본 값을 기억해서 diff 비교용 */
editOrigRef.current = {
quantity: item.quantity,
avg_price: item.avg_price,
@@ -213,7 +233,6 @@ const StockTrade = () => {
const handleEditSave = async (id) => {
setEditLoading(true);
try {
/* 변경된 필드만 추출하여 부분 수정 */
const orig = editOrigRef.current ?? {};
const diff = {};
for (const key of Object.keys(editForm)) {
@@ -289,7 +308,7 @@ const StockTrade = () => {
}
};
/* ── derived data ────────────────────────────────────────────── */
/* ── derived: AI balance ──────────────────────────────────────── */
const holdings = useMemo(() => {
if (!balance) return [];
@@ -304,7 +323,8 @@ const StockTrade = () => {
const deposit =
summary.deposit ?? balance?.deposit ?? balance?.available_cash;
/* Portfolio grouped by broker */
/* ── derived: Portfolio ───────────────────────────────────────── */
const portfolioHoldings = portfolio?.holdings ?? [];
const portfolioSummary = portfolio?.summary ?? {};
const brokerGroups = useMemo(() => {
@@ -317,7 +337,6 @@ const StockTrade = () => {
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;
@@ -335,7 +354,6 @@ const StockTrade = () => {
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)' },
@@ -356,12 +374,13 @@ const StockTrade = () => {
return (
<div className="stock">
{/* ── Header ──────────────────────────────────────────── */}
<header className="stock-header">
<div>
<p className="stock-kicker">거래 데스크</p>
<h1>주식 거래</h1>
<h1>거래 데스크</h1>
<p className="stock-sub">
연결된 계좌 잔고를 확인하고 필요 주문을 요청하세요.
실제 계좌 AI 모의투자를 곳에서 관리하세요.
</p>
<div className="stock-actions">
<Link className="button ghost" to="/stock">
@@ -370,32 +389,24 @@ const StockTrade = () => {
</div>
</div>
<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>
<span> 평가금액</span>
<strong>{formatNumber(totalEval)}</strong>
<span> 매입</span>
<strong>{formatNumber(portfolioSummary.total_buy)}</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>
<span> 평가</span>
<strong>{formatNumber(portfolioSummary.total_eval)}</strong>
</div>
<div>
<span>포트폴리오 손익</span>
<span> 손익</span>
<strong
className={`stock-profit ${profitColorClass(
toNumeric(portfolioSummary.total_profit)
@@ -409,21 +420,70 @@ const StockTrade = () => {
)}
</strong>
</div>
</>
)}
<div>
<span>보유 종목</span>
<strong>{portfolioHoldings.length}</strong>
</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>
) : null}
</div>
</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 ? (
<p className="stock-error">{portfolioError}</p>
) : null}
{/* 포트폴리오 요약 + 종목 추가 */}
{/* 포트폴리오 관리 헤더 + 추가 폼 */}
<section className="stock-panel stock-panel--wide pf-section">
<div className="stock-panel__head">
<div>
@@ -556,7 +616,7 @@ const StockTrade = () => {
)}
</section>
{/* Each broker gets a stacked card */}
{/* Broker cards stacked */}
{brokerGroups.map(([broker, items]) => {
const bSummary = getBrokerSummary(items);
const color = brokerColors[broker];
@@ -607,7 +667,6 @@ const StockTrade = () => {
className="stock-holdings__item pf-item"
>
{isEditing ? (
/* Edit mode */
<div className="pf-edit-row">
<div className="pf-edit-fields">
<label>
@@ -656,7 +715,6 @@ const StockTrade = () => {
</div>
</div>
) : (
/* Normal display */
<>
<div>
<p className="stock-holdings__name">
@@ -704,7 +762,6 @@ const StockTrade = () => {
: '-'}
</strong>
</div>
{/* action buttons */}
<div className="pf-item-actions">
<button
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}
{/* AI Balance section */}
<section className="stock-panel stock-panel--wide">
<div className="stock-panel__head">
<div>
<p className="stock-panel__eyebrow">잔고</p>
<p className="stock-panel__eyebrow">AI 모의투자</p>
<h3>보유 현황</h3>
<p className="stock-panel__sub">
연결 계좌의 실시간 잔고와 보유 종목을 확인합니다.
AI가 운용 중인 모의투자 계좌의 잔고와 보유 종목을 확인합니다.
</p>
</div>
<div className="stock-panel__actions">
@@ -780,14 +852,8 @@ const StockTrade = () => {
<div className="stock-balance">
<div className="stock-balance__summary">
{[
{
label: '총 평가',
value: totalEval,
},
{
label: '예수금',
value: deposit,
},
{ label: '총 평가', value: totalEval },
{ label: '예수금', value: deposit },
].map((item) => (
<div
key={item.label}
@@ -835,9 +901,7 @@ const StockTrade = () => {
<div className="stock-holdings__metric">
<span>현재가</span>
<strong>
{formatNumber(
getCurrentPrice(item)
)}
{formatNumber(getCurrentPrice(item))}
</strong>
</div>
<div className="stock-holdings__metric">
@@ -866,15 +930,14 @@ const StockTrade = () => {
</div>
</section>
{/* ── Manual order 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>
@@ -962,6 +1025,8 @@ const StockTrade = () => {
) : null}
</form>
</section>
</>
)}
{/* KIS modal */}
{kisModal ? (