Files
goldenChart/frontend/src/components/tradeNotification/TradeNotificationListRow.tsx
T

534 lines
20 KiB
TypeScript

/**
* 매매 시그널 알림 목록 행 — 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 = () => (
<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 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 {
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<Props> = ({
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<StrategyDto | undefined>();
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
const strategyLabel = item.strategyName?.trim()
|| strategy?.name
|| strategyRow?.value
|| '실시간 전략';
const rowRef = useRef<HTMLLIElement | HTMLDivElement>(null);
const RowTag = itemTag;
const [inView, setInView] = useState(false);
/** 펼쳐보기 중인 차트: 'candle' | 지표 id | null */
const [expandedChartKey, setExpandedChartKey] = useState<string | null>(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 = (
<article
className={[
'tnl-summary-card',
isBuy ? 'tnl-summary-card--buy' : 'tnl-summary-card--sell',
!isListLayout ? 'tnl-summary-card--grid' : '',
isSelected ? 'tnl-summary-card--selected' : '',
!isListLayout && highlightReceiving ? 'tnl-summary-card--receiving' : '',
].filter(Boolean).join(' ')}
aria-label="알림 요약"
>
<div className="tnl-summary-panel" role="presentation">
<div className="tnl-summary-head">
<div className="tnl-summary-title">
<span className="tnl-summary-ko">{primary}</span>
<span className="tnl-summary-sym">{sym || secondary}</span>
</div>
<div className="tnl-summary-head-actions">
{!isListLayout && gridActive && (
<VirtualLiveBadge
status={gridLiveStatus}
receiving={highlightReceiving}
/>
)}
<span className={`tnl-signal ${isBuy ? 'tnl-signal--buy' : 'tnl-signal--sell'}`}>
{item.signalType}
</span>
{!item.isRead && <span className="tnl-dot tnl-summary-dot" aria-label="미확인" />}
</div>
</div>
{/* 전략명 태그 — 헤더 바로 아래에 표시 */}
<div className="tnl-summary-strategy-tag" title={strategyLabel} aria-label={`전략: ${strategyLabel}`}>
<span className="tnl-summary-strategy-tag-ic" aria-hidden></span>
<span className="tnl-summary-strategy-tag-name">{strategyLabel}</span>
</div>
<div className="tnl-summary-quote" aria-label="시그널 가격">
<div className="tnl-summary-quote-left">
<span
className={`tnl-summary-arrow${isBuy ? ' tnl-summary-arrow--buy' : ' tnl-summary-arrow--sell'}`}
aria-hidden
>
{isBuy ? '▲' : '▼'}
</span>
<span className="tnl-summary-pair">{sym || secondary}/KRW</span>
<span className={`tnl-summary-type${isBuy ? ' tnl-summary-type--buy' : ' tnl-summary-type--sell'}`}>
{getSignalTypeKo(item.signalType)}
</span>
</div>
<div className="tnl-summary-quote-right">
<span
className={`tnl-summary-price${isBuy ? ' tnl-summary-price--buy' : ' tnl-summary-price--sell'}`}
>
{formatSignalPrice(item.price)}
</span>
</div>
</div>
<div className="tnl-summary-meta">
{metaRows.map(row => (
<div
key={row.label}
className={[
'tnl-summary-info-row',
row.label === '캔들 시각' ? 'tnl-summary-info-row--candle' : '',
].filter(Boolean).join(' ')}
>
<span className="tnl-summary-info-lbl">{row.label}</span>
{row.label === '캔들 시각' ? (
<span
className={[
'tnl-summary-info-val',
'tnl-summary-info-val--candle',
row.highlight ? 'tnl-summary-info-val--hi' : '',
].filter(Boolean).join(' ')}
>
{formatSignalTime(item.candleTime)}
<span className="tnl-candle-interval">
{' · '}
{candleKo}
</span>
</span>
) : (
<span className={`tnl-summary-info-val${row.highlight ? ' tnl-summary-info-val--hi' : ''}`}>
{row.value}
</span>
)}
</div>
))}
</div>
<div className="tnl-summary-strategy-field">
<span className="tnl-summary-strategy-lbl">투자전략</span>
<span className="tnl-summary-strategy-box" title={strategyLabel}>
{strategyLabel}
</span>
</div>
</div>
<div className="tnl-summary-card-actions">
<button
type="button"
className="tnl-row-icon-btn"
title="삭제"
aria-label="알림 삭제"
onClick={onDelete}
>
<IcTrash />
</button>
<button type="button" className="tnl-row-action" onClick={onDetail}>
상세
</button>
<button type="button" className="tnl-row-chart" onClick={onGoToChart}>
차트
</button>
{onTrade && (
<button type="button" className="tnl-row-trade" onClick={onTrade}>
매매
</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>
);
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 (
<RowTag
ref={rowRef as React.Ref<HTMLLIElement & HTMLDivElement>}
role={itemTag === 'div' ? 'listitem' : undefined}
className={rowClassName}
onClick={handleRowClick}
>
{summaryCard}
</RowTag>
);
}
return (
<RowTag
ref={rowRef as React.Ref<HTMLLIElement & HTMLDivElement>}
role={itemTag === 'div' ? 'listitem' : undefined}
className={rowClassName}
onClick={handleRowClick}
>
<div
className={['tnl-row-gallery', chartExpanded ? 'tnl-row-gallery--chart-expanded' : ''].filter(Boolean).join(' ')}
>
<TradeNotificationHScrollPane
className="tnl-row-gallery-detail"
label="알림 상세"
>
{summaryCard}
</TradeNotificationHScrollPane>
{chartExpanded ? (
/* 전체보기 — 통합 차트 (캔들 + 보조지표) 를 넓게 표시 */
<div className="tnl-row-gallery-combined-chart tnl-row-gallery-combined-chart--expanded">
<TradeSignalChartCard
label="캔들"
meta={candleKo}
market={item.market}
timeframe={chartTf}
theme={theme}
indicators={allIndicatorConfigs}
enabled={chartActive}
expanded
onExpand={() => setExpandedChartKey('candle')}
onRestore={() => setExpandedChartKey(null)}
onRealtimeActivity={chartActive ? bumpChartActivity : undefined}
signalMarker={signalMarker}
/>
</div>
) : (
<>
<TradeNotificationHScrollPane
className="tnl-row-gallery-signal"
label="신호 일치율"
>
<TradeNotificationSignalCard
market={item.market}
strategyId={item.strategyId}
strategy={strategy}
strategies={strategies}
strategyLabel={strategyLabel}
meta={strategyLabel}
candleType={item.candleType}
theme={theme}
ticker={ticker}
enabled={signalCardEnabled}
snapshot={snapshot}
signalLoading={signalLoading}
onExpandCharts={() => setExpandedChartKey('candle')}
/>
</TradeNotificationHScrollPane>
{/* 통합 차트 — 캔들(상단) + 전략 보조지표(하단)
key에 indicator ids를 포함하여 전략 로드 후 차트를 완전히 재마운트 */}
<div
className="tnl-row-gallery-combined-chart"
aria-label="캔들 및 보조지표 차트"
>
<TradeSignalChartCard
key={`combined-${item.market}-${chartTf}-${allIndicatorConfigs.map(c => 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 && (
<div className="tnl-combined-chart-placeholder">전략 지표 로딩 중…</div>
)}
</div>
</>
)}
</div>
</RowTag>
);
};
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);