업비트 스타일 매수/매도 탭 주문 패널 적용
- 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:
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user