From f64dc1e983c19e000a62bb6603f57cb84f21a5ba Mon Sep 17 00:00:00 2001 From: Macbook Date: Thu, 28 May 2026 15:37:02 +0900 Subject: [PATCH] =?UTF-8?q?=EC=95=B1=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/android/app/build.gradle | 4 +- app/src/App.tsx | 99 +++-- app/src/components/MobileStackHeader.tsx | 23 + app/src/contexts/AuthContext.tsx | 136 ++++++ app/src/contexts/NavigationContext.tsx | 100 ++++- app/src/contexts/TradeNotificationContext.tsx | 222 +--------- app/src/screens/LoginScreen.tsx | 12 + .../NotificationDetailScreen.tsx | 57 +++ .../notifications/NotificationListRow.tsx | 52 +++ .../notifications/NotificationsScreen.tsx | 106 +++-- app/src/screens/settings/SettingsScreen.tsx | 97 ++--- .../screens/virtual/VirtualFocusScreen.tsx | 52 ++- .../screens/virtual/VirtualHistoryScreen.tsx | 41 ++ .../virtual/VirtualTargetCardMobile.tsx | 130 ------ .../screens/virtual/VirtualTargetListRow.tsx | 70 +++ app/src/screens/virtual/VirtualTradePanel.tsx | 48 ++- .../screens/virtual/VirtualTradeScreen.tsx | 36 ++ .../screens/virtual/VirtualTradingScreen.tsx | 342 +++++---------- app/src/theme/global.css | 141 ++++++ frontend/src/hooks/useVirtualTradingCore.ts | 407 ++++++++++++++++++ scripts/upload-android-apk.sh | 14 + 21 files changed, 1434 insertions(+), 755 deletions(-) create mode 100644 app/src/components/MobileStackHeader.tsx create mode 100644 app/src/contexts/AuthContext.tsx create mode 100644 app/src/screens/LoginScreen.tsx create mode 100644 app/src/screens/notifications/NotificationDetailScreen.tsx create mode 100644 app/src/screens/notifications/NotificationListRow.tsx create mode 100644 app/src/screens/virtual/VirtualHistoryScreen.tsx delete mode 100644 app/src/screens/virtual/VirtualTargetCardMobile.tsx create mode 100644 app/src/screens/virtual/VirtualTargetListRow.tsx create mode 100644 app/src/screens/virtual/VirtualTradeScreen.tsx create mode 100644 frontend/src/hooks/useVirtualTradingCore.ts diff --git a/app/android/app/build.gradle b/app/android/app/build.gradle index ffaab5f..c07e659 100644 --- a/app/android/app/build.gradle +++ b/app/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "com.goldenchart.app" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 1 - versionName "1.0" + versionCode 2 + versionName "1.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/app/src/App.tsx b/app/src/App.tsx index cd3a736..ffa39b7 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -1,14 +1,16 @@ -import React, { lazy, Suspense, useCallback, useEffect, useState } from 'react'; +import React, { lazy, Suspense, useEffect, useState } from 'react'; import { Capacitor } from '@capacitor/core'; import { StatusBar, Style } from '@capacitor/status-bar'; -import { SplashScreen } from '@capacitor/splash-screen'; -import { initStorage } from './lib/shared'; +import { SplashScreen as CapSplashScreen } from '@capacitor/splash-screen'; +import { AuthProvider, useAuth } from './contexts/AuthContext'; import { NavigationProvider, useNavigation } from './contexts/NavigationContext'; import { TradeNotificationProvider, useTradeNotification } from './contexts/TradeNotificationContext'; import { useAppSettings, resolveAppDefaults } from './hooks/useAppSettings'; import TabBar, { type TabId } from './components/TabBar'; import { initFcmPush, type FcmPayload } from './services/fcm'; import LiveSignalBridge from './components/LiveSignalBridge'; +import LoginScreen from './screens/LoginScreen'; +import '@frontend/styles/splashScreen.css'; import './theme/global.css'; const VirtualTradingScreen = lazy(() => import('./screens/virtual/VirtualTradingScreen')); @@ -33,35 +35,21 @@ function ToastStack() { ); } -function AppInner() { - const { tab, setTab, openVirtualFocus } = useNavigation(); +function MainApp() { + const { tab, setTab, openVirtualFocus, goVirtualList, goNotifyList } = useNavigation(); const { addNotification, refreshHistory, unreadCount } = useTradeNotification(); - const { settings, isLoaded } = useAppSettings(); + const { sessionKey } = useAuth(); + const { settings, isLoaded } = useAppSettings(sessionKey); const defaults = resolveAppDefaults(settings); - const [ready, setReady] = useState(false); - - useEffect(() => { - void (async () => { - await initStorage(); - if (Capacitor.isNativePlatform()) { - try { - await StatusBar.setStyle({ style: Style.Dark }); - await StatusBar.setBackgroundColor({ color: '#0f0f23' }); - await SplashScreen.hide(); - } catch { /* web */ } - } - setReady(true); - })(); - }, []); useEffect(() => { if (!isLoaded || !defaults.fcmPushEnabled) return; void initFcmPush({ - onForeground: (title, body, data) => { + onForeground: (_title, _body, data) => { if (data?.market && data?.signalType) { addNotification({ market: data.market, - signalType: data.signalType, + signalType: data.signalType === 'SELL' ? 'SELL' : 'BUY', price: Number(data.price) || 0, candleTime: Math.floor(Date.now() / 1000), dbId: data.signalId ? Number(data.signalId) : undefined, @@ -85,9 +73,10 @@ function AppInner() { document.documentElement.setAttribute('data-theme', defaults.theme ?? 'dark'); }, [defaults.theme]); - if (!ready) { - return
GoldenChart
; - } + useEffect(() => { + if (tab === 'virtual') goNotifyList(); + else if (tab === 'notifications') goVirtualList(); + }, [tab, goVirtualList, goNotifyList]); const screens: Record = { virtual: , @@ -105,21 +94,59 @@ function AppInner() { {screens[tab]} - + ); } +function AppRoot() { + const { + authReady, + isAppEntered, + handleLoginSuccess, + handleGuestEnter, + } = useAuth(); + const [nativeReady, setNativeReady] = useState(false); + + useEffect(() => { + void (async () => { + if (Capacitor.isNativePlatform()) { + try { + await StatusBar.setStyle({ style: Style.Dark }); + await StatusBar.setBackgroundColor({ color: '#0f0f23' }); + await CapSplashScreen.hide(); + } catch { /* web dev */ } + } + setNativeReady(true); + })(); + }, []); + + if (!authReady || !nativeReady) { + return
GoldenChart
; + } + + if (!isAppEntered) { + return ( + + ); + } + + return ( + + + + + + ); +} + export default function App() { return ( - - - - - + + + ); } diff --git a/app/src/components/MobileStackHeader.tsx b/app/src/components/MobileStackHeader.tsx new file mode 100644 index 0000000..34e9d11 --- /dev/null +++ b/app/src/components/MobileStackHeader.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +interface Props { + title: string; + subtitle?: string; + onBack: () => void; + right?: React.ReactNode; +} + +export default function MobileStackHeader({ title, subtitle, onBack, right }: Props) { + return ( +
+ +
+

{title}

+ {subtitle &&

{subtitle}

} +
+ {right &&
{right}
} +
+ ); +} diff --git a/app/src/contexts/AuthContext.tsx b/app/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..f8172e3 --- /dev/null +++ b/app/src/contexts/AuthContext.tsx @@ -0,0 +1,136 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import { + clearAuthSession, + fetchAuthMe, + getAuthSession, + initStorage, + setAuthSession, + type AuthSession, + type LoginResponse, +} from '../lib/shared'; +import { invalidateAppSettingsCache } from '../hooks/useAppSettings'; + +function normalizeRole(role: string): AuthSession['role'] { + return role === 'ADMIN' ? 'ADMIN' : 'USER'; +} + +function loginToSession(res: LoginResponse): AuthSession { + return { + userId: res.userId, + username: res.username, + displayName: res.displayName ?? res.username, + role: normalizeRole(res.role), + }; +} + +interface AuthContextValue { + authReady: boolean; + authUser: AuthSession | null; + guestMode: boolean; + sessionKey: number; + isAppEntered: boolean; + handleLoginSuccess: (res: LoginResponse) => void; + handleGuestEnter: () => void; + handleLogout: () => Promise; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [authReady, setAuthReady] = useState(false); + const [authUser, setAuthUser] = useState(null); + const [guestMode, setGuestMode] = useState(false); + const [sessionKey, setSessionKey] = useState(0); + + useEffect(() => { + let cancelled = false; + void (async () => { + await initStorage(); + const stored = getAuthSession(); + if (stored) { + try { + const me = await fetchAuthMe(); + if (me && !cancelled) { + const session = loginToSession(me); + setAuthSession(session); + setAuthUser(session); + setGuestMode(false); + setSessionKey(k => k + 1); + } else if (!cancelled) { + await clearAuthSession(); + } + } catch { + if (!cancelled) { + setAuthUser(stored); + setGuestMode(false); + } + } + } + if (!cancelled) setAuthReady(true); + })(); + return () => { cancelled = true; }; + }, []); + + const handleLoginSuccess = useCallback((res: LoginResponse) => { + const session = loginToSession(res); + setAuthSession(session); + setAuthUser(session); + setGuestMode(false); + invalidateAppSettingsCache(); + setSessionKey(k => k + 1); + }, []); + + const handleGuestEnter = useCallback(() => { + void clearAuthSession(); + setAuthUser(null); + setGuestMode(true); + invalidateAppSettingsCache(); + setSessionKey(k => k + 1); + }, []); + + const handleLogout = useCallback(async () => { + await clearAuthSession(); + setAuthUser(null); + setGuestMode(false); + invalidateAppSettingsCache(); + setSessionKey(k => k + 1); + }, []); + + const value = useMemo(() => ({ + authReady, + authUser, + guestMode, + sessionKey, + isAppEntered: authUser != null || guestMode, + handleLoginSuccess, + handleGuestEnter, + handleLogout, + }), [ + authReady, + authUser, + guestMode, + sessionKey, + handleLoginSuccess, + handleGuestEnter, + handleLogout, + ]); + + return ( + + {children} + + ); +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuth must be used within AuthProvider'); + return ctx; +} diff --git a/app/src/contexts/NavigationContext.tsx b/app/src/contexts/NavigationContext.tsx index 0d73f2f..b8a9d1e 100644 --- a/app/src/contexts/NavigationContext.tsx +++ b/app/src/contexts/NavigationContext.tsx @@ -1,30 +1,116 @@ import React, { createContext, useContext, useMemo, useState, useCallback } from 'react'; import type { TabId } from '../components/TabBar'; +export type VirtualView = 'list' | 'detail' | 'trade' | 'history'; +export type NotifyView = 'list' | 'detail' | 'trade'; +export type TradeSide = 'BUY' | 'SELL'; + +export interface VirtualNavState { + view: VirtualView; + market: string | null; + tradeSide: TradeSide | null; +} + +export interface NotifyNavState { + view: NotifyView; + notifyId: string | null; + market: string | null; + tradeSide: TradeSide | null; +} + interface NavContextValue { tab: TabId; setTab: (t: TabId) => void; - focusMarket: string | null; + virtualNav: VirtualNavState; + goVirtualList: () => void; + goVirtualDetail: (market: string) => void; + goVirtualTrade: (market: string, side: TradeSide) => void; + goVirtualHistory: (market: string) => void; + /** @deprecated use goVirtualDetail */ openVirtualFocus: (market: string) => void; clearVirtualFocus: () => void; + notifyNav: NotifyNavState; + goNotifyList: () => void; + goNotifyDetail: (notifyId: string, market: string) => void; + goNotifyTrade: (market: string, side: TradeSide) => void; } +const defaultVirtual: VirtualNavState = { view: 'list', market: null, tradeSide: null }; +const defaultNotify: NotifyNavState = { view: 'list', notifyId: null, market: null, tradeSide: null }; + const NavContext = createContext(null); export function NavigationProvider({ children }: { children: React.ReactNode }) { const [tab, setTab] = useState('virtual'); - const [focusMarket, setFocusMarket] = useState(null); + const [virtualNav, setVirtualNav] = useState(defaultVirtual); + const [notifyNav, setNotifyNav] = useState(defaultNotify); - const openVirtualFocus = useCallback((market: string) => { - setFocusMarket(market); + const goVirtualList = useCallback(() => { + setVirtualNav(defaultVirtual); + }, []); + + const goVirtualDetail = useCallback((market: string) => { + setVirtualNav({ view: 'detail', market, tradeSide: null }); setTab('virtual'); }, []); - const clearVirtualFocus = useCallback(() => setFocusMarket(null), []); + const goVirtualTrade = useCallback((market: string, side: TradeSide) => { + setVirtualNav({ view: 'trade', market, tradeSide: side }); + setTab('virtual'); + }, []); + + const goVirtualHistory = useCallback((market: string) => { + setVirtualNav({ view: 'history', market, tradeSide: null }); + setTab('virtual'); + }, []); + + const openVirtualFocus = goVirtualDetail; + const clearVirtualFocus = goVirtualList; + + const goNotifyList = useCallback(() => { + setNotifyNav(defaultNotify); + }, []); + + const goNotifyDetail = useCallback((notifyId: string, market: string) => { + setNotifyNav({ view: 'detail', notifyId, market, tradeSide: null }); + setTab('notifications'); + }, []); + + const goNotifyTrade = useCallback((market: string, side: TradeSide) => { + setNotifyNav({ view: 'trade', notifyId: null, market, tradeSide: side }); + setTab('notifications'); + }, []); const value = useMemo( - () => ({ tab, setTab, focusMarket, openVirtualFocus, clearVirtualFocus }), - [tab, focusMarket, openVirtualFocus, clearVirtualFocus], + () => ({ + tab, + setTab, + virtualNav, + goVirtualList, + goVirtualDetail, + goVirtualTrade, + goVirtualHistory, + openVirtualFocus, + clearVirtualFocus, + notifyNav, + goNotifyList, + goNotifyDetail, + goNotifyTrade, + }), + [ + tab, + virtualNav, + notifyNav, + goVirtualList, + goVirtualDetail, + goVirtualTrade, + goVirtualHistory, + openVirtualFocus, + clearVirtualFocus, + goNotifyList, + goNotifyDetail, + goNotifyTrade, + ], ); return {children}; diff --git a/app/src/contexts/TradeNotificationContext.tsx b/app/src/contexts/TradeNotificationContext.tsx index 0e6da86..5a2ce86 100644 --- a/app/src/contexts/TradeNotificationContext.tsx +++ b/app/src/contexts/TradeNotificationContext.tsx @@ -1,216 +1,6 @@ -import { - deleteAllTradeSignals, - deleteTradeSignal, - deleteTradeSignalsBatch, - loadTradeSignals, - type TradeSignalDto, -} from '../lib/shared'; -import { getUiPreferences, patchUiPreferences } from '@frontend/utils/uiPreferencesDb'; -import React, { - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; - -export interface TradeSignalInfo { - market: string; - signalType: string; - price: number; - candleTime: number; - strategyName?: string; - strategyId?: number; - executionType?: string; - candleType?: string; -} - -export interface TradeNotificationItem extends TradeSignalInfo { - id: string; - dbId?: number; - isRead: boolean; - receivedAt: number; -} - -interface TradeNotificationContextValue { - toastNotifications: TradeNotificationItem[]; - allNotifications: TradeNotificationItem[]; - unreadCount: number; - addNotification: (signal: TradeSignalInfo & { dbId?: number }) => void; - dismissToast: (id: string) => void; - dismissAllToasts: () => void; - markAsRead: (id: string) => void; - markAllAsRead: () => void; - deleteNotification: (id: string) => Promise; - deleteAllNotifications: () => Promise; - refreshHistory: () => Promise; -} - -const TradeNotificationContext = createContext(null); - -function makeId(signal: Pick): string { - return `${signal.market}:${signal.candleTime}:${signal.signalType}`; -} - -function loadReadIds(): Set { - return new Set(getUiPreferences().tradeNotifications?.readIds ?? []); -} - -function saveReadIds(ids: Set) { - patchUiPreferences({ tradeNotifications: { readIds: [...ids] } }); -} - -function loadHiddenIds(): Set { - return new Set(getUiPreferences().tradeNotifications?.hiddenIds ?? []); -} - -function saveHiddenIds(ids: Set) { - patchUiPreferences({ tradeNotifications: { hiddenIds: [...ids] } }); -} - -function dtoToItem(dto: TradeSignalDto, readIds: Set): TradeNotificationItem { - const id = `${dto.market}:${dto.candleTime}:${dto.signalType}`; - return { - id, - dbId: dto.id, - market: dto.market, - signalType: dto.signalType, - price: dto.price, - candleTime: dto.candleTime, - strategyName: dto.strategyName, - strategyId: dto.strategyId, - executionType: dto.executionType, - candleType: dto.candleType, - isRead: readIds.has(id), - receivedAt: new Date(dto.createdAt).getTime() || dto.candleTime * 1000, - }; -} - -export function useTradeNotification() { - const ctx = useContext(TradeNotificationContext); - if (!ctx) throw new Error('useTradeNotification requires provider'); - return ctx; -} - -export function TradeNotificationProvider({ - children, - soundEnabled = true, -}: { - children: React.ReactNode; - soundEnabled?: boolean; -}) { - const [toasts, setToasts] = useState([]); - const [all, setAll] = useState([]); - const readRef = useRef(loadReadIds()); - const hiddenRef = useRef(loadHiddenIds()); - - const refreshHistory = useCallback(async () => { - readRef.current = loadReadIds(); - hiddenRef.current = loadHiddenIds(); - const dtos = await loadTradeSignals(); - const items = (dtos ?? []) - .map(d => dtoToItem(d, readRef.current)) - .filter(i => !hiddenRef.current.has(i.id)) - .sort((a, b) => b.receivedAt - a.receivedAt); - setAll(items); - }, []); - - useEffect(() => { - void refreshHistory(); - const t = setInterval(() => void refreshHistory(), 20000); - return () => clearInterval(t); - }, [refreshHistory]); - - const addNotification = useCallback((signal: TradeSignalInfo & { dbId?: number }) => { - const id = makeId(signal); - if (hiddenRef.current.has(id)) return; - const item: TradeNotificationItem = { - ...signal, - id, - isRead: readRef.current.has(id), - receivedAt: Date.now(), - }; - setAll(prev => { - const filtered = prev.filter(p => p.id !== id); - return [item, ...filtered].slice(0, 300); - }); - if (!item.isRead) { - setToasts(prev => [item, ...prev.filter(p => p.id !== id)].slice(0, 50)); - if (soundEnabled && typeof navigator !== 'undefined' && 'vibrate' in navigator) { - navigator.vibrate(80); - } - } - }, [soundEnabled]); - - const dismissToast = useCallback((id: string) => { - setToasts(prev => prev.filter(t => t.id !== id)); - readRef.current.add(id); - saveReadIds(readRef.current); - setAll(prev => prev.map(n => (n.id === id ? { ...n, isRead: true } : n))); - }, []); - - const dismissAllToasts = useCallback(() => { - setToasts(prev => { - prev.forEach(t => readRef.current.add(t.id)); - saveReadIds(readRef.current); - return []; - }); - setAll(prev => prev.map(n => ({ ...n, isRead: true }))); - }, []); - - const markAsRead = useCallback((id: string) => { - readRef.current.add(id); - saveReadIds(readRef.current); - setAll(prev => prev.map(n => (n.id === id ? { ...n, isRead: true } : n))); - setToasts(prev => prev.filter(t => t.id !== id)); - }, []); - - const markAllAsRead = useCallback(() => { - all.forEach(n => readRef.current.add(n.id)); - saveReadIds(readRef.current); - setAll(prev => prev.map(n => ({ ...n, isRead: true }))); - setToasts([]); - }, [all]); - - const deleteNotification = useCallback(async (id: string) => { - const item = all.find(n => n.id === id); - hiddenRef.current.add(id); - saveHiddenIds(hiddenRef.current); - if (item?.dbId) await deleteTradeSignal(item.dbId); - setAll(prev => prev.filter(n => n.id !== id)); - setToasts(prev => prev.filter(t => t.id !== id)); - }, [all]); - - const deleteAllNotifications = useCallback(async () => { - await deleteAllTradeSignals(); - all.forEach(n => hiddenRef.current.add(n.id)); - saveHiddenIds(hiddenRef.current); - setAll([]); - setToasts([]); - }, [all]); - - const unreadCount = useMemo(() => all.filter(n => !n.isRead).length, [all]); - - const value = useMemo( - () => ({ - toastNotifications: toasts, - allNotifications: all, - unreadCount, - addNotification, - dismissToast, - dismissAllToasts, - markAsRead, - markAllAsRead, - deleteNotification, - deleteAllNotifications, - refreshHistory, - }), - [toasts, all, unreadCount, addNotification, dismissToast, dismissAllToasts, markAsRead, markAllAsRead, deleteNotification, deleteAllNotifications, refreshHistory], - ); - - return ( - {children} - ); -} +/** 웹 frontend와 동일한 알림 Context (STOMP·trade-signals·uiPreferences) */ +export { + TradeNotificationProvider, + useTradeNotification, + type TradeNotificationItem, +} from '@frontend/contexts/TradeNotificationContext'; diff --git a/app/src/screens/LoginScreen.tsx b/app/src/screens/LoginScreen.tsx new file mode 100644 index 0000000..b0bf50e --- /dev/null +++ b/app/src/screens/LoginScreen.tsx @@ -0,0 +1,12 @@ +/** 웹 SplashScreen과 동일한 로그인 진입 화면 */ +import SplashScreen from '@frontend/components/SplashScreen'; +import type { LoginResponse } from '../lib/shared'; + +interface Props { + onLoginSuccess: (res: LoginResponse) => void; + onGuest: () => void; +} + +export default function LoginScreen({ onLoginSuccess, onGuest }: Props) { + return ; +} diff --git a/app/src/screens/notifications/NotificationDetailScreen.tsx b/app/src/screens/notifications/NotificationDetailScreen.tsx new file mode 100644 index 0000000..dcd3e84 --- /dev/null +++ b/app/src/screens/notifications/NotificationDetailScreen.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import type { TradeNotificationItem } from '../../contexts/TradeNotificationContext'; +import type { TradeSide } from '../../contexts/NavigationContext'; +import { + buildSignalDetailRows, + formatSignalPrice, + getSignalHeadline, +} from '@frontend/utils/tradeSignalDisplay'; +import MobileStackHeader from '../../components/MobileStackHeader'; + +interface Props { + item: TradeNotificationItem; + onBack: () => void; + onTrade: (side: TradeSide) => void; + onGoVirtual: () => void; +} + +export default function NotificationDetailScreen({ item, onBack, onTrade, onGoVirtual }: Props) { + const rows = buildSignalDetailRows(item); + const side = item.signalType === 'SELL' ? 'SELL' : 'BUY'; + + return ( +
+ + +
+
+ + + +
+ +
+
+ {side === 'BUY' ? '매수' : '매도'} +
+
{formatSignalPrice(item.price)}
+
+ +
+ {rows.map(row => ( +
+ {row.label} + {row.value} +
+ ))} +
+
+
+ ); +} diff --git a/app/src/screens/notifications/NotificationListRow.tsx b/app/src/screens/notifications/NotificationListRow.tsx new file mode 100644 index 0000000..753199a --- /dev/null +++ b/app/src/screens/notifications/NotificationListRow.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import type { TradeNotificationItem } from '../../contexts/TradeNotificationContext'; +import type { TradeSide } from '../../contexts/NavigationContext'; +import { getMarketDisplayLine } from '@frontend/utils/tradeSignalDisplay'; + +interface Props { + item: TradeNotificationItem; + onDetail: () => void; + onTrade: (side: TradeSide) => void; + onDelete: () => void; +} + +export default function NotificationListRow({ item, onDetail, onTrade, onDelete }: Props) { + const { primary, secondary } = getMarketDisplayLine(item.market); + const isBuy = item.signalType === 'BUY'; + + return ( +
+
+
+
+ + {isBuy ? '매수' : '매도'} + + {primary} +
+
+ {secondary} · ₩{item.price?.toLocaleString()} · {new Date(item.receivedAt).toLocaleString('ko-KR', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })} +
+ {item.strategyName && ( +
{item.strategyName}
+ )} +
+ +
+
+ + + +
+
+ ); +} diff --git a/app/src/screens/notifications/NotificationsScreen.tsx b/app/src/screens/notifications/NotificationsScreen.tsx index ec9b3d1..6d44907 100644 --- a/app/src/screens/notifications/NotificationsScreen.tsx +++ b/app/src/screens/notifications/NotificationsScreen.tsx @@ -1,8 +1,21 @@ -import React from 'react'; +import React, { useMemo, useState } from 'react'; import { useTradeNotification } from '../../contexts/TradeNotificationContext'; import { useNavigation } from '../../contexts/NavigationContext'; +import { useAuth } from '../../contexts/AuthContext'; +import { useVirtualTradingCore } from '@frontend/hooks/useVirtualTradingCore'; +import NotificationListRow from './NotificationListRow'; +import NotificationDetailScreen from './NotificationDetailScreen'; +import VirtualTradeScreen from '../virtual/VirtualTradeScreen'; export default function NotificationsScreen() { + const { + notifyNav, + goNotifyList, + goNotifyDetail, + goNotifyTrade, + goVirtualDetail, + } = useNavigation(); + const { sessionKey } = useAuth(); const { allNotifications, unreadCount, @@ -12,8 +25,14 @@ export default function NotificationsScreen() { deleteAllNotifications, refreshHistory, } = useTradeNotification(); - const { openVirtualFocus } = useNavigation(); - const [refreshing, setRefreshing] = React.useState(false); + const { summary, refreshPaperData } = useVirtualTradingCore({ settingsSessionKey: sessionKey }); + + const [refreshing, setRefreshing] = useState(false); + + const selectedItem = useMemo( + () => allNotifications.find(n => n.id === notifyNav.notifyId) ?? null, + [allNotifications, notifyNav.notifyId], + ); const onRefresh = async () => { setRefreshing(true); @@ -21,13 +40,42 @@ export default function NotificationsScreen() { setRefreshing(false); }; + if (notifyNav.view === 'detail' && selectedItem) { + return ( + { + markAsRead(selectedItem.id); + goNotifyList(); + }} + onTrade={side => goNotifyTrade(selectedItem.market, side)} + onGoVirtual={() => { + markAsRead(selectedItem.id); + goVirtualDetail(selectedItem.market); + }} + /> + ); + } + + if (notifyNav.view === 'trade' && notifyNav.market && notifyNav.tradeSide) { + return ( + + ); + } + return (

알림 {unreadCount > 0 && `(${unreadCount})`}

- - + +
@@ -37,47 +85,21 @@ export default function NotificationsScreen() {

전략 조건 충족 시 알림이 표시됩니다

) : ( -
+
{allNotifications.map(item => ( -
{ + item={item} + onDetail={() => { markAsRead(item.id); - openVirtualFocus(item.market); + goNotifyDetail(item.id, item.market); }} - role="button" - tabIndex={0} - > -
-
- - {item.signalType === 'BUY' ? '매수' : '매도'} - - {item.market} -
- -
-
₩{item.price?.toLocaleString()}
- {item.strategyName && ( -
{item.strategyName}
- )} -
- {new Date(item.receivedAt).toLocaleString('ko-KR')} -
-
+ onTrade={side => { + markAsRead(item.id); + goNotifyTrade(item.market, side); + }} + onDelete={() => void deleteNotification(item.id)} + /> ))}
)} diff --git a/app/src/screens/settings/SettingsScreen.tsx b/app/src/screens/settings/SettingsScreen.tsx index 4034cd6..ac88944 100644 --- a/app/src/screens/settings/SettingsScreen.tsx +++ b/app/src/screens/settings/SettingsScreen.tsx @@ -1,6 +1,5 @@ import React, { useCallback, useEffect, useState } from 'react'; import { - loginUser, resetPaperAccount, sendFcmTest, loadFcmStatus, @@ -8,23 +7,19 @@ import { getStoredDeviceId, type AppSettingsDto, } from '../../lib/shared'; -import { getAuthSession, setAuthSession, clearAuthSession } from '../../lib/shared'; +import { useAuth } from '../../contexts/AuthContext'; import { useAppSettings, resolveAppDefaults } from '../../hooks/useAppSettings'; import { SettingGroup, SettingRow, Toggle } from '../../components/SettingsList'; import { initFcmPush, checkPushPermission } from '../../services/fcm'; import { API_BASE } from '../../lib/shared'; export default function SettingsScreen() { - const { settings, save, isLoaded } = useAppSettings(); + const { authUser, guestMode, sessionKey, handleLogout } = useAuth(); + const { settings, save, isLoaded } = useAppSettings(sessionKey); const defaults = resolveAppDefaults(settings); - const session = getAuthSession(); - - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); const [apiUrl, setApiUrl] = useState(API_BASE); const [fcmAvailable, setFcmAvailable] = useState(false); const [pushPerm, setPushPerm] = useState('prompt'); - const [loginError, setLoginError] = useState(''); useEffect(() => { void loadFcmStatus().then(s => setFcmAvailable(!!s?.available)); @@ -33,34 +28,8 @@ export default function SettingsScreen() { const patch = useCallback((p: AppSettingsDto) => save(p as unknown as Parameters[0]), [save]); - const handleLogin = async () => { - setLoginError(''); - try { - const res = await loginUser(username, password); - setAuthSession({ - userId: res.userId, - username: res.username, - displayName: res.displayName ?? res.username, - role: res.role === 'ADMIN' ? 'ADMIN' : 'USER', - }); - setUsername(''); - setPassword(''); - } catch (e) { - setLoginError(e instanceof Error ? e.message : '로그인 실패'); - } - }; - - const handleLogout = async () => { - await clearAuthSession(); - window.location.reload(); - }; - - const handleFcmToggle = async (enabled: boolean) => { - patch({ fcmPushEnabled: enabled }); - if (enabled) { - await initFcmPush(); - setPushPerm(await checkPushPermission()); - } + const onLogout = () => { + void handleLogout(); }; if (!isLoaded) return
설정 로딩…
; @@ -71,6 +40,33 @@ export default function SettingsScreen() {

설정

+ + {authUser ? ( + <> + + {authUser.displayName} ({authUser.username}) + + + {authUser.role} + + + ) : ( + + 게스트 (기기별 데이터) + + )} + + + + {!guestMode && authUser && ( +

+ 가상매매·알림·설정은 웹(exdev)과 동일한 계정 DB를 사용합니다. +

+ )} +
+ {getStoredDeviceId()} - {session ? ( - - - - ) : ( - <> - - setUsername(e.target.value)} style={{ width: 120, padding: 8, borderRadius: 8, border: '1px solid var(--gc-border)', background: 'transparent' }} /> - - - setPassword(e.target.value)} style={{ width: 120, padding: 8, borderRadius: 8, border: '1px solid var(--gc-border)', background: 'transparent' }} /> - - - - - {loginError &&
{loginError}
} - - )}
); diff --git a/app/src/screens/virtual/VirtualFocusScreen.tsx b/app/src/screens/virtual/VirtualFocusScreen.tsx index bc06356..ef3a400 100644 --- a/app/src/screens/virtual/VirtualFocusScreen.tsx +++ b/app/src/screens/virtual/VirtualFocusScreen.tsx @@ -2,7 +2,9 @@ import React from 'react'; import type { StrategyDto } from '../../lib/shared'; import type { VirtualIndicatorSnapshot } from '@frontend/hooks/useVirtualIndicatorSnapshots'; import type { VirtualSessionConfig, VirtualTargetItem } from '@frontend/utils/virtualTradingStorage'; +import type { TradeSide } from '../../contexts/NavigationContext'; import { buildConditionMetrics, computeMatchRate } from '@frontend/utils/virtualSignalMetrics'; +import MobileStackHeader from '../../components/MobileStackHeader'; interface Props { target: VirtualTargetItem; @@ -11,7 +13,8 @@ interface Props { snapshot?: VirtualIndicatorSnapshot; liveConnected: boolean; onBack: () => void; - onOpenTrade: () => void; + onTrade: (side: TradeSide) => void; + onHistory: () => void; } export default function VirtualFocusScreen({ @@ -19,21 +22,34 @@ export default function VirtualFocusScreen({ snapshot, liveConnected, onBack, - onOpenTrade, + onTrade, + onHistory, }: Props) { const metrics = snapshot?.rows?.length ? buildConditionMetrics(snapshot.rows) : []; const buyPct = computeMatchRate(metrics.filter(m => m.row.side === 'buy'), snapshot?.matchRate); const sellPct = computeMatchRate(metrics.filter(m => m.row.side === 'sell')); return ( -
-
- -

{target.market}

- -
+
+ + +
+
+ + + +
-
일치율
@@ -56,16 +72,18 @@ export default function VirtualFocusScreen({

조건 목록

-
+
{(snapshot?.rows ?? []).map(row => ( -
-
-
{row.displayName}
-
{row.side} · {row.timeframe}
+
+
+
+
{row.displayName}
+
{row.side} · {row.timeframe}
+
+ + {row.satisfied === true ? '충족' : row.satisfied === false ? '미충족' : '—'} +
- - {row.satisfied === true ? '충족' : row.satisfied === false ? '미충족' : '—'} -
))}
diff --git a/app/src/screens/virtual/VirtualHistoryScreen.tsx b/app/src/screens/virtual/VirtualHistoryScreen.tsx new file mode 100644 index 0000000..d395623 --- /dev/null +++ b/app/src/screens/virtual/VirtualHistoryScreen.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import type { PaperTradeDto } from '../../lib/shared'; +import MobileStackHeader from '../../components/MobileStackHeader'; + +interface Props { + market: string; + trades: PaperTradeDto[]; + onBack: () => void; +} + +export default function VirtualHistoryScreen({ market, trades, onBack }: Props) { + const filtered = trades.filter(t => t.symbol === market).slice(0, 50); + + return ( +
+ +
+ {filtered.length === 0 ? ( +
+

내역 없음

+

이 종목의 모의 거래 내역이 없습니다.

+
+ ) : ( +
+ {filtered.map(t => ( +
+
+ + {t.side === 'BUY' ? '매수' : '매도'} + + {t.quantity} @ ₩{t.price?.toLocaleString()} +
+
{t.createdAt ?? ''}
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/app/src/screens/virtual/VirtualTargetCardMobile.tsx b/app/src/screens/virtual/VirtualTargetCardMobile.tsx deleted file mode 100644 index 92f8b41..0000000 --- a/app/src/screens/virtual/VirtualTargetCardMobile.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import React from 'react'; -import type { StrategyDto } from '../../lib/shared'; -import type { VirtualIndicatorSnapshot } from '@frontend/hooks/useVirtualIndicatorSnapshots'; -import type { VirtualLiveStatus } from '@frontend/hooks/useVirtualTargetLiveStatus'; -import type { VirtualCardViewMode, VirtualSessionConfig, VirtualTargetItem } from '@frontend/utils/virtualTradingStorage'; -import { resolveVirtualTargetStrategyId } from '@frontend/utils/virtualTargetStrategy'; -import { buildConditionMetrics, computeMatchRate } from '@frontend/utils/virtualSignalMetrics'; - -interface Props { - target: VirtualTargetItem; - session: VirtualSessionConfig; - strategies: StrategyDto[]; - snapshot?: VirtualIndicatorSnapshot; - viewMode: VirtualCardViewMode; - liveFlash?: boolean; - liveStatus?: VirtualLiveStatus; - onFocus: () => void; - onTrade: () => void; - onRemove: () => void; - onTogglePin: () => void; - onStrategyChange: (id: number | null) => void; - onCandleTypeChange: (ct: string) => void; -} - -export default function VirtualTargetCardMobile({ - target, - session, - strategies, - snapshot, - viewMode, - liveFlash, - liveStatus, - onFocus, - onTrade, - onRemove, - onTogglePin, - onStrategyChange, -}: Props) { - const strategyId = resolveVirtualTargetStrategyId(target, session.globalStrategyId); - const strategyName = strategies.find(s => s.id === strategyId)?.name ?? '전략 없음'; - const metrics = snapshot?.rows?.length ? buildConditionMetrics(snapshot.rows) : []; - const buyPct = computeMatchRate(metrics.filter(m => m.row.side === 'buy'), snapshot?.matchRate); - const sellPct = computeMatchRate(metrics.filter(m => m.row.side === 'sell')); - - return ( -
-
-
-
{target.koreanName ?? target.market}
-
{target.market}
-
-
- -
{(snapshot?.matchRate ?? buyPct).toFixed(0)}%
-
-
- -
-
-
-
-
-
-
-
- -
- {strategyName} · {snapshot?.timeframe ?? '—'} -
- - {viewMode === 'detail' && snapshot?.rows && ( -
- {snapshot.rows.slice(0, 6).map(row => ( -
- {row.displayName} - - {row.satisfied === true ? '✓' : row.satisfied === false ? '✗' : '—'} - -
- ))} -
- )} - -
e.stopPropagation()}> - - - - {!target.pinned && ( - - )} -
- - -
- ); -} diff --git a/app/src/screens/virtual/VirtualTargetListRow.tsx b/app/src/screens/virtual/VirtualTargetListRow.tsx new file mode 100644 index 0000000..51d4c7d --- /dev/null +++ b/app/src/screens/virtual/VirtualTargetListRow.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import type { StrategyDto } from '../../lib/shared'; +import type { VirtualIndicatorSnapshot } from '@frontend/hooks/useVirtualIndicatorSnapshots'; +import type { VirtualLiveStatus } from '@frontend/hooks/useVirtualTargetLiveStatus'; +import type { VirtualTargetItem } from '@frontend/utils/virtualTradingStorage'; +import { resolveVirtualTargetStrategyId } from '@frontend/utils/virtualTargetStrategy'; +import type { TradeSide } from '../../contexts/NavigationContext'; + +interface Props { + target: VirtualTargetItem; + globalStrategyId: number | null; + strategies: StrategyDto[]; + snapshot?: VirtualIndicatorSnapshot; + liveStatus?: VirtualLiveStatus; + onDetail: () => void; + onTrade: (side: TradeSide) => void; + onTogglePin: () => void; + onRemove: () => void; +} + +export default function VirtualTargetListRow({ + target, + globalStrategyId, + strategies, + snapshot, + liveStatus, + onDetail, + onTrade, + onTogglePin, + onRemove, +}: Props) { + const strategyId = resolveVirtualTargetStrategyId(target, globalStrategyId); + const strategyName = strategies.find(s => s.id === strategyId)?.name ?? '전략 없음'; + const matchPct = snapshot?.matchRate ?? 0; + + return ( +
+
+
+
+ + {target.koreanName ?? target.market} +
+
+ {target.market} · {strategyName} · {matchPct.toFixed(0)}% +
+
+
+ + {!target.pinned && ( + + )} +
+
+
+ + + +
+
+ ); +} diff --git a/app/src/screens/virtual/VirtualTradePanel.tsx b/app/src/screens/virtual/VirtualTradePanel.tsx index 17ed454..bad1847 100644 --- a/app/src/screens/virtual/VirtualTradePanel.tsx +++ b/app/src/screens/virtual/VirtualTradePanel.tsx @@ -5,29 +5,34 @@ import { Haptics, ImpactStyle } from '@capacitor/haptics'; interface Props { market: string; summary: PaperSummaryDto | null; + /** 단일 매수/매도 화면 또는 둘 다 */ + side?: 'BUY' | 'SELL' | 'both'; onOrder: (side: 'BUY' | 'SELL', qty: number, price: number) => Promise; } -export default function VirtualTradePanel({ market, summary, onOrder }: Props) { +export default function VirtualTradePanel({ market, summary, side = 'both', onOrder }: Props) { const [qty, setQty] = useState('0.001'); const [price, setPrice] = useState(''); const [busy, setBusy] = useState(false); const position = summary?.positions?.find(p => p.symbol === market); - const submit = async (side: 'BUY' | 'SELL') => { + const submit = async (orderSide: 'BUY' | 'SELL') => { const q = parseFloat(qty); const p = parseFloat(price) || 0; if (!Number.isFinite(q) || q <= 0) return; setBusy(true); try { - await onOrder(side, q, p); + await onOrder(orderSide, q, p); try { await Haptics.impact({ style: ImpactStyle.Medium }); } catch { /* web */ } } finally { setBusy(false); } }; + const showBuy = side === 'both' || side === 'BUY'; + const showSell = side === 'both' || side === 'SELL'; + return (
{position && ( @@ -37,12 +42,7 @@ export default function VirtualTradePanel({ market, summary, onOrder }: Props) {
)} - setQty(e.target.value)} - style={inputStyle} - /> + setQty(e.target.value)} style={inputStyle} /> -
- - +
+ {showBuy && ( + + )} + {showSell && ( + + )}
); diff --git a/app/src/screens/virtual/VirtualTradeScreen.tsx b/app/src/screens/virtual/VirtualTradeScreen.tsx new file mode 100644 index 0000000..9afe3ce --- /dev/null +++ b/app/src/screens/virtual/VirtualTradeScreen.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { placePaperOrder, type PaperSummaryDto } from '../../lib/shared'; +import type { TradeSide } from '../../contexts/NavigationContext'; +import MobileStackHeader from '../../components/MobileStackHeader'; +import VirtualTradePanel from './VirtualTradePanel'; + +interface Props { + market: string; + side: TradeSide; + summary: PaperSummaryDto | null; + onBack: () => void; + onOrderDone: () => Promise; +} + +export default function VirtualTradeScreen({ market, side, summary, onBack, onOrderDone }: Props) { + return ( +
+ +
+ { + await placePaperOrder({ market, side: orderSide, price, quantity: qty }); + await onOrderDone(); + }} + /> +
+
+ ); +} diff --git a/app/src/screens/virtual/VirtualTradingScreen.tsx b/app/src/screens/virtual/VirtualTradingScreen.tsx index 438ebb0..23e06ac 100644 --- a/app/src/screens/virtual/VirtualTradingScreen.tsx +++ b/app/src/screens/virtual/VirtualTradingScreen.tsx @@ -1,183 +1,103 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { - loadPaperSummary, - loadPaperTrades, - loadStrategies, - placePaperOrder, - resetPaperAccount, - type PaperSummaryDto, - type PaperTradeDto, - type StrategyDto, -} from '../../lib/shared'; -import { useVirtualIndicatorSnapshots } from '@frontend/hooks/useVirtualIndicatorSnapshots'; -import { useVirtualAutoTrade } from '@frontend/hooks/useVirtualAutoTrade'; -import { useVirtualTargetLiveStatus } from '@frontend/hooks/useVirtualTargetLiveStatus'; -import { - loadVirtualSession, - loadVirtualTargets, - saveVirtualSession, - saveVirtualTargets, - loadVirtualCardViewMode, - saveVirtualCardViewMode, - type VirtualSessionConfig, - type VirtualTargetItem, - type VirtualCardViewMode, - resolveTargetCandleType, -} from '@frontend/utils/virtualTradingStorage'; -import { - syncVirtualTargetsToBackend, - stopVirtualLiveOnBackend, -} from '@frontend/utils/virtualLiveStrategySync'; +import React, { useState } from 'react'; +import { useAuth } from '../../contexts/AuthContext'; +import { useVirtualTradingCore } from '@frontend/hooks/useVirtualTradingCore'; import { resolveVirtualTargetStrategyId } from '@frontend/utils/virtualTargetStrategy'; -import { virtualTargetLimitMessage, isVirtualTargetAddAllowed } from '@frontend/utils/virtualTargetLimits'; -import { persistVirtualTargetPinned } from '@frontend/utils/virtualTargetMutations'; -import { useAppSettings, resolveAppDefaults } from '../../hooks/useAppSettings'; import { useNavigation } from '../../contexts/NavigationContext'; import BottomSheet from '../../components/BottomSheet'; import SegmentedControl from '../../components/SegmentedControl'; -import VirtualTargetCardMobile from './VirtualTargetCardMobile'; +import VirtualTargetListRow from './VirtualTargetListRow'; import VirtualFocusScreen from './VirtualFocusScreen'; -import VirtualTradePanel from './VirtualTradePanel'; - -type RightTab = 'trade' | 'history'; +import VirtualTradeScreen from './VirtualTradeScreen'; +import VirtualHistoryScreen from './VirtualHistoryScreen'; +import { MarketSearchPanel } from '@frontend/components/MarketSearchPanel'; export default function VirtualTradingScreen() { - const { focusMarket, clearVirtualFocus, openVirtualFocus } = useNavigation(); - const { settings } = useAppSettings(); - const defaults = resolveAppDefaults(settings); + const { + virtualNav, + goVirtualList, + goVirtualDetail, + goVirtualTrade, + goVirtualHistory, + } = useNavigation(); + const { sessionKey: settingsSessionKey, authUser, guestMode } = useAuth(); - const [targets, setTargets] = useState(() => loadVirtualTargets()); - const [session, setSession] = useState(() => loadVirtualSession()); - const [strategies, setStrategies] = useState([]); - const [summary, setSummary] = useState(null); - const [trades, setTrades] = useState([]); - const [loading, setLoading] = useState(true); - const [selectedMarket, setSelectedMarket] = useState('KRW-BTC'); - const [viewMode, setViewMode] = useState(() => loadVirtualCardViewMode()); - const [sheetOpen, setSheetOpen] = useState(false); - const [addSheetOpen, setAddSheetOpen] = useState(false); - const [rightTab, setRightTab] = useState('trade'); - const [newMarket, setNewMarket] = useState(''); - - const refreshSummary = useCallback(async () => { - const [s, t] = await Promise.all([loadPaperSummary(), loadPaperTrades()]); - setSummary(s); - setTrades(t ?? []); - }, []); - - useEffect(() => { - void Promise.all([ - loadStrategies().then(setStrategies).catch(() => []), - refreshSummary(), - ]).finally(() => setLoading(false)); - }, [refreshSummary]); - - useEffect(() => { saveVirtualTargets(targets); }, [targets]); - useEffect(() => { saveVirtualSession(session); }, [session]); - useEffect(() => { saveVirtualCardViewMode(viewMode); }, [viewMode]); - - const targetRefs = useMemo( - () => targets.map(t => ({ - market: t.market, - strategyId: resolveVirtualTargetStrategyId(t, session.globalStrategyId), - })), - [targets, session.globalStrategyId], - ); - - const snapshots = useVirtualIndicatorSnapshots( - targetRefs, - strategies as Parameters[1], - session.running, - ); - - const liveStatus = useVirtualTargetLiveStatus(targetRefs, session.running); - - const liveConnected = useMemo( - () => Object.values(liveStatus.statusByMarket).some(s => s === 'live' || s === 'connecting'), - [liveStatus.statusByMarket], - ); - - useVirtualAutoTrade({ + const core = useVirtualTradingCore({ settingsSessionKey }); + const { targets, session, + strategies, + summary, + trades, + loading, snapshots, - enabled: defaults.paperAutoTradeEnabled && defaults.paperTradingEnabled, - paperAutoTradeBudgetPct: defaults.paperAutoTradeBudgetPct ?? 95, - positions: summary?.positions, - onFilled: refreshSummary, - }); + liveStatusByMarket, + handleStart, + handleStop, + handleRemoveTarget, + handleTogglePin, + handleGlobalStrategyChange, + handleExecutionTypeChange, + handlePositionModeChange, + addTarget, + reloadTargetsFromStorage, + } = core; - const toggleRunning = useCallback(async () => { - const next = { ...session, running: !session.running }; - setSession(next); - if (next.running) { - await syncVirtualTargetsToBackend(targets, next, true); - } else { - await stopVirtualLiveOnBackend(targets, next); - } - }, [session, targets]); - - const handleAddTarget = useCallback(() => { - const market = newMarket.trim().toUpperCase(); - if (!market) return; - if (targets.some(t => t.market === market)) return; - if (!isVirtualTargetAddAllowed(targets.length, defaults.virtualTargetMaxCount)) { - alert(virtualTargetLimitMessage(defaults.virtualTargetMaxCount)); - return; - } - setTargets(prev => [...prev, { market, strategyId: null }]); - setNewMarket(''); - setAddSheetOpen(false); - }, [newMarket, targets, defaults.virtualTargetMaxCount]); - - const handleRemoveTarget = useCallback((market: string) => { - const t = targets.find(x => x.market === market); - if (t?.pinned) return; - setTargets(prev => prev.filter(x => x.market !== market)); - }, [targets]); - - const handleTogglePin = useCallback(async (market: string) => { - const t = targets.find(x => x.market === market); - if (!t) return; - const pinned = !t.pinned; - setTargets(prev => prev.map(x => (x.market === market ? { ...x, pinned } : x))); - await persistVirtualTargetPinned(market, pinned); - }, [targets]); + const [addSheetOpen, setAddSheetOpen] = useState(false); const pnlPct = summary?.totalReturnPct ?? 0; + const liveConnected = Object.values(liveStatusByMarket).some(s => s === 'live' || s === 'connecting'); - if (focusMarket) { - const target = targets.find(t => t.market === focusMarket); - if (target) { - return ( - { - const sid = resolveVirtualTargetStrategyId(target, session.globalStrategyId); - return snapshots[`${target.market}:${sid ?? ''}`] ?? snapshots[target.market]; - })()} - liveConnected={Object.values(liveStatus.statusByMarket).some(s => s === 'live')} - onBack={clearVirtualFocus} - onOpenTrade={() => { - setSelectedMarket(focusMarket); - setSheetOpen(true); - }} - /> - ); - } - clearVirtualFocus(); + const market = virtualNav.market; + const target = market ? targets.find(t => t.market === market) : undefined; + + if (virtualNav.view === 'detail' && market && target) { + const sid = resolveVirtualTargetStrategyId(target, session.globalStrategyId); + const snap = snapshots[`${market}:${sid ?? ''}`] ?? snapshots[market]; + return ( + goVirtualTrade(market, side)} + onHistory={() => goVirtualHistory(market)} + /> + ); + } + + if (virtualNav.view === 'trade' && market && virtualNav.tradeSide) { + return ( + + ); + } + + if (virtualNav.view === 'history' && market) { + return ( + + ); } return (

가상매매

+ @@ -187,9 +107,7 @@ export default function VirtualTradingScreen() {
총 자산
-
- ₩{(summary?.totalAsset ?? 0).toLocaleString()} -
+
₩{(summary?.totalAsset ?? 0).toLocaleString()}
수익률
@@ -207,7 +125,7 @@ export default function VirtualTradingScreen() { setNewMarket(e.target.value)} - style={{ - width: '100%', - padding: '12px 14px', - borderRadius: 10, - border: '1px solid var(--gc-border)', - background: 'rgba(255,255,255,0.05)', - marginBottom: 12, - }} - /> - - - - setSheetOpen(false)} height="half"> -
- - -
- {rightTab === 'trade' ? ( - { - await placePaperOrder({ market: selectedMarket, side, price, quantity: qty }); - await refreshSummary(); + setAddSheetOpen(false)} height="full"> +
+ { + addTarget(m); + setAddSheetOpen(false); }} + onClose={() => setAddSheetOpen(false)} /> - ) : ( -
- {trades.filter(t => t.symbol === selectedMarket).slice(0, 30).map(t => ( -
-
- {t.side} - {t.quantity} @ ₩{t.price?.toLocaleString()} -
-
{t.createdAt ?? ''}
-
- ))} -
- )} +