712 lines
27 KiB
TypeScript
712 lines
27 KiB
TypeScript
/**
|
|
* 매매 시그널 알림 목록 화면 — 좌: 알림 목록, 우: 매매·호가 (가상매매 우측 패널과 동일)
|
|
*/
|
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import type { Theme, TradeOrderFillRequest } from '../types';
|
|
import type { TickerData } from '../hooks/useMarketTicker';
|
|
import { useTradeNotification, type TradeNotificationItem } from '../contexts/TradeNotificationContext';
|
|
import {
|
|
DEFAULT_BACKTEST_SETTINGS,
|
|
loadPaperSummary,
|
|
loadStrategy,
|
|
runBacktest,
|
|
type PaperSummaryDto,
|
|
} from '../utils/backendApi';
|
|
import { loadAnalysisCandles } from '../utils/analysisChartData';
|
|
import { buildChartBacktestReportModel } from '../utils/backtestReportModel';
|
|
import { candleTypeToTimeframe } from '../utils/strategyToChartIndicators';
|
|
import { normalizeEpochSec } from '../utils/backtestUiUtils';
|
|
import { useIndicatorSettings } from '../hooks/useIndicatorSettings';
|
|
import BacktestAnalysisReportModal from './backtest/BacktestAnalysisReportModal';
|
|
import type { BacktestAnalysisReportModel } from './backtest/BacktestAnalysisReportModal';
|
|
import { coerceFiniteNumber } from '../utils/safeFormat';
|
|
import {
|
|
TradeSplitOrderPanel,
|
|
TradeOrderbookPanelContent,
|
|
TradeRightPanelTabs,
|
|
TradeRightPanelBody,
|
|
} from './trade';
|
|
import { useUpbitOrderbook } from '../hooks/useUpbitOrderbook';
|
|
import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades';
|
|
import { useAppSettings } from '../hooks/useAppSettings';
|
|
import { readStoredBool, storeBool } from './strategyEditor/usePanelResize';
|
|
import '../styles/strategyEditorTheme.css';
|
|
import '../styles/virtualTradingDashboard.css';
|
|
import '../styles/builderPageShell.css';
|
|
import '../styles/tradeNotificationList.css';
|
|
import TradeNotificationListRow from './tradeNotification/TradeNotificationListRow';
|
|
import TradeNotificationGridLayoutPicker from './tradeNotification/TradeNotificationGridLayoutPicker';
|
|
import FullscreenChartModal from './tradeNotification/FullscreenChartModal';
|
|
import {
|
|
loadTradeNotificationGridPreset,
|
|
loadTradeNotificationListLayout,
|
|
loadTradeNotificationCandleFilter,
|
|
loadTradeNotificationListSort,
|
|
saveTradeNotificationCandleFilter,
|
|
saveTradeNotificationGridPreset,
|
|
saveTradeNotificationListLayout,
|
|
saveTradeNotificationListSort,
|
|
TRADE_NOTIFICATION_CANDLE_FILTER_OPTIONS,
|
|
TRADE_NOTIFICATION_READ_FILTER_OPTIONS,
|
|
TRADE_NOTIFICATION_SORT_OPTIONS,
|
|
type TradeNotificationCandleFilter,
|
|
type TradeNotificationListLayout,
|
|
type TradeNotificationListSort,
|
|
type TradeNotificationReadFilter,
|
|
} from '../utils/tradeNotificationListLayout';
|
|
import { normalizeStartCandleType } from '../utils/strategyStartNodes';
|
|
import {
|
|
getTradeNotificationGridPreset,
|
|
type TradeNotificationGridPresetId,
|
|
} from '../utils/tradeNotificationGridPresets';
|
|
import { sortTradeNotifications } from '../utils/tradeListSort';
|
|
|
|
type RightTab = 'trade' | 'orderbook';
|
|
|
|
const TNL_RIGHT_OPEN_KEY = 'tnl-right-open';
|
|
const REPORT_BAR_COUNT = 300;
|
|
const REPORT_INDICATOR_TYPES = [
|
|
'RSI', 'MACD', 'CCI', 'SMA', 'EMA', 'IchimokuCloud', 'Stochastic', 'ADX', 'MFI', 'NewPsychological',
|
|
] as const;
|
|
|
|
const IcTrash = () => (
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
<polyline points="3 6 5 6 21 6" />
|
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
|
<line x1="10" y1="11" x2="10" y2="17" />
|
|
<line x1="14" y1="11" x2="14" y2="17" />
|
|
</svg>
|
|
);
|
|
|
|
const IcRefresh = () => (
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
<polyline points="23 4 23 10 17 10" />
|
|
<polyline points="1 20 1 14 7 14" />
|
|
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
|
|
</svg>
|
|
);
|
|
|
|
interface Props {
|
|
theme: Theme;
|
|
onGoToChart: (market: string) => void;
|
|
tickers?: Map<string, TickerData>;
|
|
defaultMarket?: string;
|
|
paperTradingEnabled?: boolean;
|
|
paperAutoTradeEnabled?: boolean;
|
|
onPaperOrderFilled?: () => void;
|
|
}
|
|
|
|
function fillBothSides(
|
|
market: string,
|
|
price: number,
|
|
setFillBuy: React.Dispatch<React.SetStateAction<TradeOrderFillRequest | null>>,
|
|
setFillSell: React.Dispatch<React.SetStateAction<TradeOrderFillRequest | null>>,
|
|
) {
|
|
const seq = Date.now();
|
|
setFillBuy({ market, price, side: 'buy', seq });
|
|
setFillSell({ market, price, side: 'sell', seq: seq + 1 });
|
|
}
|
|
|
|
export const TradeNotificationListPage: React.FC<Props> = ({
|
|
theme,
|
|
onGoToChart,
|
|
tickers,
|
|
defaultMarket = 'KRW-BTC',
|
|
paperTradingEnabled = true,
|
|
paperAutoTradeEnabled = false,
|
|
onPaperOrderFilled,
|
|
}) => {
|
|
const {
|
|
allNotifications,
|
|
unreadCount,
|
|
markAsRead,
|
|
markAllAsRead,
|
|
deleteNotification,
|
|
deleteNotifications,
|
|
deleteAllNotifications,
|
|
openDetail,
|
|
refreshHistory,
|
|
} = useTradeNotification();
|
|
|
|
const [query, setQuery] = useState('');
|
|
const normalizedQuery = useMemo(() => query.trim().toLowerCase(), [query]);
|
|
|
|
const [filter, setFilter] = useState<TradeNotificationReadFilter>('all');
|
|
const [listSort, setListSort] = useState<TradeNotificationListSort>(
|
|
() => loadTradeNotificationListSort(),
|
|
);
|
|
const [candleFilter, setCandleFilter] = useState<TradeNotificationCandleFilter>(
|
|
() => loadTradeNotificationCandleFilter(),
|
|
);
|
|
const [listLayout, setListLayout] = useState<TradeNotificationListLayout>(
|
|
() => loadTradeNotificationListLayout(),
|
|
);
|
|
const [gridPreset, setGridPreset] = useState<TradeNotificationGridPresetId>(
|
|
() => loadTradeNotificationGridPreset(),
|
|
);
|
|
const [selectedMarket, setSelectedMarket] = useState(defaultMarket);
|
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
const [reportOpen, setReportOpen] = useState(false);
|
|
const [reportModel, setReportModel] = useState<BacktestAnalysisReportModel | null>(null);
|
|
const [reportLoadingId, setReportLoadingId] = useState<string | null>(null);
|
|
const reportGenRef = useRef(0);
|
|
const [rightTab, setRightTab] = useState<RightTab>('trade');
|
|
const [fillBuy, setFillBuy] = useState<TradeOrderFillRequest | null>(null);
|
|
const [fillSell, setFillSell] = useState<TradeOrderFillRequest | null>(null);
|
|
const [summary, setSummary] = useState<PaperSummaryDto | null>(null);
|
|
const orderAnchorRef = useRef<HTMLDivElement>(null);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [rightOpen, setRightOpen] = useState(() => readStoredBool(TNL_RIGHT_OPEN_KEY, true));
|
|
const [fullscreenItem, setFullscreenItem] = useState<TradeNotificationItem | null>(null);
|
|
const { settings: appSettings } = useAppSettings();
|
|
const { getParams } = useIndicatorSettings();
|
|
const chartLiveReceiveHighlight = appSettings.chartLiveReceiveHighlight ?? true;
|
|
|
|
const refreshPaperData = useCallback(async () => {
|
|
try {
|
|
const s = await loadPaperSummary();
|
|
setSummary(s);
|
|
} catch {
|
|
setSummary(null);
|
|
}
|
|
onPaperOrderFilled?.();
|
|
}, [onPaperOrderFilled]);
|
|
|
|
useEffect(() => {
|
|
void refreshPaperData();
|
|
}, [refreshPaperData]);
|
|
|
|
const tradePrice = useMemo(
|
|
() => coerceFiniteNumber(tickers?.get(selectedMarket)?.tradePrice),
|
|
[tickers, selectedMarket],
|
|
);
|
|
|
|
const posQty = useMemo(() => {
|
|
const p = summary?.positions?.find(x => x.symbol === selectedMarket);
|
|
return coerceFiniteNumber(p?.quantity) ?? 0;
|
|
}, [summary?.positions, 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, price?: number) => {
|
|
setSelectedMarket(market);
|
|
const p = price ?? coerceFiniteNumber(tickers?.get(market)?.tradePrice);
|
|
if (p != null && p > 0) {
|
|
fillBothSides(market, p, setFillBuy, setFillSell);
|
|
}
|
|
setRightTab('trade');
|
|
}, [tickers]);
|
|
|
|
/** 알림 목록 아이템 "매매" 버튼 — 우측 패널 열고 해당 종목 현재가 입력 */
|
|
const handleTradeFromAlert = useCallback((item: TradeNotificationItem) => {
|
|
if (!item.isRead) markAsRead(item.id);
|
|
const price = coerceFiniteNumber(tickers?.get(item.market)?.tradePrice)
|
|
?? coerceFiniteNumber(item.price);
|
|
handleSelectMarket(item.market, price ?? undefined);
|
|
setRightOpen(true);
|
|
setRightTab('trade');
|
|
}, [markAsRead, tickers, handleSelectMarket]);
|
|
|
|
const selectedCount = selectedId ? 1 : 0;
|
|
|
|
const handleNotificationSelect = useCallback((item: TradeNotificationItem) => {
|
|
if (!item.isRead) markAsRead(item.id);
|
|
setSelectedId(prev => {
|
|
if (prev === item.id) return null;
|
|
const price = coerceFiniteNumber(item.price);
|
|
if (price != null && price > 0) {
|
|
fillBothSides(item.market, price, setFillBuy, setFillSell);
|
|
} else {
|
|
handleSelectMarket(item.market);
|
|
}
|
|
setRightTab('trade');
|
|
return item.id;
|
|
});
|
|
}, [markAsRead, handleSelectMarket]);
|
|
|
|
const handleReportFromAlert = useCallback(async (item: TradeNotificationItem) => {
|
|
if (!item.isRead) markAsRead(item.id);
|
|
if (!item.strategyId) {
|
|
window.alert('전략 정보가 없어 상세분석 레포트를 생성할 수 없습니다.');
|
|
return;
|
|
}
|
|
if (reportLoadingId) return;
|
|
|
|
const gen = ++reportGenRef.current;
|
|
setReportLoadingId(item.id);
|
|
setReportModel(null);
|
|
setReportOpen(false);
|
|
|
|
try {
|
|
const strategy = await loadStrategy(item.strategyId);
|
|
if (gen !== reportGenRef.current) return;
|
|
if (!strategy) {
|
|
window.alert('전략을 불러올 수 없습니다.');
|
|
return;
|
|
}
|
|
|
|
const market = item.market.startsWith('KRW-') ? item.market : `KRW-${item.market}`;
|
|
const timeframe = candleTypeToTimeframe(item.candleType ?? '1m');
|
|
const toTimeSec = normalizeEpochSec(item.candleTime);
|
|
const bars = await loadAnalysisCandles(market, timeframe, toTimeSec, REPORT_BAR_COUNT);
|
|
if (gen !== reportGenRef.current) return;
|
|
if (bars.length < 10) {
|
|
window.alert('캔들 데이터가 부족해 분석 레포트를 생성할 수 없습니다.');
|
|
return;
|
|
}
|
|
|
|
const strategyName = strategy.name || item.strategyName || '전략';
|
|
const res = await runBacktest({
|
|
strategyId: item.strategyId,
|
|
bars: bars.map(b => ({
|
|
time: b.time,
|
|
open: b.open,
|
|
high: b.high,
|
|
low: b.low,
|
|
close: b.close,
|
|
volume: b.volume,
|
|
})),
|
|
timeframe,
|
|
symbol: market,
|
|
strategyName,
|
|
settings: DEFAULT_BACKTEST_SETTINGS,
|
|
indicatorParams: Object.fromEntries(
|
|
REPORT_INDICATOR_TYPES.map(t => [t, getParams(t) as Record<string, unknown>]),
|
|
),
|
|
});
|
|
if (gen !== reportGenRef.current) return;
|
|
if (!res) {
|
|
window.alert('백테스트 분석에 실패했습니다.');
|
|
return;
|
|
}
|
|
|
|
const model = buildChartBacktestReportModel({
|
|
analysis: res.analysis ?? null,
|
|
stats: res.stats ?? null,
|
|
signals: res.signals,
|
|
strategyName,
|
|
symbol: market.replace(/^KRW-/, ''),
|
|
timeframe,
|
|
barCount: bars.length,
|
|
createdAt: new Date(item.receivedAt).toISOString(),
|
|
});
|
|
if (!model) {
|
|
window.alert('분석 데이터를 생성할 수 없습니다.');
|
|
return;
|
|
}
|
|
|
|
setReportModel(model);
|
|
setReportOpen(true);
|
|
} catch {
|
|
if (gen !== reportGenRef.current) return;
|
|
window.alert('상세분석 레포트 생성 중 오류가 발생했습니다.');
|
|
} finally {
|
|
if (gen === reportGenRef.current) setReportLoadingId(null);
|
|
}
|
|
}, [markAsRead, reportLoadingId, getParams]);
|
|
|
|
const handleDeleteOne = useCallback(async (item: TradeNotificationItem, e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
if (!window.confirm('이 알림을 삭제하시겠습니까?')) return;
|
|
await deleteNotification(item.id);
|
|
setSelectedId(prev => (prev === item.id ? null : prev));
|
|
}, [deleteNotification]);
|
|
|
|
const handleDeleteSelected = useCallback(async () => {
|
|
if (!selectedId) return;
|
|
if (!window.confirm('선택한 알림을 삭제하시겠습니까?')) return;
|
|
await deleteNotifications([selectedId]);
|
|
setSelectedId(null);
|
|
}, [selectedId, deleteNotifications]);
|
|
|
|
const handleDeleteAll = useCallback(async () => {
|
|
if (allNotifications.length === 0) return;
|
|
if (!window.confirm(`알림 ${allNotifications.length}건을 모두 삭제하시겠습니까?`)) return;
|
|
await deleteAllNotifications();
|
|
setSelectedId(null);
|
|
}, [allNotifications.length, deleteAllNotifications]);
|
|
|
|
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 filtered = useMemo(() => {
|
|
let items = filter === 'unread'
|
|
? allNotifications.filter(n => !n.isRead)
|
|
: allNotifications;
|
|
if (candleFilter) {
|
|
items = items.filter(
|
|
n => normalizeStartCandleType(n.candleType) === candleFilter,
|
|
);
|
|
}
|
|
if (normalizedQuery) {
|
|
items = items.filter(n => {
|
|
const mkt = n.market.toLowerCase();
|
|
const sym = n.market.includes('-') ? n.market.split('-')[1].toLowerCase() : mkt;
|
|
const strat = (n.strategyName ?? '').toLowerCase();
|
|
const sigKo = n.signalType === 'BUY' ? '매수' : '매도';
|
|
return (
|
|
mkt.includes(normalizedQuery) ||
|
|
sym.includes(normalizedQuery) ||
|
|
strat.includes(normalizedQuery) ||
|
|
sigKo.includes(normalizedQuery)
|
|
);
|
|
});
|
|
}
|
|
return items;
|
|
}, [allNotifications, filter, candleFilter, normalizedQuery]);
|
|
|
|
const sorted = useMemo(
|
|
() => sortTradeNotifications(filtered, listSort),
|
|
[filtered, listSort],
|
|
);
|
|
|
|
const handleListSortChange = useCallback((sort: TradeNotificationListSort) => {
|
|
setListSort(sort);
|
|
saveTradeNotificationListSort(sort);
|
|
}, []);
|
|
|
|
const handleCandleFilterChange = useCallback((value: TradeNotificationCandleFilter) => {
|
|
setCandleFilter(value);
|
|
saveTradeNotificationCandleFilter(value);
|
|
}, []);
|
|
|
|
const handleListLayoutChange = useCallback((layout: TradeNotificationListLayout) => {
|
|
setListLayout(layout);
|
|
saveTradeNotificationListLayout(layout);
|
|
}, []);
|
|
|
|
const handlePageRefresh = useCallback(async () => {
|
|
if (refreshing) return;
|
|
setRefreshing(true);
|
|
try {
|
|
await Promise.all([
|
|
refreshHistory({ serverOnly: true }),
|
|
refreshPaperData(),
|
|
]);
|
|
} finally {
|
|
setRefreshing(false);
|
|
}
|
|
}, [refreshing, refreshHistory, refreshPaperData]);
|
|
|
|
const handleGridPresetChange = useCallback((preset: TradeNotificationGridPresetId) => {
|
|
setGridPreset(preset);
|
|
saveTradeNotificationGridPreset(preset);
|
|
}, []);
|
|
|
|
const toggleRightPanel = useCallback(() => {
|
|
setRightOpen(prev => {
|
|
const next = !prev;
|
|
storeBool(TNL_RIGHT_OPEN_KEY, next);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const isListView = listLayout === 'list';
|
|
const gridCols = getTradeNotificationGridPreset(gridPreset).cols;
|
|
|
|
return (
|
|
<>
|
|
{fullscreenItem && (
|
|
<FullscreenChartModal
|
|
item={fullscreenItem}
|
|
theme={theme}
|
|
onClose={() => setFullscreenItem(null)}
|
|
/>
|
|
)}
|
|
<BacktestAnalysisReportModal
|
|
open={reportOpen}
|
|
onClose={() => setReportOpen(false)}
|
|
model={reportModel}
|
|
/>
|
|
<div
|
|
className={[
|
|
'tnl-page',
|
|
'tnl-page--with-right',
|
|
'bps-page--vtd',
|
|
'se-page',
|
|
`se-page--${theme}`,
|
|
'app',
|
|
theme,
|
|
rightOpen ? '' : 'tnl-page--right-collapsed',
|
|
].filter(Boolean).join(' ')}
|
|
>
|
|
<div className="tnl-body">
|
|
<div className="tnl-main" style={{ containerType: 'inline-size', containerName: 'tnl-main' }}>
|
|
<div className="tnl-header">
|
|
<h1 className="tnl-title">매매 시그널 알림</h1>
|
|
<div className="tnl-toolbar">
|
|
{/* 검색란 */}
|
|
<div className={`tnl-search-box${query ? ' tnl-search-box--active' : ''}`}>
|
|
<svg className="tnl-search-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
<circle cx="11" cy="11" r="8" />
|
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
</svg>
|
|
<input
|
|
type="text"
|
|
className="tnl-search-input"
|
|
placeholder="종목·전략 검색"
|
|
value={query}
|
|
onChange={e => setQuery(e.target.value)}
|
|
aria-label="알림 목록 검색"
|
|
/>
|
|
{query && (
|
|
<button
|
|
type="button"
|
|
className="tnl-search-clear"
|
|
onClick={() => setQuery('')}
|
|
aria-label="검색어 초기화"
|
|
>
|
|
✕
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<span className="tnl-chip tnl-chip--warn">미확인 {unreadCount}건</span>
|
|
{selectedCount > 0 && (
|
|
<span className="tnl-chip tnl-chip--selected">선택 {selectedCount}건</span>
|
|
)}
|
|
<div className="tnl-toolbar-selects">
|
|
<select
|
|
className="tnl-toolbar-select"
|
|
value={filter}
|
|
aria-label="표시 범위"
|
|
onChange={e => setFilter(e.target.value as TradeNotificationReadFilter)}
|
|
>
|
|
{TRADE_NOTIFICATION_READ_FILTER_OPTIONS.map(o => (
|
|
<option key={o.value} value={o.value}>{o.label}</option>
|
|
))}
|
|
</select>
|
|
<span className="header-toolbar-divider" aria-hidden="true" />
|
|
<select
|
|
className="tnl-toolbar-select"
|
|
value={listSort}
|
|
aria-label="목록 정렬"
|
|
onChange={e => handleListSortChange(e.target.value as TradeNotificationListSort)}
|
|
>
|
|
{TRADE_NOTIFICATION_SORT_OPTIONS.map(o => (
|
|
<option key={o.value} value={o.value}>{o.label}</option>
|
|
))}
|
|
</select>
|
|
<span className="header-toolbar-divider" aria-hidden="true" />
|
|
<select
|
|
className="tnl-toolbar-select"
|
|
value={candleFilter}
|
|
aria-label="시간봉 필터"
|
|
onChange={e => handleCandleFilterChange(e.target.value as TradeNotificationCandleFilter)}
|
|
>
|
|
<option value="">전체</option>
|
|
{TRADE_NOTIFICATION_CANDLE_FILTER_OPTIONS.map(o => (
|
|
<option key={o.value} value={o.value}>{o.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
{unreadCount > 0 && (
|
|
<button type="button" className="tnl-btn tnl-btn--primary" onClick={markAllAsRead}>
|
|
모두 읽음
|
|
</button>
|
|
)}
|
|
<div className="tnl-layout-toggle vtd-view-toggle" role="group" aria-label="목록 표시 방식">
|
|
<button
|
|
type="button"
|
|
className={`vtd-view-toggle-btn${isListView ? ' vtd-view-toggle-btn--on' : ''}`}
|
|
onClick={() => handleListLayoutChange('list')}
|
|
>
|
|
목록형
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`vtd-view-toggle-btn${!isListView ? ' vtd-view-toggle-btn--on' : ''}`}
|
|
onClick={() => handleListLayoutChange('grid')}
|
|
>
|
|
그리드형
|
|
</button>
|
|
</div>
|
|
<span className="header-toolbar-divider" aria-hidden="true" />
|
|
<div className="tnl-toolbar-end">
|
|
<button
|
|
type="button"
|
|
className={['tnl-icon-btn', refreshing ? 'tnl-icon-btn--refreshing' : ''].filter(Boolean).join(' ')}
|
|
title="화면 새로고침"
|
|
aria-label="화면 새로고침"
|
|
disabled={refreshing}
|
|
onClick={() => void handlePageRefresh()}
|
|
>
|
|
<IcRefresh />
|
|
</button>
|
|
<TradeNotificationGridLayoutPicker
|
|
value={gridPreset}
|
|
onChange={handleGridPresetChange}
|
|
disabled={isListView}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="tnl-icon-btn"
|
|
title={selectedCount > 0 ? `선택 알림 삭제 (${selectedCount}건)` : '선택 알림 삭제'}
|
|
aria-label={selectedCount > 0 ? `선택 알림 ${selectedCount}건 삭제` : '선택 알림 삭제'}
|
|
disabled={selectedCount === 0}
|
|
onClick={() => void handleDeleteSelected()}
|
|
>
|
|
<IcTrash />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="tnl-icon-btn tnl-icon-btn--danger"
|
|
title="모두 삭제"
|
|
aria-label="모두 삭제"
|
|
disabled={allNotifications.length === 0}
|
|
onClick={() => void handleDeleteAll()}
|
|
>
|
|
<IcTrash />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={['tnl-list-wrap', !isListView ? 'tnl-list-wrap--grid' : ''].filter(Boolean).join(' ')}>
|
|
{sorted.length === 0 ? (
|
|
<div className="tnl-empty">
|
|
{normalizedQuery ? `"${query}" 에 해당하는 알림이 없습니다.` : '표시할 알림이 없습니다.'}
|
|
</div>
|
|
) : (
|
|
<ul
|
|
className={[
|
|
'tnl-list',
|
|
isListView ? 'tnl-list--gallery' : 'tnl-list--grid',
|
|
!isListView ? `tnl-grid--cols-${gridCols}` : '',
|
|
].filter(Boolean).join(' ')}
|
|
style={!isListView ? { gridTemplateColumns: `repeat(${gridCols}, minmax(0, 1fr))` } : undefined}
|
|
>
|
|
{sorted.map(item => (
|
|
<TradeNotificationListRow
|
|
key={item.id}
|
|
item={item}
|
|
theme={theme}
|
|
layoutMode={listLayout}
|
|
isSelected={selectedId === item.id}
|
|
chartsEnabled={isListView}
|
|
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
|
|
onSelect={() => handleNotificationSelect(item)}
|
|
onDelete={e => void handleDeleteOne(item, e)}
|
|
onDetail={() => {
|
|
if (!item.isRead) markAsRead(item.id);
|
|
openDetail(item, { centered: true });
|
|
}}
|
|
onGoToChart={() => {
|
|
markAsRead(item.id);
|
|
setFullscreenItem(item);
|
|
}}
|
|
onTrade={() => handleTradeFromAlert(item)}
|
|
onReport={() => void handleReportFromAlert(item)}
|
|
reportLoading={reportLoadingId === item.id}
|
|
tickers={tickers}
|
|
/>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className={`tnl-side-wrap tnl-side-wrap--right${rightOpen ? ' tnl-side-wrap--open' : ''}`}>
|
|
<button
|
|
type="button"
|
|
className={`bps-panel-handle bps-panel-handle--right${rightOpen ? ' bps-panel-handle--open' : ''}`}
|
|
onClick={toggleRightPanel}
|
|
title={rightOpen ? '매매·호가 패널 닫기' : '매매·호가 패널 열기'}
|
|
aria-expanded={rightOpen}
|
|
aria-label={rightOpen ? '매매·호가 패널 닫기' : '매매·호가 패널 열기'}
|
|
>
|
|
<svg
|
|
width="8"
|
|
height="14"
|
|
viewBox="0 0 8 14"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.8"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
aria-hidden
|
|
>
|
|
{rightOpen ? (
|
|
<polyline points="2,1 6,7 2,13" />
|
|
) : (
|
|
<polyline points="6,1 2,7 6,13" />
|
|
)}
|
|
</svg>
|
|
</button>
|
|
<aside
|
|
className={`tnl-right trade-right-panel${rightOpen ? ' tnl-right--open' : ''}`}
|
|
aria-label="매매·호가"
|
|
aria-hidden={!rightOpen}
|
|
>
|
|
<TradeRightPanelTabs
|
|
activeTab={rightTab}
|
|
onTabChange={setRightTab}
|
|
className="tnl-right-tabs"
|
|
/>
|
|
<TradeRightPanelBody
|
|
anchorRef={orderAnchorRef}
|
|
bodyClassName="tnl-right-body"
|
|
>
|
|
{rightTab === 'trade' ? (
|
|
<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()}
|
|
/>
|
|
) : (
|
|
<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}
|
|
/>
|
|
)}
|
|
</TradeRightPanelBody>
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default TradeNotificationListPage;
|