매매 시그널 알림 화면 수정
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* 매매 시그널 알림 목록 행 — 좌: 요약 카드(고정) · 우: 캔들+지표 카드(가로 스크롤)
|
||||
*/
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { Theme } from '../../types';
|
||||
import type { StrategyDto } from '../../utils/backendApi';
|
||||
import { loadStrategy } from '../../utils/backendApi';
|
||||
import type { TradeNotificationItem } from '../../contexts/TradeNotificationContext';
|
||||
import {
|
||||
buildSignalDetailRows,
|
||||
formatCandleTypeKo,
|
||||
formatSignalPrice,
|
||||
getMarketDisplayLine,
|
||||
getSignalTypeKo,
|
||||
} from '../../utils/tradeSignalDisplay';
|
||||
import {
|
||||
buildStrategyChartIndicators,
|
||||
candleTypeToTimeframe,
|
||||
} from '../../utils/strategyToChartIndicators';
|
||||
import { formatIndicatorDisplayLabel } from '../../utils/indicatorRegistry';
|
||||
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
|
||||
import TradeSignalChartCard from './TradeSignalChartCard';
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
export type TradeNotificationRowLayout = 'list' | 'grid';
|
||||
|
||||
interface Props {
|
||||
item: TradeNotificationItem;
|
||||
theme: Theme;
|
||||
isSelected: boolean;
|
||||
layoutMode?: TradeNotificationRowLayout;
|
||||
chartsEnabled?: boolean;
|
||||
onSelect: () => void;
|
||||
onDelete: (e: React.MouseEvent) => void;
|
||||
onDetail: () => void;
|
||||
onGoToChart: () => void;
|
||||
}
|
||||
|
||||
const MAX_INDICATOR_CARDS = 8;
|
||||
|
||||
const TradeNotificationListRow: React.FC<Props> = ({
|
||||
item,
|
||||
theme,
|
||||
isSelected,
|
||||
layoutMode = 'list',
|
||||
chartsEnabled = true,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onDetail,
|
||||
onGoToChart,
|
||||
}) => {
|
||||
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 strategyLabel = item.strategyName?.trim()
|
||||
|| strategy?.name
|
||||
|| strategyRow?.value
|
||||
|| '실시간 전략';
|
||||
const rowRef = useRef<HTMLLIElement>(null);
|
||||
const [inView, setInView] = useState(false);
|
||||
/** 펼쳐보기 중인 차트: 'candle' | 지표 id | null */
|
||||
const [expandedChartKey, setExpandedChartKey] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isListLayout) return;
|
||||
const el = rowRef.current;
|
||||
if (!el) return;
|
||||
const io = new IntersectionObserver(
|
||||
entries => {
|
||||
if (entries[0]?.isIntersecting) setInView(true);
|
||||
},
|
||||
{ rootMargin: '120px 0px', threshold: 0.05 },
|
||||
);
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, [isListLayout]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isListLayout || !item.strategyId || !chartsEnabled) {
|
||||
setStrategy(undefined);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void loadStrategy(item.strategyId).then(s => {
|
||||
if (!cancelled && s) setStrategy(s);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [item.strategyId, chartsEnabled, isListLayout]);
|
||||
|
||||
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 chartActive = isListLayout && chartsEnabled && inView;
|
||||
const chartExpanded = isListLayout && expandedChartKey != null;
|
||||
const expandedIndicator = indicatorCards.find(c => c.id === expandedChartKey);
|
||||
|
||||
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' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
aria-label="알림 요약"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="tnl-summary-panel"
|
||||
onClick={onSelect}
|
||||
>
|
||||
<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">
|
||||
<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-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">
|
||||
<span className="tnl-summary-info-lbl">{row.label}</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>
|
||||
</button>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
|
||||
if (!isListLayout) {
|
||||
return (
|
||||
<li
|
||||
ref={rowRef}
|
||||
className={[
|
||||
'tnl-row',
|
||||
'tnl-row--grid',
|
||||
!item.isRead ? 'tnl-row--unread' : '',
|
||||
isSelected ? 'tnl-row--selected' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
{summaryCard}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
ref={rowRef}
|
||||
className={[
|
||||
'tnl-row',
|
||||
'tnl-row--gallery',
|
||||
!item.isRead ? 'tnl-row--unread' : '',
|
||||
isSelected ? 'tnl-row--selected' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
<div className={['tnl-row-gallery', chartExpanded ? 'tnl-row-gallery--chart-expanded' : ''].filter(Boolean).join(' ')}>
|
||||
{summaryCard}
|
||||
|
||||
{chartExpanded ? (
|
||||
<div className="tnl-charts-expanded" aria-label="차트 전체보기">
|
||||
{expandedChartKey === 'candle' && (
|
||||
<TradeSignalChartCard
|
||||
label="캔들"
|
||||
meta={candleKo}
|
||||
market={item.market}
|
||||
timeframe={chartTf}
|
||||
theme={theme}
|
||||
indicators={[]}
|
||||
enabled={chartActive}
|
||||
expanded
|
||||
onExpand={() => setExpandedChartKey('candle')}
|
||||
onRestore={() => setExpandedChartKey(null)}
|
||||
/>
|
||||
)}
|
||||
{expandedIndicator && (
|
||||
<TradeSignalChartCard
|
||||
label={expandedIndicator.label}
|
||||
meta={candleKo}
|
||||
market={item.market}
|
||||
timeframe={chartTf}
|
||||
theme={theme}
|
||||
indicators={expandedIndicator.config}
|
||||
enabled={chartActive}
|
||||
expanded
|
||||
onExpand={() => setExpandedChartKey(expandedIndicator.id)}
|
||||
onRestore={() => setExpandedChartKey(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="tnl-charts-scroll" aria-label="캔들·지표 차트">
|
||||
<div className="tnl-charts-track">
|
||||
<TradeSignalChartCard
|
||||
label="캔들"
|
||||
meta={candleKo}
|
||||
market={item.market}
|
||||
timeframe={chartTf}
|
||||
theme={theme}
|
||||
indicators={[]}
|
||||
enabled={chartActive}
|
||||
expanded={false}
|
||||
onExpand={() => setExpandedChartKey('candle')}
|
||||
onRestore={() => setExpandedChartKey(null)}
|
||||
/>
|
||||
|
||||
{indicatorCards.map(card => (
|
||||
<TradeSignalChartCard
|
||||
key={card.id}
|
||||
label={card.label}
|
||||
meta={candleKo}
|
||||
market={item.market}
|
||||
timeframe={chartTf}
|
||||
theme={theme}
|
||||
indicators={card.config}
|
||||
enabled={chartActive}
|
||||
expanded={false}
|
||||
onExpand={() => setExpandedChartKey(card.id)}
|
||||
onRestore={() => setExpandedChartKey(null)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{item.strategyId != null && indicatorCards.length === 0 && chartActive && (
|
||||
<section className="tnl-chart-card tnl-chart-card--placeholder">
|
||||
<p className="tnl-muted">전략 지표 로딩 중…</p>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export default TradeNotificationListRow;
|
||||
Reference in New Issue
Block a user