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 (
+ {/* ── Header ──────────────────────────────────────────── */}

거래 데스크

-

주식 거래

+

거래 데스크

- 연결된 계좌 잔고를 확인하고 필요한 주문을 요청하세요. + 실제 계좌와 AI 모의투자를 한 곳에서 관리하세요.

@@ -370,598 +389,644 @@ const StockTrade = () => {
-

계좌 요약

-
-
- 총 평가금액 - {formatNumber(totalEval)} +

+ {activeTab === TAB_PORTFOLIO + ? '쟁승토리 계좌 요약' + : 'AI 투자 요약'} +

+ {activeTab === TAB_PORTFOLIO ? ( + /* Portfolio summary */ +
+
+ 총 매입 + {formatNumber(portfolioSummary.total_buy)} +
+
+ 총 평가 + {formatNumber(portfolioSummary.total_eval)} +
+
+ 총 손익 + + {formatNumber(portfolioSummary.total_profit)} + {portfolioSummary.total_profit_rate != null && ( + + ({formatPercent(portfolioSummary.total_profit_rate)}) + + )} + +
+
+ 보유 종목 + {portfolioHoldings.length} +
-
- 예수금 - {formatNumber(deposit)} + ) : ( + /* AI balance summary */ +
+
+ 총 평가금액 + {formatNumber(totalEval)} +
+
+ 예수금 + {formatNumber(deposit)} +
+
+ 보유 종목 + {holdings.length} +
-
- 보유 종목 - {holdings.length} -
- {portfolioHoldings.length > 0 && ( - <> -
- 포트폴리오 종목 - {portfolioHoldings.length} -
-
- 포트폴리오 평가 - {formatNumber(portfolioSummary.total_eval)} -
-
- 포트폴리오 손익 - - {formatNumber(portfolioSummary.total_profit)} - {portfolioSummary.total_profit_rate != null && ( - - ({formatPercent(portfolioSummary.total_profit_rate)}) - - )} - -
- - )} -
- {summary.note ? ( + )} + {activeTab === TAB_AI && summary.note ? (

{summary.note}

) : null}
- {/* ── Portfolio sections (broker별) ─────────────────────── */} - {portfolioError ? ( -

{portfolioError}

- ) : null} + {/* ── Main Tabs ───────────────────────────────────────── */} +
+ + +
- {/* 총 포트폴리오 요약 + 종목 추가 */} -
-
-
-

포트폴리오

-

수동 입력 종목 관리

-

- 증권사별 보유 종목을 수동 등록하면 현재가를 자동 조회합니다. (3분 캐시) -

-
-
- {portfolioLoading ? ( - - ) : null} - - -
-
+ {/* ════════════════════════════════════════════════════════ + TAB 1: 쟁승토리 계좌 + ════════════════════════════════════════════════════════ */} + {activeTab === TAB_PORTFOLIO && ( + <> + {portfolioError ? ( +

{portfolioError}

+ ) : null} - {/* Add form */} - {addFormOpen && ( -
- - - - - - - {addError &&

{addError}

} -
- )} - - {/* Portfolio total summary */} - {portfolioHoldings.length > 0 && ( -
- {[ - { 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) => ( -
- {s.label} - - {s.isRate ? formatPercent(s.value) : formatNumber(s.value)} - -
- ))} -
- )} -
- - {/* Each broker gets a stacked card */} - {brokerGroups.map(([broker, items]) => { - const bSummary = getBrokerSummary(items); - const color = brokerColors[broker]; - return ( -
+ {/* 포트폴리오 관리 헤더 + 추가 폼 */} +
-

- {broker} -

-

{broker} 보유 현황

+

포트폴리오

+

수동 입력 종목 관리

- {items.length}종목 · 평가{' '} - {formatNumber(bSummary.totalEval)} · 손익{' '} - - {formatNumber(bSummary.totalProfit)} ( - {formatPercent(bSummary.totalProfitRate)}) - + 증권사별 보유 종목을 수동 등록하면 현재가를 자동 조회합니다. (3분 캐시)

+
+ {portfolioLoading ? ( + + ) : null} + + +
-
- {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 ( -
- {isEditing ? ( - /* Edit mode */ -
-
- - -
-
- - -
+ {/* Add form */} + {addFormOpen && ( +
+ + + + + + + {addError &&

{addError}

} +
+ )} + + {/* Portfolio total summary */} + {portfolioHoldings.length > 0 && ( +
+ {[ + { 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) => ( +
+ {s.label} + + {s.isRate ? formatPercent(s.value) : formatNumber(s.value)} + +
+ ))} +
+ )} +
+ + {/* Broker cards stacked */} + {brokerGroups.map(([broker, items]) => { + const bSummary = getBrokerSummary(items); + const color = brokerColors[broker]; + return ( +
+
+
+

+ {broker} +

+

{broker} 보유 현황

+

+ {items.length}종목 · 평가{' '} + {formatNumber(bSummary.totalEval)} · 손익{' '} + + {formatNumber(bSummary.totalProfit)} ( + {formatPercent(bSummary.totalProfitRate)}) + +

+
+
+
+ {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 ( +
+ {isEditing ? ( +
+
+ + +
+
+ + +
+
+ ) : ( + <> +
+

+ {item.name ?? item.ticker ?? 'N/A'} +

+ + {item.ticker ?? ''} + +
+
+ 수량 + {formatNumber(item.quantity)} +
+
+ 매입가 + {formatNumber(item.avg_price)} +
+
+ 현재가 + + {item.current_price != null + ? formatNumber(item.current_price) + : '조회 실패'} + +
+
+ 수익률 + + {profitRate != null + ? formatPercent(profitRate) + : '-'} + +
+
+ 평가손익 + + {profitAmt != null + ? formatNumber(profitAmt) + : '-'} + +
+
+ + {isDeleting ? ( + <> + + + + ) : ( + + )} +
+ + )}
- ) : ( - /* Normal display */ - <> + ); + })} +
+
+ ); + })} + + {portfolioLoaded && portfolioHoldings.length === 0 && !portfolioError && ( +
+

+ 등록된 종목이 없습니다. 상단의 + 종목 추가 버튼으로 보유 종목을 등록하세요. +

+
+ )} + + )} + + {/* ════════════════════════════════════════════════════════ + TAB 2: AI 투자 (모의투자) + ════════════════════════════════════════════════════════ */} + {activeTab === TAB_AI && ( + <> + {balanceError ?

{balanceError}

: null} + + {/* AI Balance section */} +
+
+
+

AI 모의투자

+

보유 현황

+

+ AI가 운용 중인 모의투자 계좌의 잔고와 보유 종목을 확인합니다. +

+
+
+ {balanceLoading ? ( + 조회 중 + ) : null} + +
+
+
+
+ {[ + { label: '총 평가', value: totalEval }, + { label: '예수금', value: deposit }, + ].map((item) => ( +
+ {item.label} + {formatNumber(item.value)} +
+ ))} +
+ {holdings.length ? ( +
+ {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 ( +

- {item.name ?? item.ticker ?? 'N/A'} + {item.name ?? item.code ?? 'N/A'}

- {item.ticker ?? ''} + {item.code ?? ''}
수량 - {formatNumber(item.quantity)} + + {formatNumber(getQty(item))} +
매입가 - {formatNumber(item.avg_price)} + + {formatNumber(getBuyPrice(item))} +
현재가 - - {item.current_price != null - ? formatNumber(item.current_price) - : '조회 실패'} + + {formatNumber(getCurrentPrice(item))}
수익률 - {profitRate != null - ? formatPercent(profitRate) - : '-'} + {formatPercent(profitRate)}
평가손익 - {profitAmt != null - ? formatNumber(profitAmt) - : '-'} + {formatNumber(profitLoss)}
- {/* action buttons */} -
- - {isDeleting ? ( - <> - - - - ) : ( - - )} -
- - )} -
- ); - })} +
+ ); + })} +
+ ) : ( +

보유 종목이 없습니다.

+ )}
- ); - })} - {/* ── Existing balance section ─────────────────────────── */} - {balanceError ?

{balanceError}

: null} - -
-
-
-

