Files
goldenChart/frontend/src/components/RightSidePanel.tsx
T
2026-05-23 15:11:48 +09:00

223 lines
7.3 KiB
TypeScript

/**
* 실시간 차트 우측 패널
* - 매매 탭: 상단 매수 / 하단 매도
* - 호가 탭: 실시간 호가창
*/
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { OrderbookPanel } from './OrderbookPanel';
import TradeOrderPanel from './TradeOrderPanel';
import type { OrderbookDisplayUnit, WsStatus } from '../hooks/useUpbitOrderbook';
import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades';
import type { OrderbookTickerInfo } from './OrderbookPanel';
import type { TradeOrderFillRequest } from '../types';
type PanelTab = 'trade' | 'orderbook';
export interface RightSidePanelProps {
/** 모바일 등 외부 제어 시 열림 상태 */
open?: boolean;
onOpenChange?: (open: boolean) => void;
/** 모바일 도크에서 호가/매매 탭 지정 */
activeTab?: PanelTab;
onTabChange?: (tab: PanelTab) => void;
market: string;
tradePrice: number | null;
asks: OrderbookDisplayUnit[];
bids: OrderbookDisplayUnit[];
totalAskSize: number;
totalBidSize: number;
wsStatus: WsStatus;
bestAsk: number;
bestBid: number;
spread: number;
spreadPct: number;
prevClose: number;
tickerInfo?: OrderbookTickerInfo;
fillRequest?: TradeOrderFillRequest | null;
onOrderbookPick?: (price: number, rowType: 'ask' | 'bid') => void;
onMarketSelect?: (market: string) => void;
availableKrw?: number;
availableCoinQty?: number;
paperTradingEnabled?: boolean;
paperAutoTradeEnabled?: boolean;
onPaperOrderFilled?: () => void;
}
const PANEL_TABS: { id: PanelTab; label: string }[] = [
{ id: 'trade', label: '매매' },
{ id: 'orderbook', label: '호가' },
];
const RightSidePanel: React.FC<RightSidePanelProps> = ({
open: openControlled,
onOpenChange,
activeTab: activeTabControlled,
onTabChange,
market,
tradePrice,
asks,
bids,
totalAskSize,
totalBidSize,
wsStatus,
bestAsk,
bestBid,
spread,
spreadPct,
prevClose,
tickerInfo,
fillRequest,
onOrderbookPick,
onMarketSelect,
availableKrw = 0,
availableCoinQty = 0,
paperTradingEnabled = false,
paperAutoTradeEnabled = false,
onPaperOrderFilled,
}) => {
const [openInternal, setOpenInternal] = useState(false);
const [tabInternal, setTabInternal] = useState<PanelTab>('trade');
const tradeSectionRef = useRef<HTMLDivElement>(null);
const { trades: recentTrades, strength: tradeStrength } = useUpbitRecentTrades(market);
const isOpenControlled = openControlled !== undefined && onOpenChange !== undefined;
const open = isOpenControlled ? !!openControlled : openInternal;
const setOpen = (next: boolean | ((p: boolean) => boolean)) => {
const cur = isOpenControlled ? !!openControlled : openInternal;
const value = typeof next === 'function' ? next(cur) : next;
if (isOpenControlled && onOpenChange) onOpenChange(value);
else setOpenInternal(value);
};
const activeTab = activeTabControlled ?? tabInternal;
const setActiveTab = useCallback((tab: PanelTab) => {
if (onTabChange) onTabChange(tab);
else setTabInternal(tab);
}, [onTabChange]);
useEffect(() => {
if (!fillRequest) return;
setOpen(true);
setActiveTab('trade');
}, [fillRequest?.seq, fillRequest?.side, setOpen, setActiveTab]);
const handleOrderbookRowClick = useCallback((price: number, rowType: 'ask' | 'bid') => {
setActiveTab('trade');
onOrderbookPick?.(price, rowType);
}, [onOrderbookPick]);
return (
<div className={`rsp-wrap ${open ? 'rsp-wrap--open' : ''}`}>
<button
type="button"
className="rsp-handle"
onClick={() => setOpen(v => !v)}
title={open ? '패널 닫기' : '패널 열기'}
>
<svg
width="8" height="14"
viewBox="0 0 8 14"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
{open
? <polyline points="2,1 6,7 2,13" />
: <polyline points="6,1 2,7 6,13" />}
</svg>
</button>
<div className="rsp-panel">
<div className="rsp-tabs">
{PANEL_TABS.map(tab => (
<button
key={tab.id}
type="button"
className={`rsp-tab ${activeTab === tab.id ? 'rsp-tab--active' : ''}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
<div className="rsp-body">
{activeTab === 'trade' && (
<div className="rsp-trade-stack" ref={tradeSectionRef}>
<div className="rsp-trade-card rsp-trade-card--buy">
<div className="rsp-trade-card-title">매수</div>
<div className="rsp-trade-card-body">
<TradeOrderPanel
side="buy"
market={market}
tradePrice={tradePrice}
availableKrw={availableKrw}
fillRequest={fillRequest}
searchAnchorRef={tradeSectionRef}
onMarketSelect={onMarketSelect}
showSymbolField
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={onPaperOrderFilled}
/>
</div>
</div>
<div className="rsp-trade-card rsp-trade-card--sell">
<div className="rsp-trade-card-title">매도</div>
<div className="rsp-trade-card-body">
<TradeOrderPanel
side="sell"
market={market}
tradePrice={tradePrice}
availableKrw={availableKrw}
availableCoinQty={availableCoinQty}
fillRequest={fillRequest}
searchAnchorRef={tradeSectionRef}
onMarketSelect={onMarketSelect}
showSymbolField={false}
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={onPaperOrderFilled}
/>
</div>
</div>
</div>
)}
{activeTab === 'orderbook' && (
<div className="rsp-ob-stack">
<div className="rsp-trade-card rsp-ob-card">
<div className="rsp-trade-card-title rsp-ob-card-title">실시간 호가</div>
<div className="rsp-trade-card-body rsp-ob-card-body">
<OrderbookPanel
market={market}
asks={asks}
bids={bids}
totalAskSize={totalAskSize}
totalBidSize={totalBidSize}
wsStatus={wsStatus}
bestAsk={bestAsk}
bestBid={bestBid}
spread={spread}
spreadPct={spreadPct}
prevClose={prevClose}
tickerInfo={tickerInfo}
recentTrades={recentTrades}
tradeStrength={tradeStrength}
onRowClick={handleOrderbookRowClick}
/>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
};
export default RightSidePanel;