매매 시그널 알림 화면 수정

This commit is contained in:
Macbook
2026-05-28 21:58:15 +09:00
parent 9221b8dea2
commit cbad62a5b0
10 changed files with 1814 additions and 75 deletions
@@ -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;