From c601293ed7709ca791fabdc161e4a7dfebe5bb21 Mon Sep 17 00:00:00 2001 From: Macbook Date: Thu, 11 Jun 2026 12:25:36 +0900 Subject: [PATCH] =?UTF-8?q?=EC=97=85=EB=B9=84=ED=8A=B8=20=EC=8A=A4?= =?UTF-8?q?=ED=83=80=EC=9D=BC=20=EB=A7=A4=EC=88=98/=EB=A7=A4=EB=8F=84=20?= =?UTF-8?q?=ED=83=AD=20=EC=A3=BC=EB=AC=B8=20=ED=8C=A8=EB=84=90=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TradeTabOrderPanel 신규 생성: 매수|매도|간편주문|거래내역 탭 - 매수 탭: 빨간 하단 인디케이터, 매도 탭: 파란 하단 인디케이터 - 간편주문: 시장가 즉시체결 (KRW 금액 입력 + % 버튼) - 거래내역: historySlot prop으로 외부 콘텐츠 주입 - VirtualTradingPage: 외부 '거래' 탭 제거 → 거래내역 내부 탭으로 이동 - PaperTradingPage: 미체결/체결/원장 거래내역 내부 탭으로 이동 - TradeNotificationListPage·RightSidePanel: TradeTabOrderPanel 적용 - tradeRightPanel.css: ttop-* · qop-* 스타일 추가 Co-authored-by: Cursor --- frontend/src/components/PaperTradingPage.tsx | 66 ++-- frontend/src/components/RightSidePanel.tsx | 5 +- .../components/TradeNotificationListPage.tsx | 5 +- .../src/components/VirtualTradingPage.tsx | 34 +- .../components/trade/TradeTabOrderPanel.tsx | 335 ++++++++++++++++++ frontend/src/components/trade/index.ts | 2 + frontend/src/styles/tradeRightPanel.css | 260 ++++++++++++++ 7 files changed, 653 insertions(+), 54 deletions(-) create mode 100644 frontend/src/components/trade/TradeTabOrderPanel.tsx diff --git a/frontend/src/components/PaperTradingPage.tsx b/frontend/src/components/PaperTradingPage.tsx index 299c2c3..1350f15 100644 --- a/frontend/src/components/PaperTradingPage.tsx +++ b/frontend/src/components/PaperTradingPage.tsx @@ -23,7 +23,7 @@ import PaperPendingOrdersList from './paper/PaperPendingOrdersList'; import PaperLedgerTab from './paper/PaperLedgerTab'; import PaperAssetTrendChart from './paper/PaperAssetTrendChart'; import { computePaperMetrics } from '../utils/paperMetrics'; -import { TradeSplitOrderPanel, TradeOrderbookPanelContent, TradeRightPanelBody } from './trade'; +import { TradeTabOrderPanel, TradeOrderbookPanelContent, TradeRightPanelBody } from './trade'; import type { Theme, TradeOrderFillRequest } from '../types'; import PaperAnalysisChart from './paper/PaperAnalysisChart'; import PaperLeftStrategyTab from './paper/PaperLeftStrategyTab'; @@ -38,7 +38,7 @@ import { formatVirtualTargetOptionLabel } from '../utils/virtualTargetNames'; type HistoryTab = 'open' | 'recent' | 'ledger'; type CenterTab = 'invest' | 'chart'; -type RightTab = 'trade' | 'orderbook' | 'history'; +type RightTab = 'trade' | 'orderbook'; type LeftTab = 'strategy' | 'settings'; interface Props { @@ -140,7 +140,7 @@ const PaperTradingPage: React.FC = ({ } finally { setInitialLoading(false); } - if (opts?.focusHistory) setRightTab('history'); + if (opts?.focusHistory) setRightTab('trade'); }, []); useEffect(() => { @@ -293,9 +293,33 @@ const PaperTradingPage: React.FC = ({
- +
+ ); + + const tradeHistorySlot = ( +
+
+ + + +
+
+ {historyTab === 'open' ? ( + void loadData()} /> + ) : historyTab === 'ledger' ? ( + + ) : ( + + )} +
); @@ -421,7 +445,7 @@ const PaperTradingPage: React.FC = ({ right={( {rightTab === 'trade' ? ( - = ({ paperTradingEnabled={paperTradingEnabled} paperAutoTradeEnabled={paperAutoTradeEnabled} onPaperOrderFilled={() => void loadData({ focusHistory: true })} + historySlot={tradeHistorySlot} /> - ) : rightTab === 'orderbook' ? ( + ) : ( = ({ tradeStrength={tradeStrength ?? undefined} onRowClick={handleOrderbookRowClick} /> - ) : ( -
-
- - - -
-
- {historyTab === 'open' ? ( - void loadData()} /> - ) : historyTab === 'ledger' ? ( - - ) : ( - - )} -
-
)}
)} diff --git a/frontend/src/components/RightSidePanel.tsx b/frontend/src/components/RightSidePanel.tsx index f333ad1..aa22a8a 100644 --- a/frontend/src/components/RightSidePanel.tsx +++ b/frontend/src/components/RightSidePanel.tsx @@ -5,7 +5,7 @@ */ import React, { useState, useEffect, useRef, useCallback } from 'react'; import { - TradeSplitOrderPanel, + TradeTabOrderPanel, TradeOrderbookPanelContent, TradeRightPanelTabs, TradeRightPanelBody, @@ -158,7 +158,7 @@ const RightSidePanel: React.FC = ({ {activeTab === 'trade' && ( - = ({ paperTradingEnabled={paperTradingEnabled} paperAutoTradeEnabled={paperAutoTradeEnabled} onPaperOrderFilled={onPaperOrderFilled} + hideHistory /> )} diff --git a/frontend/src/components/TradeNotificationListPage.tsx b/frontend/src/components/TradeNotificationListPage.tsx index 4e99d29..24e40b9 100644 --- a/frontend/src/components/TradeNotificationListPage.tsx +++ b/frontend/src/components/TradeNotificationListPage.tsx @@ -23,7 +23,7 @@ import BacktestAnalysisReportModal from './backtest/BacktestAnalysisReportModal' import type { BacktestAnalysisReportModel } from './backtest/BacktestAnalysisReportModal'; import { coerceFiniteNumber } from '../utils/safeFormat'; import { - TradeSplitOrderPanel, + TradeTabOrderPanel, TradeOrderbookPanelContent, TradeRightPanelTabs, TradeRightPanelBody, @@ -753,7 +753,7 @@ export const TradeNotificationListPage: React.FC = ({ bodyClassName="tnl-right-body" > {rightTab === 'trade' ? ( - = ({ paperTradingEnabled={paperTradingEnabled} paperAutoTradeEnabled={paperAutoTradeEnabled} onPaperOrderFilled={() => void refreshPaperData()} + hideHistory /> ) : ( = ({ setTrades(tr); } catch { /* ignore */ } onPaperOrderFilled?.(); - if (opts?.focusHistory) setRightTab('history'); + if (opts?.focusHistory) setRightTab('trade'); }, [onPaperOrderFilled]); const handleGlobalDisplayModeChange = useCallback((mode: VirtualCardDisplayMode) => { @@ -472,12 +472,20 @@ const VirtualTradingPage: React.FC = ({
-
); + const tradeHistorySlot = ( + + ); + return ( = ({ right={( {rightTab === 'trade' ? ( - = ({ paperTradingEnabled={paperTradingEnabled} paperAutoTradeEnabled={paperAutoTradeEnabled} onPaperOrderFilled={() => void refreshPaperData({ focusHistory: true })} + historySlot={tradeHistorySlot} /> - ) : rightTab === 'orderbook' ? ( + ) : ( = ({ tradeStrength={tradeStrength ?? undefined} onRowClick={handleOrderbookRowClick} /> - ) : ( - )} )} diff --git a/frontend/src/components/trade/TradeTabOrderPanel.tsx b/frontend/src/components/trade/TradeTabOrderPanel.tsx new file mode 100644 index 0000000..920e86d --- /dev/null +++ b/frontend/src/components/trade/TradeTabOrderPanel.tsx @@ -0,0 +1,335 @@ +/** + * 업비트 스타일 주문 패널 + * 매수 | 매도 | 간편주문 | 거래내역 탭 + */ +import React, { useState, useEffect, useCallback } from 'react'; +import type { TradeOrderFillRequest } from '../../types'; +import TradeOrderPanel, { fmtKrw } from '../TradeOrderPanel'; +import { placePaperOrder } from '../../utils/backendApi'; +import { getKoreanName } from '../../utils/marketNameCache'; +import { coerceFiniteNumber } from '../../utils/safeFormat'; + +export type TradeTabOrderTab = 'buy' | 'sell' | 'quick' | 'history'; + +export interface TradeTabOrderPanelProps { + market: string; + tradePrice: number | null; + availableKrw?: number; + availableSymbolBuyKrw?: number; + availableCoinQty?: number; + fillBuy?: TradeOrderFillRequest | null; + fillSell?: TradeOrderFillRequest | null; + /** 차트 호가에서 자동 입력 */ + fillRequest?: TradeOrderFillRequest | null; + searchAnchorRef?: React.RefObject; + onMarketSelect?: (market: string) => void; + paperTradingEnabled?: boolean; + paperAutoTradeEnabled?: boolean; + onPaperOrderFilled?: () => void; + className?: string; + defaultTab?: TradeTabOrderTab; + /** 거래내역 탭 콘텐츠 (외부 주입) */ + historySlot?: React.ReactNode; + /** 거래내역 탭 숨김 */ + hideHistory?: boolean; +} + +/* ─────────────────────────── 간편주문 ─────────────────────────── */ + +const PCT_BTNS = [10, 25, 50, 100] as const; + +function parseNum(s: string): number { + const v = Number(String(s).replace(/,/g, '').trim()); + return Number.isFinite(v) ? v : 0; +} + +interface QuickOrderPanelProps { + market: string; + tradePrice: number | null; + availableKrw: number; + availableCoinQty: number; + paperTradingEnabled: boolean; + paperAutoTradeEnabled: boolean; + onPaperOrderFilled?: () => void; +} + +const QuickOrderPanel: React.FC = ({ + market, + tradePrice, + availableKrw, + availableCoinQty, + paperTradingEnabled, + paperAutoTradeEnabled, + onPaperOrderFilled, +}) => { + const [side, setSide] = useState<'buy' | 'sell'>('buy'); + const [amountStr, setAmountStr] = useState(''); + const [pct, setPct] = useState(null); + + const code = market.replace(/^KRW-/, ''); + const koreanName = getKoreanName(market); + const refPrice = coerceFiniteNumber(tradePrice) ?? 0; + + useEffect(() => { + setAmountStr(''); + setPct(null); + }, [market, side]); + + const handlePct = useCallback((p: number) => { + setPct(p); + if (side === 'buy') { + const budget = availableKrw > 0 ? (availableKrw * p) / 100 : 0; + setAmountStr(budget > 0 ? fmtKrw(budget) : ''); + } else { + const qty = availableCoinQty > 0 ? (availableCoinQty * p) / 100 : 0; + const krw = refPrice > 0 ? qty * refPrice : 0; + setAmountStr(krw > 0 ? fmtKrw(krw) : ''); + } + }, [side, availableKrw, availableCoinQty, refPrice]); + + const handleSubmit = useCallback(async () => { + if (!paperTradingEnabled) { + window.alert('모의투자가 비활성화되어 있습니다. 설정 → 모의투자에서 사용을 켜 주세요.'); + return; + } + const totalKrw = parseNum(amountStr); + if (totalKrw <= 0 || refPrice <= 0) { + window.alert('주문금액을 입력해 주세요.'); + return; + } + const qty = totalKrw / refPrice; + try { + const result = await placePaperOrder({ + market, + side: side === 'buy' ? 'BUY' : 'SELL', + orderKind: 'market', + orderType: 'MARKET', + price: refPrice, + quantity: qty, + source: 'MANUAL', + }); + if (!result?.trade && !result?.order) { + window.alert('모의 주문에 실패했습니다.'); + return; + } + onPaperOrderFilled?.(); + const label = side === 'buy' ? '매수' : '매도'; + if (result.trade) { + window.alert( + `[모의투자] ${koreanName || code} ${label} 체결\n` + + `가격: ${fmtKrw(result.trade.price)} KRW\n` + + `수량: ${result.trade.quantity} ${code}`, + ); + } else { + window.alert(`[모의투자] ${koreanName || code} ${label} 주문이 접수되었습니다.`); + } + setAmountStr(''); + setPct(null); + } catch (e) { + window.alert(e instanceof Error ? e.message : '모의 주문 처리 중 오류가 발생했습니다.'); + } + }, [paperTradingEnabled, amountStr, refPrice, market, side, koreanName, code, onPaperOrderFilled]); + + const totalKrw = parseNum(amountStr); + const estQty = refPrice > 0 && totalKrw > 0 ? totalKrw / refPrice : 0; + + const balance = side === 'buy' + ? `${availableKrw > 0 ? fmtKrw(availableKrw) : '-'} KRW` + : `${availableCoinQty > 0 ? availableCoinQty.toFixed(8).replace(/\.?0+$/, '') : '-'} ${code}`; + + return ( +
+ {/* 매수/매도 선택 */} +
+ + +
+ + {/* 주문가능 */} +
+ 주문가능 + {balance} +
+ + {/* 주문금액 */} +
+ 주문금액 (KRW) + { + setAmountStr(e.target.value.replace(/[^\d,]/g, '')); + setPct(null); + }} + /> +
+ + {/* % 버튼 */} +
+ {PCT_BTNS.map(p => ( + + ))} + +
+ + {/* 예상 수량 */} + {estQty > 0 && ( +
+ ≈ {estQty >= 1 + ? estQty.toFixed(8).replace(/\.?0+$/, '') + : estQty.toFixed(8)} {code} +
+ )} + + {/* 메타 */} +
+ 시장가 즉시 체결 · 수수료(부가세 포함): 0.05% +
+ + {/* 제출 */} +
+ +
+
+ ); +}; + +/* ─────────────────────────── 메인 컴포넌트 ─────────────────────────── */ + +const TAB_LIST: { id: TradeTabOrderTab; label: string }[] = [ + { id: 'buy', label: '매수' }, + { id: 'sell', label: '매도' }, + { id: 'quick', label: '간편주문' }, + { id: 'history', label: '거래내역' }, +]; + +const TradeTabOrderPanel: React.FC = ({ + market, + tradePrice, + availableKrw = 0, + availableSymbolBuyKrw, + availableCoinQty = 0, + fillBuy, + fillSell, + fillRequest, + searchAnchorRef, + onMarketSelect, + paperTradingEnabled = false, + paperAutoTradeEnabled = false, + onPaperOrderFilled, + className = '', + defaultTab = 'buy', + historySlot, + hideHistory = false, +}) => { + const [activeTab, setActiveTab] = useState(defaultTab); + + const resolvedBuyFill = fillBuy ?? (fillRequest?.side === 'buy' ? fillRequest : null); + const resolvedSellFill = fillSell ?? (fillRequest?.side === 'sell' ? fillRequest : null); + + useEffect(() => { + if (!fillRequest) return; + setActiveTab(fillRequest.side === 'buy' ? 'buy' : 'sell'); + }, [fillRequest?.seq, fillRequest?.side]); + + const tabs = hideHistory ? TAB_LIST.filter(t => t.id !== 'history') : TAB_LIST; + + return ( +
+ {/* 탭 바 */} +
+ {tabs.map(t => ( + + ))} +
+ + {/* 탭 본문 */} +
+ {(activeTab === 'buy' || activeTab === 'sell') && ( +
+
+ +
+
+ )} + + {activeTab === 'quick' && ( + + )} + + {activeTab === 'history' && ( +
+ {historySlot ?? ( +
거래내역이 없습니다.
+ )} +
+ )} +
+
+ ); +}; + +export default TradeTabOrderPanel; diff --git a/frontend/src/components/trade/index.ts b/frontend/src/components/trade/index.ts index d29c8a9..7ff29f7 100644 --- a/frontend/src/components/trade/index.ts +++ b/frontend/src/components/trade/index.ts @@ -4,6 +4,8 @@ export { default as TradeSellOrderPanel } from './TradeSellOrderPanel'; export type { TradeSellOrderPanelProps } from './TradeSellOrderPanel'; export { default as TradeSplitOrderPanel } from './TradeSplitOrderPanel'; export type { TradeSplitOrderPanelProps } from './TradeSplitOrderPanel'; +export { default as TradeTabOrderPanel } from './TradeTabOrderPanel'; +export type { TradeTabOrderPanelProps, TradeTabOrderTab } from './TradeTabOrderPanel'; export { default as TradeOrderbookPanelContent } from './TradeOrderbookPanelContent'; export type { TradeOrderbookPanelContentProps } from './TradeOrderbookPanelContent'; export { default as TradeRightPanelTabs } from './TradeRightPanelTabs'; diff --git a/frontend/src/styles/tradeRightPanel.css b/frontend/src/styles/tradeRightPanel.css index 4c344d1..8dd61b7 100644 --- a/frontend/src/styles/tradeRightPanel.css +++ b/frontend/src/styles/tradeRightPanel.css @@ -411,3 +411,263 @@ .trade-right-panel .ptd-order-card--trade-inline .top-paper-hint { display: block; } + +/* ══════════════════════════════════════════════════════════════════ + 업비트 스타일 탭 주문 패널 (TradeTabOrderPanel) + ttop-wrap > ttop-tabs / ttop-body + ══════════════════════════════════════════════════════════════════ */ + +/* 래퍼: 전체 높이 flex 컬럼 */ +.trade-right-panel .ttop-wrap { + display: flex; + flex-direction: column; + flex: 1 1 0; + min-height: 0; + height: 100%; + overflow: hidden; +} + +/* ── 탭 바 ── */ +.trade-right-panel .ttop-tabs { + display: flex; + flex-shrink: 0; + border-bottom: 1px solid var(--border); + background: var(--bg2); +} + +.trade-right-panel .ttop-tab { + flex: 1; + padding: 13px 4px 11px; + font-size: 13px; + font-weight: 600; + background: transparent; + border: none; + border-bottom: 3px solid transparent; + margin-bottom: -1px; + color: var(--text3); + cursor: pointer; + transition: color 0.12s, border-color 0.12s; + white-space: nowrap; +} +.trade-right-panel .ttop-tab:hover { + color: var(--text2); +} + +/* 매수 탭 활성 */ +.trade-right-panel .ttop-tab--buy.ttop-tab--on { + color: var(--gc-trade-buy); + border-bottom-color: var(--gc-trade-buy); +} +/* 매도 탭 활성 */ +.trade-right-panel .ttop-tab--sell.ttop-tab--on { + color: var(--gc-trade-sell); + border-bottom-color: var(--gc-trade-sell); +} +/* 간편주문·거래내역 탭 활성 */ +.trade-right-panel .ttop-tab--quick.ttop-tab--on, +.trade-right-panel .ttop-tab--history.ttop-tab--on { + color: var(--accent, #7aa2f7); + border-bottom-color: var(--accent, #7aa2f7); +} + +/* ── 탭 본문 ── */ +.trade-right-panel .ttop-body { + flex: 1 1 0; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* 주문 폼 카드 영역 (매수/매도 탭) */ +.trade-right-panel .ttop-card-body { + flex: 1 1 0; + min-height: 0; + display: flex; + flex-direction: column; + overflow-y: auto; + overflow-x: hidden; +} + +/* ttop-card-body 안의 order-card — ptd-split-card-body 안과 동일 규칙 */ +.trade-right-panel .ptd-split-panel--right .ttop-card-body > .ptd-order-card--trade-inline { + flex: 1 1 0; + width: 100%; + min-height: 0; + height: 100%; + max-height: 100%; + overflow: visible; +} + +/* 거래내역 영역 */ +.trade-right-panel .ttop-history { + flex: 1 1 0; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + display: flex; + flex-direction: column; +} + +.trade-right-panel .ttop-empty { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + color: var(--text3); + padding: 24px; + text-align: center; +} + +/* ── 간편주문 폼 (QuickOrderPanel) ── */ +.trade-right-panel .qop-wrap { + flex: 1 1 0; + min-height: 0; + display: flex; + flex-direction: column; + padding: var(--gc-trade-card-pad); + gap: var(--gc-trade-row-gap); + overflow-y: auto; + overflow-x: hidden; +} + +/* 매수/매도 전환 */ +.trade-right-panel .qop-side-row { + display: flex; + gap: 6px; + flex-shrink: 0; +} +.trade-right-panel .qop-side-btn { + flex: 1; + padding: 10px 8px; + font-size: 13px; + font-weight: 700; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg3); + color: var(--text2); + cursor: pointer; + transition: background 0.12s, border-color 0.12s, color 0.12s; +} +.trade-right-panel .qop-side-btn--buy.qop-side-btn--on { + background: color-mix(in srgb, var(--gc-trade-buy) 15%, var(--bg3)); + border-color: var(--gc-trade-buy); + color: var(--gc-trade-buy); +} +.trade-right-panel .qop-side-btn--sell.qop-side-btn--on { + background: color-mix(in srgb, var(--gc-trade-sell) 15%, var(--bg3)); + border-color: var(--gc-trade-sell); + color: var(--gc-trade-sell); +} + +/* 주문가능 */ +.trade-right-panel .qop-available { + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; + font-size: 12px; +} +.trade-right-panel .qop-available-label { + color: var(--text2); +} +.trade-right-panel .qop-available-value { + font-weight: 700; + color: var(--text); +} + +/* 주문금액 필드 */ +.trade-right-panel .qop-field { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + flex-shrink: 0; +} +.trade-right-panel .qop-label { + flex: 0 0 var(--gc-trade-row-label-w); + font-size: 12px; + color: var(--text2); + white-space: nowrap; +} +.trade-right-panel .qop-input { + flex: 1 1 0; + min-width: 0; + padding: 8px 10px; + font-size: 13px; + line-height: 1.25; + border: 1px solid var(--gc-trade-upbit-input-border); + border-radius: 4px; + background: var(--bg); + color: var(--text); + text-align: right; +} + +/* % 버튼 */ +.trade-right-panel .qop-pct-row { + display: flex; + gap: 4px; + flex-shrink: 0; + margin-left: calc(var(--gc-trade-row-label-w) + 8px); +} +.trade-right-panel .qop-pct-btn { + flex: 1 1 0; + min-width: 0; + padding: 6px 2px; + font-size: 11px; + border: 1px solid var(--gc-trade-upbit-input-border); + border-radius: 4px; + background: var(--bg); + color: var(--text2); + cursor: pointer; + transition: border-color 0.1s, color 0.1s; +} +.trade-right-panel .qop-pct-btn--active { + border-color: var(--gc-trade-upbit-accent); + color: var(--gc-trade-upbit-accent); + background: color-mix(in srgb, var(--gc-trade-upbit-accent) 8%, var(--bg)); +} + +/* 예상 수량 */ +.trade-right-panel .qop-estimate { + font-size: 11px; + color: var(--text3); + text-align: right; + flex-shrink: 0; +} + +/* 메타 안내 */ +.trade-right-panel .qop-meta { + font-size: 11px; + color: var(--text3); + text-align: right; + flex-shrink: 0; + margin-top: auto; + padding-top: 8px; +} + +/* 제출 버튼 */ +.trade-right-panel .qop-actions { + flex-shrink: 0; + padding-top: 4px; +} +.trade-right-panel .qop-submit { + width: 100%; + padding: 13px 12px; + font-size: 15px; + font-weight: 700; + border: none; + border-radius: 4px; + cursor: pointer; + transition: opacity 0.12s; +} +.trade-right-panel .qop-submit:hover { opacity: 0.9; } +.trade-right-panel .qop-submit--buy { + background: var(--gc-trade-buy); + color: #fff; +} +.trade-right-panel .qop-submit--sell { + background: var(--gc-trade-sell); + color: #fff; +}