업비트 스타일 매수/매도 탭 주문 패널 적용

- TradeTabOrderPanel 신규 생성: 매수|매도|간편주문|거래내역 탭
  - 매수 탭: 빨간 하단 인디케이터, 매도 탭: 파란 하단 인디케이터
  - 간편주문: 시장가 즉시체결 (KRW 금액 입력 + % 버튼)
  - 거래내역: historySlot prop으로 외부 콘텐츠 주입
- VirtualTradingPage: 외부 '거래' 탭 제거 → 거래내역 내부 탭으로 이동
- PaperTradingPage: 미체결/체결/원장 거래내역 내부 탭으로 이동
- TradeNotificationListPage·RightSidePanel: TradeTabOrderPanel 적용
- tradeRightPanel.css: ttop-* · qop-* 스타일 추가

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Macbook
2026-06-11 12:25:36 +09:00
parent 1752ec555a
commit c601293ed7
7 changed files with 653 additions and 54 deletions
@@ -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<HTMLElement | null>;
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<QuickOrderPanelProps> = ({
market,
tradePrice,
availableKrw,
availableCoinQty,
paperTradingEnabled,
paperAutoTradeEnabled,
onPaperOrderFilled,
}) => {
const [side, setSide] = useState<'buy' | 'sell'>('buy');
const [amountStr, setAmountStr] = useState('');
const [pct, setPct] = useState<number | null>(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 (
<div className={`qop-wrap qop-wrap--${side}`}>
{/* 매수/매도 선택 */}
<div className="qop-side-row">
<button
type="button"
className={`qop-side-btn qop-side-btn--buy${side === 'buy' ? ' qop-side-btn--on' : ''}`}
onClick={() => setSide('buy')}
>
</button>
<button
type="button"
className={`qop-side-btn qop-side-btn--sell${side === 'sell' ? ' qop-side-btn--on' : ''}`}
onClick={() => setSide('sell')}
>
</button>
</div>
{/* 주문가능 */}
<div className="qop-available">
<span className="qop-available-label"></span>
<span className="qop-available-value">{balance}</span>
</div>
{/* 주문금액 */}
<div className="qop-field">
<span className="qop-label"> (KRW)</span>
<input
type="text"
className="qop-input"
value={amountStr}
placeholder="0"
inputMode="numeric"
onChange={e => {
setAmountStr(e.target.value.replace(/[^\d,]/g, ''));
setPct(null);
}}
/>
</div>
{/* % 버튼 */}
<div className="qop-pct-row">
{PCT_BTNS.map(p => (
<button
key={p}
type="button"
className={`qop-pct-btn${pct === p ? ' qop-pct-btn--active' : ''}`}
onClick={() => handlePct(p)}
>
{`${p}%`}
</button>
))}
<button
type="button"
className={`qop-pct-btn${pct === -1 ? ' qop-pct-btn--active' : ''}`}
onClick={() => { setPct(-1); setAmountStr(''); }}
>
</button>
</div>
{/* 예상 수량 */}
{estQty > 0 && (
<div className="qop-estimate">
{estQty >= 1
? estQty.toFixed(8).replace(/\.?0+$/, '')
: estQty.toFixed(8)} {code}
</div>
)}
{/* 메타 */}
<div className="qop-meta">
<span> · ( ): 0.05%</span>
</div>
{/* 제출 */}
<div className="qop-actions">
<button
type="button"
className={`qop-submit qop-submit--${side}`}
onClick={handleSubmit}
>
{side === 'buy' ? '매수' : '매도'}
</button>
</div>
</div>
);
};
/* ─────────────────────────── 메인 컴포넌트 ─────────────────────────── */
const TAB_LIST: { id: TradeTabOrderTab; label: string }[] = [
{ id: 'buy', label: '매수' },
{ id: 'sell', label: '매도' },
{ id: 'quick', label: '간편주문' },
{ id: 'history', label: '거래내역' },
];
const TradeTabOrderPanel: React.FC<TradeTabOrderPanelProps> = ({
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<TradeTabOrderTab>(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 (
<div className={`ttop-wrap ptd-split-panel--right${className ? ` ${className}` : ''}`}>
{/* 탭 바 */}
<div className="ttop-tabs">
{tabs.map(t => (
<button
key={t.id}
type="button"
className={`ttop-tab ttop-tab--${t.id}${activeTab === t.id ? ' ttop-tab--on' : ''}`}
onClick={() => setActiveTab(t.id)}
>
{t.label}
</button>
))}
</div>
{/* 탭 본문 */}
<div className="ttop-body">
{(activeTab === 'buy' || activeTab === 'sell') && (
<div className="ttop-card-body">
<div className="ptd-order-card ptd-order-card--trade-inline">
<TradeOrderPanel
side={activeTab}
market={market}
tradePrice={tradePrice}
availableKrw={availableKrw}
availableSymbolBuyKrw={availableSymbolBuyKrw}
availableCoinQty={availableCoinQty}
fillRequest={activeTab === 'buy' ? resolvedBuyFill : resolvedSellFill}
searchAnchorRef={searchAnchorRef}
onMarketSelect={onMarketSelect}
showSymbolField={false}
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={onPaperOrderFilled}
/>
</div>
</div>
)}
{activeTab === 'quick' && (
<QuickOrderPanel
market={market}
tradePrice={tradePrice}
availableKrw={availableKrw}
availableCoinQty={availableCoinQty}
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={onPaperOrderFilled}
/>
)}
{activeTab === 'history' && (
<div className="ttop-history">
{historySlot ?? (
<div className="ttop-empty"> .</div>
)}
</div>
)}
</div>
</div>
);
};
export default TradeTabOrderPanel;
+2
View File
@@ -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';