diff --git a/frontend/src/components/TradeNotificationListPage.tsx b/frontend/src/components/TradeNotificationListPage.tsx index b83badf..94da0b8 100644 --- a/frontend/src/components/TradeNotificationListPage.tsx +++ b/frontend/src/components/TradeNotificationListPage.tsx @@ -5,7 +5,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { Theme, TradeOrderFillRequest } from '../types'; import type { TickerData } from '../hooks/useMarketTicker'; import { useTradeNotification, type TradeNotificationItem } from '../contexts/TradeNotificationContext'; -import { buildSignalDetailRows, formatSignalPrice, getSignalHeadline } from '../utils/tradeSignalDisplay'; import { loadPaperSummary, type PaperSummaryDto } from '../utils/backendApi'; import { coerceFiniteNumber } from '../utils/safeFormat'; import TradeOrderPanel from './TradeOrderPanel'; @@ -14,6 +13,20 @@ import PaperSplitPanel from './paper/PaperSplitPanel'; import { useUpbitOrderbook } from '../hooks/useUpbitOrderbook'; import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades'; import '../styles/virtualTradingDashboard.css'; +import '../styles/tradeNotificationList.css'; +import TradeNotificationListRow from './tradeNotification/TradeNotificationListRow'; +import TradeNotificationGridLayoutPicker from './tradeNotification/TradeNotificationGridLayoutPicker'; +import { + loadTradeNotificationGridPreset, + loadTradeNotificationListLayout, + saveTradeNotificationGridPreset, + saveTradeNotificationListLayout, + type TradeNotificationListLayout, +} from '../utils/tradeNotificationListLayout'; +import { + getTradeNotificationGridPreset, + type TradeNotificationGridPresetId, +} from '../utils/tradeNotificationGridPresets'; type RightTab = 'trade' | 'orderbook'; @@ -69,6 +82,12 @@ export const TradeNotificationListPage: React.FC = ({ } = useTradeNotification(); const [filter, setFilter] = useState<'all' | 'unread'>('all'); + const [listLayout, setListLayout] = useState( + () => loadTradeNotificationListLayout(), + ); + const [gridPreset, setGridPreset] = useState( + () => loadTradeNotificationGridPreset(), + ); const [selectedMarket, setSelectedMarket] = useState(defaultMarket); const [selectedNotifyId, setSelectedNotifyId] = useState(null); const [rightTab, setRightTab] = useState('trade'); @@ -172,15 +191,57 @@ export const TradeNotificationListPage: React.FC = ({ ? allNotifications.filter(n => !n.isRead) : allNotifications; + const handleListLayoutChange = useCallback((layout: TradeNotificationListLayout) => { + setListLayout(layout); + saveTradeNotificationListLayout(layout); + }, []); + + const handleGridPresetChange = useCallback((preset: TradeNotificationGridPresetId) => { + setGridPreset(preset); + saveTradeNotificationGridPreset(preset); + }, []); + + const isListView = listLayout === 'list'; + const gridCols = getTradeNotificationGridPreset(gridPreset).cols; + return (
-
+
-

매매 시그널 알림

-

- 알림을 선택하면 우측 매매 패널에 종목·가격이 자동 입력됩니다. -

+
+
+

매매 시그널 알림

+

+ {isListView + ? '각 알림 왼쪽은 요약, 오른쪽은 캔들·지표 차트입니다. 차트 영역은 가로 스크롤로 확인할 수 있습니다.' + : `목록형과 동일한 알림 상세 카드를 ${getTradeNotificationGridPreset(gridPreset).label} 그리드로 표시합니다. 우측 아이콘으로 배치를 변경할 수 있습니다.`} +

