모의투자 관리 기능 추가

This commit is contained in:
Macbook
2026-05-31 03:47:07 +09:00
parent 9d7dddfa57
commit 16d0e2c226
78 changed files with 4688 additions and 972 deletions
+192 -133
View File
@@ -1,44 +1,58 @@
/**
* 모의투자 대시보드 — 첨부 UI (3열 + 핵심 성과 지표)
* 모의투자 대시보드 — 가상매매와 동일 3열 레이아웃(좌·중앙·우 접기/리사이즈)
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
loadPaperSummary,
loadPaperTrades,
loadPaperTradesPaged,
loadPaperPendingOrders,
loadStrategies,
resetPaperAccount,
type PaperOrderDto,
type PaperPositionDto,
type PaperSummaryDto,
type PaperTradeDto,
type StrategyDto,
} from '../utils/backendApi';
import type { TickerData } from '../hooks/useMarketTicker';
import { coerceFiniteNumber } from '../utils/safeFormat';
import PaperInvestmentKpi from './paper/PaperInvestmentKpi';
import PaperAllocationTable from './paper/PaperAllocationTable';
import PaperPendingOrdersList from './paper/PaperPendingOrdersList';
import PaperLedgerTab from './paper/PaperLedgerTab';
import PaperAssetTrendChart from './paper/PaperAssetTrendChart';
import { computePaperMetrics } from '../utils/paperMetrics';
import { fmtKrw } from './TradeOrderPanel';
import TradeOrderPanel from './TradeOrderPanel';
import { TradeSplitOrderPanel, TradeOrderbookPanelContent, TradeRightPanelBody } from './trade';
import type { Theme, TradeOrderFillRequest } from '../types';
import PaperAnalysisChart from './paper/PaperAnalysisChart';
import PaperLeftStrategyTab from './paper/PaperLeftStrategyTab';
import PaperLeftSettingsTab from './paper/PaperLeftSettingsTab';
import PaperCompactOrderbook from './paper/PaperCompactOrderbook';
import PaperSplitPanel from './paper/PaperSplitPanel';
import PaperTradeHistoryList from './paper/PaperTradeHistoryList';
import BuilderPageShell from './layout/BuilderPageShell';
import { useUpbitOrderbook } from '../hooks/useUpbitOrderbook';
import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades';
type HistoryTab = 'open' | 'recent';
type HistoryTab = 'open' | 'recent' | 'ledger';
type CenterTab = 'invest' | 'chart';
type RightTab = 'trade' | 'orderbook' | 'history';
type LeftTab = 'strategy' | 'settings';
interface Props {
theme?: Theme;
tickers?: Map<string, { tradePrice: number | null }>;
tickers?: Map<string, TickerData>;
onGoChart?: (market: string) => void;
refreshKey?: number;
defaultMarket?: string;
paperTradingEnabled?: boolean;
paperAutoTradeEnabled?: boolean;
onPaperOrderFilled?: () => void;
/** 설정 → 모의투자 카테고리로 이동 */
onOpenSettings?: () => void;
}
function buildMarkPrices(
summary: PaperSummaryDto | null,
tickers?: Map<string, { tradePrice: number | null }>,
tickers?: Map<string, TickerData>,
): Record<string, number> {
const m: Record<string, number> = {};
summary?.positions?.forEach(p => {
@@ -50,7 +64,7 @@ function buildMarkPrices(
function positionPriceKey(
positions: PaperPositionDto[] | undefined,
tickers?: Map<string, { tradePrice: number | null }>,
tickers?: Map<string, TickerData>,
): string {
if (!positions?.length) return '';
return positions.map(p => `${p.symbol}:${tickers?.get(p.symbol)?.tradePrice ?? ''}`).join('|');
@@ -68,37 +82,52 @@ const PaperTradingPage: React.FC<Props> = ({
paperTradingEnabled = true,
paperAutoTradeEnabled = false,
onPaperOrderFilled,
onOpenSettings,
}) => {
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
const [summary, setSummary] = useState<PaperSummaryDto | null>(null);
const [trades, setTrades] = useState<PaperTradeDto[]>([]);
const [pendingOrders, setPendingOrders] = useState<PaperOrderDto[]>([]);
const [dataRefreshKey, setDataRefreshKey] = useState(0);
const [initialLoading, setInitialLoading] = useState(true);
const [selectedMarket, setSelectedMarket] = useState(defaultMarket);
const [centerTab, setCenterTab] = useState<CenterTab>('invest');
const [historyTab, setHistoryTab] = useState<HistoryTab>('recent');
const [rightTab, setRightTab] = useState<RightTab>('trade');
const [leftTab, setLeftTab] = useState<LeftTab>('strategy');
const [fillBuy, setFillBuy] = useState<TradeOrderFillRequest | null>(null);
const [fillSell, setFillSell] = useState<TradeOrderFillRequest | null>(null);
const orderAnchorRef = useRef<HTMLDivElement>(null);
const fillSeqRef = useRef(0);
const tickersRef = useRef(tickers);
tickersRef.current = tickers;
const summaryRef = useRef(summary);
summaryRef.current = summary;
const loadData = useCallback(async () => {
const loadData = useCallback(async (opts?: { focusHistory?: boolean }) => {
try {
const base = await loadPaperSummary();
const marks = buildMarkPrices(base, tickersRef.current);
const full = Object.keys(marks).length > 0 ? await loadPaperSummary(marks) : base;
setSummary(full);
setTrades(await loadPaperTrades());
const tradePage = await loadPaperTradesPaged({ page: 0, size: 100 });
setTrades(tradePage.content);
setPendingOrders(await loadPaperPendingOrders());
setDataRefreshKey(k => k + 1);
} finally {
setInitialLoading(false);
}
}, []);
onPaperOrderFilled?.();
if (opts?.focusHistory) setRightTab('history');
}, [onPaperOrderFilled]);
useEffect(() => {
void Promise.all([
loadData(),
loadStrategies().then(setStrategies).catch(() => setStrategies([])),
]);
}, [loadData]);
useEffect(() => { void loadData(); }, [loadData]);
useEffect(() => { if (refreshKey > 0) void loadData(); }, [refreshKey, loadData]);
const priceKey = positionPriceKey(summary?.positions, tickers);
@@ -118,39 +147,84 @@ const PaperTradingPage: React.FC<Props> = ({
return () => { cancelled = true; };
}, [priceKey, initialLoading]);
const strategyNames = useMemo(
() => Object.fromEntries(strategies.map(s => [s.id, s.name])),
[strategies],
);
const metrics = useMemo(() => computePaperMetrics(trades, summary), [trades, summary]);
const s = summary;
const tradePrice = tickers?.get(selectedMarket)?.tradePrice ?? null;
const posQty = useMemo(() => {
const p = s?.positions?.find(x => x.symbol === selectedMarket);
return p?.quantity ?? 0;
return coerceFiniteNumber(p?.quantity) ?? 0;
}, [s?.positions, selectedMarket]);
const handleObPick = useCallback((price: number, rowType: 'ask' | 'bid') => {
const side = rowType === 'ask' ? 'buy' : 'sell';
fillSeqRef.current += 1;
const req: TradeOrderFillRequest = {
market: selectedMarket,
price,
side,
seq: fillSeqRef.current,
};
if (side === 'buy') setFillBuy(req); else setFillSell(req);
const selectedAlloc = useMemo(
() => s?.allocations?.find(a => a.symbol === selectedMarket),
[s?.allocations, selectedMarket],
);
const orderableCash = s?.orderableCash ?? s?.cashBalance ?? 0;
const tradePrice = useMemo(
() => coerceFiniteNumber(tickers?.get(selectedMarket)?.tradePrice),
[tickers, selectedMarket],
);
const { orderbook, wsStatus: obWsStatus, spread } = useUpbitOrderbook(selectedMarket);
const { trades: recentTrades, strength: tradeStrength } = useUpbitRecentTrades(selectedMarket);
const selectedTicker = tickers?.get(selectedMarket);
const orderbookPrevClose = selectedTicker
? (selectedTicker.tradePrice ?? 0) - (selectedTicker.changePrice ?? 0)
: 0;
const orderbookTickerInfo = selectedTicker ? {
tradePrice: selectedTicker.tradePrice,
changeRate: selectedTicker.changeRate,
changePrice: selectedTicker.changePrice,
accTradePrice24: selectedTicker.accTradePrice24,
accTradeVolume24: selectedTicker.accTradeVolume24,
highPrice: selectedTicker.highPrice,
lowPrice: selectedTicker.lowPrice,
openingPrice: selectedTicker.openingPrice,
} : undefined;
const handleSelectMarket = useCallback((market: string) => {
setSelectedMarket(market);
const price = tickers?.get(market)?.tradePrice;
if (price != null && price > 0) {
const seq = Date.now();
setFillBuy({ market, price, side: 'buy', seq });
setFillSell({ market, price, side: 'sell', seq: seq + 1 });
}
setRightTab('trade');
}, [tickers]);
const handleOrderbookRowClick = useCallback((price: number, rowType: 'ask' | 'bid') => {
setRightTab('trade');
const side = rowType === 'bid' ? 'buy' : 'sell';
const req: TradeOrderFillRequest = { market: selectedMarket, price, side, seq: Date.now() };
if (side === 'buy') setFillBuy(req);
else setFillSell(req);
}, [selectedMarket]);
const handleReset = useCallback(async () => {
if (!window.confirm('모의투자 계좌를 초기화합니다. 계속할까요?')) return;
const res = await resetPaperAccount();
if (res) { setSummary(res); setTrades([]); }
if (res) {
setSummary(res);
setTrades([]);
setPendingOrders([]);
}
}, []);
const rightTabs = (
<div className="bps-right-tabs">
<button type="button" className={`bps-right-tab${rightTab === 'trade' ? ' bps-right-tab--on' : ''}`} onClick={() => setRightTab('trade')}></button>
<button type="button" className={`bps-right-tab${rightTab === 'orderbook' ? ' bps-right-tab--on' : ''}`} onClick={() => setRightTab('orderbook')}></button>
<button type="button" className={`bps-right-tab${rightTab === 'history' ? ' bps-right-tab--on' : ''}`} onClick={() => setRightTab('history')}></button>
<button type="button" className={`bps-right-tab${rightTab === 'history' ? ' bps-right-tab--on' : ''}`} onClick={() => setRightTab('history')}>
{trades.length > 0 ? ` (${trades.length})` : ''}
</button>
</div>
);
@@ -166,10 +240,16 @@ const PaperTradingPage: React.FC<Props> = ({
theme={theme}
title="모의투자"
subtitle="Paper Trading"
pageClassName="bps-page--vtd bps-page--ptd"
loading={initialLoading}
loadingText="모의투자 대시보드 로딩…"
collapsiblePanels
leftStorageKey="ptd-left-width"
footerStorageKey="ptd-footer-height"
leftDefaultWidth={380}
leftCollapsedStorageKey="ptd-left-open"
rightStorageKey="ptd-right-width"
rightDefaultWidth={440}
rightCollapsedStorageKey="ptd-right-open"
headerActions={(
<>
<button type="button" className="bps-btn bps-btn--ghost" onClick={() => void loadData()}></button>
@@ -185,129 +265,108 @@ const PaperTradingPage: React.FC<Props> = ({
market={selectedMarket}
summary={s}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onMarketSelect={setSelectedMarket}
onMarketSelect={handleSelectMarket}
/>
) : (
<PaperLeftSettingsTab
onOpenSettings={onOpenSettings}
onAccountReset={() => { setSummary(null); setTrades([]); void loadData(); }}
/>
)}
centerHead={(
<span className="bps-center-head-title">{coinCode(selectedMarket)} / KRW</span>
<div className="ptd-center-head">
<div className="ptd-tabs ptd-tabs--center">
<button type="button" className={`ptd-tab${centerTab === 'invest' ? ' active' : ''}`} onClick={() => setCenterTab('invest')}> </button>
<button type="button" className={`ptd-tab${centerTab === 'chart' ? ' active' : ''}`} onClick={() => setCenterTab('chart')}></button>
</div>
<span className="bps-center-head-title">{coinCode(selectedMarket)} / KRW</span>
</div>
)}
center={(
center={centerTab === 'invest' ? (
<div className="ptd-card ptd-invest-card ptd-center-fill">
<PaperInvestmentKpi summary={s} />
<PaperAllocationTable
allocations={s?.allocations ?? []}
selectedMarket={selectedMarket}
onSelectMarket={handleSelectMarket}
onChanged={() => void loadData()}
/>
<div className="ptd-invest-metrics">
<PaperAssetTrendChart refreshKey={dataRefreshKey} />
<div className="ptd-metrics-grid">
<MetricCard icon="📉" title="MDD" value={`${metrics.mddPct.toFixed(2)}%`} sub="최대 낙폭" tone="down" />
<MetricCard icon="📊" title="Sharpe Ratio" value={metrics.sharpeRatio.toFixed(2)} sub="위험 대비 수익" tone="up" />
<MetricCard icon="🏆" title="Win Rate" value={`${metrics.winRatePct.toFixed(0)}%`} sub="승률" tone="up" />
<MetricCard icon="🛡" title="Profit Factor" value={metrics.profitFactor.toFixed(2)} sub="총이익/총손실" tone="up" />
</div>
</div>
</div>
) : (
<div className="ptd-card ptd-chart-card ptd-center-fill">
<PaperAnalysisChart market={selectedMarket} theme={theme} />
</div>
)}
rightTabs={rightTabs}
right={(
<div className="ptd-right-body" ref={orderAnchorRef}>
<TradeRightPanelBody anchorRef={orderAnchorRef} embeddedInShell>
{rightTab === 'trade' ? (
<PaperSplitPanel
className="ptd-split-panel--right"
topTitle="매수"
bottomTitle="매도"
top={(
<TradeOrderPanel
side="buy"
market={selectedMarket}
tradePrice={tradePrice}
availableKrw={s?.cashBalance ?? 0}
fillRequest={fillBuy}
searchAnchorRef={orderAnchorRef}
onMarketSelect={setSelectedMarket}
showSymbolField
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={() => { onPaperOrderFilled?.(); void loadData(); }}
/>
)}
bottom={(
<TradeOrderPanel
side="sell"
market={selectedMarket}
tradePrice={tradePrice}
availableCoinQty={posQty}
fillRequest={fillSell}
onMarketSelect={setSelectedMarket}
showSymbolField={false}
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={() => { onPaperOrderFilled?.(); void loadData(); }}
/>
)}
<TradeSplitOrderPanel
market={selectedMarket}
tradePrice={tradePrice}
availableKrw={orderableCash}
availableSymbolBuyKrw={selectedAlloc?.availableBuyKrw}
fillBuy={fillBuy}
fillSell={fillSell}
searchAnchorRef={orderAnchorRef}
onMarketSelect={handleSelectMarket}
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={() => void loadData({ focusHistory: true })}
/>
) : rightTab === 'orderbook' ? (
<PaperSplitPanel
className="ptd-split-panel--right"
topTitle="매도 호가"
bottomTitle="매수 호가"
top={(
<PaperCompactOrderbook
market={selectedMarket}
onPick={handleObPick}
section="asks"
fillHeight
hideHeader
depth={10}
/>
)}
bottom={(
<PaperCompactOrderbook
market={selectedMarket}
onPick={handleObPick}
section="bids"
fillHeight
hideHeader
depth={10}
/>
)}
<TradeOrderbookPanelContent
market={selectedMarket}
asks={orderbook.asks}
bids={orderbook.bids}
totalAskSize={orderbook.totalAskSize}
totalBidSize={orderbook.totalBidSize}
wsStatus={obWsStatus}
bestAsk={spread.bestAsk}
bestBid={spread.bestBid}
spread={spread.spread}
spreadPct={spread.pct}
prevClose={orderbookPrevClose}
tickerInfo={orderbookTickerInfo}
recentTrades={recentTrades}
tradeStrength={tradeStrength ?? undefined}
onRowClick={handleOrderbookRowClick}
/>
) : (
<PaperSplitPanel
className="ptd-split-panel--right"
topTitle="체결 구분"
bottomTitle={historyTab === 'open' ? '미체결' : '최근체결'}
top={(
<div className="ptd-tabs ptd-tabs--in-card">
<button type="button" className={`ptd-tab${historyTab === 'open' ? ' active' : ''}`} onClick={() => setHistoryTab('open')}></button>
<button type="button" className={`ptd-tab${historyTab === 'recent' ? ' active' : ''}`} onClick={() => setHistoryTab('recent')}></button>
</div>
)}
bottom={historyTab === 'open' ? (
<p className="ptd-muted"> ( )</p>
) : (
<table className="ptd-table ptd-table--compact">
<thead>
<tr><th></th><th></th><th></th><th></th><th></th></tr>
</thead>
<tbody>
{trades.slice(0, 20).map(t => (
<tr key={t.id}>
<td className="ptd-time">{t.createdAt?.slice(11, 19) ?? '—'}</td>
<td>{coinCode(t.symbol)}</td>
<td className={t.side === 'BUY' ? 'up' : 'down'}>{t.side === 'BUY' ? '매수' : '매도'}</td>
<td>{fmtKrw(t.price)}</td>
<td><span className="ptd-status ptd-status--done"></span></td>
</tr>
))}
{!trades.length && <tr><td colSpan={5} className="ptd-muted"> </td></tr>}
</tbody>
</table>
)}
/>
<div className="ptd-right-history">
<div className="ptd-tabs ptd-tabs--history">
<button type="button" className={`ptd-tab${historyTab === 'open' ? ' active' : ''}`} onClick={() => setHistoryTab('open')}></button>
<button type="button" className={`ptd-tab${historyTab === 'recent' ? ' active' : ''}`} onClick={() => setHistoryTab('recent')}></button>
<button type="button" className={`ptd-tab${historyTab === 'ledger' ? ' active' : ''}`} onClick={() => setHistoryTab('ledger')}></button>
</div>
<div className="ptd-right-history-body">
{historyTab === 'open' ? (
<PaperPendingOrdersList orders={pendingOrders} onChanged={() => void loadData()} />
) : historyTab === 'ledger' ? (
<PaperLedgerTab refreshKey={dataRefreshKey} />
) : (
<PaperTradeHistoryList
trades={trades}
strategyNames={strategyNames}
tickers={tickers}
onSelectMarket={handleSelectMarket}
emptyText="체결 내역이 없습니다."
className="ptd-trade-history--fill"
/>
)}
</div>
</div>
)}
</div>
)}
footerLabel="성과 지표"
footer={(
<div className="ptd-metrics-grid">
<MetricCard icon="📉" title="MDD" value={`${metrics.mddPct.toFixed(2)}%`} sub="최대 낙폭" tone="down" />
<MetricCard icon="📊" title="Sharpe Ratio" value={metrics.sharpeRatio.toFixed(2)} sub="위험 대비 수익" tone="up" />
<MetricCard icon="🏆" title="Win Rate" value={`${metrics.winRatePct.toFixed(0)}%`} sub="승률" tone="up" />
<MetricCard icon="🛡" title="Profit Factor" value={metrics.profitFactor.toFixed(2)} sub="총이익/총손실" tone="up" />
</div>
</TradeRightPanelBody>
)}
/>
);
+47 -90
View File
@@ -4,9 +4,13 @@
* - 호가 탭: 실시간 호가창
*/
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { OrderbookPanel } from './OrderbookPanel';
import TradeOrderPanel from './TradeOrderPanel';
import PaperSplitPanel from './paper/PaperSplitPanel';
import {
TradeSplitOrderPanel,
TradeOrderbookPanelContent,
TradeRightPanelTabs,
TradeRightPanelBody,
type TradeRightPanelTab,
} from './trade';
import type { OrderbookDisplayUnit, WsStatus } from '../hooks/useUpbitOrderbook';
import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades';
import type { OrderbookTickerInfo } from './OrderbookPanel';
@@ -19,8 +23,8 @@ export interface RightSidePanelProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
/** 모바일 도크에서 호가/매매 탭 지정 */
activeTab?: PanelTab;
onTabChange?: (tab: PanelTab) => void;
activeTab?: TradeRightPanelTab;
onTabChange?: (tab: TradeRightPanelTab) => void;
market: string;
tradePrice: number | null;
asks: OrderbookDisplayUnit[];
@@ -44,11 +48,6 @@ export interface RightSidePanelProps {
onPaperOrderFilled?: () => void;
}
const PANEL_TABS: { id: PanelTab; label: string }[] = [
{ id: 'trade', label: '매매' },
{ id: 'orderbook', label: '호가' },
];
const RightSidePanel: React.FC<RightSidePanelProps> = ({
open: openControlled,
onOpenChange,
@@ -77,7 +76,7 @@ const RightSidePanel: React.FC<RightSidePanelProps> = ({
onPaperOrderFilled,
}) => {
const [openInternal, setOpenInternal] = useState(false);
const [tabInternal, setTabInternal] = useState<PanelTab>('trade');
const [tabInternal, setTabInternal] = useState<TradeRightPanelTab>('trade');
const tradeSectionRef = useRef<HTMLDivElement>(null);
const { trades: recentTrades, strength: tradeStrength } = useUpbitRecentTrades(market);
@@ -91,7 +90,7 @@ const RightSidePanel: React.FC<RightSidePanelProps> = ({
};
const activeTab = activeTabControlled ?? tabInternal;
const setActiveTab = useCallback((tab: PanelTab) => {
const setActiveTab = useCallback((tab: TradeRightPanelTab) => {
if (onTabChange) onTabChange(tab);
else setTabInternal(tab);
}, [onTabChange]);
@@ -130,91 +129,49 @@ const RightSidePanel: React.FC<RightSidePanelProps> = ({
</svg>
</button>
<div className="rsp-panel">
<div className="rsp-tabs bps-right-tabs">
{PANEL_TABS.map(tab => (
<button
key={tab.id}
type="button"
className={`bps-right-tab${activeTab === tab.id ? ' bps-right-tab--on' : ''}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
<div className="rsp-panel trade-right-panel">
<TradeRightPanelTabs
activeTab={activeTab}
onTabChange={setActiveTab}
className="rsp-tabs"
/>
<div className="rsp-body ptd-right-body" ref={tradeSectionRef}>
<TradeRightPanelBody anchorRef={tradeSectionRef} className="rsp-body-wrap">
{activeTab === 'trade' && (
<PaperSplitPanel
className="ptd-split-panel--right"
topTitle="매수"
bottomTitle="매도"
top={(
<div className="ptd-order-card">
<TradeOrderPanel
side="buy"
market={market}
tradePrice={tradePrice}
availableKrw={availableKrw}
fillRequest={fillRequest}
searchAnchorRef={tradeSectionRef}
onMarketSelect={onMarketSelect}
showSymbolField
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={onPaperOrderFilled}
/>
</div>
)}
bottom={(
<div className="ptd-order-card">
<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>
)}
<TradeSplitOrderPanel
market={market}
tradePrice={tradePrice}
availableKrw={availableKrw}
availableCoinQty={availableCoinQty}
fillRequest={fillRequest}
searchAnchorRef={tradeSectionRef}
onMarketSelect={onMarketSelect}
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={onPaperOrderFilled}
/>
)}
{activeTab === 'orderbook' && (
<div className="rsp-ob-stack ptd-ob-stack--fill">
<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>
<TradeOrderbookPanelContent
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 ?? undefined}
onRowClick={handleOrderbookRowClick}
/>
)}
</div>
</TradeRightPanelBody>
</div>
</div>
);
@@ -0,0 +1,187 @@
/**
* 화면(탭/창) 공유 스트림 위에서 드래그로 영역 선택 → PNG File 반환
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import {
captureErrorMessage,
clientRectToVideoPixels,
cropVideoFrameToFile,
requestDisplayCaptureStream,
stopMediaStream,
type ScreenCaptureRect,
} from '../utils/screenRegionCapture';
interface Props {
onCapture: (file: File) => void;
onCancel: () => void;
}
type Phase = 'starting' | 'ready' | 'selecting' | 'processing';
const MIN_DRAG_PX = 8;
const ScreenRegionCaptureOverlay: React.FC<Props> = ({ onCapture, onCancel }) => {
const videoRef = useRef<HTMLVideoElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const dragStartRef = useRef<{ x: number; y: number } | null>(null);
const [phase, setPhase] = useState<Phase>('starting');
const [selection, setSelection] = useState<ScreenCaptureRect | null>(null);
const [error, setError] = useState<string | null>(null);
const cleanup = useCallback(() => {
stopMediaStream(streamRef.current);
streamRef.current = null;
}, []);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const stream = await requestDisplayCaptureStream();
if (cancelled) {
stopMediaStream(stream);
return;
}
streamRef.current = stream;
const video = videoRef.current;
if (!video) {
stopMediaStream(stream);
onCancel();
return;
}
video.srcObject = stream;
video.muted = true;
await video.play();
if (cancelled) return;
setPhase('ready');
} catch (e) {
if (!cancelled) {
setError(captureErrorMessage(e));
setTimeout(onCancel, 2200);
}
}
})();
return () => {
cancelled = true;
cleanup();
};
}, [cleanup, onCancel]);
const finishCancel = useCallback(() => {
cleanup();
onCancel();
}, [cleanup, onCancel]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') finishCancel();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [finishCancel]);
const normalizeRect = (x0: number, y0: number, x1: number, y1: number): ScreenCaptureRect => {
const left = Math.min(x0, x1);
const top = Math.min(y0, y1);
return {
left,
top,
width: Math.abs(x1 - x0),
height: Math.abs(y1 - y0),
};
};
const handlePointerDown = (e: React.PointerEvent) => {
if (phase !== 'ready' && phase !== 'selecting') return;
if (e.button !== 0) return;
dragStartRef.current = { x: e.clientX, y: e.clientY };
setSelection({ left: e.clientX, top: e.clientY, width: 0, height: 0 });
setPhase('selecting');
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
};
const handlePointerMove = (e: React.PointerEvent) => {
if (!dragStartRef.current) return;
const start = dragStartRef.current;
setSelection(normalizeRect(start.x, start.y, e.clientX, e.clientY));
};
const handlePointerUp = async (e: React.PointerEvent) => {
if (!dragStartRef.current) return;
const start = dragStartRef.current;
dragStartRef.current = null;
const clientRect = normalizeRect(start.x, start.y, e.clientX, e.clientY);
if (clientRect.width < MIN_DRAG_PX || clientRect.height < MIN_DRAG_PX) {
setSelection(null);
setPhase('ready');
return;
}
const video = videoRef.current;
if (!video) {
finishCancel();
return;
}
setPhase('processing');
try {
const vidRect = clientRectToVideoPixels(clientRect, video);
if (!vidRect) throw new Error('REGION_TOO_SMALL');
const file = await cropVideoFrameToFile(video, vidRect);
cleanup();
onCapture(file);
} catch (err) {
setError(captureErrorMessage(err));
setPhase('ready');
setSelection(null);
}
};
const hint =
phase === 'starting'
? '화면 공유 준비 중…'
: phase === 'processing'
? '캡처 처리 중…'
: '캡처할 영역을 드래그하세요 · Esc 취소';
return createPortal(
<div className="screen-capture-overlay" role="dialog" aria-modal aria-label="화면 영역 캡처">
<video ref={videoRef} className="screen-capture-video" playsInline />
<div
className="screen-capture-interaction"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={e => void handlePointerUp(e)}
onPointerCancel={() => {
dragStartRef.current = null;
setSelection(null);
setPhase('ready');
}}
>
{selection && selection.width > 0 && selection.height > 0 && (
<div
className="screen-capture-selection"
style={{
left: selection.left,
top: selection.top,
width: selection.width,
height: selection.height,
}}
/>
)}
</div>
<div className="screen-capture-toolbar">
<span className="screen-capture-hint">{error ?? hint}</span>
<button type="button" className="screen-capture-cancel" onClick={finishCancel}>
</button>
</div>
</div>,
document.body,
);
};
export default ScreenRegionCaptureOverlay;
+174 -123
View File
@@ -166,6 +166,8 @@ interface SettingsPageProps {
onVerificationIssueNotify?: (v: boolean) => void;
trendSearchSettings?: TrendSearchAppSettings;
onTrendSearchSettingsChange?: (s: TrendSearchAppSettings) => void;
/** 설정 화면 진입 시 선택할 카테고리 (예: 모의투자 화면에서 링크) */
initialCategory?: SettingsCategoryId;
}
// ── 카테고리 정의 ────────────────────────────────────────────────────────────
@@ -233,6 +235,19 @@ const IcPaper = () => (
<circle cx="16" cy="6" r="2" fill="currentColor" stroke="none"/>
</svg>
);
const IcVirtual = () => (
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="6" width="8" height="10" rx="1.5"/>
<rect x="12" y="4" width="8" height="12" rx="1.5"/>
<path d="M10 11h2"/>
</svg>
);
const IcLive = () => (
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="9"/>
<path d="M8 11h6M11 8v6"/>
</svg>
);
const IcTrendSearch = () => (
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="4,17 9,11 13,14 18,6"/>
@@ -264,7 +279,9 @@ const ALL_CATEGORIES: Category[] = [
{ id: 'indicators', label: '보조지표 설정', icon: <IcIndicators />, desc: '지표 추가 Main 탭 보조지표 기본 파라미터·색상·기준선' },
{ id: 'backtest', label: '백테스팅', icon: <IcBacktest />, desc: '백테스팅 실행 옵션, 자본·비용·리스크 관리 설정' },
{ id: 'strategy', label: '전략 설정', icon: <IcStrategy />, desc: '투자 전략 기본 파라미터 및 조건 설정' },
{ id: 'paper', label: '가상매매', icon: <IcPaper />, desc: '가상 자본, 수수료, 투자대상 한도, 자동매매 ON/OFF 등 가상매매 설정' },
{ id: 'paper', label: '모의투자', icon: <IcPaper />, desc: '모의투자 활성화, 자동매매, 초기 자본·수수료·슬리피지, 계좌 초기화' },
{ id: 'virtual', label: '가상매매', icon: <IcVirtual />, desc: '가상매매 화면 투자대상 목록 한도 등 가상매매 전용 설정' },
{ id: 'live', label: '실거래', icon: <IcLive />, desc: '매매 운영 모드, 업비트 API 키, 실거래 자동매매' },
{ id: 'trend-search', label: '추세검색', icon: <IcTrendSearch />, desc: '상승추세 배점, 합격 점수, 결과 수, 자동갱신·투자대상 자동추가' },
{ id: 'alert', label: '알림 설정', icon: <IcAlert />, desc: '가격 알림, 신호 알림, 알림 방식 설정' },
{ id: 'network', label: '네트워크', icon: <IcNetwork />, desc: 'API 서버 주소, WebSocket, 연결 상태' },
@@ -319,7 +336,7 @@ const StgCard: React.FC<{
// ── 각 카테고리 패널 ─────────────────────────────────────────────────────────
interface PaperPanelProps {
interface PaperTradingSettingsPanelProps {
paperTradingEnabled?: boolean;
onPaperTradingEnabled?: (v: boolean) => void;
paperInitialCapital?: number;
@@ -334,21 +351,10 @@ interface PaperPanelProps {
onPaperAutoTradeEnabled?: (v: boolean) => void;
paperAutoTradeBudgetPct?: number;
onPaperAutoTradeBudgetPct?: (v: number) => void;
virtualTargetMaxCount?: number;
onVirtualTargetMaxCount?: (v: number) => void;
onPaperAccountReset?: () => void;
tradingMode?: string;
onTradingMode?: (v: string) => void;
liveAutoTradeEnabled?: boolean;
onLiveAutoTradeEnabled?: (v: boolean) => void;
hasUpbitKeys?: boolean;
upbitAccessKeyMasked?: string | null;
onUpbitKeys?: (access: string, secret: string) => void;
liveAutoTradeBudgetPct?: number;
onLiveAutoTradeBudgetPct?: (v: number) => void;
}
const PaperPanel: React.FC<PaperPanelProps> = ({
const PaperTradingSettingsPanel: React.FC<PaperTradingSettingsPanelProps> = ({
paperTradingEnabled = true,
onPaperTradingEnabled,
paperInitialCapital = 10_000_000,
@@ -363,63 +369,29 @@ const PaperPanel: React.FC<PaperPanelProps> = ({
onPaperAutoTradeEnabled,
paperAutoTradeBudgetPct = 95,
onPaperAutoTradeBudgetPct,
virtualTargetMaxCount = 20,
onVirtualTargetMaxCount,
onPaperAccountReset,
tradingMode = 'PAPER',
onTradingMode,
liveAutoTradeEnabled = false,
onLiveAutoTradeEnabled,
hasUpbitKeys = false,
upbitAccessKeyMasked = null,
onUpbitKeys,
liveAutoTradeBudgetPct = 95,
onLiveAutoTradeBudgetPct,
}) => {
const [accessKey, setAccessKey] = useState('');
const [secretKey, setSecretKey] = useState('');
return (
}) => (
<>
<SettingSection title="매매 운영 모드">
<SettingRow label="실행 대상" desc="가상투자만, 실거래(업비트 API)만, 또는 둘 다 병행할 수 있습니다.">
<select className="stg-select" value={tradingMode} onChange={e => onTradingMode?.(e.target.value)}>
<option value="PAPER"></option>
<option value="LIVE"></option>
<option value="BOTH"> + </option>
</select>
</SettingRow>
</SettingSection>
<SettingSection title="가상매매">
<SettingRow label="가상매매 활성화" desc="켜면 가상매매·실시간 차트 매매 패널·전략 Match 자동매매가 가상 계좌로 체결됩니다.">
<SettingSection title="모의투자">
<SettingRow
label="모의투자 활성화"
desc="켜면 모의투자 화면·실시간 차트 매매 패널·전략 Match 자동매매가 모의 계좌로 체결됩니다."
>
<label className="stg-toggle">
<input type="checkbox" checked={paperTradingEnabled} onChange={e => onPaperTradingEnabled?.(e.target.checked)} />
<span className="stg-toggle-slider" />
</label>
</SettingRow>
<SettingRow
label="투자대상 목록 최대 개수"
desc="가상매매 화면·추세검색에서 등록할 수 있는 투자대상 종목 수 상한입니다."
>
<input
type="number"
className="stg-input stg-input--num"
value={virtualTargetMaxCount}
min={1}
max={100}
step={1}
onChange={e => onVirtualTargetMaxCount?.(Number(e.target.value))}
/>
</SettingRow>
</SettingSection>
<SettingSection title="자동매매">
<SettingRow
label="자동매매 ON/OFF"
desc="가상투자 화면 타이틀바에서 변경합니다. Match 충족 시 자동 매수·매도 여부를 제어합니다."
label="자동매매"
desc="Match 충족 시 모의 계좌로 자동 매수·매도합니다. 모의투자·가상매매 화면 타이틀바에서도 변경할 수 있습니다."
>
<span className={`stg-badge ${paperAutoTradeEnabled ? 'stg-badge--on' : 'stg-badge--off'}`}>
{paperAutoTradeEnabled ? 'ON (타이틀바)' : 'OFF (타이틀바)'}
</span>
<label className="stg-toggle">
<input type="checkbox" checked={paperAutoTradeEnabled} onChange={e => onPaperAutoTradeEnabled?.(e.target.checked)} />
<span className="stg-toggle-slider" />
</label>
</SettingRow>
<SettingRow
label="자동 매수 예산 (%)"
@@ -436,9 +408,6 @@ const PaperPanel: React.FC<PaperPanelProps> = ({
onChange={e => onPaperAutoTradeBudgetPct?.(Number(e.target.value))}
/>
</SettingRow>
{!paperAutoTradeEnabled && (
<p className="stg-hint"> ON/OFF는 . OFF Match· .</p>
)}
</SettingSection>
<SettingSection title="계좌·비용">
<SettingRow label="초기 자본 (KRW)" desc="계좌 초기화 시 적용되는 시작 현금입니다.">
@@ -485,69 +454,135 @@ const PaperPanel: React.FC<PaperPanelProps> = ({
</SettingRow>
</SettingSection>
<SettingSection title="계좌 관리">
<SettingRow label="계좌 초기화" desc="보유 종목·체결 이력을 삭제하고 초기 자본으로 현금을 되돌립니다.">
<SettingRow
label="계좌 초기화"
desc="보유 종목·체결·원장·투자금 한도·미체결 주문을 삭제하고 초기 자본으로 현금을 되돌립니다."
>
<button type="button" className="stg-btn stg-btn--danger" onClick={onPaperAccountReset}>
</button>
</SettingRow>
</SettingSection>
<SettingSection title="실거래 (업비트 Open API)">
<SettingRow label="실거래 자동매매" desc="ON이면 전략 시그널·손절/익절 시 업비트에 실제 주문합니다. API 키에 주문 권한이 필요합니다.">
<label className="stg-toggle">
<input type="checkbox" checked={liveAutoTradeEnabled} onChange={e => onLiveAutoTradeEnabled?.(e.target.checked)} />
<span className="stg-toggle-slider" />
</label>
</SettingRow>
<SettingRow label="API 키 상태" desc={hasUpbitKeys ? `등록됨 (${upbitAccessKeyMasked})` : 'Access / Secret 키를 입력 후 저장하세요.'}>
<span style={{ fontSize: 12, color: hasUpbitKeys ? 'var(--accent)' : 'var(--text3)' }}>
{hasUpbitKeys ? '연결됨' : '미등록'}
</span>
</SettingRow>
<SettingRow label="Access Key" desc="입력 내용이 그대로 표시됩니다. 저장 시 DB에 AES 암호화되어 보관됩니다.">
<input
type="text"
className="stg-input stg-input--sensitive"
placeholder={hasUpbitKeys ? '변경 시 새 Access Key 입력' : 'Access Key'}
value={accessKey}
onChange={e => setAccessKey(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
</SettingRow>
<SettingRow label="Secret Key" desc="입력 내용이 그대로 표시됩니다. 저장 시 암호화되며 API로 전체 키는 반환되지 않습니다.">
<input
type="text"
className="stg-input stg-input--sensitive"
placeholder={hasUpbitKeys ? '변경 시 새 Secret Key 입력' : 'Secret Key'}
value={secretKey}
onChange={e => setSecretKey(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
</SettingRow>
{(accessKey.trim() || secretKey.trim()) && (
<SettingRow label="">
<button
type="button"
className="stg-btn stg-btn--primary"
onClick={() => {
onUpbitKeys?.(accessKey.trim(), secretKey.trim());
setAccessKey('');
setSecretKey('');
}}
>
API
</button>
</SettingRow>
)}
<SettingRow label="실거래 자동매수 예산 (%)" desc="시장가 매수 시 KRW 잔고 대비 사용 비율 (모의투자 예산과 별도)">
<input type="number" className="stg-input stg-input--narrow" min={1} max={100} step={1}
value={liveAutoTradeBudgetPct}
onChange={e => onLiveAutoTradeBudgetPct?.(Number(e.target.value))} />
<span className="stg-unit">%</span>
</SettingRow>
</SettingSection>
</>
);
const VirtualTradingSettingsPanel: React.FC<{
virtualTargetMaxCount?: number;
onVirtualTargetMaxCount?: (v: number) => void;
}> = ({ virtualTargetMaxCount = 20, onVirtualTargetMaxCount }) => (
<SettingSection title="가상매매">
<SettingRow
label="투자대상 목록 최대 개수"
desc="가상매매 화면·추세검색에서 등록할 수 있는 투자대상 종목 수 상한입니다."
>
<input
type="number"
className="stg-input stg-input--num"
value={virtualTargetMaxCount}
min={1}
max={100}
step={1}
onChange={e => onVirtualTargetMaxCount?.(Number(e.target.value))}
/>
</SettingRow>
<p className="stg-hint">
·· .
</p>
</SettingSection>
);
const LiveTradingSettingsPanel: React.FC<{
tradingMode?: string;
onTradingMode?: (v: string) => void;
liveAutoTradeEnabled?: boolean;
onLiveAutoTradeEnabled?: (v: boolean) => void;
hasUpbitKeys?: boolean;
upbitAccessKeyMasked?: string | null;
onUpbitKeys?: (access: string, secret: string) => void;
liveAutoTradeBudgetPct?: number;
onLiveAutoTradeBudgetPct?: (v: number) => void;
}> = ({
tradingMode = 'PAPER',
onTradingMode,
liveAutoTradeEnabled = false,
onLiveAutoTradeEnabled,
hasUpbitKeys = false,
upbitAccessKeyMasked = null,
onUpbitKeys,
liveAutoTradeBudgetPct = 95,
onLiveAutoTradeBudgetPct,
}) => {
const [accessKey, setAccessKey] = useState('');
const [secretKey, setSecretKey] = useState('');
return (
<>
<SettingSection title="매매 운영 모드">
<SettingRow label="실행 대상" desc="모의투자만, 실거래(업비트 API)만, 또는 둘 다 병행할 수 있습니다.">
<select className="stg-select" value={tradingMode} onChange={e => onTradingMode?.(e.target.value)}>
<option value="PAPER"></option>
<option value="LIVE"></option>
<option value="BOTH"> + </option>
</select>
</SettingRow>
</SettingSection>
<SettingSection title="실거래 (업비트 Open API)">
<SettingRow label="실거래 자동매매" desc="ON이면 전략 시그널·손절/익절 시 업비트에 실제 주문합니다. API 키에 주문 권한이 필요합니다.">
<label className="stg-toggle">
<input type="checkbox" checked={liveAutoTradeEnabled} onChange={e => onLiveAutoTradeEnabled?.(e.target.checked)} />
<span className="stg-toggle-slider" />
</label>
</SettingRow>
<SettingRow label="API 키 상태" desc={hasUpbitKeys ? `등록됨 (${upbitAccessKeyMasked})` : 'Access / Secret 키를 입력 후 저장하세요.'}>
<span style={{ fontSize: 12, color: hasUpbitKeys ? 'var(--accent)' : 'var(--text3)' }}>
{hasUpbitKeys ? '연결됨' : '미등록'}
</span>
</SettingRow>
<SettingRow label="Access Key" desc="입력 내용이 그대로 표시됩니다. 저장 시 DB에 AES 암호화되어 보관됩니다.">
<input
type="text"
className="stg-input stg-input--sensitive"
placeholder={hasUpbitKeys ? '변경 시 새 Access Key 입력' : 'Access Key'}
value={accessKey}
onChange={e => setAccessKey(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
</SettingRow>
<SettingRow label="Secret Key" desc="입력 내용이 그대로 표시됩니다. 저장 시 암호화되며 API로 전체 키는 반환되지 않습니다.">
<input
type="text"
className="stg-input stg-input--sensitive"
placeholder={hasUpbitKeys ? '변경 시 새 Secret Key 입력' : 'Secret Key'}
value={secretKey}
onChange={e => setSecretKey(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
</SettingRow>
{(accessKey.trim() || secretKey.trim()) && (
<SettingRow label="">
<button
type="button"
className="stg-btn stg-btn--primary"
onClick={() => {
onUpbitKeys?.(accessKey.trim(), secretKey.trim());
setAccessKey('');
setSecretKey('');
}}
>
API
</button>
</SettingRow>
)}
<SettingRow label="실거래 자동매수 예산 (%)" desc="시장가 매수 시 KRW 잔고 대비 사용 비율 (모의투자 예산과 별도)">
<input type="number" className="stg-input stg-input--narrow" min={1} max={100} step={1}
value={liveAutoTradeBudgetPct}
onChange={e => onLiveAutoTradeBudgetPct?.(Number(e.target.value))} />
<span className="stg-unit">%</span>
</SettingRow>
</SettingSection>
</>
);
};
@@ -1707,9 +1742,10 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
onVerificationIssueNotify,
trendSearchSettings,
onTrendSearchSettingsChange,
initialCategory,
}) => {
const categories = filterCategories(menuPermissions);
const [active, setActive] = useState<CategoryId>('general');
const [active, setActive] = useState<CategoryId>(initialCategory ?? 'general');
const [indicatorSaveUi, setIndicatorSaveUi] = useState<IndicatorSaveUiState | null>(null);
useEffect(() => {
@@ -1719,6 +1755,13 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
}
}, [categories, active]);
useEffect(() => {
if (!initialCategory) return;
if (categories.some(c => c.id === initialCategory)) {
setActive(initialCategory);
}
}, [initialCategory, categories]);
useEffect(() => {
if (active !== 'indicators') setIndicatorSaveUi(null);
}, [active]);
@@ -1811,7 +1854,7 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
</AdminPasswordGate>
);
case 'paper': return (
<PaperPanel
<PaperTradingSettingsPanel
paperTradingEnabled={paperTradingEnabled}
onPaperTradingEnabled={onPaperTradingEnabled}
paperInitialCapital={paperInitialCapital}
@@ -1826,9 +1869,17 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
onPaperAutoTradeEnabled={onPaperAutoTradeEnabled}
paperAutoTradeBudgetPct={paperAutoTradeBudgetPct}
onPaperAutoTradeBudgetPct={onPaperAutoTradeBudgetPct}
onPaperAccountReset={onPaperAccountReset}
/>
);
case 'virtual': return (
<VirtualTradingSettingsPanel
virtualTargetMaxCount={virtualTargetMaxCount}
onVirtualTargetMaxCount={onVirtualTargetMaxCount}
onPaperAccountReset={onPaperAccountReset}
/>
);
case 'live': return (
<LiveTradingSettingsPanel
tradingMode={tradingMode}
onTradingMode={onTradingMode}
liveAutoTradeEnabled={liveAutoTradeEnabled}
+62 -5
View File
@@ -36,6 +36,11 @@ import { getIndicatorListLabels } from '../utils/indicatorSettingsList';
import type { ChartType, Theme, Timeframe, ChartMode, IndicatorConfig } from '../types';
import { MarketSearchPanel } from './MarketSearchPanel';
import { getKoreanName } from '../utils/marketNameCache';
import {
formatChartLiveChangePct,
formatChartLivePrice,
type ChartLiveQuote,
} from '../utils/chartLiveQuote';
import LayoutPicker from './LayoutPicker';
import { DEFAULT_SYNC, type LayoutDef, type SyncOptions } from '../utils/layoutTypes';
import { loadStrategies } from '../utils/backendApi';
@@ -134,6 +139,11 @@ export interface ToolbarProps {
onOpenBulkIndicatorSettings?: () => void;
/** 지표 추가 팝업 항목 — 해당 지표 설정 모달 */
onOpenIndicatorSettings?: (type: string) => void;
/** 종목 선택 우측 시세 (실시간 차트 — 하단 upbit-bar 대체) */
showChartQuote?: boolean;
chartLiveQuote?: ChartLiveQuote | null;
chartQuoteLoading?: boolean;
chartQuoteError?: string | null;
}
// ─── SVG 아이콘 ────────────────────────────────────────────────────────────
@@ -800,6 +810,10 @@ const Toolbar: React.FC<ToolbarProps> = ({
liveStrategyActive = false,
onOpenBulkIndicatorSettings,
onOpenIndicatorSettings,
showChartQuote = false,
chartLiveQuote = null,
chartQuoteLoading = false,
chartQuoteError = null,
}) => {
const [showMarket, setShowMarket] = React.useState(false);
const [showTfMenu, setShowTfMenu] = React.useState(false);
@@ -854,6 +868,13 @@ const Toolbar: React.FC<ToolbarProps> = ({
setShowLayout(true);
}, [showLayout]);
const openMarketSearch = useCallback(() => {
setShowMarket(v => !v);
setShowTfMenu(false);
setShowCtMenu(false);
setShowIndMenu(false);
}, []);
return (
<>
{/* 종목 검색 패널 */}
@@ -869,18 +890,54 @@ const Toolbar: React.FC<ToolbarProps> = ({
{/* ── Left ─────────────────────────────────────────────────────────── */}
<div className="tv-toolbar-left">
{/* Symbol Search → 클릭 시 MarketSearchPanel 열기 */}
{/* 종목 검색(아이콘) · 선택 종목 표시 — 클릭 시 MarketSearchPanel */}
<div className="tv-sym-wrap">
<button
className="tv-sym-btn"
onClick={() => { setShowMarket(v => !v); setShowTfMenu(false); setShowCtMenu(false); setShowIndMenu(false); }}
title="종목 변경"
type="button"
className="tv-sym-btn tv-sym-btn--search"
onClick={openMarketSearch}
title="종목 검색"
aria-expanded={showMarket}
aria-haspopup="dialog"
>
<IcSearch />
<span className="tv-sym-text">{getKoreanName(symbol)}</span>
</button>
</div>
<div className="tv-toolbar-quote" aria-label="선택 종목">
<button
type="button"
className="tv-quote-ko"
onClick={openMarketSearch}
title="종목 변경"
aria-expanded={showMarket}
>
{getKoreanName(symbol)}
</button>
<span className="tv-quote-en">{symbol}</span>
{showChartQuote && chartQuoteLoading && (
<span className="tv-quote-status"> </span>
)}
{showChartQuote && chartQuoteError && (
<span className="tv-quote-status tv-quote-status--err"> {chartQuoteError}</span>
)}
{showChartQuote && !chartQuoteLoading && !chartQuoteError && chartLiveQuote?.price != null && (
<>
<span
className="tv-quote-price"
style={{ color: chartLiveQuote.isUp ? 'var(--up)' : 'var(--down)' }}
>
{formatChartLivePrice(chartLiveQuote.price)}
</span>
{chartLiveQuote.changePct != null && (
<span className={`tv-quote-change ${chartLiveQuote.isUp ? 'up' : 'down'}`}>
{formatChartLiveChangePct(chartLiveQuote.changePct)}
</span>
)}
</>
)}
</div>
{/* Compare */}
<button className="tv-icon-btn" title="워치리스트" onClick={onToggleWatch}>
<IcPlus />
+9
View File
@@ -131,6 +131,14 @@ const IcStrategyEditor = () => (
</svg>
);
const IcPaper = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="1.5" y="4" width="13" height="9" rx="1.5"/>
<path d="M1.5 6.5h13"/>
<circle cx="8" cy="10" r="1.5"/>
</svg>
);
const IcVirtual = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="2" width="12" height="12" rx="1.5"/>
@@ -158,6 +166,7 @@ const IcVerificationBoard = () => (
const MENU_ITEMS: { page: MenuPage; label: string; icon: React.ReactNode }[] = [
{ page: 'dashboard', label: '대시보드', icon: <IcDashboard /> },
{ page: 'chart', label: '실시간차트', icon: <IcChart /> },
{ page: 'paper', label: '모의투자', icon: <IcPaper /> },
{ page: 'virtual', label: '가상매매', icon: <IcVirtual /> },
{ page: 'trend-search', label: '추세검색', icon: <IcTrendSearch /> },
{ page: 'strategy-editor', label: '전략편집기', icon: <IcStrategyEditor /> },
@@ -7,9 +7,12 @@ import type { TickerData } from '../hooks/useMarketTicker';
import { useTradeNotification, type TradeNotificationItem } from '../contexts/TradeNotificationContext';
import { loadPaperSummary, type PaperSummaryDto } from '../utils/backendApi';
import { coerceFiniteNumber } from '../utils/safeFormat';
import TradeOrderPanel from './TradeOrderPanel';
import { OrderbookPanel } from './OrderbookPanel';
import PaperSplitPanel from './paper/PaperSplitPanel';
import {
TradeSplitOrderPanel,
TradeOrderbookPanelContent,
TradeRightPanelTabs,
TradeRightPanelBody,
} from './trade';
import { useUpbitOrderbook } from '../hooks/useUpbitOrderbook';
import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades';
import { useAppSettings } from '../hooks/useAppSettings';
@@ -504,93 +507,52 @@ export const TradeNotificationListPage: React.FC<Props> = ({
</svg>
</button>
<aside
className={`tnl-right${rightOpen ? ' tnl-right--open' : ''}`}
className={`tnl-right trade-right-panel${rightOpen ? ' tnl-right--open' : ''}`}
aria-label="매매·호가"
aria-hidden={!rightOpen}
>
<div className="bps-right-tabs tnl-right-tabs">
<button
type="button"
className={`bps-right-tab${rightTab === 'trade' ? ' bps-right-tab--on' : ''}`}
onClick={() => setRightTab('trade')}
>
</button>
<button
type="button"
className={`bps-right-tab${rightTab === 'orderbook' ? ' bps-right-tab--on' : ''}`}
onClick={() => setRightTab('orderbook')}
>
</button>
</div>
<div className="tnl-right-body ptd-right-body" ref={orderAnchorRef}>
<TradeRightPanelTabs
activeTab={rightTab}
onTabChange={setRightTab}
className="tnl-right-tabs"
/>
<TradeRightPanelBody
anchorRef={orderAnchorRef}
bodyClassName="tnl-right-body"
>
{rightTab === 'trade' ? (
<PaperSplitPanel
className="ptd-split-panel--right"
topTitle="매수"
bottomTitle="매도"
top={(
<div className="ptd-order-card">
<TradeOrderPanel
side="buy"
market={selectedMarket}
tradePrice={tradePrice}
availableKrw={coerceFiniteNumber(summary?.cashBalance) ?? 0}
fillRequest={fillBuy}
searchAnchorRef={orderAnchorRef}
onMarketSelect={m => handleSelectMarket(m)}
showSymbolField
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={() => void refreshPaperData()}
/>
</div>
)}
bottom={(
<div className="ptd-order-card">
<TradeOrderPanel
side="sell"
market={selectedMarket}
tradePrice={tradePrice}
availableCoinQty={posQty}
fillRequest={fillSell}
onMarketSelect={m => handleSelectMarket(m)}
showSymbolField={false}
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={() => void refreshPaperData()}
/>
</div>
)}
<TradeSplitOrderPanel
market={selectedMarket}
tradePrice={tradePrice}
availableKrw={coerceFiniteNumber(summary?.cashBalance) ?? 0}
fillBuy={fillBuy}
fillSell={fillSell}
searchAnchorRef={orderAnchorRef}
onMarketSelect={m => handleSelectMarket(m)}
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={() => void refreshPaperData()}
/>
) : (
<div className="rsp-ob-stack ptd-ob-stack--fill">
<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={selectedMarket}
asks={orderbook.asks}
bids={orderbook.bids}
totalAskSize={orderbook.totalAskSize}
totalBidSize={orderbook.totalBidSize}
wsStatus={obWsStatus}
bestAsk={spread.bestAsk}
bestBid={spread.bestBid}
spread={spread.spread}
spreadPct={spread.pct}
prevClose={orderbookPrevClose}
tickerInfo={orderbookTickerInfo}
recentTrades={recentTrades}
tradeStrength={tradeStrength}
onRowClick={handleOrderbookRowClick}
/>
</div>
</div>
</div>
<TradeOrderbookPanelContent
market={selectedMarket}
asks={orderbook.asks}
bids={orderbook.bids}
totalAskSize={orderbook.totalAskSize}
totalBidSize={orderbook.totalBidSize}
wsStatus={obWsStatus}
bestAsk={spread.bestAsk}
bestBid={spread.bestBid}
spread={spread.spread}
spreadPct={spread.pct}
prevClose={orderbookPrevClose}
tickerInfo={orderbookTickerInfo}
recentTrades={recentTrades}
tradeStrength={tradeStrength ?? undefined}
onRowClick={handleOrderbookRowClick}
/>
)}
</div>
</TradeRightPanelBody>
</aside>
</div>
</div>
+88 -70
View File
@@ -17,6 +17,8 @@ export interface TradeOrderPanelProps {
/** 현재가 (KRW) — 종목 변경 시 초기값 */
tradePrice: number | null;
availableKrw?: number;
/** 종목별 추가 매수 가능 (한도 기준) */
availableSymbolBuyKrw?: number;
/** 매도 시 보유 수량 (모의투자) */
availableCoinQty?: number;
/** 호가·차트에서 자동 입력 */
@@ -89,6 +91,7 @@ const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
market,
tradePrice,
availableKrw = 0,
availableSymbolBuyKrw,
availableCoinQty = 0,
fillRequest,
searchAnchorRef,
@@ -99,6 +102,9 @@ const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
onPaperOrderFilled,
}) => {
const isBuy = side === 'buy';
const buyBudgetKrw = isBuy && availableSymbolBuyKrw != null
? Math.min(availableKrw, availableSymbolBuyKrw)
: availableKrw;
const displayMarket = (fillRequest?.side === side ? fillRequest.market : null) ?? market;
const code = coinCode(displayMarket);
const koreanName = getKoreanName(displayMarket);
@@ -156,8 +162,8 @@ const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
const applyPct = useCallback((pct: number) => {
setPctMode(pct);
if (isBuy) {
if (orderKind === 'market' || availableKrw <= 0) return;
const budget = (availableKrw * pct) / 100;
if (orderKind === 'market' || buyBudgetKrw <= 0) return;
const budget = (buyBudgetKrw * pct) / 100;
const p = orderKind === 'limit' && price > 0 ? price : refPrice;
if (p > 0) {
const q = budget / p;
@@ -168,7 +174,7 @@ const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
if (availableCoinQty <= 0) return;
const q = (availableCoinQty * pct) / 100;
setQtyStr(q >= 1 ? q.toFixed(8).replace(/\.?0+$/, '') : q.toFixed(8));
}, [isBuy, availableKrw, availableCoinQty, orderKind, price, refPrice]);
}, [isBuy, buyBudgetKrw, availableCoinQty, orderKind, price, refPrice]);
const bumpPrice = useCallback((delta: number) => {
const base = price > 0 ? price : refPrice;
@@ -202,26 +208,36 @@ const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
return;
}
try {
const trade = await placePaperOrder({
const result = await placePaperOrder({
market: displayMarket,
side: isBuy ? 'BUY' : 'SELL',
orderKind: orderKind === 'market' ? 'market' : 'limit',
orderType: orderKind === 'market' ? 'MARKET' : 'LIMIT',
price: execPrice,
quantity: qty,
source: 'MANUAL',
});
if (!trade) {
if (!result?.trade && !result?.order) {
window.alert('모의 주문에 실패했습니다.');
return;
}
onPaperOrderFilled?.();
window.alert(
`[모의투자] ${koreanName || code} ${label} 체결\n` +
`가격: ${fmtKrw(trade.price)} KRW\n` +
`수량: ${trade.quantity} ${code}\n` +
`수료: ${fmtKrw(trade.feeAmount)} KRW\n` +
`체결 후 현금: ${fmtKrw(trade.cashAfter)} KRW`,
);
if (result.order) {
window.alert(
`[모의투자] ${koreanName || code} ${label} 미체결 등록\n` +
`지정가: ${fmtKrw(result.order.limitPrice ?? execPrice)} KRW\n` +
`: ${result.order.quantity} ${code}`,
);
} else if (result.trade) {
const trade = result.trade;
window.alert(
`[모의투자] ${koreanName || code} ${label} 체결\n` +
`가격: ${fmtKrw(trade.price)} KRW\n` +
`수량: ${trade.quantity} ${code}\n` +
`수수료: ${fmtKrw(trade.feeAmount)} KRW\n` +
`체결 후 현금: ${fmtKrw(trade.cashAfter)} KRW`,
);
}
setQtyStr('');
setPctMode(null);
} catch (e) {
@@ -245,8 +261,12 @@ const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
const minOrder = 5000;
const feeRate = 0.05;
const balanceDisplay = isBuy
? `${buyBudgetKrw > 0 ? fmtKrw(buyBudgetKrw) : '-'} KRW`
: `${availableCoinQty > 0 ? availableCoinQty.toFixed(8).replace(/\.?0+$/, '') : '0'} ${code}`;
return (
<div className={`top-panel top-panel--${side}`}>
<div className={`top-panel top-panel--upbit top-panel--${side}`}>
<div className="top-panel-fields">
{paperTradingEnabled && !paperAutoTradeEnabled && showSymbolField && (
<p className="top-paper-hint"> OFF · .</p>
@@ -317,71 +337,70 @@ const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
</div>
</div>
<div className="top-row top-row--balance">
<div
className="top-row top-row--balance"
title={isBuy && availableSymbolBuyKrw != null
? `종목별 한도 ${fmtKrw(availableSymbolBuyKrw)} KRW`
: undefined}
>
<span className="top-label">{isBuy ? '주문가능' : '보유수량'}</span>
<span className="top-balance">
{isBuy
? `${fmtKrw(availableKrw)} KRW`
: `${availableCoinQty > 0 ? availableCoinQty.toFixed(8).replace(/\.?0+$/, '') : '0'} ${code}`}
</span>
<span className="top-balance">{balanceDisplay}</span>
</div>
<div className="top-price-qty-block">
<div className="top-field top-field--price">
<label className="top-label">{priceLabel} (KRW)</label>
<div className="top-input-group">
<input
type="text"
className="top-input"
value={priceStr}
disabled={orderKind === 'market'}
onChange={handlePriceChange}
inputMode="numeric"
pattern="[0-9,]*"
autoComplete="off"
/>
<button type="button" className="top-step" onClick={() => bumpPrice(-step)} disabled={orderKind === 'market'}></button>
<button type="button" className="top-step" onClick={() => bumpPrice(step)} disabled={orderKind === 'market'}>+</button>
</div>
</div>
<div className="top-field top-field--qty">
<label className="top-label"> ({code})</label>
<div className="top-field top-field--price">
<label className="top-label">{priceLabel} (KRW)</label>
<div className="top-input-group">
<input
type="text"
className="top-input top-input--full"
value={qtyStr}
className="top-input"
value={priceStr}
disabled={orderKind === 'market'}
onChange={handleQtyChange}
inputMode="decimal"
pattern="[0-9.]*"
onChange={handlePriceChange}
inputMode="numeric"
pattern="[0-9,]*"
autoComplete="off"
placeholder="0"
/>
</div>
<div className="top-pct-row top-pct-row--block">
{PCT_BTNS.map(p => (
<button
key={p}
type="button"
className={`top-pct-btn${pctMode === p ? ' top-pct-btn--active' : ''}`}
onClick={() => applyPct(p)}
>
{p === 100 ? '최대' : `${p}%`}
</button>
))}
<button
type="button"
className={`top-pct-btn top-pct-btn--direct${pctMode === -1 ? ' top-pct-btn--active' : ''}`}
onClick={() => setPctMode(-1)}
>
</button>
<button type="button" className="top-step" onClick={() => bumpPrice(-step)} disabled={orderKind === 'market'}></button>
<button type="button" className="top-step" onClick={() => bumpPrice(step)} disabled={orderKind === 'market'}>+</button>
</div>
</div>
<div className="top-field">
<div className="top-field top-field--qty">
<label className="top-label"> ({code})</label>
<input
type="text"
className="top-input top-input--full"
value={qtyStr}
disabled={orderKind === 'market'}
onChange={handleQtyChange}
inputMode="decimal"
pattern="[0-9.]*"
autoComplete="off"
placeholder="0"
/>
</div>
<div className="top-pct-row top-pct-row--under-qty">
{PCT_BTNS.map(p => (
<button
key={p}
type="button"
className={`top-pct-btn${pctMode === p ? ' top-pct-btn--active' : ''}`}
onClick={() => applyPct(p)}
>
{`${p}%`}
</button>
))}
<button
type="button"
className={`top-pct-btn top-pct-btn--direct${pctMode === -1 ? ' top-pct-btn--active' : ''}`}
onClick={() => setPctMode(-1)}
>
</button>
</div>
<div className="top-field top-field--total">
<label className="top-label"> (KRW)</label>
<input
type="text"
@@ -393,11 +412,10 @@ const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
<div className="top-meta">
<span className="top-meta-line">
<span>조건: 보통 </span>
<span className="top-meta-sep">·</span>
<span> {fmtKrw(minOrder)}</span>
<span>: {fmtKrw(minOrder)} KRW</span>
<span className="top-meta-sep">·</span>
<span> {feeRate}%</span>
<span>( ): {feeRate}%</span>
</span>
</div>
</div>
+32 -67
View File
@@ -15,9 +15,7 @@ import {
} from '../utils/backendApi';
import type { Theme, TradeOrderFillRequest } from '../types';
import type { TickerData } from '../hooks/useMarketTicker';
import TradeOrderPanel from './TradeOrderPanel';
import { OrderbookPanel } from './OrderbookPanel';
import PaperSplitPanel from './paper/PaperSplitPanel';
import { TradeSplitOrderPanel, TradeOrderbookPanelContent, TradeRightPanelBody } from './trade';
import { useUpbitOrderbook } from '../hooks/useUpbitOrderbook';
import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades';
import PaperTradeHistoryList from './paper/PaperTradeHistoryList';
@@ -423,7 +421,7 @@ const VirtualTradingPage: React.FC<Props> = ({
leftDefaultWidth={380}
leftCollapsedStorageKey="vtd-left-open"
rightStorageKey="vtd-right-width"
rightDefaultWidth={380}
rightDefaultWidth={440}
rightCollapsedStorageKey="vtd-right-open"
left={(
<VirtualLeftTargetPanel
@@ -466,71 +464,38 @@ const VirtualTradingPage: React.FC<Props> = ({
)}
rightTabs={rightTabs}
right={(
<div className="ptd-right-body" ref={orderAnchorRef}>
<TradeRightPanelBody anchorRef={orderAnchorRef} embeddedInShell>
{rightTab === 'trade' ? (
<PaperSplitPanel
className="ptd-split-panel--right"
topTitle="매수"
bottomTitle="매도"
top={(
<div className="ptd-order-card">
<TradeOrderPanel
side="buy"
market={selectedMarket}
tradePrice={tradePrice}
availableKrw={coerceFiniteNumber(summary?.cashBalance) ?? 0}
fillRequest={fillBuy}
searchAnchorRef={orderAnchorRef}
onMarketSelect={handleSelectMarket}
showSymbolField
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={() => void refreshPaperData({ focusHistory: true })}
/>
</div>
)}
bottom={(
<div className="ptd-order-card">
<TradeOrderPanel
side="sell"
market={selectedMarket}
tradePrice={tradePrice}
availableCoinQty={posQty}
fillRequest={fillSell}
onMarketSelect={handleSelectMarket}
showSymbolField={false}
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={() => void refreshPaperData({ focusHistory: true })}
/>
</div>
)}
<TradeSplitOrderPanel
market={selectedMarket}
tradePrice={tradePrice}
availableKrw={coerceFiniteNumber(summary?.cashBalance) ?? 0}
fillBuy={fillBuy}
fillSell={fillSell}
searchAnchorRef={orderAnchorRef}
onMarketSelect={handleSelectMarket}
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={() => void refreshPaperData({ focusHistory: true })}
/>
) : rightTab === 'orderbook' ? (
<div className="rsp-ob-stack ptd-ob-stack--fill">
<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={selectedMarket}
asks={orderbook.asks}
bids={orderbook.bids}
totalAskSize={orderbook.totalAskSize}
totalBidSize={orderbook.totalBidSize}
wsStatus={obWsStatus}
bestAsk={spread.bestAsk}
bestBid={spread.bestBid}
spread={spread.spread}
spreadPct={spread.pct}
prevClose={orderbookPrevClose}
tickerInfo={orderbookTickerInfo}
recentTrades={recentTrades}
tradeStrength={tradeStrength}
onRowClick={handleOrderbookRowClick}
/>
</div>
</div>
</div>
<TradeOrderbookPanelContent
market={selectedMarket}
asks={orderbook.asks}
bids={orderbook.bids}
totalAskSize={orderbook.totalAskSize}
totalBidSize={orderbook.totalBidSize}
wsStatus={obWsStatus}
bestAsk={spread.bestAsk}
bestBid={spread.bestBid}
spread={spread.spread}
spreadPct={spread.pct}
prevClose={orderbookPrevClose}
tickerInfo={orderbookTickerInfo}
recentTrades={recentTrades}
tradeStrength={tradeStrength ?? undefined}
onRowClick={handleOrderbookRowClick}
/>
) : (
<PaperTradeHistoryList
trades={trades}
@@ -541,7 +506,7 @@ const VirtualTradingPage: React.FC<Props> = ({
className="ptd-trade-history--fill"
/>
)}
</div>
</TradeRightPanelBody>
)}
/>
);
@@ -377,7 +377,7 @@ export default function BuilderPageShell({
<div className={`bps-side-wrap bps-side-wrap--right${rightOpen ? ' bps-side-wrap--open' : ''}`}>
<PanelCollapseHandle side="right" open={rightOpen} onToggle={toggleRight} label="우측 패널" />
<aside
className={`bps-right bps-right--collapsible${rightOpen ? ' bps-right--collapsible--open' : ''}`}
className={`bps-right trade-right-panel bps-right--collapsible${rightOpen ? ' bps-right--collapsible--open' : ''}`}
>
{rightTabs ?? (rightTitle ? (
<div className="bps-panel-head" style={{ margin: 0, padding: '12px 12px 10px', borderBottom: '1px solid var(--se-border)' }}>
@@ -397,7 +397,7 @@ export default function BuilderPageShell({
onPointerDown={handleRightSplitter}
/>
<aside
className="bps-right"
className="bps-right trade-right-panel"
style={{ width: rightWidth, flex: `0 0 ${rightWidth}px` }}
>
{rightTabs ?? (rightTitle ? (
@@ -0,0 +1,84 @@
import React, { useCallback } from 'react';
import type { PaperAllocationDto } from '../../utils/backendApi';
import { patchPaperAllocation } from '../../utils/backendApi';
import { fmtKrw } from '../TradeOrderPanel';
function coinCode(symbol: string): string {
return symbol.replace(/^KRW-/, '');
}
interface Props {
allocations: PaperAllocationDto[];
selectedMarket?: string;
onSelectMarket?: (market: string) => void;
onChanged?: () => void;
}
const PaperAllocationTable: React.FC<Props> = ({
allocations,
selectedMarket,
onSelectMarket,
onChanged,
}) => {
const toggleActive = useCallback(async (row: PaperAllocationDto) => {
await patchPaperAllocation(row.symbol, { isActive: !row.isActive });
onChanged?.();
}, [onChanged]);
if (!allocations.length) {
return <p className="ptd-muted"> . .</p>;
}
return (
<div className="ptd-alloc-table-wrap">
<table className="ptd-table ptd-alloc-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{allocations.map(row => {
const ret = row.symbolReturnPct ?? 0;
const selected = row.symbol === selectedMarket;
return (
<tr
key={row.symbol}
className={selected ? 'ptd-alloc-row--selected' : ''}
onClick={() => onSelectMarket?.(row.symbol)}
role="button"
tabIndex={0}
onKeyDown={e => { if (e.key === 'Enter') onSelectMarket?.(row.symbol); }}
>
<td>{row.koreanName || coinCode(row.symbol)}</td>
<td>{(row.weightPct ?? 0).toFixed(1)}%</td>
<td>{fmtKrw(row.maxInvestKrw)}</td>
<td>{fmtKrw(row.investedKrw ?? 0)}</td>
<td>{fmtKrw(row.evalAmount ?? 0)}</td>
<td className={ret >= 0 ? 'up' : 'down'}>{ret >= 0 ? '+' : ''}{ret.toFixed(2)}%</td>
<td onClick={e => e.stopPropagation()}>
<button
type="button"
className={`ptd-alloc-toggle${row.isActive !== false ? ' ptd-alloc-toggle--on' : ''}`}
onClick={() => void toggleActive(row)}
title={row.isActive !== false ? '신규 매수 허용' : '신규 매수 차단'}
>
{row.isActive !== false ? 'ON' : 'OFF'}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
};
export default PaperAllocationTable;
@@ -0,0 +1,62 @@
import React, { useEffect, useMemo, useState } from 'react';
import { loadPaperDailySnapshots, type PaperDailySnapshotDto } from '../../utils/backendApi';
import { fmtKrw } from '../TradeOrderPanel';
interface Props {
refreshKey?: number;
}
const PaperAssetTrendChart: React.FC<Props> = ({ refreshKey = 0 }) => {
const [snapshots, setSnapshots] = useState<PaperDailySnapshotDto[]>([]);
useEffect(() => {
let cancelled = false;
void loadPaperDailySnapshots().then(rows => {
if (!cancelled) setSnapshots(rows);
});
return () => { cancelled = true; };
}, [refreshKey]);
const { path, min, max, last } = useMemo(() => {
if (snapshots.length < 2) return { path: '', min: 0, max: 0, last: 0 };
const vals = snapshots.map(s => s.totalAsset);
const mn = Math.min(...vals);
const mx = Math.max(...vals);
const w = 280;
const h = 48;
const pad = 4;
const range = mx - mn || 1;
const pts = snapshots.map((s, i) => {
const x = pad + (i / (snapshots.length - 1)) * (w - pad * 2);
const y = h - pad - ((s.totalAsset - mn) / range) * (h - pad * 2);
return `${x},${y}`;
});
return { path: `M ${pts.join(' L ')}`, min: mn, max: mx, last: vals[vals.length - 1] };
}, [snapshots]);
if (snapshots.length < 2) {
return (
<div className="ptd-asset-trend ptd-asset-trend--empty">
<span className="ptd-muted"> .</span>
</div>
);
}
return (
<div className="ptd-asset-trend">
<div className="ptd-asset-trend-head">
<span> </span>
<span className="ptd-asset-trend-last">{fmtKrw(last)}</span>
</div>
<svg viewBox="0 0 280 48" className="ptd-asset-trend-svg" preserveAspectRatio="none">
<path d={path} fill="none" stroke="var(--gc-trade-buy, #ef5350)" strokeWidth="1.5" />
</svg>
<div className="ptd-asset-trend-range">
<span>{fmtKrw(min)}</span>
<span>{fmtKrw(max)}</span>
</div>
</div>
);
};
export default PaperAssetTrendChart;
@@ -0,0 +1,47 @@
import React from 'react';
import type { PaperSummaryDto } from '../../utils/backendApi';
import { fmtKrw } from '../TradeOrderPanel';
interface Props {
summary: PaperSummaryDto | null;
}
const PaperInvestmentKpi: React.FC<Props> = ({ summary: s }) => {
if (!s) return <p className="ptd-muted"> </p>;
const retTone = (s.totalReturnPct ?? 0) >= 0 ? 'up' : 'down';
const unTone = (s.unrealizedPnl ?? 0) >= 0 ? 'up' : 'down';
return (
<div className="ptd-invest-kpi">
<div className="ptd-invest-kpi-row ptd-invest-kpi-row--main">
<div className="ptd-invest-kpi-cell">
<span className="ptd-invest-kpi-label"></span>
<span className="ptd-invest-kpi-value">{fmtKrw(s.totalAsset)}</span>
</div>
<div className={`ptd-invest-kpi-cell ptd-invest-kpi-cell--${retTone}`}>
<span className="ptd-invest-kpi-label"></span>
<span className="ptd-invest-kpi-value">{(s.totalReturnPct ?? 0) >= 0 ? '+' : ''}{(s.totalReturnPct ?? 0).toFixed(2)}%</span>
</div>
<div className={`ptd-invest-kpi-cell ptd-invest-kpi-cell--${unTone}`}>
<span className="ptd-invest-kpi-label"></span>
<span className="ptd-invest-kpi-value">{fmtKrw(s.unrealizedPnl)}</span>
</div>
</div>
<div className="ptd-invest-kpi-row">
<div className="ptd-invest-kpi-cell">
<span className="ptd-invest-kpi-label"></span>
<span className="ptd-invest-kpi-value">{fmtKrw(s.cashBalance)}</span>
</div>
<div className="ptd-invest-kpi-cell">
<span className="ptd-invest-kpi-label"></span>
<span className="ptd-invest-kpi-value">{fmtKrw(s.realizedPnl)}</span>
</div>
<div className="ptd-invest-kpi-cell">
<span className="ptd-invest-kpi-label"></span>
<span className="ptd-invest-kpi-value">{fmtKrw(s.orderableCash ?? s.cashBalance)}</span>
</div>
</div>
</div>
);
};
export default PaperInvestmentKpi;
@@ -0,0 +1,63 @@
import React, { useEffect, useState } from 'react';
import { loadPaperLedger, type PaperLedgerEntryDto } from '../../utils/backendApi';
import { fmtKrw } from '../TradeOrderPanel';
const ENTRY_LABELS: Record<string, string> = {
INITIAL: '초기 예탁',
BUY_SETTLE: '매수 결제',
SELL_SETTLE: '매도 입금',
RESET: '계좌 초기화',
FEE: '수수료',
ADJUST: '조정',
};
interface Props {
refreshKey?: number;
}
const PaperLedgerTab: React.FC<Props> = ({ refreshKey = 0 }) => {
const [entries, setEntries] = useState<PaperLedgerEntryDto[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setLoading(true);
void loadPaperLedger({ page: 0, size: 50 }).then(page => {
if (!cancelled) {
setEntries(page.content);
setLoading(false);
}
});
return () => { cancelled = true; };
}, [refreshKey]);
if (loading) return <p className="ptd-muted"> </p>;
if (!entries.length) return <p className="ptd-muted"> .</p>;
return (
<table className="ptd-table ptd-table--compact">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{entries.map(e => (
<tr key={e.id}>
<td className="ptd-time">{e.createdAt?.slice(5, 16).replace('T', ' ') ?? '—'}</td>
<td>{ENTRY_LABELS[e.entryType] ?? e.entryType}</td>
<td className={e.amount >= 0 ? 'up' : 'down'}>{e.amount >= 0 ? '+' : ''}{fmtKrw(e.amount)}</td>
<td>{fmtKrw(e.cashAfter)}</td>
<td>{e.symbol?.replace(/^KRW-/, '') ?? '—'}</td>
</tr>
))}
</tbody>
</table>
);
};
export default PaperLedgerTab;
@@ -6,9 +6,10 @@ import PaperSplitPanel from './PaperSplitPanel';
interface Props {
onSettingsSaved?: () => void;
onAccountReset?: () => void;
onOpenSettings?: () => void;
}
const PaperLeftSettingsTab: React.FC<Props> = ({ onSettingsSaved, onAccountReset }) => {
const PaperLeftSettingsTab: React.FC<Props> = ({ onSettingsSaved, onAccountReset, onOpenSettings }) => {
const { defaults, save, isLoaded } = useAppSettings();
if (!isLoaded) {
@@ -28,6 +29,11 @@ const PaperLeftSettingsTab: React.FC<Props> = ({ onSettingsSaved, onAccountReset
const top = (
<>
{onOpenSettings && (
<button type="button" className="bps-btn bps-btn--ghost ptd-left-settings-link" onClick={onOpenSettings}>
</button>
)}
<div className="ptd-left-section">
<label className="ptd-left-row">
<span> </span>
@@ -0,0 +1,58 @@
import React, { useCallback } from 'react';
import type { PaperOrderDto } from '../../utils/backendApi';
import { cancelPaperOrder } from '../../utils/backendApi';
import { fmtKrw } from '../TradeOrderPanel';
function coinCode(symbol: string): string {
return symbol.replace(/^KRW-/, '');
}
interface Props {
orders: PaperOrderDto[];
onChanged?: () => void;
}
const PaperPendingOrdersList: React.FC<Props> = ({ orders, onChanged }) => {
const handleCancel = useCallback(async (id: number) => {
if (!window.confirm('미체결 주문을 취소할까요?')) return;
await cancelPaperOrder(id);
onChanged?.();
}, [onChanged]);
if (!orders.length) {
return <p className="ptd-muted"> .</p>;
}
return (
<table className="ptd-table ptd-table--compact">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{orders.map(o => (
<tr key={o.id}>
<td>{coinCode(o.symbol)}</td>
<td className={o.side === 'BUY' ? 'up' : 'down'}>{o.side === 'BUY' ? '매수' : '매도'}</td>
<td>{fmtKrw(o.limitPrice ?? 0)}</td>
<td>{o.quantity}</td>
<td>{o.side === 'BUY' ? fmtKrw(o.reservedCash) : o.reservedQty}</td>
<td>
<button type="button" className="bps-btn bps-btn--ghost" onClick={() => void handleCancel(o.id)}>
</button>
</td>
</tr>
))}
</tbody>
</table>
);
};
export default PaperPendingOrdersList;
@@ -129,6 +129,20 @@ const PaperTradeHistoryList: React.FC<Props> = ({
{strategyName}
</span>
</div>
{!isBuy && t.realizedPnlDelta != null && (
<div className="vtd-target-strategy-field vtd-trade-meta-field">
<span className="vtd-target-strategy-label"></span>
<span className={`vtd-trade-meta-value ${t.realizedPnlDelta >= 0 ? 'vtd-target-up' : 'vtd-target-dn'}`}>
{t.realizedPnlDelta >= 0 ? '+' : ''}{fmtKrw(t.realizedPnlDelta)}
</span>
</div>
)}
<div className="vtd-target-strategy-field vtd-trade-meta-field">
<span className="vtd-target-strategy-label"> </span>
<span className="vtd-trade-meta-value">{fmtKrw(t.cashAfter)}</span>
</div>
</div>
);
})}
@@ -0,0 +1,21 @@
/**
* 우측 매매 패널 — 매수 주문 화면 (공용)
*/
import React from 'react';
import TradeOrderPanel, { type TradeOrderPanelProps } from '../TradeOrderPanel';
export type TradeBuyOrderPanelProps = Omit<
TradeOrderPanelProps,
'side' | 'availableCoinQty'
>;
const TradeBuyOrderPanel: React.FC<TradeBuyOrderPanelProps> = ({
showSymbolField = true,
...props
}) => (
<div className="ptd-order-card ptd-order-card--trade-inline">
<TradeOrderPanel side="buy" showSymbolField={showSymbolField} {...props} />
</div>
);
export default TradeBuyOrderPanel;
@@ -0,0 +1,39 @@
/**
* 우측 호가 탭 — 실시간 호가 카드 (공용)
*/
import React from 'react';
import { OrderbookPanel } from '../OrderbookPanel';
import type { OrderbookDisplayUnit, WsStatus } from '../../hooks/useUpbitOrderbook';
import type { OrderbookTickerInfo } from '../OrderbookPanel';
import type { RecentTrade } from '../../hooks/useUpbitRecentTrades';
export interface TradeOrderbookPanelContentProps {
market: string;
asks: OrderbookDisplayUnit[];
bids: OrderbookDisplayUnit[];
totalAskSize: number;
totalBidSize: number;
wsStatus: WsStatus;
bestAsk: number;
bestBid: number;
spread: number;
spreadPct: number;
prevClose: number;
tickerInfo?: OrderbookTickerInfo;
recentTrades?: RecentTrade[];
tradeStrength?: number;
onRowClick?: (price: number, rowType: 'ask' | 'bid') => void;
}
const TradeOrderbookPanelContent: React.FC<TradeOrderbookPanelContentProps> = (props) => (
<div className="rsp-ob-stack ptd-ob-stack--fill">
<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 {...props} />
</div>
</div>
</div>
);
export default TradeOrderbookPanelContent;
@@ -0,0 +1,40 @@
/**
* 우측 패널 본문 — bps-right-body + ptd-right-body (paperDashboard compact 매매 UI)
*/
import React from 'react';
interface Props {
children: React.ReactNode;
className?: string;
bodyClassName?: string;
anchorRef?: React.Ref<HTMLDivElement>;
/** BuilderPageShell이 이미 bps-right-body를 제공할 때 (모의·가상매매) */
embeddedInShell?: boolean;
}
const TradeRightPanelBody: React.FC<Props> = ({
children,
className = '',
bodyClassName = '',
anchorRef,
embeddedInShell = false,
}) => {
const inner = (
<div
className={`ptd-right-body${bodyClassName ? ` ${bodyClassName}` : ''}`}
ref={anchorRef}
>
{children}
</div>
);
if (embeddedInShell) {
return inner;
}
return (
<div className={`bps-right-body${className ? ` ${className}` : ''}`}>
{inner}
</div>
);
};
export default TradeRightPanelBody;
@@ -0,0 +1,33 @@
/**
* 우측 패널 — 매매 / 호가 탭 (알림목록·모의투자·실시간 차트 공용)
*/
import React from 'react';
export type TradeRightPanelTab = 'trade' | 'orderbook';
interface Props {
activeTab: TradeRightPanelTab;
onTabChange: (tab: TradeRightPanelTab) => void;
className?: string;
}
const TradeRightPanelTabs: React.FC<Props> = ({ activeTab, onTabChange, className = '' }) => (
<div className={`bps-right-tabs${className ? ` ${className}` : ''}`}>
<button
type="button"
className={`bps-right-tab${activeTab === 'trade' ? ' bps-right-tab--on' : ''}`}
onClick={() => onTabChange('trade')}
>
</button>
<button
type="button"
className={`bps-right-tab${activeTab === 'orderbook' ? ' bps-right-tab--on' : ''}`}
onClick={() => onTabChange('orderbook')}
>
</button>
</div>
);
export default TradeRightPanelTabs;
@@ -0,0 +1,21 @@
/**
* 우측 매매 패널 — 매도 주문 화면 (공용)
*/
import React from 'react';
import TradeOrderPanel, { type TradeOrderPanelProps } from '../TradeOrderPanel';
export type TradeSellOrderPanelProps = Omit<
TradeOrderPanelProps,
'side' | 'availableKrw' | 'availableSymbolBuyKrw'
>;
const TradeSellOrderPanel: React.FC<TradeSellOrderPanelProps> = ({
showSymbolField = false,
...props
}) => (
<div className="ptd-order-card ptd-order-card--trade-inline">
<TradeOrderPanel side="sell" showSymbolField={showSymbolField} {...props} />
</div>
);
export default TradeSellOrderPanel;
@@ -0,0 +1,83 @@
/**
* 우측 매매 탭 — 상단 매수 / 하단 매도 분할 레이아웃 (알림목록·모의투자·가상매매·차트 공용)
*/
import React from 'react';
import type { TradeOrderFillRequest } from '../../types';
import PaperSplitPanel from '../paper/PaperSplitPanel';
import TradeBuyOrderPanel from './TradeBuyOrderPanel';
import TradeSellOrderPanel from './TradeSellOrderPanel';
export interface TradeSplitOrderPanelProps {
market: string;
tradePrice: number | null;
availableKrw?: number;
/** 종목별 추가 매수 가능 (모의투자 한도) */
availableSymbolBuyKrw?: number;
availableCoinQty?: number;
fillBuy?: TradeOrderFillRequest | null;
fillSell?: TradeOrderFillRequest | null;
/** 단일 fill (차트 호가 연동 — side에 따라 매수/매도에만 적용) */
fillRequest?: TradeOrderFillRequest | null;
searchAnchorRef?: React.RefObject<HTMLElement | null>;
onMarketSelect?: (market: string) => void;
paperTradingEnabled?: boolean;
paperAutoTradeEnabled?: boolean;
onPaperOrderFilled?: () => void;
className?: string;
}
const TradeSplitOrderPanel: React.FC<TradeSplitOrderPanelProps> = ({
market,
tradePrice,
availableKrw = 0,
availableSymbolBuyKrw,
availableCoinQty = 0,
fillBuy,
fillSell,
fillRequest,
searchAnchorRef,
onMarketSelect,
paperTradingEnabled = false,
paperAutoTradeEnabled = false,
onPaperOrderFilled,
className = '',
}) => {
const resolvedBuyFill = fillBuy ?? (fillRequest?.side === 'buy' ? fillRequest : null);
const resolvedSellFill = fillSell ?? (fillRequest?.side === 'sell' ? fillRequest : null);
return (
<PaperSplitPanel
className={`ptd-split-panel--right${className ? ` ${className}` : ''}`}
topTitle="매수"
bottomTitle="매도"
top={(
<TradeBuyOrderPanel
market={market}
tradePrice={tradePrice}
availableKrw={availableKrw}
availableSymbolBuyKrw={availableSymbolBuyKrw}
fillRequest={resolvedBuyFill}
searchAnchorRef={searchAnchorRef}
onMarketSelect={onMarketSelect}
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={onPaperOrderFilled}
/>
)}
bottom={(
<TradeSellOrderPanel
market={market}
tradePrice={tradePrice}
availableCoinQty={availableCoinQty}
fillRequest={resolvedSellFill}
onMarketSelect={onMarketSelect}
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={onPaperOrderFilled}
/>
)}
/>
);
};
export default TradeSplitOrderPanel;
+11
View File
@@ -0,0 +1,11 @@
export { default as TradeBuyOrderPanel } from './TradeBuyOrderPanel';
export type { TradeBuyOrderPanelProps } from './TradeBuyOrderPanel';
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 TradeOrderbookPanelContent } from './TradeOrderbookPanelContent';
export type { TradeOrderbookPanelContentProps } from './TradeOrderbookPanelContent';
export { default as TradeRightPanelTabs } from './TradeRightPanelTabs';
export type { TradeRightPanelTab } from './TradeRightPanelTabs';
export { default as TradeRightPanelBody } from './TradeRightPanelBody';
@@ -1,8 +1,9 @@
/**
* 검증 이슈 첨부 이미지 — 파일 선택·드래그·클립보드 붙여넣기
* 검증 이슈 첨부 이미지 — 화면 캡처·파일 선택·드래그·클립보드 붙여넣기
* issueId 있음: 서버 즉시 업로드 / 없음: draft 로컬 보관(등록 시 저장 후 업로드)
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import ScreenRegionCaptureOverlay from '../ScreenRegionCaptureOverlay';
import {
deleteVerificationIssueImage,
loadVerificationIssueImages,
@@ -68,6 +69,7 @@ const VerificationIssueAttachmentsSection: React.FC<Props> = ({
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const [ctxMenu, setCtxMenu] = useState<CtxMenuState | null>(null);
const [screenCaptureOpen, setScreenCaptureOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const zoneRef = useRef<HTMLDivElement>(null);
@@ -170,6 +172,11 @@ const VerificationIssueAttachmentsSection: React.FC<Props> = ({
}
}, [uploadFiles]);
const handleScreenCapture = useCallback((file: File) => {
setScreenCaptureOpen(false);
void uploadFiles([file]);
}, [uploadFiles]);
const handleDeleteServer = async (imageId: number) => {
if (issueId == null) return;
if (!window.confirm('이 이미지를 삭제할까요?')) return;
@@ -237,14 +244,25 @@ const VerificationIssueAttachmentsSection: React.FC<Props> = ({
<h3 className="vbd-attachments-title">
{totalCount > 0 && `(${totalCount})`}
</h3>
<button
type="button"
className="vbd-btn vbd-btn--sm"
onClick={() => fileInputRef.current?.click()}
disabled={disabled || uploading}
>
</button>
<div className="vbd-attachments-actions">
<button
type="button"
className="vbd-btn vbd-btn--sm"
title="화면 공유 후 드래그로 영역 캡처"
onClick={() => setScreenCaptureOpen(true)}
disabled={disabled || uploading}
>
</button>
<button
type="button"
className="vbd-btn vbd-btn--sm"
onClick={() => fileInputRef.current?.click()}
disabled={disabled || uploading}
>
</button>
</div>
<input
ref={fileInputRef}
type="file"
@@ -282,8 +300,8 @@ const VerificationIssueAttachmentsSection: React.FC<Props> = ({
) : showEmptyHint ? (
<p className="vbd-attach-hint">
{isDraft || saveOnSubmit
? '저장 시 함께 반영됩니다. 드래그·우클릭 붙여넣기·파일 선택'
: '이미지를 드래그하거나, 우클릭 → 클립보드 붙여넣기, 또는 「파일 선택'}
? '저장 시 함께 반영됩니다. 화면 캡처·드래그·우클릭 붙여넣기·파일 선택'
: '화면 캡처·드래그·우클릭 붙여넣기·파일 선택'}
</p>
) : (
<div className="vbd-attach-grid">
@@ -385,8 +403,18 @@ const VerificationIssueAttachmentsSection: React.FC<Props> = ({
<button type="button" onClick={() => void pasteFromClipboard()}>
</button>
<button type="button" onClick={() => { setCtxMenu(null); setScreenCaptureOpen(true); }}>
</button>
</div>
)}
{screenCaptureOpen && (
<ScreenRegionCaptureOverlay
onCapture={handleScreenCapture}
onCancel={() => setScreenCaptureOpen(false)}
/>
)}
</div>
);
};