534 lines
20 KiB
TypeScript
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);
|