잔고

-

보유 현황

-

- 연결 계좌의 실시간 잔고와 보유 종목을 확인합니다. -

-
-
- {balanceLoading ? ( - 조회 중 - ) : null} - -
-
-
-
- {[ - { - label: '총 평가', - value: totalEval, - }, - { - label: '예수금', - value: deposit, - }, - ].map((item) => ( -
- {item.label} - {formatNumber(item.value)} + {/* Manual order section */} +
+
+
+

수동 주문

+

직접 매수/매도

+

+ 종목명 또는 종목코드를 입력하고 매수/매도 주문을 요청합니다. +

- ))} -
- {holdings.length ? ( -
- {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 ( -
-
-

- {item.name ?? item.code ?? 'N/A'} -

- - {item.code ?? ''} - -
-
- 수량 - - {formatNumber(getQty(item))} - -
-
- 매입가 - - {formatNumber(getBuyPrice(item))} - -
-
- 현재가 - - {formatNumber( - getCurrentPrice(item) - )} - -
-
- 수익률 - - {formatPercent(profitRate)} - -
-
- 평가손익 - - {formatNumber(profitLoss)} - -
-
- ); - })}
- ) : ( -

보유 종목이 없습니다.

- )} -
-
- - {/* ── Manual order section ─────────────────────────────── */} -
-
-
-

수동 주문

-

직접 매수/매도

-

- 종목명 또는 종목코드를 입력하고 매수/매도 주문을 - 요청합니다. -

-
-
-
- - - - - - {manualError ? ( -

{manualError}

- ) : null} - {manualResult ? ( -
-

요청 결과

-
-                                {typeof manualResult === 'string'
-                                    ? manualResult
-                                    : JSON.stringify(manualResult, null, 2)}
-                            
-
- ) : null} -
-
+
+ + + + + + {manualError ? ( +

{manualError}

+ ) : null} + {manualResult ? ( +
+

요청 결과

+
+                                        {typeof manualResult === 'string'
+                                            ? manualResult
+                                            : JSON.stringify(manualResult, null, 2)}
+                                    
+
+ ) : null} +
+ + + )} {/* KIS modal */} {kisModal ? (