알림목록 단일 선택모드
This commit is contained in:
@@ -5,7 +5,20 @@ 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 { loadPaperSummary, type PaperSummaryDto } from '../utils/backendApi';
|
||||
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,
|
||||
@@ -51,6 +64,10 @@ 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>
|
||||
@@ -128,7 +145,11 @@ export const TradeNotificationListPage: React.FC<Props> = ({
|
||||
() => loadTradeNotificationGridPreset(),
|
||||
);
|
||||
const [selectedMarket, setSelectedMarket] = useState(defaultMarket);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
|
||||
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);
|
||||
@@ -138,6 +159,7 @@ export const TradeNotificationListPage: React.FC<Props> = ({
|
||||
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 () => {
|
||||
@@ -200,54 +222,123 @@ export const TradeNotificationListPage: React.FC<Props> = ({
|
||||
setRightTab('trade');
|
||||
}, [markAsRead, tickers, handleSelectMarket]);
|
||||
|
||||
const selectedCount = selectedIds.size;
|
||||
const selectedCount = selectedId ? 1 : 0;
|
||||
|
||||
const handleNotificationSelect = useCallback((item: TradeNotificationItem) => {
|
||||
if (!item.isRead) markAsRead(item.id);
|
||||
setSelectedIds(prev => {
|
||||
const adding = !prev.has(item.id);
|
||||
const next = new Set(prev);
|
||||
if (adding) {
|
||||
next.add(item.id);
|
||||
const price = coerceFiniteNumber(item.price);
|
||||
if (price != null && price > 0) {
|
||||
fillBothSides(item.market, price, setFillBuy, setFillSell);
|
||||
} else {
|
||||
handleSelectMarket(item.market);
|
||||
}
|
||||
setRightTab('trade');
|
||||
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 {
|
||||
next.delete(item.id);
|
||||
handleSelectMarket(item.market);
|
||||
}
|
||||
return next;
|
||||
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);
|
||||
setSelectedIds(prev => {
|
||||
if (!prev.has(item.id)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(item.id);
|
||||
return next;
|
||||
});
|
||||
setSelectedId(prev => (prev === item.id ? null : prev));
|
||||
}, [deleteNotification]);
|
||||
|
||||
const handleDeleteSelected = useCallback(async () => {
|
||||
if (selectedCount === 0) return;
|
||||
const ids = [...selectedIds];
|
||||
if (!window.confirm(`선택한 알림 ${ids.length}건을 삭제하시겠습니까?`)) return;
|
||||
await deleteNotifications(ids);
|
||||
setSelectedIds(new Set());
|
||||
}, [selectedCount, selectedIds, deleteNotifications]);
|
||||
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();
|
||||
setSelectedIds(new Set());
|
||||
setSelectedId(null);
|
||||
}, [allNotifications.length, deleteAllNotifications]);
|
||||
|
||||
const handleOrderbookRowClick = useCallback((price: number, rowType: 'ask' | 'bid') => {
|
||||
@@ -342,6 +433,11 @@ export const TradeNotificationListPage: React.FC<Props> = ({
|
||||
onClose={() => setFullscreenItem(null)}
|
||||
/>
|
||||
)}
|
||||
<BacktestAnalysisReportModal
|
||||
open={reportOpen}
|
||||
onClose={() => setReportOpen(false)}
|
||||
model={reportModel}
|
||||
/>
|
||||
<div
|
||||
className={[
|
||||
'tnl-page',
|
||||
@@ -506,7 +602,7 @@ export const TradeNotificationListPage: React.FC<Props> = ({
|
||||
item={item}
|
||||
theme={theme}
|
||||
layoutMode={listLayout}
|
||||
isSelected={selectedIds.has(item.id)}
|
||||
isSelected={selectedId === item.id}
|
||||
chartsEnabled={isListView}
|
||||
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
|
||||
onSelect={() => handleNotificationSelect(item)}
|
||||
@@ -520,6 +616,8 @@ export const TradeNotificationListPage: React.FC<Props> = ({
|
||||
setFullscreenItem(item);
|
||||
}}
|
||||
onTrade={() => handleTradeFromAlert(item)}
|
||||
onReport={() => void handleReportFromAlert(item)}
|
||||
reportLoading={reportLoadingId === item.id}
|
||||
tickers={tickers}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -37,6 +37,16 @@ const IcTrash = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IcReport = () => (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
<line x1="10" y1="9" x2="8" y2="9" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export type TradeNotificationRowLayout = 'list' | 'grid';
|
||||
|
||||
interface Props {
|
||||
@@ -53,6 +63,9 @@ interface Props {
|
||||
onGoToChart: () => void;
|
||||
/** 매매 버튼 클릭 → 우측 매매 패널 열기 */
|
||||
onTrade?: () => void;
|
||||
/** 상세분석 레포트 버튼 클릭 */
|
||||
onReport?: () => void;
|
||||
reportLoading?: boolean;
|
||||
}
|
||||
|
||||
const MAX_INDICATOR_CARDS = 8;
|
||||
@@ -70,6 +83,8 @@ const TradeNotificationListRow: React.FC<Props> = ({
|
||||
onDetail,
|
||||
onGoToChart,
|
||||
onTrade,
|
||||
onReport,
|
||||
reportLoading = false,
|
||||
}) => {
|
||||
useTradeAlertTimeFormat();
|
||||
const isBuy = item.signalType === 'BUY';
|
||||
@@ -317,6 +332,18 @@ const TradeNotificationListRow: React.FC<Props> = ({
|
||||
매매
|
||||
</button>
|
||||
)}
|
||||
{onReport && (
|
||||
<button
|
||||
type="button"
|
||||
className={['tnl-row-report', reportLoading ? 'tnl-row-report--loading' : ''].filter(Boolean).join(' ')}
|
||||
title="상세분석 레포트"
|
||||
aria-label="상세분석 레포트"
|
||||
disabled={reportLoading}
|
||||
onClick={onReport}
|
||||
>
|
||||
<IcReport />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
|
||||
@@ -774,6 +774,35 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tnl-row-report {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-left: 2px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text3);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tnl-row-report:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--se-gold, #e6c200) 14%, transparent);
|
||||
color: var(--se-gold, #e6c200);
|
||||
border-color: color-mix(in srgb, var(--se-gold, #e6c200) 40%, transparent);
|
||||
}
|
||||
.tnl-row-report:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: wait;
|
||||
}
|
||||
.tnl-row-report--loading svg {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.tnl-charts-expanded-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Reference in New Issue
Block a user