알림목록 단일 선택모드

This commit is contained in:
Macbook
2026-06-07 12:52:58 +09:00
parent 864e5e8eb1
commit a3024ccdf9
3 changed files with 185 additions and 31 deletions
@@ -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,15 +222,12 @@ 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);
setSelectedId(prev => {
if (prev === item.id) return null;
const price = coerceFiniteNumber(item.price);
if (price != null && price > 0) {
fillBothSides(item.market, price, setFillBuy, setFillSell);
@@ -216,38 +235,110 @@ export const TradeNotificationListPage: React.FC<Props> = ({
handleSelectMarket(item.market);
}
setRightTab('trade');
} else {
next.delete(item.id);
}
return next;
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;