diff --git a/src/pages/stock/Stock.css b/src/pages/stock/Stock.css index ce33349..3548223 100644 --- a/src/pages/stock/Stock.css +++ b/src/pages/stock/Stock.css @@ -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 { diff --git a/src/pages/stock/StockTrade.jsx b/src/pages/stock/StockTrade.jsx index ea8d5cf..6982a10 100644 --- a/src/pages/stock/StockTrade.jsx +++ b/src/pages/stock/StockTrade.jsx @@ -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 (
거래 데스크
-- 연결된 계좌 잔고를 확인하고 필요한 주문을 요청하세요. + 실제 계좌와 AI 모의투자를 한 곳에서 관리하세요.
계좌 요약
-+ {activeTab === TAB_PORTFOLIO + ? '쟁승토리 계좌 요약' + : 'AI 투자 요약'} +
+ {activeTab === TAB_PORTFOLIO ? ( + /* Portfolio summary */ +{summary.note}
) : null}{portfolioError}
- ) : null} + {/* ── Main Tabs ───────────────────────────────────────── */} +포트폴리오
-- 증권사별 보유 종목을 수동 등록하면 현재가를 자동 조회합니다. (3분 캐시) -
-{portfolioError}
+ ) : null} - {/* Add form */} - {addFormOpen && ( - - )} - - {/* Portfolio total summary */} - {portfolioHoldings.length > 0 && ( -- {broker} -
-포트폴리오
+- {items.length}종목 · 평가{' '} - {formatNumber(bSummary.totalEval)} · 손익{' '} - - {formatNumber(bSummary.totalProfit)} ( - {formatPercent(bSummary.totalProfitRate)}) - + 증권사별 보유 종목을 수동 등록하면 현재가를 자동 조회합니다. (3분 캐시)
+ {broker} +
++ {items.length}종목 · 평가{' '} + {formatNumber(bSummary.totalEval)} · 손익{' '} + + {formatNumber(bSummary.totalProfit)} ( + {formatPercent(bSummary.totalProfitRate)}) + +
++ {item.name ?? item.ticker ?? 'N/A'} +
+ + {item.ticker ?? ''} + ++ 등록된 종목이 없습니다. 상단의 + 종목 추가 버튼으로 보유 종목을 등록하세요. +
+{balanceError}
: null} + + {/* AI Balance section */} +AI 모의투자
++ AI가 운용 중인 모의투자 계좌의 잔고와 보유 종목을 확인합니다. +
+- {item.name ?? item.ticker ?? 'N/A'} + {item.name ?? item.code ?? 'N/A'}
- {item.ticker ?? ''} + {item.code ?? ''}보유 종목이 없습니다.
+ )}{balanceError}
: null} - -잔고
-- 연결 계좌의 실시간 잔고와 보유 종목을 확인합니다. -
-수동 주문
++ 종목명 또는 종목코드를 입력하고 매수/매도 주문을 요청합니다. +
- {item.name ?? item.code ?? 'N/A'} -
- - {item.code ?? ''} - -보유 종목이 없습니다.
- )} -수동 주문
-- 종목명 또는 종목코드를 입력하고 매수/매도 주문을 - 요청합니다. -
-