+
+
+
+ + +
+ +
+
미확인 {unreadCount}건
@@ -231,76 +292,38 @@ export const TradeNotificationListPage: React.FC = ({
-
+
{filtered.length === 0 ? (
표시할 알림이 없습니다.
) : ( -
    - {filtered.map(item => { - const isBuy = item.signalType === 'BUY'; - const detailRows = buildSignalDetailRows(item); - const isSelected = selectedNotifyId === item.id; - return ( -
  • - - - - -
  • - ); - })} +
      + {filtered.map(item => ( + handleNotificationSelect(item)} + onDelete={e => void handleDeleteOne(item, e)} + onDetail={() => { + if (!item.isRead) markAsRead(item.id); + openDetail(item, { centered: true }); + }} + onGoToChart={() => { + markAsRead(item.id); + onGoToChart(item.market); + }} + /> + ))}
    )}
diff --git a/frontend/src/components/TradingChart.tsx b/frontend/src/components/TradingChart.tsx index c399f70..765b817 100644 --- a/frontend/src/components/TradingChart.tsx +++ b/frontend/src/components/TradingChart.tsx @@ -131,6 +131,8 @@ interface TradingChartProps { chartVisible?: boolean; /** pane(캔들·거래량·보조지표) 구분선 */ paneSeparatorOptions?: ChartPaneSeparatorOptions; + /** true: pane 합산 높이로 chart-container 를 키우지 않고 래퍼 높이에 맞춤 (미니 차트·알림 목록) */ + paneLayoutClamp?: boolean; } const TradingChart: React.FC = ({ @@ -162,6 +164,7 @@ const TradingChart: React.FC = ({ seriesPriceLabelsEnabled = true, chartVisible = true, paneSeparatorOptions, + paneLayoutClamp = false, }) => { const containerRef = useRef(null); const wrapperRef = useRef(null); // 스크롤 래퍼 @@ -325,13 +328,13 @@ const TradingChart: React.FC = ({ required = mgr.resetPaneHeights(wrapperH); } - if (required > wrapperH + 4) { + if (!paneLayoutClamp && required > wrapperH + 4) { container.style.height = `${required}px`; } else { container.style.height = ''; } // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [paneLayoutClamp]); /** 캔들 확대 보기 → 원복 직후 가격·시간축·pane 비율 정상화 */ useEffect(() => { diff --git a/frontend/src/components/tradeNotification/TradeNotificationGridLayoutPicker.tsx b/frontend/src/components/tradeNotification/TradeNotificationGridLayoutPicker.tsx new file mode 100644 index 0000000..e899dd0 --- /dev/null +++ b/frontend/src/components/tradeNotification/TradeNotificationGridLayoutPicker.tsx @@ -0,0 +1,207 @@ +/** + * 그리드형 n×n 레이아웃 선택 (TradingView 스타일 팝업) + */ +import React, { useEffect, useRef, useState } from 'react'; +import { + gridPresetsByGroup, + getTradeNotificationGridPreset, + type TradeNotificationGridPreset, + type TradeNotificationGridPresetId, +} from '../../utils/tradeNotificationGridPresets'; + +interface Props { + value: TradeNotificationGridPresetId; + onChange: (id: TradeNotificationGridPresetId) => void; + disabled?: boolean; +} + +/** 20×20 와이어프레임 아이콘 */ +const LayoutIcon: React.FC<{ preset: TradeNotificationGridPreset }> = ({ preset }) => { + const s = 20; + const g = 1; + const cells: Array<{ x: number; y: number; w: number; h: number }> = []; + + switch (preset.id) { + case '1x1': + cells.push({ x: 0, y: 0, w: s, h: s }); + break; + case '2x1': + cells.push({ x: 0, y: 0, w: 9, h: s }, { x: 10, y: 0, w: 9, h: s }); + break; + case '1x2': + cells.push({ x: 0, y: 0, w: s, h: 9 }, { x: 0, y: 10, w: s, h: 9 }); + break; + case '3x1': + cells.push( + { x: 0, y: 0, w: 6, h: s }, + { x: 7, y: 0, w: 6, h: s }, + { x: 14, y: 0, w: 6, h: s }, + ); + break; + case '1x3': + cells.push( + { x: 0, y: 0, w: s, h: 6 }, + { x: 0, y: 7, w: s, h: 6 }, + { x: 0, y: 14, w: s, h: 6 }, + ); + break; + case '2x1-31': + cells.push({ x: 0, y: 0, w: 9, h: s }, { x: 10, y: 0, w: 9, h: 9 }, { x: 10, y: 10, w: 9, h: 9 }); + break; + case '2x2': + cells.push( + { x: 0, y: 0, w: 9, h: 9 }, + { x: 10, y: 0, w: 9, h: 9 }, + { x: 0, y: 10, w: 9, h: 9 }, + { x: 10, y: 10, w: 9, h: 9 }, + ); + break; + case '4x1': + for (let i = 0; i < 4; i++) cells.push({ x: i * 5, y: 0, w: 4, h: s }); + break; + case '1x4': + for (let i = 0; i < 4; i++) cells.push({ x: 0, y: i * 5, w: s, h: 4 }); + break; + case '2x1-211': + cells.push( + { x: 0, y: 0, w: 9, h: 9 }, + { x: 10, y: 0, w: 9, h: 9 }, + { x: 0, y: 10, w: 9, h: 9 }, + { x: 10, y: 10, w: 9, h: 9 }, + ); + break; + case '5x1': + for (let i = 0; i < 5; i++) cells.push({ x: i * 4, y: 0, w: 3, h: s }); + break; + case '3x2': + for (let c = 0; c < 3; c++) { + for (let r = 0; r < 2; r++) { + cells.push({ x: c * 7, y: r * 10, w: 6, h: 9 }); + } + } + break; + case '2x3': + for (let c = 0; c < 2; c++) { + for (let r = 0; r < 3; r++) { + cells.push({ x: c * 10, y: r * 7, w: 9, h: 6 }); + } + } + break; + case '4x2': + for (let c = 0; c < 4; c++) { + for (let r = 0; r < 2; r++) { + cells.push({ x: c * 5, y: r * 10, w: 4, h: 9 }); + } + } + break; + case '2x4': + for (let c = 0; c < 2; c++) { + for (let r = 0; r < 4; r++) { + cells.push({ x: c * 10, y: r * 5, w: 9, h: 4 }); + } + } + break; + default: + cells.push({ x: 0, y: 0, w: s, h: s }); + } + + return ( + + {cells.map((c, i) => ( + + ))} + + ); +}; + +const TradeNotificationGridLayoutPicker: React.FC = ({ + value, + onChange, + disabled = false, +}) => { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const active = getTradeNotificationGridPreset(value); + const groups = [...gridPresetsByGroup().entries()].sort((a, b) => a[0] - b[0]); + + useEffect(() => { + if (!open) return; + const onDoc = (e: MouseEvent) => { + if (!rootRef.current?.contains(e.target as Node)) setOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpen(false); + }; + document.addEventListener('mousedown', onDoc); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onDoc); + document.removeEventListener('keydown', onKey); + }; + }, [open]); + + return ( +
+ + + {open && ( +
+

그리드 배치

+ {groups.map(([group, presets]) => ( +
+ {group} +
+ {presets.map(preset => ( + + ))} +
+
+ ))} +
+ )} +
+ ); +}; + +export default TradeNotificationGridLayoutPicker; diff --git a/frontend/src/components/tradeNotification/TradeNotificationListRow.tsx b/frontend/src/components/tradeNotification/TradeNotificationListRow.tsx new file mode 100644 index 0000000..8b7b149 --- /dev/null +++ b/frontend/src/components/tradeNotification/TradeNotificationListRow.tsx @@ -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 = () => ( + + + + + + +); + +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 = ({ + 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(); + const strategyLabel = item.strategyName?.trim() + || strategy?.name + || strategyRow?.value + || '실시간 전략'; + const rowRef = useRef(null); + const [inView, setInView] = useState(false); + /** 펼쳐보기 중인 차트: 'candle' | 지표 id | null */ + const [expandedChartKey, setExpandedChartKey] = useState(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 = ( +
+ + +
+ + + +
+
+ ); + + if (!isListLayout) { + return ( +
  • + {summaryCard} +
  • + ); + } + + return ( +
  • +
    + {summaryCard} + + {chartExpanded ? ( +
    + {expandedChartKey === 'candle' && ( + setExpandedChartKey('candle')} + onRestore={() => setExpandedChartKey(null)} + /> + )} + {expandedIndicator && ( + setExpandedChartKey(expandedIndicator.id)} + onRestore={() => setExpandedChartKey(null)} + /> + )} +
    + ) : ( +
    +
    + setExpandedChartKey('candle')} + onRestore={() => setExpandedChartKey(null)} + /> + + {indicatorCards.map(card => ( + setExpandedChartKey(card.id)} + onRestore={() => setExpandedChartKey(null)} + /> + ))} + + {item.strategyId != null && indicatorCards.length === 0 && chartActive && ( +
    +

    전략 지표 로딩 중…

    +
    + )} +
    +
    + )} +
    +
  • + ); +}; + +export default TradeNotificationListRow; diff --git a/frontend/src/components/tradeNotification/TradeSignalChartCard.tsx b/frontend/src/components/tradeNotification/TradeSignalChartCard.tsx new file mode 100644 index 0000000..9ae3b07 --- /dev/null +++ b/frontend/src/components/tradeNotification/TradeSignalChartCard.tsx @@ -0,0 +1,96 @@ +/** + * 매매 시그널 알림 — 캔들·지표 미니 차트 카드 (펼쳐보기 / 원래보기) + */ +import React from 'react'; +import type { IndicatorConfig } from '../../types'; +import type { Theme, Timeframe } from '../../types'; +import TradeSignalMiniChart from './TradeSignalMiniChart'; + +const IcExpand = () => ( + + + + + + +); + +const IcRestore = () => ( + + + + + + +); + +export interface TradeSignalChartCardProps { + label: string; + meta: string; + market: string; + timeframe: Timeframe; + theme: Theme; + indicators: IndicatorConfig[]; + enabled: boolean; + expanded: boolean; + onExpand: () => void; + onRestore: () => void; +} + +const TradeSignalChartCard: React.FC = ({ + label, + meta, + market, + timeframe, + theme, + indicators, + enabled, + expanded, + onExpand, + onRestore, +}) => ( +
    +
    + {label} + {meta} +
    +
    + + {expanded ? ( + + ) : ( + + )} +
    +
    +); + +export default TradeSignalChartCard; diff --git a/frontend/src/components/tradeNotification/TradeSignalMiniChart.tsx b/frontend/src/components/tradeNotification/TradeSignalMiniChart.tsx new file mode 100644 index 0000000..5de901c --- /dev/null +++ b/frontend/src/components/tradeNotification/TradeSignalMiniChart.tsx @@ -0,0 +1,217 @@ +/** + * 매매 시그널 알림 행 — 캔들·지표 미니 차트 카드 + */ +import React, { + useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, +} from 'react'; +import TradingChart from '../TradingChart'; +import type { IndicatorConfig } from '../../types'; +import type { + ChartMode, ChartType, Drawing, LegendData, OHLCVBar, Theme, Timeframe, +} from '../../types'; +import { isUpbitMarket } from '../../utils/upbitApi'; +import { useChartRealtimeData } from '../../hooks/useChartRealtimeData'; +import { useHistoryLoader } from '../../hooks/useHistoryLoader'; +import type { ChartManager } from '../../utils/ChartManager'; +import { pinCandleWatch } from '../../utils/backendApi'; +import { timeframeToCandleType } from '../../utils/chartCandleType'; +import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone'; + +interface Props { + market: string; + timeframe: Timeframe; + theme?: Theme; + indicators?: IndicatorConfig[]; + enabled?: boolean; + /** 전체보기 모드 — 부모 높이에 맞춤 */ + fillHeight?: boolean; +} + +const noop = () => {}; + +const TradeSignalMiniChart: React.FC = ({ + market, + timeframe, + theme = 'dark', + indicators = [], + enabled = true, + fillHeight = false, +}) => { + const managerRef = useRef(null); + const canvasWrapRef = useRef(null); + const [chartReloadTick, setChartReloadTick] = useState(0); + const chartReloadTriggeredRef = useRef(false); + const pendingBarRef = useRef(null); + const barsMarketRef = useRef(null); + const chartLiveReadyRef = useRef(false); + const marketRef = useRef(market); + marketRef.current = market; + + const handleCandlesReady = useCallback(() => { + chartLiveReadyRef.current = true; + const pending = pendingBarRef.current; + const mgr = managerRef.current; + if (!pending || !mgr || barsMarketRef.current !== marketRef.current) return; + pendingBarRef.current = null; + mgr.updateBar(pending); + const wrap = canvasWrapRef.current; + if (wrap && wrap.clientHeight > 0) mgr.resetPaneHeights(wrap.clientHeight); + }, []); + + const applyRealtimeBar = useCallback((bar: OHLCVBar, append: boolean) => { + if (barsMarketRef.current !== marketRef.current) return; + if (!chartLiveReadyRef.current || !managerRef.current) { + pendingBarRef.current = bar; + return; + } + if (append) managerRef.current.appendBar(bar); + else managerRef.current.updateBar(bar); + }, []); + + const handleTickUpdate = useCallback((bar: OHLCVBar) => { + applyRealtimeBar(bar, false); + }, [applyRealtimeBar]); + + const handleNewCandle = useCallback((bar: OHLCVBar) => { + applyRealtimeBar(bar, true); + }, [applyRealtimeBar]); + + const useUpbit = isUpbitMarket(market); + + const { bars, barsMarket, isLoading } = useChartRealtimeData( + market, + timeframe, + useMemo(() => ({ + onTickUpdate: handleTickUpdate, + onNewCandle: handleNewCandle, + enabled: enabled && useUpbit, + source: 'BACKEND_STOMP' as const, + }), [handleTickUpdate, handleNewCandle, enabled, useUpbit]), + ); + + const { isLoadingMore } = useHistoryLoader({ + symbol: market, + timeframe, + isUpbit: useUpbit && enabled, + managerRef, + }); + + barsMarketRef.current = barsMarket; + const barsReady = bars.length >= 2 && barsMarket === market; + + const applyPaneHeights = useCallback(() => { + const wrap = canvasWrapRef.current; + const mgr = managerRef.current; + if (!wrap || wrap.clientHeight <= 0 || !mgr?.hasMainSeries()) return; + mgr.resetPaneHeights(wrap.clientHeight); + }, []); + + useLayoutEffect(() => { + chartLiveReadyRef.current = false; + pendingBarRef.current = null; + chartReloadTriggeredRef.current = false; + }, [market, timeframe, enabled]); + + useEffect(() => { + if (!enabled) return; + const wrap = canvasWrapRef.current; + if (!wrap) return; + + const timers = [200, 600, 1200].map(delay => + setTimeout(() => { + if (!canvasWrapRef.current) return; + const h = canvasWrapRef.current.clientHeight; + if (h <= 0) return; + if (managerRef.current?.hasMainSeries()) { + managerRef.current.resetPaneHeights(h); + } else if (!chartReloadTriggeredRef.current && delay >= 600 && barsReady) { + chartReloadTriggeredRef.current = true; + setChartReloadTick(t => t + 1); + } + }, delay), + ); + + const ro = new ResizeObserver(() => applyPaneHeights()); + ro.observe(wrap); + return () => { + timers.forEach(clearTimeout); + ro.disconnect(); + }; + }, [market, timeframe, enabled, barsReady, applyPaneHeights, fillHeight]); + + useEffect(() => { + if (!enabled || !barsReady) return; + if (!managerRef.current?.hasMainSeries() && !chartReloadTriggeredRef.current) { + chartReloadTriggeredRef.current = true; + setChartReloadTick(t => t + 1); + } + }, [enabled, barsReady]); + + useEffect(() => { + if (!enabled) return; + const t = setTimeout(() => applyPaneHeights(), 80); + return () => clearTimeout(t); + }, [fillHeight, enabled, applyPaneHeights]); + + useEffect(() => { + if (!enabled || !useUpbit) return; + void pinCandleWatch(market, timeframeToCandleType(timeframe)); + if (timeframeToCandleType(timeframe) !== '1m') { + void pinCandleWatch(market, '1m'); + } + }, [market, timeframe, enabled, useUpbit]); + + if (!enabled) { + return ( +
    + 차트 대기 +
    + ); + } + + return ( +
    + {isLoading &&
    로딩…
    } + {isLoadingMore && ( +
    +
    +
    + )} + i.id).join(',')}`} + chartVisible + bars={bars} + barsMarket={barsMarket} + market={market} + timeframe={timeframe} + chartType={'candlestick' as ChartType} + theme={theme} + mode={'chart' as ChartMode} + indicators={indicators} + drawingTool="cursor" + drawings={[] as Drawing[]} + logScale={false} + drawingsLocked + drawingsVisible={false} + onCrosshair={noop as (d: LegendData | null) => void} + onManagerReady={mgr => { managerRef.current = mgr; }} + onAddDrawing={noop as (d: Drawing) => void} + onCandlesReady={handleCandlesReady} + magnifierEnabled={false} + volumeVisible={false} + showHoverToolbar={false} + seriesPriceLabelsEnabled={false} + paneLayoutClamp + /> +
    + ); +}; + +export default TradeSignalMiniChart; diff --git a/frontend/src/styles/tradeNotificationList.css b/frontend/src/styles/tradeNotificationList.css new file mode 100644 index 0000000..e3e037c --- /dev/null +++ b/frontend/src/styles/tradeNotificationList.css @@ -0,0 +1,764 @@ +/* 매매 시그널 알림 목록 — 갤러리 행 (요약 카드 + 가로 스크롤 차트) */ + +.tnl-row--gallery { + display: block; + overflow: visible; +} + +.tnl-row-gallery { + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 10px; + height: 320px; + max-height: 320px; + padding: 10px; + box-sizing: border-box; +} + +/* 가상매매 투자대상 카드(vtd)와 동일한 3단 구성 */ +.tnl-summary-card { + flex: 0 0 544px; + width: 544px; + height: 300px; + max-height: 300px; + align-self: flex-start; + display: flex; + flex-direction: column; + border: 1px solid color-mix(in srgb, var(--accent, #7aa2f7) 28%, var(--se-border, var(--border))); + border-radius: 12px; + background: var(--se-panel-card-bg, var(--bg2)); + overflow: hidden; +} + +.tnl-summary-card--buy { + border-color: color-mix(in srgb, var(--accent, #3f7ef5) 35%, var(--se-border, var(--border))); +} + +.tnl-summary-card--sell { + border-color: color-mix(in srgb, #ef5350 35%, var(--se-border, var(--border))); +} + +.tnl-summary-panel { + flex: 1; + min-height: 0; + width: 100%; + padding: 14px 16px 12px; + border: none; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; + display: flex; + flex-direction: column; + gap: 12px; + overflow: hidden; + box-sizing: border-box; +} + +.tnl-summary-panel:hover { + background: color-mix(in srgb, var(--accent, #7aa2f7) 6%, transparent); +} + +/* 1행: 종목명 */ +.tnl-summary-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + flex-shrink: 0; +} + +.tnl-summary-title { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + flex: 1; +} + +.tnl-summary-ko { + font-size: 17px; + font-weight: 700; + line-height: 1.2; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tnl-summary-sym { + font-size: 12px; + font-weight: 600; + color: var(--text3, var(--se-text-muted)); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tnl-summary-head-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.tnl-summary-dot { + flex-shrink: 0; +} + +/* 2행: 페어 · 시그널 가격 */ +.tnl-summary-quote { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-shrink: 0; + padding: 4px 2px; +} + +.tnl-summary-quote-left { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + flex: 1; +} + +.tnl-summary-arrow { + font-size: 11px; + line-height: 1; + flex-shrink: 0; +} + +.tnl-summary-arrow--buy { + color: var(--accent, #3f7ef5); +} + +.tnl-summary-arrow--sell { + color: #ef5350; +} + +.tnl-summary-pair { + font-size: 12px; + font-weight: 600; + color: var(--text3, var(--se-text-muted)); + white-space: nowrap; +} + +.tnl-summary-type { + font-size: 12px; + font-weight: 700; + white-space: nowrap; +} + +.tnl-summary-type--buy { + color: var(--accent, #3f7ef5); +} + +.tnl-summary-type--sell { + color: #ef5350; +} + +.tnl-summary-quote-right { + display: flex; + align-items: baseline; + flex-shrink: 0; + margin-left: auto; +} + +.tnl-summary-price { + font-size: 20px; + font-weight: 800; + font-variant-numeric: tabular-nums; + white-space: nowrap; + line-height: 1.1; +} + +.tnl-summary-price--buy { + color: var(--accent, #3f7ef5); +} + +.tnl-summary-price--sell { + color: #ef5350; +} + +/* 3행+: 상세 정보 */ +.tnl-summary-meta { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: 10px; + overflow-y: auto; + overflow-x: hidden; + padding-right: 2px; +} + +.tnl-summary-info-row { + display: grid; + grid-template-columns: 88px minmax(0, 1fr); + align-items: start; + gap: 10px 14px; +} + +.tnl-summary-info-lbl { + font-size: 12px; + font-weight: 600; + color: var(--text3, var(--se-text-muted)); + line-height: 1.4; + flex-shrink: 0; +} + +.tnl-summary-info-val { + font-size: 13px; + font-weight: 500; + color: var(--text2); + line-height: 1.45; + word-break: break-word; + text-align: right; +} + +.tnl-summary-info-val--hi { + font-weight: 700; + color: var(--text); +} + +/* 하단: 투자전략 (vtd-target-strategy-field) */ +.tnl-summary-strategy-field { + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; + width: 100%; + padding-top: 4px; + border-top: 1px solid var(--border); +} + +.tnl-summary-strategy-lbl { + font-size: 12px; + font-weight: 600; + color: var(--text3, var(--se-text-muted)); + white-space: nowrap; + flex-shrink: 0; +} + +.tnl-summary-strategy-box { + flex: 1; + min-width: 0; + padding: 8px 12px; + border-radius: 8px; + border: 1px solid var(--se-border, var(--border)); + background: var(--bg, var(--bg3)); + color: var(--text); + font-size: 13px; + font-weight: 600; + line-height: 1.35; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: left; +} + +.tnl-summary-card-actions { + display: flex; + align-items: center; + gap: 4px; + padding: 8px 10px; + border-top: 1px solid var(--border); + background: var(--bg3, var(--se-center-bg)); + flex-shrink: 0; +} + +.tnl-row-gallery--chart-expanded .tnl-charts-scroll { + display: none; +} + +.tnl-charts-expanded { + flex: 1; + min-width: 0; + height: 300px; + max-height: 300px; + display: flex; + flex-direction: column; + border-radius: 12px; + border: 1px solid var(--se-border, var(--border)); + background: color-mix(in srgb, var(--bg) 40%, var(--bg2)); + overflow: hidden; + box-sizing: border-box; +} + +.tnl-charts-expanded .tnl-chart-card--expanded { + flex: 1; + width: 100%; + height: 100%; + max-height: none; + border: none; + border-radius: 0; + gap: 8px; + padding: 0 4px 4px; + box-sizing: border-box; +} + +.tnl-charts-scroll { + flex: 1; + min-width: 0; + height: 300px; + max-height: 300px; + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; + overscroll-behavior-x: contain; + border-radius: 12px; + border: 1px solid var(--se-border, var(--border)); + background: color-mix(in srgb, var(--bg) 40%, var(--bg2)); +} + +.tnl-charts-scroll::-webkit-scrollbar { + height: 8px; +} + +.tnl-charts-scroll::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--text3) 35%, transparent); + border-radius: 4px; +} + +.tnl-charts-track { + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 10px; + padding: 10px; + height: 100%; + box-sizing: border-box; + width: max-content; +} + +.tnl-chart-card { + position: relative; + flex: 0 0 300px; + width: 300px; + height: 280px; + max-height: 280px; + display: flex; + flex-direction: column; + gap: 6px; + border: 1px solid var(--se-border, var(--border)); + border-radius: 10px; + background: var(--se-panel-card-bg, var(--bg2)); + overflow: hidden; + box-sizing: border-box; +} + +.tnl-chart-card-body { + position: relative; + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.tnl-chart-card--expanded .tnl-chart-card-body { + flex: 1; + min-height: 0; +} + +.tnl-chart-card--placeholder { + align-items: center; + justify-content: center; +} + +.tnl-chart-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 10px 0; + flex-shrink: 0; +} + +.tnl-chart-card-label { + font-size: 12px; + font-weight: 700; +} + +.tnl-chart-card-meta { + font-size: 10px; + color: var(--text3); + font-weight: 600; +} + +.tnl-mini-chart-canvas { + position: relative; + flex: 0 0 236px; + height: 236px; + max-height: 236px; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.tnl-mini-chart-canvas--fill { + flex: 1 1 auto; + height: 100%; + max-height: none; +} + +.tnl-mini-chart-canvas > .tv-chart-wrap { + flex: 1 1 0; + min-height: 0; + max-height: 100%; + width: 100%; + overflow: hidden !important; +} + +.tnl-mini-chart-loading { + position: absolute; + inset: 0; + z-index: 2; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + color: var(--text3); + background: color-mix(in srgb, var(--bg2) 80%, transparent); +} + +.tnl-mini-chart-placeholder { + flex: 0 0 236px; + height: 236px; + max-height: 236px; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + color: var(--text3); +} + +.tnl-mini-chart-placeholder--fill { + flex: 1; + height: 100%; + max-height: none; +} + +/* 차트 하단 우측 — 펼쳐보기 / 원래보기 */ +.tnl-chart-view-btn { + position: absolute; + right: 8px; + bottom: 8px; + z-index: 4; + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + padding: 0; + border: 1px solid color-mix(in srgb, var(--accent, #3f7ef5) 35%, var(--se-border, var(--border))); + border-radius: 8px; + background: color-mix(in srgb, var(--bg2) 88%, transparent); + color: var(--accent, #3f7ef5); + cursor: pointer; + box-shadow: 0 2px 8px color-mix(in srgb, var(--bg) 50%, transparent); + transition: background 0.15s, border-color 0.15s, transform 0.1s; +} + +.tnl-chart-view-btn:hover { + background: color-mix(in srgb, var(--accent, #3f7ef5) 18%, var(--bg2)); + border-color: var(--accent, #3f7ef5); +} + +.tnl-chart-view-btn:active { + transform: scale(0.96); +} + +.tnl-chart-view-btn--restore { + color: var(--text2); + border-color: var(--se-border, var(--border)); + background: color-mix(in srgb, var(--bg3) 92%, transparent); +} + +.tnl-chart-view-btn--restore:hover { + color: var(--text); + border-color: var(--accent, #7aa2f7); + background: color-mix(in srgb, var(--accent, #7aa2f7) 12%, var(--bg3)); +} + +.tnl-muted { + font-size: 12px; + color: var(--text3); + margin: 0; +} + +.tnl-list--gallery { + gap: 12px; +} + +/* 헤더 — 제목(좌) · 목록/그리드 전환(우) */ +.tnl-header-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 14px; +} + +.tnl-header-intro { + flex: 1; + min-width: 0; +} + +.tnl-header-intro .tnl-title { + margin-bottom: 6px; +} + +.tnl-header-intro .tnl-sub { + margin-bottom: 0; +} + +.tnl-header-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-shrink: 0; + padding-top: 2px; +} + +.tnl-layout-toggle { + flex-shrink: 0; +} + +/* 그리드 n×n 배치 선택 */ +.tnl-grid-layout-picker { + position: relative; + flex-shrink: 0; +} + +.tnl-grid-layout-trigger { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + padding: 0; + border: 1px solid var(--se-border, var(--border)); + border-radius: 8px; + background: var(--bg2, var(--se-panel-card-bg)); + color: var(--text2); + cursor: pointer; + transition: border-color 0.15s, background 0.15s, color 0.15s; +} + +.tnl-grid-layout-trigger:hover:not(:disabled) { + border-color: var(--accent, #3f7ef5); + color: var(--accent, #3f7ef5); + background: color-mix(in srgb, var(--accent, #3f7ef5) 10%, var(--bg2)); +} + +.tnl-grid-layout-trigger--open { + border-color: var(--accent, #3f7ef5); + color: var(--accent, #3f7ef5); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #3f7ef5) 35%, transparent); +} + +.tnl-grid-layout-trigger--disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.tnl-grid-layout-popover { + position: absolute; + top: calc(100% + 8px); + right: 0; + z-index: 120; + width: min(320px, calc(100vw - 48px)); + max-height: min(420px, 70vh); + overflow-y: auto; + padding: 10px 12px 12px; + border-radius: 10px; + border: 1px solid var(--se-border, var(--border)); + background: var(--bg2, #1a1f2e); + box-shadow: + 0 12px 32px color-mix(in srgb, var(--bg) 55%, transparent), + 0 0 0 1px color-mix(in srgb, var(--text3) 12%, transparent); +} + +.tnl-grid-layout-popover-title { + margin: 0 0 10px; + font-size: 11px; + font-weight: 700; + color: var(--text3); + letter-spacing: 0.04em; +} + +.tnl-grid-layout-group { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; + border-top: 1px solid color-mix(in srgb, var(--border) 80%, transparent); +} + +.tnl-grid-layout-group:first-of-type { + border-top: none; + padding-top: 0; +} + +.tnl-grid-layout-group-lbl { + flex: 0 0 14px; + font-size: 11px; + font-weight: 600; + color: var(--text3); + text-align: center; +} + +.tnl-grid-layout-group-row { + display: flex; + flex-wrap: wrap; + gap: 6px; + flex: 1; + min-width: 0; +} + +.tnl-grid-preset-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 32px; + padding: 0; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: var(--text2); + cursor: pointer; + transition: border-color 0.12s, background 0.12s, color 0.12s; +} + +.tnl-grid-preset-btn:hover { + background: color-mix(in srgb, var(--accent, #3f7ef5) 10%, transparent); + color: var(--text); +} + +.tnl-grid-preset-btn--on { + border-color: var(--accent, #3f7ef5); + background: color-mix(in srgb, var(--accent, #3f7ef5) 14%, transparent); + color: var(--accent, #3f7ef5); +} + +.tnl-grid-preset-icon-svg { + display: block; +} + +/* 그리드형 — 목록형과 동일한 알림 상세 카드 · n×n */ +.tnl-list-wrap.tnl-list-wrap--grid { + flex: 1; + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; + padding: 12px 24px 24px; + box-sizing: border-box; + container-type: inline-size; + container-name: tnl-grid-wrap; +} + +ul.tnl-list.tnl-list--grid { + flex: 1; + min-height: 0; + width: 100%; + display: grid; + flex-direction: unset; + grid-auto-rows: auto; + gap: 14px; + align-content: start; + align-items: start; + list-style: none; + margin: 0; + padding: 4px 4px 16px; + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; +} + +@container tnl-grid-wrap (max-width: 640px) { + ul.tnl-list.tnl-list--grid { + grid-template-columns: minmax(0, 1fr) !important; + } +} + +@container tnl-grid-wrap (max-width: 960px) { + ul.tnl-list.tnl-list--grid.tnl-grid--cols-3, + ul.tnl-list.tnl-list--grid.tnl-grid--cols-4, + ul.tnl-list.tnl-list--grid.tnl-grid--cols-5 { + grid-template-columns: repeat(2, minmax(0, 1fr)) !important; + } +} + +@container tnl-grid-wrap (max-width: 1200px) { + ul.tnl-list.tnl-list--grid.tnl-grid--cols-4, + ul.tnl-list.tnl-list--grid.tnl-grid--cols-5 { + grid-template-columns: repeat(3, minmax(0, 1fr)) !important; + } +} + +.tnl-row.tnl-row--grid { + display: block; + min-width: 0; + width: 100%; + border: none; + background: transparent; + border-radius: 0; + overflow: visible; +} + +/* 목록형과 동일 카드 박스(544×300) — 그리드 셀 너비에 맞춤 */ +.tnl-row--grid .tnl-summary-card, +.tnl-summary-card--grid { + flex: none; + width: 100%; + max-width: none; + height: 300px; + max-height: 300px; + min-height: 300px; +} + +.tnl-row--grid.tnl-row--unread .tnl-summary-card { + border-color: rgba(122, 162, 247, 0.45); +} + +.tnl-row--grid .tnl-summary-card--selected, +.tnl-row--grid.tnl-row--selected .tnl-summary-card { + border-color: var(--accent, #7aa2f7); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #7aa2f7) 35%, transparent); +} + +.tnl-row--gallery.tnl-row--selected .tnl-summary-card, +.tnl-row--gallery.tnl-row--selected .tnl-charts-scroll, +.tnl-row--gallery.tnl-row--selected .tnl-charts-expanded { + border-color: var(--accent, #7aa2f7); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #7aa2f7) 35%, transparent); +} + +@container tnl-main (max-width: 900px) { + .tnl-row-gallery { + flex-direction: column; + height: auto; + max-height: none; + } + + .tnl-summary-card { + flex: 0 0 auto; + width: 100%; + max-width: 100%; + max-height: none; + } + + .tnl-charts-scroll, + .tnl-charts-expanded { + height: 300px; + max-height: 300px; + } +} diff --git a/frontend/src/types/uiPreferences.ts b/frontend/src/types/uiPreferences.ts index 4e13e88..7d98a70 100644 --- a/frontend/src/types/uiPreferences.ts +++ b/frontend/src/types/uiPreferences.ts @@ -31,6 +31,10 @@ export interface UiPreferences { tradeNotifications?: { readIds?: string[]; hiddenIds?: string[]; + /** 매매 시그널 알림 목록 화면 — list | grid */ + listLayout?: 'list' | 'grid'; + /** 그리드형 열 배치 프리셋 id (tradeNotificationGridPresets) */ + gridPreset?: string; }; } diff --git a/frontend/src/utils/tradeNotificationGridPresets.ts b/frontend/src/utils/tradeNotificationGridPresets.ts new file mode 100644 index 0000000..aec8634 --- /dev/null +++ b/frontend/src/utils/tradeNotificationGridPresets.ts @@ -0,0 +1,76 @@ +/** 매매 시그널 알림 — 그리드형 n×n 레이아웃 프리셋 (TradingView 스타일) */ + +export type TradeNotificationGridPresetId = + | '1x1' + | '2x1' + | '1x2' + | '3x1' + | '1x3' + | '2x1-31' + | '2x2' + | '4x1' + | '1x4' + | '2x1-211' + | '5x1' + | '3x2' + | '2x3' + | '4x2' + | '2x4'; + +export interface TradeNotificationGridPreset { + id: TradeNotificationGridPresetId; + /** 스크롤 목록에 적용할 열 수 */ + cols: number; + /** 팝업 그룹 라벨 (패널 수) */ + group: number; + /** 짧은 표기 (2×2 등) */ + label: string; +} + +export const TRADE_NOTIFICATION_GRID_PRESETS: TradeNotificationGridPreset[] = [ + { id: '1x1', cols: 1, group: 1, label: '1×1' }, + { id: '2x1', cols: 2, group: 2, label: '2×1' }, + { id: '1x2', cols: 1, group: 2, label: '1×2' }, + { id: '3x1', cols: 3, group: 3, label: '3×1' }, + { id: '1x3', cols: 1, group: 3, label: '1×3' }, + { id: '2x1-31', cols: 2, group: 3, label: '2+1' }, + { id: '2x2', cols: 2, group: 4, label: '2×2' }, + { id: '4x1', cols: 4, group: 4, label: '4×1' }, + { id: '1x4', cols: 1, group: 4, label: '1×4' }, + { id: '2x1-211', cols: 2, group: 4, label: '2+2' }, + { id: '5x1', cols: 5, group: 5, label: '5×1' }, + { id: '3x2', cols: 3, group: 6, label: '3×2' }, + { id: '2x3', cols: 2, group: 6, label: '2×3' }, + { id: '4x2', cols: 4, group: 8, label: '4×2' }, + { id: '2x4', cols: 2, group: 8, label: '2×4' }, +]; + +const PRESET_MAP = new Map( + TRADE_NOTIFICATION_GRID_PRESETS.map(p => [p.id, p]), +); + +export const DEFAULT_TRADE_NOTIFICATION_GRID_PRESET: TradeNotificationGridPresetId = '2x2'; + +export function getTradeNotificationGridPreset( + id: string | undefined | null, +): TradeNotificationGridPreset { + const preset = id ? PRESET_MAP.get(id as TradeNotificationGridPresetId) : undefined; + return preset ?? PRESET_MAP.get(DEFAULT_TRADE_NOTIFICATION_GRID_PRESET)!; +} + +export function normalizeTradeNotificationGridPreset( + id: string | undefined | null, +): TradeNotificationGridPresetId { + return getTradeNotificationGridPreset(id).id; +} + +/** 그룹별 프리셋 (팝업 행 구성) */ +export function gridPresetsByGroup(): Map { + const map = new Map(); + for (const p of TRADE_NOTIFICATION_GRID_PRESETS) { + const list = map.get(p.group) ?? []; + list.push(p); + map.set(p.group, list); + } + return map; +} diff --git a/frontend/src/utils/tradeNotificationListLayout.ts b/frontend/src/utils/tradeNotificationListLayout.ts new file mode 100644 index 0000000..76cd2ed --- /dev/null +++ b/frontend/src/utils/tradeNotificationListLayout.ts @@ -0,0 +1,27 @@ +import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb'; +import { + DEFAULT_TRADE_NOTIFICATION_GRID_PRESET, + normalizeTradeNotificationGridPreset, + type TradeNotificationGridPresetId, +} from './tradeNotificationGridPresets'; + +export type TradeNotificationListLayout = 'list' | 'grid'; + +export function loadTradeNotificationListLayout(): TradeNotificationListLayout { + const v = getUiPreferences().tradeNotifications?.listLayout; + return v === 'grid' ? 'grid' : 'list'; +} + +export function saveTradeNotificationListLayout(layout: TradeNotificationListLayout): void { + patchUiPreferences({ tradeNotifications: { listLayout: layout } }); +} + +export function loadTradeNotificationGridPreset(): TradeNotificationGridPresetId { + return normalizeTradeNotificationGridPreset( + getUiPreferences().tradeNotifications?.gridPreset ?? DEFAULT_TRADE_NOTIFICATION_GRID_PRESET, + ); +} + +export function saveTradeNotificationGridPreset(preset: TradeNotificationGridPresetId): void { + patchUiPreferences({ tradeNotifications: { gridPreset: preset } }); +}