/** * 매매 시그널 알림 목록 행 — 4칸이 목록 너비를 꽉 채움, 보조지표 2개↑ 시 우측 슬롯만 스크롤 */ import React, { useCallback, useEffect, useMemo, useRef, useState, memo } from 'react'; import type { Theme } from '../../types'; import type { TickerData } from '../../hooks/useMarketTicker'; import { useLiveReceiveFlash } from '../../hooks/useLiveReceiveFlash'; import { useTradeNotificationSignalSnapshot } from '../../hooks/useTradeNotificationSignalSnapshot'; import type { StrategyDto } from '../../utils/backendApi'; import { resolveNotificationStrategy } from '../../utils/resolveNotificationStrategy'; import { normalizeMarketCode } from '../../utils/strategyHydrate'; import type { TradeNotificationItem } from '../../contexts/TradeNotificationContext'; import { buildSignalDetailRows, formatCandleTypeKo, formatSignalPrice, formatSignalTime, getMarketDisplayLine, getSignalTypeKo, } from '../../utils/tradeSignalDisplay'; import { useTradeAlertTimeFormat } from '../../utils/tradeAlertTimeFormat'; import { buildStrategyChartIndicators, candleTypeToTimeframe, } from '../../utils/strategyToChartIndicators'; import { formatIndicatorDisplayLabel } from '../../utils/indicatorRegistry'; import { useIndicatorSettings } from '../../hooks/useIndicatorSettings'; import TradeSignalChartCard from './TradeSignalChartCard'; import TradeNotificationSignalCard from './TradeNotificationSignalCard'; import TradeNotificationHScrollPane from './TradeNotificationHScrollPane'; import VirtualLiveBadge from '../virtual/VirtualLiveBadge'; import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus'; const IcTrash = () => ( ); const IcReport = () => ( ); export type TradeNotificationRowLayout = 'list' | 'grid'; interface Props { item: TradeNotificationItem; theme: Theme; isSelected: boolean; layoutMode?: TradeNotificationRowLayout; chartsEnabled?: boolean; chartLiveReceiveHighlight?: boolean; /** 해당 종목 시세 (전체 tickers Map 대신 행 단위 전달 — memo·리렌더 최소화) */ ticker?: TickerData; onSelect: () => void; onDelete: (e: React.MouseEvent) => void; onDetail: () => void; onGoToChart: () => void; /** 매매 버튼 클릭 → 우측 매매 패널 열기 */ onTrade?: () => void; /** 상세분석 레포트 버튼 클릭 */ onReport?: () => void; reportLoading?: boolean; /** 가상 스크롤 목록에서는 div + role=listitem */ itemTag?: 'li' | 'div'; /** 목록 화면에서 선로드한 전략 목록 (ID·이름 fallback) */ prefetchedStrategies?: StrategyDto[]; } const MAX_INDICATOR_CARDS = 8; const TradeNotificationListRow: React.FC = ({ item, theme, isSelected, layoutMode = 'list', chartsEnabled = true, chartLiveReceiveHighlight = true, ticker, onSelect, onDelete, onDetail, onGoToChart, onTrade, onReport, reportLoading = false, itemTag = 'li', prefetchedStrategies = [], }) => { useTradeAlertTimeFormat(); const isBuy = item.signalType === 'BUY'; const allDetailRows = buildSignalDetailRows(item); const metaRows = allDetailRows.filter( row => row.label !== '종목' && row.label !== '매매 구분' && row.label !== '기준 가격' && row.label !== '전략', ); const strategyRow = allDetailRows.find(row => row.label === '전략'); const { primary, secondary } = getMarketDisplayLine(item.market); const sym = item.market.replace(/^KRW-/, ''); const isListLayout = layoutMode === 'list'; const chartTf = candleTypeToTimeframe(item.candleType ?? '1m'); const candleKo = formatCandleTypeKo(item.candleType); const { getParams, getVisualConfig } = useIndicatorSettings(); const [strategy, setStrategy] = useState(); const [strategies, setStrategies] = useState([]); const strategyLabel = item.strategyName?.trim() || strategy?.name || strategyRow?.value || '실시간 전략'; const rowRef = useRef(null); const RowTag = itemTag; const [inView, setInView] = useState(false); /** 펼쳐보기 중인 차트: 'candle' | 지표 id | null */ const [expandedChartKey, setExpandedChartKey] = useState(null); useEffect(() => { const el = rowRef.current; if (!el) return; const io = new IntersectionObserver( entries => { setInView(entries[0]?.isIntersecting ?? false); }, { rootMargin: '120px 0px', threshold: 0.05 }, ); io.observe(el); return () => io.disconnect(); }, [item.id]); const panelActive = isListLayout && inView; const gridActive = !isListLayout && inView; const marketCode = normalizeMarketCode(item.market); useEffect(() => { if (!panelActive || !isListLayout) { setStrategy(undefined); setStrategies([]); return; } let cancelled = false; setStrategies(prefetchedStrategies); if (!item.strategyId && !item.strategyName?.trim()) { return () => { cancelled = true; }; } void resolveNotificationStrategy( item.strategyId, item.strategyName, prefetchedStrategies, item.market, ).then(({ strategy: s, strategyId: sid }) => { if (!cancelled && s) setStrategy(s); else if (!cancelled && !s && sid == null) setStrategy(undefined); }); return () => { cancelled = true; }; }, [item.strategyId, item.strategyName, item.market, panelActive, isListLayout, prefetchedStrategies]); useEffect(() => { setExpandedChartKey(null); }, [item.id, layoutMode]); const indicatorCards = useMemo(() => { if (!isListLayout || !strategy) return []; const all = buildStrategyChartIndicators(strategy, getParams, getVisualConfig); return all.slice(0, MAX_INDICATOR_CARDS).map(ind => ({ id: ind.id, type: ind.type, label: formatIndicatorDisplayLabel(ind.type), config: [ind], })); }, [strategy, getParams, getVisualConfig, isListLayout]); /** 통합 차트용: 전략의 모든 보조지표를 하나의 차트에 넘긴다 */ const allIndicatorConfigs = useMemo( () => indicatorCards.flatMap(c => c.config), [indicatorCards], ); const chartExpanded = isListLayout && expandedChartKey != null; const chartActive = isListLayout && chartsEnabled && panelActive; const signalCardEnabled = isListLayout && panelActive && !chartExpanded; const expandedIndicator = indicatorCards.find(c => c.id === expandedChartKey); const signalSnapshotEnabled = item.strategyId != null && ( signalCardEnabled || gridActive ); const { snapshot, loading: signalLoading } = useTradeNotificationSignalSnapshot( item.market, item.strategyId, strategy, item.strategyName, signalSnapshotEnabled, prefetchedStrategies, ); const [chartActivitySeq, setChartActivitySeq] = useState(0); const bumpChartActivity = useCallback(() => { setChartActivitySeq(n => n + 1); }, []); const liveReceiveEnabled = inView && chartLiveReceiveHighlight; const receiveSignal = useMemo(() => { if (!liveReceiveEnabled) return null; return [ ticker?.tradePrice ?? '', ticker?.changeRate ?? '', snapshot?.updatedAt ?? 0, chartActivitySeq, ].join('|'); }, [ liveReceiveEnabled, ticker?.tradePrice, ticker?.changeRate, snapshot?.updatedAt, chartActivitySeq, ]); const receiving = useLiveReceiveFlash(receiveSignal, liveReceiveEnabled); const highlightReceiving = chartLiveReceiveHighlight && receiving; const gridLiveStatus: VirtualLiveStatus = gridActive ? (ticker?.tradePrice != null || snapshot?.updatedAt ? 'live' : 'connecting') : 'idle'; const indicatorChartCount = indicatorCards.length; /** 보조지표 2개 이상이면 첫 1개만 보이고 나머지는 좌우 스크롤 */ const indicatorScrollNeeded = indicatorChartCount >= 2; const indicatorPlaceholder = item.strategyId != null && indicatorChartCount === 0 && chartActive; const signalMarker = useMemo( () => ({ candleTime: item.candleTime, signalType: item.signalType, price: item.price, }), [item.candleTime, item.signalType, item.price], ); const handleRowClick = useCallback((e: React.MouseEvent) => { const t = e.target as HTMLElement; if (t.closest( 'button, a, input, select, textarea, .tnl-chart-view-btn, .tnl-hscroll-pane__rail-btn', )) { return; } onSelect(); }, [onSelect]); const summaryCard = (
{primary} {sym || secondary}
{!isListLayout && gridActive && ( )} {item.signalType} {!item.isRead && }
{/* 전략명 태그 — 헤더 바로 아래에 표시 */}
{strategyLabel}
{isBuy ? '▲' : '▼'} {sym || secondary}/KRW {getSignalTypeKo(item.signalType)}
{formatSignalPrice(item.price)}
{metaRows.map(row => (
{row.label} {row.label === '캔들 시각' ? ( {formatSignalTime(item.candleTime)} {' · '} {candleKo} ) : ( {row.value} )}
))}
투자전략 {strategyLabel}
{onTrade && ( )} {onReport && ( )}
); const rowClassName = [ 'tnl-row', isListLayout ? 'tnl-row--gallery' : 'tnl-row--grid', !item.isRead ? 'tnl-row--unread' : '', isSelected ? 'tnl-row--selected' : '', receiving && isListLayout ? 'tnl-row--receiving' : '', ].filter(Boolean).join(' '); if (!isListLayout) { return ( } role={itemTag === 'div' ? 'listitem' : undefined} className={rowClassName} onClick={handleRowClick} > {summaryCard} ); } return ( } role={itemTag === 'div' ? 'listitem' : undefined} className={rowClassName} onClick={handleRowClick} >
{summaryCard} {chartExpanded ? ( /* 전체보기 — 통합 차트 (캔들 + 보조지표) 를 넓게 표시 */
setExpandedChartKey('candle')} onRestore={() => setExpandedChartKey(null)} onRealtimeActivity={chartActive ? bumpChartActivity : undefined} signalMarker={signalMarker} />
) : ( <> setExpandedChartKey('candle')} /> {/* 통합 차트 — 캔들(상단) + 전략 보조지표(하단) key에 indicator ids를 포함하여 전략 로드 후 차트를 완전히 재마운트 */}
c.id).join(',')}`} label="캔들" meta={candleKo} market={item.market} timeframe={chartTf} theme={theme} indicators={allIndicatorConfigs} enabled={chartActive} expanded={false} onExpand={() => setExpandedChartKey('candle')} onRestore={() => setExpandedChartKey(null)} onRealtimeActivity={chartActive ? bumpChartActivity : undefined} signalMarker={signalMarker} /> {indicatorPlaceholder && (
전략 지표 로딩 중…
)}
)}
); }; function listRowPropsEqual(prev: Props, next: Props): boolean { if (prev.item !== next.item) { if ( prev.item.id !== next.item.id || prev.item.isRead !== next.item.isRead || prev.item.receivedAt !== next.item.receivedAt || prev.item.strategyId !== next.item.strategyId || prev.item.strategyName !== next.item.strategyName ) return false; } return ( prev.theme === next.theme && prev.isSelected === next.isSelected && prev.layoutMode === next.layoutMode && prev.chartsEnabled === next.chartsEnabled && prev.chartLiveReceiveHighlight === next.chartLiveReceiveHighlight && prev.reportLoading === next.reportLoading && prev.itemTag === next.itemTag && prev.prefetchedStrategies === next.prefetchedStrategies && prev.ticker?.tradePrice === next.ticker?.tradePrice && prev.ticker?.changeRate === next.ticker?.changeRate && prev.ticker?.accTradePrice24 === next.ticker?.accTradePrice24 && prev.onSelect === next.onSelect && prev.onDelete === next.onDelete && prev.onDetail === next.onDetail && prev.onGoToChart === next.onGoToChart && prev.onTrade === next.onTrade && prev.onReport === next.onReport ); } export default memo(TradeNotificationListRow, listRowPropsEqual);