From 650fb354a8f272920746a146f036e62fc4197366 Mon Sep 17 00:00:00 2001 From: Macbook Date: Sat, 6 Jun 2026 02:53:39 +0900 Subject: [PATCH] =?UTF-8?q?=EC=95=8C=EB=A6=BC=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/TradeAlertModal.tsx | 44 ++- .../components/TradeNotificationListPage.tsx | 62 ++- .../FullscreenChartModal.tsx | 130 +++++++ .../StrategyConditionView.tsx | 229 +++++++++++ .../TradeNotificationListRow.tsx | 112 ++---- frontend/src/styles/appPopup.css | 313 +++++++++++++++ frontend/src/styles/tradeNotificationList.css | 368 ++++++++++++++++-- 7 files changed, 1146 insertions(+), 112 deletions(-) create mode 100644 frontend/src/components/tradeNotification/FullscreenChartModal.tsx create mode 100644 frontend/src/components/tradeNotification/StrategyConditionView.tsx diff --git a/frontend/src/components/TradeAlertModal.tsx b/frontend/src/components/TradeAlertModal.tsx index 95d4cc2..31af289 100644 --- a/frontend/src/components/TradeAlertModal.tsx +++ b/frontend/src/components/TradeAlertModal.tsx @@ -1,11 +1,12 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useDraggablePanel } from '../hooks/useDraggablePanel'; -import { placePaperOrder, placeLiveOrder, loadLiveSummary } from '../utils/backendApi'; +import { placePaperOrder, placeLiveOrder, loadLiveSummary, loadStrategy, type StrategyDto } from '../utils/backendApi'; import { formatSignalTime } from '../utils/tradeSignalDisplay'; import { formatUpbitKrwPrice } from '../utils/safeFormat'; import { TRADE_BUY_COLOR, TRADE_SELL_COLOR } from '../utils/tradeSignalColors'; import { useTradeAlertTimeFormat } from '../utils/tradeAlertTimeFormat'; +import StrategyConditionView from './tradeNotification/StrategyConditionView'; export interface TradeSignalInfo { market: string; @@ -79,6 +80,19 @@ export const TradeAlertModal: React.FC = ({ const [activePct, setActivePct] = useState(null); const [ordering, setOrdering] = useState(false); + /* ── 전략 조건 — 마운트 즉시 로딩 ──────────────────────────── */ + const [strategy, setStrategy] = useState(null); + const [stratLoading, setStratLoading] = useState(false); + + useEffect(() => { + if (!signal.strategyId) return; + setStratLoading(true); + loadStrategy(signal.strategyId) + .then(s => setStrategy(s)) + .catch(() => setStrategy(null)) + .finally(() => setStratLoading(false)); + }, [signal.strategyId]); + const { panelRef, dragging, @@ -187,6 +201,9 @@ export const TradeAlertModal: React.FC = ({ + {/* ── 스크롤 가능 본문 영역 ─────────────────────── */} +
+
{signal.market} @@ -222,6 +239,27 @@ export const TradeAlertModal: React.FC = ({
+ {/* ── 전략 조건 섹션 ────────────────────────────── */} + {signal.strategyId && ( +
+
+ 전략 조건 + {stratLoading && 로딩 중…} +
+
+ {stratLoading && ( +
전략 조건 로딩 중…
+ )} + {!stratLoading && !strategy && ( +
전략 조건을 불러올 수 없습니다.
+ )} + {!stratLoading && strategy && ( + + )} +
+
+ )} +
자산 비율 @@ -287,6 +325,8 @@ export const TradeAlertModal: React.FC = ({ {!useLive && !usePaper && '※ 설정에서 모의투자 또는 API 키를 활성화하세요'}

+ +
{/* /.tam-modal-body */}
); diff --git a/frontend/src/components/TradeNotificationListPage.tsx b/frontend/src/components/TradeNotificationListPage.tsx index f1fea82..0a3bd7d 100644 --- a/frontend/src/components/TradeNotificationListPage.tsx +++ b/frontend/src/components/TradeNotificationListPage.tsx @@ -23,6 +23,7 @@ import '../styles/builderPageShell.css'; import '../styles/tradeNotificationList.css'; import TradeNotificationListRow from './tradeNotification/TradeNotificationListRow'; import TradeNotificationGridLayoutPicker from './tradeNotification/TradeNotificationGridLayoutPicker'; +import FullscreenChartModal from './tradeNotification/FullscreenChartModal'; import { loadTradeNotificationGridPreset, loadTradeNotificationListLayout, @@ -110,6 +111,9 @@ export const TradeNotificationListPage: React.FC = ({ refreshHistory, } = useTradeNotification(); + const [query, setQuery] = useState(''); + const normalizedQuery = useMemo(() => query.trim().toLowerCase(), [query]); + const [filter, setFilter] = useState('all'); const [listSort, setListSort] = useState( () => loadTradeNotificationListSort(), @@ -132,6 +136,7 @@ export const TradeNotificationListPage: React.FC = ({ const orderAnchorRef = useRef(null); const [refreshing, setRefreshing] = useState(false); const [rightOpen, setRightOpen] = useState(() => readStoredBool(TNL_RIGHT_OPEN_KEY, true)); + const [fullscreenItem, setFullscreenItem] = useState(null); const { settings: appSettings } = useAppSettings(); const chartLiveReceiveHighlight = appSettings.chartLiveReceiveHighlight ?? true; @@ -252,8 +257,22 @@ export const TradeNotificationListPage: React.FC = ({ n => normalizeStartCandleType(n.candleType) === candleFilter, ); } + if (normalizedQuery) { + items = items.filter(n => { + const mkt = n.market.toLowerCase(); + const sym = n.market.includes('-') ? n.market.split('-')[1].toLowerCase() : mkt; + const strat = (n.strategyName ?? '').toLowerCase(); + const sigKo = n.signalType === 'BUY' ? '매수' : '매도'; + return ( + mkt.includes(normalizedQuery) || + sym.includes(normalizedQuery) || + strat.includes(normalizedQuery) || + sigKo.includes(normalizedQuery) + ); + }); + } return items; - }, [allNotifications, filter, candleFilter]); + }, [allNotifications, filter, candleFilter, normalizedQuery]); const sorted = useMemo( () => sortTradeNotifications(filtered, listSort), @@ -305,6 +324,14 @@ export const TradeNotificationListPage: React.FC = ({ const gridCols = getTradeNotificationGridPreset(gridPreset).cols; return ( + <> + {fullscreenItem && ( + setFullscreenItem(null)} + /> + )}
= ({

매매 시그널 알림

+ {/* 검색란 */} +
+ + + + + setQuery(e.target.value)} + aria-label="알림 목록 검색" + /> + {query && ( + + )} +
+ 미확인 {unreadCount}건 {selectedCount > 0 && ( 선택 {selectedCount}건 @@ -425,7 +478,9 @@ export const TradeNotificationListPage: React.FC = ({
{sorted.length === 0 ? ( -
표시할 알림이 없습니다.
+
+ {normalizedQuery ? `"${query}" 에 해당하는 알림이 없습니다.` : '표시할 알림이 없습니다.'} +
) : (
    = ({ }} onGoToChart={() => { markAsRead(item.id); - onGoToChart(item.market); + setFullscreenItem(item); }} tickers={tickers} /> @@ -540,6 +595,7 @@ export const TradeNotificationListPage: React.FC = ({
+ ); }; diff --git a/frontend/src/components/tradeNotification/FullscreenChartModal.tsx b/frontend/src/components/tradeNotification/FullscreenChartModal.tsx new file mode 100644 index 0000000..7fb0768 --- /dev/null +++ b/frontend/src/components/tradeNotification/FullscreenChartModal.tsx @@ -0,0 +1,130 @@ +/** + * 매매 시그널 알림 — 차트 전체화면 모달 + * 알림 아이템에 이미 표시된 캔들+보조지표 차트를 전체 화면으로 확대 표시 + */ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import type { Theme } from '../../types'; +import type { TradeNotificationItem } from '../../contexts/TradeNotificationContext'; +import type { StrategyDto } from '../../utils/backendApi'; +import { loadStrategy } from '../../utils/backendApi'; +import { buildStrategyChartIndicators, candleTypeToTimeframe } from '../../utils/strategyToChartIndicators'; +import { useIndicatorSettings } from '../../hooks/useIndicatorSettings'; +import { formatStrategyCandleLabel } from '../../utils/strategyStartNodes'; +import TradeSignalMiniChart from './TradeSignalMiniChart'; +import { TRADE_BUY_COLOR, TRADE_SELL_COLOR } from '../../utils/tradeSignalColors'; +import { formatSignalTime } from '../../utils/tradeSignalDisplay'; +import { formatUpbitKrwPrice } from '../../utils/safeFormat'; + +const MAX_INDICATOR_CARDS = 8; + +interface Props { + item: TradeNotificationItem; + theme: Theme; + onClose: () => void; +} + +const FullscreenChartModal: React.FC = ({ item, theme, onClose }) => { + const { getParams, getVisualConfig } = useIndicatorSettings(); + const [strategy, setStrategy] = useState(null); + + /* 전략 로딩 */ + useEffect(() => { + if (!item.strategyId) return; + loadStrategy(item.strategyId) + .then(s => setStrategy(s)) + .catch(() => {}); + }, [item.strategyId]); + + /* 보조지표 config */ + const allIndicatorConfigs = useMemo(() => { + if (!strategy) return []; + const all = buildStrategyChartIndicators(strategy, getParams, getVisualConfig); + return all.slice(0, MAX_INDICATOR_CARDS); + }, [strategy, getParams, getVisualConfig]); + + const timeframe = candleTypeToTimeframe(item.candleType ?? '1m'); + const isBuy = item.signalType === 'BUY'; + const accentColor = isBuy ? TRADE_BUY_COLOR : TRADE_SELL_COLOR; + const sym = item.market.replace(/^KRW-/, ''); + const tfLabel = formatStrategyCandleLabel(item.candleType ?? '1m'); + + /* ESC 키로 닫기 */ + const handleKeyDown = useCallback((e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }, [onClose]); + + useEffect(() => { + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [handleKeyDown]); + + /* body 스크롤 막기 */ + useEffect(() => { + const prev = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { document.body.style.overflow = prev; }; + }, []); + + return ( +
+
e.stopPropagation()} + > + {/* 헤더 */} +
+
+ + {item.signalType} + + {sym} + {tfLabel} + + ₩{formatUpbitKrwPrice(item.price)} + + + {formatSignalTime(item.candleTime)} + +
+
+ {item.strategyName && ( + {item.strategyName} + )} + +
+
+ + {/* 차트 바디 */} +
+ +
+
+
+ ); +}; + +export default FullscreenChartModal; diff --git a/frontend/src/components/tradeNotification/StrategyConditionView.tsx b/frontend/src/components/tradeNotification/StrategyConditionView.tsx new file mode 100644 index 0000000..03c188b --- /dev/null +++ b/frontend/src/components/tradeNotification/StrategyConditionView.tsx @@ -0,0 +1,229 @@ +/** + * 전략 조건 읽기전용 뷰 — TradeAlertModal 내 "전략 조건" 탭에 표시 + * LogicNode 트리를 전략 편집기와 유사한 카드 형태로 렌더링한다. + */ +import React from 'react'; +import type { LogicNode } from '../../utils/strategyTypes'; +import { CONDITION_LABEL } from '../../utils/strategyTypes'; +import { getStrategyIndicatorDisplayName } from '../../utils/strategyPaletteStorage'; +import { getIndicatorPaletteCategory } from '../strategyEditor/paletteCategories'; +import { nodeToText } from '../../utils/strategyEditorShared'; +import { formatStrategyCandleLabel } from '../../utils/strategyStartNodes'; +import type { StrategyDto } from '../../utils/backendApi'; + +/* ── nodeToText 출력에서 조건대상1 / 조건대상2 분리 ─────────────── */ +/** nodeToText 가 COND_OPTIONS 라벨로 출력하는 문자열 목록 (긴 것 우선으로 체크) */ +const COND_SEARCH = [ + '구름 상향 돌파', '구름 하향 돌파', + '선행스팬1 상향돌파 선행스팬2', '선행스팬1 하향돌파 선행스팬2', + '선행스팬1 > 선행스팬2', '선행스팬1 < 선행스팬2', + '후행스팬 > 가격', '후행스팬 < 가격', + '상향 돌파', '하향 돌파', + '상승 기울기', '하락 기울기', + '이상 (>=)', '이하 (<=)', + '초과 (>)', '미만 (<)', + '같음 (==)', '다름 (!=)', + '차이 > 값', '차이 < 값', + 'N일 연속 유지', + '구름 위', '구름 아래', '구름 안', +]; + +function splitDescByCondType(descText: string): { left: string; right: string; mid: string } { + for (const cond of COND_SEARCH) { + const idx = descText.indexOf(cond); + if (idx > 0) { + return { + left: descText.slice(0, idx).trim(), + mid: cond, + right: descText.slice(idx + cond.length).trim(), + }; + } + } + return { left: descText, mid: '', right: '' }; +} + +/* ── 개별 조건 카드 ────────────────────────────────────────────── */ +const ConditionCard: React.FC<{ node: LogicNode }> = ({ node }) => { + const cond = node.condition; + if (!cond) return null; + + const indType = cond.indicatorType; + const indLabel = getStrategyIndicatorDisplayName(indType); + const cat = getIndicatorPaletteCategory(indType); + const fullText = nodeToText(node); + + const prefix = `${indLabel} - `; + const descText = fullText.startsWith(prefix) ? fullText.slice(prefix.length) : fullText; + const { left, mid: condStr, right } = splitDescByCondType(descText); + + const candleRange = cond.candleRange != null && cond.candleRange > 0 + ? `${cond.candleRange}캔들 전` + : '현재 캔들'; + + const condLabelDisplay = CONDITION_LABEL[cond.conditionType] ?? (condStr || cond.conditionType); + + return ( +
+
+ {indType} + {indLabel} - {descText} +
+
+
+ 캔들범위 + {candleRange} +
+ {left && ( +
+ 조건대상1 + {left} +
+ )} + {right && ( +
+ 조건대상2 + {right} +
+ )} +
+ 조건 + {condLabelDisplay} +
+
+
+ ); +}; + +/* ── 노드 트리 재귀 렌더러 ─────────────────────────────────────── */ +function renderNode(node: LogicNode): React.ReactNode { + if (node.type === 'CONDITION') { + return ; + } + + if (node.type === 'AND' || node.type === 'OR') { + const op = node.type as 'AND' | 'OR'; + const opKo = op === 'AND' ? 'AND (그리고)' : 'OR (또는)'; + const children = node.children ?? []; + return ( +
+
+ {op} + {opKo} +
+ AND + OR +
+
+
+ {children.map(child => renderNode(child))} +
+
+ ); + } + + if (node.type === 'NOT') { + const inner = node.children?.[0]; + return ( +
+
+ NOT + NOT (부정) +
+
+ {inner ? renderNode(inner) : 빈 NOT} +
+
+ ); + } + + if (node.type === 'TIMEFRAME') { + const inner = node.children?.[0]; + const types = node.candleTypes?.length ? node.candleTypes : [node.candleType ?? '1m']; + const tfLabel = types.map(formatStrategyCandleLabel).join(' · '); + return ( +
+
+ + START + {tfLabel} +
+ {inner + ?
{renderNode(inner)}
+ : 빈 조건 + } +
+ ); + } + + return null; +} + +/* ── 매수/매도 조건 트리 ─────────────────────────────────────────── */ +const ConditionTree: React.FC<{ root: LogicNode | null | undefined }> = ({ root }) => { + if (!root) return

조건이 없습니다.

; + return
{renderNode(root)}
; +}; + +/* ── 메인 컴포넌트 ───────────────────────────────────────────────── */ +interface Props { + strategy: StrategyDto; + /** 알림의 신호 타입으로 초기 탭 결정 */ + signalType?: 'BUY' | 'SELL'; +} + +const StrategyConditionView: React.FC = ({ strategy, signalType }) => { + const hasBuy = Boolean(strategy.buyCondition); + const hasSell = Boolean(strategy.sellCondition); + const showBoth = hasBuy && hasSell; + + const [tab, setTab] = React.useState<'buy' | 'sell'>( + signalType === 'SELL' && hasSell ? 'sell' : 'buy', + ); + + const buyRoot = strategy.buyCondition as LogicNode | null | undefined; + const sellRoot = strategy.sellCondition as LogicNode | null | undefined; + + return ( +
+ {showBoth && ( +
+ + +
+ )} + + {!showBoth && hasBuy && ( +
매수 조건 (Entry)
+ )} + {!showBoth && hasSell && ( +
매도 조건 (Exit)
+ )} + +
+ {showBoth ? ( + tab === 'buy' + ? + : + ) : hasBuy ? ( + + ) : ( + + )} +
+
+ ); +}; + +export default StrategyConditionView; diff --git a/frontend/src/components/tradeNotification/TradeNotificationListRow.tsx b/frontend/src/components/tradeNotification/TradeNotificationListRow.tsx index 2f58101..4c12ee4 100644 --- a/frontend/src/components/tradeNotification/TradeNotificationListRow.tsx +++ b/frontend/src/components/tradeNotification/TradeNotificationListRow.tsx @@ -142,6 +142,12 @@ const TradeNotificationListRow: React.FC = ({ })); }, [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; @@ -218,6 +224,12 @@ const TradeNotificationListRow: React.FC = ({
+ {/* 전략명 태그 — 헤더 바로 아래에 표시 */} +
+ + {strategyLabel} +
+
= ({ {chartExpanded ? ( - -
- {expandedChartKey === 'candle' && ( - setExpandedChartKey('candle')} - onRestore={() => setExpandedChartKey(null)} - onRealtimeActivity={chartActive ? bumpChartActivity : undefined} - /> - )} - {expandedIndicator && ( - setExpandedChartKey(expandedIndicator.id)} - onRestore={() => setExpandedChartKey(null)} - onRealtimeActivity={chartActive ? bumpChartActivity : undefined} - /> - )} -
-
+ /* 전체보기 — 통합 차트 (캔들 + 보조지표) 를 넓게 표시 */ +
+ setExpandedChartKey('candle')} + onRestore={() => setExpandedChartKey(null)} + onRealtimeActivity={chartActive ? bumpChartActivity : undefined} + /> +
) : ( <> = ({ /> -
+ {/* 통합 차트 — 캔들(상단) + 전략 보조지표(하단) + key에 indicator ids를 포함하여 전략 로드 후 차트를 완전히 재마운트 */} +
c.id).join(',')}`} label="캔들" meta={candleKo} market={item.market} timeframe={chartTf} theme={theme} - indicators={[]} + indicators={allIndicatorConfigs} enabled={chartActive} expanded={false} onExpand={() => setExpandedChartKey('candle')} onRestore={() => setExpandedChartKey(null)} onRealtimeActivity={chartActive ? bumpChartActivity : undefined} /> + {indicatorPlaceholder && ( +
전략 지표 로딩 중…
+ )}
- - -
- {indicatorCards.map(card => ( - setExpandedChartKey(card.id)} - onRestore={() => setExpandedChartKey(null)} - onRealtimeActivity={chartActive ? bumpChartActivity : undefined} - /> - ))} - - {indicatorPlaceholder && ( -
-

전략 지표 로딩 중…

-
- )} -
-
)}
diff --git a/frontend/src/styles/appPopup.css b/frontend/src/styles/appPopup.css index de996e2..42b5fb7 100644 --- a/frontend/src/styles/appPopup.css +++ b/frontend/src/styles/appPopup.css @@ -36,6 +36,13 @@ pointer-events: auto; } +/* tam-overlay 모달은 전략 조건 표시를 위해 더 넓게 */ +.tam-overlay > .tam-modal { + width: 580px; + min-width: 480px; + max-height: 88vh; +} + /* ── 셸 (모달 컨테이너) ── */ .app-popup-shell, .tam-modal, @@ -352,3 +359,309 @@ html.theme-light .chart-ctx-menu, border-color: var(--border); box-shadow: var(--popup-shadow); } + +/* ── TradeAlertModal 스크롤 본문 ─────────────────────────── */ +.tam-modal-body { + flex: 1; + min-height: 0; + overflow-y: auto; + display: flex; + flex-direction: column; + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--text3) 35%, transparent) transparent; +} + +/* ── 전략 조건 섹션 (인라인 표시) ─────────────────────────── */ +.tam-condition-section { + border-top: 1px solid var(--se-border, var(--border)); + border-bottom: 1px solid var(--se-border, var(--border)); + background: color-mix(in srgb, var(--bg2) 60%, transparent); +} + +.tam-condition-section-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px 6px; +} + +.tam-condition-section-title { + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--text3, var(--se-text-muted)); +} + +.tam-condition-section-loading { + font-size: 0.7rem; + color: var(--text3, var(--se-text-muted)); +} + +/* ── 전략 조건 패널 ─────────────────────────────────────────── */ +.tam-condition-panel { + padding: 0 8px 10px; +} + +.tam-condition-loading, +.tam-condition-empty { + display: flex; + align-items: center; + justify-content: center; + min-height: 60px; + font-size: 0.78rem; + color: var(--text3, var(--se-text-muted)); +} + +/* ── StrategyConditionView 공통 ─────────────────────────────── */ +.scv-root { + display: flex; + flex-direction: column; + gap: 0; +} + +.scv-signal-tabs { + display: flex; + gap: 4px; + margin-bottom: 10px; +} + +.scv-signal-tab { + flex: 1; + padding: 6px 10px; + border: 1px solid var(--se-border, var(--border)); + border-radius: 7px; + background: transparent; + color: var(--text2); + font-size: 0.76rem; + font-weight: 500; + cursor: pointer; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} + +.scv-signal-tab--active.scv-signal-tab--buy { + background: color-mix(in srgb, var(--up, #ef5350) 15%, transparent); + border-color: var(--up, #ef5350); + color: var(--up, #ef5350); +} + +.scv-signal-tab--active.scv-signal-tab--sell { + background: color-mix(in srgb, var(--down, #4dabf7) 15%, transparent); + border-color: var(--down, #4dabf7); + color: var(--down, #4dabf7); +} + +.scv-signal-label { + font-size: 0.78rem; + font-weight: 600; + margin-bottom: 8px; + padding: 4px 8px; + border-radius: 6px; +} + +.scv-signal-label--buy { + color: var(--up, #ef5350); + background: color-mix(in srgb, var(--up, #ef5350) 12%, transparent); +} + +.scv-signal-label--sell { + color: var(--down, #4dabf7); + background: color-mix(in srgb, var(--down, #4dabf7) 12%, transparent); +} + +.scv-body { + display: flex; + flex-direction: column; + gap: 8px; +} + +.scv-tree { + display: flex; + flex-direction: column; + gap: 8px; +} + +.scv-empty { + font-size: 0.75rem; + color: var(--text3); + padding: 4px 0; +} + +/* ── AND/OR 그룹 ─────────────────────────────────────────────── */ +.scv-group { + border-radius: 8px; + border: 1px solid var(--se-border, var(--border)); + overflow: hidden; +} + +.scv-group--or { + border-color: color-mix(in srgb, #f5a623 40%, var(--border)); +} + +.scv-group--and { + border-color: color-mix(in srgb, #7b5ea7 40%, var(--border)); +} + +.scv-group-header { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 10px; + background: color-mix(in srgb, var(--bg2) 60%, var(--bg)); + border-bottom: 1px solid var(--se-border, var(--border)); +} + +.scv-group-badge { + font-size: 0.65rem; + font-weight: 700; + padding: 2px 7px; + border-radius: 5px; + letter-spacing: 0.04em; +} + +.scv-group-badge--or { + background: color-mix(in srgb, #f5a623 25%, transparent); + color: #f5a623; + border: 1px solid color-mix(in srgb, #f5a623 50%, transparent); +} + +.scv-group-badge--and { + background: color-mix(in srgb, #7b5ea7 25%, transparent); + color: #b39ddb; + border: 1px solid color-mix(in srgb, #7b5ea7 50%, transparent); +} + +.scv-group-badge--not { + background: color-mix(in srgb, #f44336 20%, transparent); + color: #ef9a9a; + border: 1px solid color-mix(in srgb, #f44336 40%, transparent); +} + +.scv-group-label { + font-size: 0.75rem; + color: var(--text2); + flex: 1; +} + +.scv-group-op-toggle { + display: flex; + gap: 4px; + font-size: 0.68rem; + font-weight: 600; +} + +.scv-op-active { + color: var(--text); + background: var(--bg2); + padding: 1px 6px; + border-radius: 4px; + border: 1px solid var(--se-border, var(--border)); +} + +.scv-op-dim { + color: var(--text3); + padding: 1px 6px; +} + +.scv-group-body { + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px; +} + +/* ── TIMEFRAME 섹션 ──────────────────────────────────────────── */ +.scv-timeframe { + border-radius: 8px; + border: 1px solid color-mix(in srgb, #f5a623 35%, var(--border)); + overflow: hidden; + background: color-mix(in srgb, #f5a623 5%, transparent); +} + +.scv-timeframe-header { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 10px; + font-size: 0.76rem; + font-weight: 600; + color: var(--text); + background: color-mix(in srgb, #f5a623 12%, var(--bg2)); + border-bottom: 1px solid color-mix(in srgb, #f5a623 30%, var(--border)); +} + +.scv-timeframe-dot { + color: #f5a623; + font-size: 0.5rem; +} + +.scv-timeframe-badge { + background: color-mix(in srgb, #f5a623 20%, transparent); + color: #f5a623; + border: 1px solid color-mix(in srgb, #f5a623 45%, transparent); + border-radius: 4px; + padding: 1px 7px; + font-size: 0.68rem; + font-weight: 700; +} + +.scv-timeframe-body { + padding: 8px; +} + +/* ── 개별 조건 카드 ──────────────────────────────────────────── */ +.scv-cond-card { + border-radius: 7px; + border: 1px solid var(--se-border, var(--border)); + background: color-mix(in srgb, var(--bg) 50%, var(--bg2)); + overflow: hidden; +} + +.scv-cond-header { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-bottom: 1px solid var(--se-border, var(--border)); + background: color-mix(in srgb, var(--bg2) 60%, var(--bg)); +} + +.scv-ind-chip { + flex-shrink: 0; + font-size: 0.63rem !important; + padding: 2px 7px !important; +} + +.scv-cond-title { + font-size: 0.75rem; + color: var(--text); + line-height: 1.35; + word-break: break-all; +} + +.scv-cond-fields { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1px; + background: var(--se-border, var(--border)); +} + +.scv-cond-field { + display: flex; + flex-direction: column; + gap: 2px; + padding: 5px 8px; + background: color-mix(in srgb, var(--bg) 60%, var(--bg2)); +} + +.scv-cond-field-lbl { + font-size: 0.63rem; + color: var(--text3, var(--se-text-muted)); + letter-spacing: 0.02em; +} + +.scv-cond-field-val { + font-size: 0.74rem; + color: var(--text); + font-weight: 500; +} diff --git a/frontend/src/styles/tradeNotificationList.css b/frontend/src/styles/tradeNotificationList.css index 682a393..fe9450b 100644 --- a/frontend/src/styles/tradeNotificationList.css +++ b/frontend/src/styles/tradeNotificationList.css @@ -54,18 +54,19 @@ box-sizing: border-box; } -/* 목록 한 줄 = 알림상세 · 신호일치율 · 캔들 · 보조지표(1칸) — 가로 100% 꽉 참 */ +/* 목록 한 줄 = 알림상세 · 신호일치율 · 통합차트(캔들+보조지표) — 가로 100% 꽉 참 */ .tnl-row-gallery { --tnl-summary-card-width: 260px; --tnl-signal-slot-width: 280px; --tnl-chart-card-width: 312px; + --tnl-row-inner-h: 340px; display: flex; flex-direction: row; align-items: stretch; gap: 10px; width: 100%; - height: 320px; - max-height: 320px; + height: calc(var(--tnl-row-inner-h) + 20px); + max-height: calc(var(--tnl-row-inner-h) + 20px); padding: 10px; box-sizing: border-box; } @@ -83,8 +84,8 @@ .tnl-row-gallery-candle { flex: 1.12 1 0; min-width: 0; - height: 300px; - max-height: 300px; + height: var(--tnl-row-inner-h, 420px); + max-height: var(--tnl-row-inner-h, 420px); display: flex; flex-direction: column; align-items: stretch; @@ -113,8 +114,62 @@ .tnl-row-gallery > .tnl-hscroll-pane, .tnl-row-gallery > .tnl-row-gallery-candle { - height: 300px; - max-height: 300px; + height: var(--tnl-row-inner-h, 420px); + max-height: var(--tnl-row-inner-h, 420px); +} + +/* ── 통합 차트 영역 (캔들 위, 보조지표 아래) ───────────────────── */ +.tnl-row-gallery-combined-chart { + flex: 2.24 1 0; /* 구 캔들(1.12) + 구 보조지표(1.12) 합산 */ + min-width: 0; + height: var(--tnl-row-inner-h, 420px); + max-height: var(--tnl-row-inner-h, 420px); + display: flex; + flex-direction: column; + align-items: stretch; + 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; + position: relative; +} + +.tnl-row-gallery-combined-chart > .tnl-chart-card { + flex: 1 1 auto; + width: 100%; + min-width: 0; + max-width: none; + height: 100%; + max-height: 100%; + border: none; + border-radius: 0; +} + +/* 전체보기 상태에서 통합 차트가 나머지 공간을 모두 차지 */ +.tnl-row-gallery--chart-expanded .tnl-row-gallery-combined-chart { + flex: 1 1 0; + min-width: 0; + height: var(--tnl-row-inner-h, 420px); + max-height: var(--tnl-row-inner-h, 420px); +} + +.tnl-row-gallery-combined-chart--expanded { + flex: 1 1 0 !important; +} + +/* 지표 로딩 중 오버레이 */ +.tnl-combined-chart-placeholder { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + color: var(--text3, var(--se-text-muted)); + background: color-mix(in srgb, var(--bg2) 65%, transparent); + pointer-events: none; + z-index: 2; } .tnl-row-gallery .tnl-chart-card { @@ -141,8 +196,8 @@ align-items: stretch; min-width: 0; min-height: 0; - height: 300px; - max-height: 300px; + height: var(--tnl-row-inner-h, 420px); + max-height: var(--tnl-row-inner-h, 420px); border-radius: 12px; border: 1px solid var(--se-border, var(--border)); background: color-mix(in srgb, var(--bg) 40%, var(--bg2)); @@ -436,7 +491,7 @@ flex: 1; min-height: 0; width: 100%; - padding: 14px 16px 12px; + padding: 10px 14px 10px; border: none; background: transparent; color: inherit; @@ -444,7 +499,8 @@ cursor: pointer; display: flex; flex-direction: column; - gap: 12px; + gap: 0; + justify-content: space-between; overflow: hidden; box-sizing: border-box; } @@ -473,14 +529,13 @@ } .tnl-summary-ko { - font-size: 17px; + font-size: 15px; font-weight: 700; line-height: 1.2; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - white-space: nowrap; } .tnl-summary-sym { @@ -510,9 +565,9 @@ display: flex; align-items: center; justify-content: space-between; - gap: 12px; + gap: 8px; flex-shrink: 0; - padding: 4px 2px; + padding: 1px 2px; } .tnl-summary-quote-left { @@ -566,7 +621,7 @@ } .tnl-summary-price { - font-size: 20px; + font-size: 17px; font-weight: 800; font-variant-numeric: tabular-nums; white-space: nowrap; @@ -583,27 +638,25 @@ /* 3행+: 상세 정보 */ .tnl-summary-meta { - flex: 1; - min-height: 0; + flex: 0 0 auto; display: flex; flex-direction: column; - gap: 10px; - overflow-y: auto; + gap: 5px; + overflow-y: visible; overflow-x: hidden; padding-right: 2px; - padding-bottom: 6px; } .tnl-summary-info-row { display: grid; - grid-template-columns: 88px minmax(0, 1fr); + grid-template-columns: 80px minmax(0, 1fr); align-items: start; - gap: 10px 14px; + gap: 5px 10px; } /* 캔들 시각 — 10분봉·15분봉 등 분봉 라벨이 다음 줄로 밀리지 않도록 */ .tnl-summary-info-row--candle { - grid-template-columns: 88px minmax(108px, 1fr); + grid-template-columns: 80px minmax(100px, 1fr); } .tnl-summary-info-val--candle { @@ -616,7 +669,7 @@ } .tnl-summary-info-lbl { - font-size: 12px; + font-size: 11px; font-weight: 600; color: var(--text3, var(--se-text-muted)); line-height: 1.4; @@ -624,10 +677,10 @@ } .tnl-summary-info-val { - font-size: 13px; + font-size: 12px; font-weight: 500; color: var(--text2); - line-height: 1.45; + line-height: 1.4; word-break: break-word; text-align: right; } @@ -638,18 +691,56 @@ } /* 하단: 투자전략 (vtd-target-strategy-field) */ +/* ── 전략명 태그 (헤더 바로 아래) ──────────────────────────────── */ +.tnl-summary-strategy-tag { + display: flex; + align-items: center; + gap: 5px; + padding: 4px 9px; + border-radius: 5px; + background: var(--bg2, rgba(255,255,255,0.06)); + border: 1px solid var(--border, rgba(255,255,255,0.1)); + width: fit-content; + max-width: 100%; + overflow: hidden; + flex-shrink: 0; +} + +.tnl-summary-strategy-tag-ic { + font-size: 0.65rem; + flex-shrink: 0; + opacity: 0.75; +} + +.tnl-summary-strategy-tag-name { + font-size: 0.72rem; + font-weight: 600; + color: var(--text2, var(--se-text-muted)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* 그리드 레이아웃에서 전략 태그 약간 작게 */ +.tnl-summary-card--grid .tnl-summary-strategy-tag { + padding: 3px 7px; +} +.tnl-summary-card--grid .tnl-summary-strategy-tag-name { + font-size: 0.68rem; +} + .tnl-summary-strategy-field { display: flex; align-items: center; - gap: 12px; + gap: 8px; flex-shrink: 0; width: 100%; - padding-top: 10px; + padding-top: 8px; border-top: 1px solid var(--border); } .tnl-summary-strategy-lbl { - font-size: 12px; + font-size: 11px; font-weight: 600; color: var(--text3, var(--se-text-muted)); white-space: nowrap; @@ -659,12 +750,12 @@ .tnl-summary-strategy-box { flex: 1; min-width: 0; - padding: 8px 12px; - border-radius: 8px; + padding: 5px 10px; + border-radius: 6px; border: 1px solid var(--se-border, var(--border)); background: var(--bg, var(--bg3)); color: var(--text); - font-size: 13px; + font-size: 12px; font-weight: 600; line-height: 1.35; overflow: hidden; @@ -677,7 +768,7 @@ display: flex; align-items: center; gap: 4px; - padding: 8px 10px; + padding: 6px 10px; border-top: 1px solid var(--border); background: var(--bg3, var(--se-center-bg)); flex-shrink: 0; @@ -956,6 +1047,72 @@ pointer-events: none; } +/* ── 검색 박스 ─────────────────────────────────────────────── */ +.tnl-search-box { + display: flex; + align-items: center; + gap: 6px; + padding: 0 10px; + height: 32px; + border: 1px solid var(--se-border, var(--border)); + border-radius: 8px; + background: var(--bg2, var(--se-panel-card-bg)); + flex-shrink: 0; + min-width: 180px; + max-width: 240px; + transition: border-color 0.15s; +} + +.tnl-search-box:focus-within, +.tnl-search-box--active { + border-color: var(--accent, #3f7ef5); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #3f7ef5) 25%, transparent); +} + +.tnl-search-icon { + flex-shrink: 0; + color: var(--text3, var(--se-text-muted)); + opacity: 0.7; +} + +.tnl-search-input { + flex: 1; + border: none; + background: transparent; + color: var(--text); + font-size: 12px; + outline: none; + min-width: 0; + line-height: 1.4; +} + +.tnl-search-input::placeholder { + color: var(--text3, var(--se-text-muted)); + opacity: 0.7; +} + +.tnl-search-clear { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + flex-shrink: 0; + border: none; + background: transparent; + color: var(--text3, var(--se-text-muted)); + cursor: pointer; + padding: 0; + font-size: 10px; + border-radius: 50%; + transition: color 0.15s, background 0.15s; +} + +.tnl-search-clear:hover { + color: var(--text); + background: color-mix(in srgb, var(--text3) 15%, transparent); +} + /* 툴바 — 좌: 필터·액션 · 우: 목록/그리드·배치·삭제 */ .tnl-page--with-right .tnl-toolbar { display: flex; @@ -1298,3 +1455,146 @@ ul.tnl-list.tnl-list--grid { min-width: min(var(--tnl-signal-slot-width), 88vw); } } + +/* ══════════════════════════════════════════════════════════════ + 차트 전체화면 모달 (tnl-chart-fs-*) +══════════════════════════════════════════════════════════════ */ +.tnl-chart-fs-overlay { + position: fixed; + inset: 0; + z-index: 9500; + background: rgba(0, 0, 0, 0.88); + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + box-sizing: border-box; +} + +.tnl-chart-fs-inner { + width: 100%; + height: 100%; + max-width: calc(100vw - 32px); + max-height: calc(100vh - 32px); + display: flex; + flex-direction: column; + background: var(--bg, #131722); + border-radius: 14px; + border: 1px solid var(--se-border, var(--border)); + overflow: hidden; + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.7); +} + +/* ── 헤더 ─────────────────────────────────────────────────── */ +.tnl-chart-fs-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 16px; + border-bottom: 1px solid var(--se-border, var(--border)); + flex-shrink: 0; + background: color-mix(in srgb, var(--bg2) 60%, var(--bg)); +} + +.tnl-chart-fs-title { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + flex: 1; +} + +.tnl-chart-fs-signal-badge { + flex-shrink: 0; + font-size: 0.68rem; + font-weight: 700; + color: #fff; + padding: 2px 8px; + border-radius: 5px; + letter-spacing: 0.04em; +} + +.tnl-chart-fs-market { + font-size: 1rem; + font-weight: 700; + color: var(--text); +} + +.tnl-chart-fs-tf { + font-size: 0.75rem; + color: var(--text3, var(--se-text-muted)); + background: var(--bg2); + border: 1px solid var(--se-border, var(--border)); + border-radius: 5px; + padding: 1px 7px; +} + +.tnl-chart-fs-price { + font-size: 0.9rem; + font-weight: 700; +} + +.tnl-chart-fs-time { + font-size: 0.72rem; + color: var(--text3, var(--se-text-muted)); +} + +.tnl-chart-fs-actions { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; +} + +.tnl-chart-fs-strategy { + font-size: 0.72rem; + color: var(--text2); + background: var(--bg2); + border: 1px solid var(--se-border, var(--border)); + border-radius: 5px; + padding: 2px 9px; + max-width: 200px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tnl-chart-fs-close { + flex-shrink: 0; + width: 32px; + height: 32px; + border: 1px solid var(--se-border, var(--border)); + border-radius: 7px; + background: transparent; + color: var(--text2); + cursor: pointer; + font-size: 0.85rem; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s, color 0.15s; +} + +.tnl-chart-fs-close:hover { + background: color-mix(in srgb, var(--text3) 20%, transparent); + color: var(--text); +} + +/* ── 차트 바디 ────────────────────────────────────────────── */ +.tnl-chart-fs-body { + flex: 1 1 0; + min-height: 0; + display: flex; + flex-direction: column; + position: relative; +} + +.tnl-chart-fs-body .tnl-mini-chart-canvas { + height: 100% !important; + flex: 1 1 0; +} + +.tnl-chart-fs-body .tnl-mini-chart-canvas--fill { + height: 100% !important; +}