import React, { useState, useEffect, useCallback, lazy, Suspense } from 'react'; import { useIndicatorSettings } from './hooks/useIndicatorSettings'; import { getAppSettingsCache, useAppSettings } from './hooks/useAppSettings'; import { clampVirtualTargetMax } from './utils/virtualTargetLimits'; import { resolveTrendSearchAppSettings } from './utils/trendSearchAppSettings'; import TopMenuBar, { type MenuPage } from './components/TopMenuBar'; import { TradeNotificationProvider } from './contexts/TradeNotificationContext'; import { VerificationIssueNotificationProvider } from './contexts/VerificationIssueNotificationContext'; import { FormalLoginGate } from './components/FormalLoginGate'; import { isFormalLogin, isTradingMenuPage, FORMAL_LOGIN_REQUIRED_MSG, } from './utils/tradingAccess'; import { NotifyTopMenuBar } from './components/NotifyUiBindings'; import { AppNotificationLayer } from './components/AppNotificationLayer'; import type { Theme } from './types'; import type { ChartRealtimeSource } from './hooks/useChartRealtimeData'; import { useIsMobile } from './hooks/useMediaQuery'; import LoginModal from './components/LoginModal'; import AppDownloadModal from './components/AppDownloadModal'; import SplashScreen from './components/SplashScreen'; import { getAuthSession, setAuthSession, clearAuthSession, type AuthSession } from './utils/auth'; import { isAppEntered, setAppEntered, clearAppEntered } from './utils/appEntry'; import { syncDocumentTheme } from './utils/documentTheme'; import { loadLocalTheme, migrateDbThemeToLocalIfNeeded, saveLocalTheme, } from './utils/localThemeStorage'; import { useMenuPermissions, invalidateMenuPermissionsCache } from './hooks/useMenuPermissions'; import { firstAllowedTopMenu, normalizeRole, type SettingsCategoryId } from './utils/permissions'; import { clearAdminUnlock } from './utils/adminUnlock'; import type { LoginResponse } from './utils/backendApi'; import { invalidateAppSettingsCache } from './hooks/useAppSettings'; import { invalidateIndicatorSettingsCache } from './hooks/useIndicatorSettings'; const LAST_MENU_KEY = 'gc_last_menu'; const CHART_ONLY_INITIAL: ReadonlySet = new Set(['dashboard', 'settings', 'verification-board']); function loadLastMenu(): MenuPage { try { const saved = localStorage.getItem(LAST_MENU_KEY) as MenuPage | null; // 가상매매·투자관리 등 로그인이 필요한 메뉴는 첫 로드 시 복원하지 않음 if (saved && saved !== 'chart') return saved; } catch { /* localStorage 불가 */ } return 'chart'; } function saveLastMenu(page: MenuPage): void { try { localStorage.setItem(LAST_MENU_KEY, page); } catch { /* no-op */ } } import { useChartWorkspaceContext } from './chart/ChartWorkspaceContext'; const ChartWorkspaceRoot = lazy(() => import('./chart/ChartWorkspaceRoot').then(m => ({ default: m.ChartWorkspaceRoot })) ); import './App.css'; // virtualTradingDashboard.css는 VirtualTradingPage.tsx에서 import // paperDashboard.css는 PaperTradingPage.tsx에서 import // tradeRightPanel.css는 ChartWorkspaceView.tsx에서 import // backtestDashboard.css는 BacktestHistoryPage.tsx, AnalysisReportPage.tsx에서 import // appPopup.css는 AppPopup.tsx에서 import const DashboardPage = lazy(() => import('./components/DashboardPage')); const StrategyPage = lazy(() => import('./components/StrategyPage')); const StrategyEditorPage = lazy(() => import('./components/StrategyEditorPage')); const TradeNotificationListPage = lazy(() => import('./components/TradeNotificationListPage')); const BacktestHistoryPage = lazy(() => import('./components/BacktestHistoryPage').then(m => ({ default: m.BacktestHistoryPage }))); const AnalysisReportPage = lazy(() => import('./components/analysisReport/AnalysisReportPage').then(m => ({ default: m.AnalysisReportPage }))); const SettingsPage = lazy(() => import('./components/SettingsPage')); const PaperTradingPage = lazy(() => import('./components/PaperTradingPage')); const VirtualTradingPage = lazy(() => import('./components/VirtualTradingPage')); const TrendSearchPage = lazy(() => import('./components/TrendSearchPage')); const VerificationBoardPage = lazy(() => import('./components/VerificationBoardPage')); function PageLoadFallback() { return
화면 로딩 중…
; } function AppMainContent({ theme, onThemeToggle, sessionKey, formalLogin, requireFormalLogin, authUser, guestMode, loginOpen, setLoginOpen, appDownloadOpen, setAppDownloadOpen, menuPage, setMenuPage, guardedSetMenuPage, settingsInitialCategory, setSettingsInitialCategory, verificationFocusIssueId, setVerificationFocusIssueId, menuPermissions, canMenu, appDefaults, appSettingsLoaded, saveAppDef, getIndicatorParams, saveIndicatorParams, getIndicatorVisual, saveIndicatorVisual, saveAllIndicatorDefaults, indicatorSettingsRevision, handleLoginSuccess, handleLogout, goToMarketChart, chartSlot, }: { theme: Theme; onThemeToggle: () => void; sessionKey: number; formalLogin: boolean; requireFormalLogin: () => void; authUser: AuthSession | null; guestMode: boolean; loginOpen: boolean; setLoginOpen: (v: boolean) => void; appDownloadOpen: boolean; setAppDownloadOpen: (v: boolean) => void; menuPage: MenuPage; setMenuPage: (p: MenuPage) => void; guardedSetMenuPage: (p: MenuPage) => void; settingsInitialCategory: SettingsCategoryId | undefined; setSettingsInitialCategory: (c: SettingsCategoryId | undefined) => void; verificationFocusIssueId: number | null; setVerificationFocusIssueId: (id: number | null) => void; menuPermissions: ReturnType['permissions']; canMenu: ReturnType['can']; appDefaults: ReturnType['defaults']; appSettingsLoaded: boolean; saveAppDef: ReturnType['save']; getIndicatorParams: ReturnType['getParams']; saveIndicatorParams: ReturnType['saveParams']; getIndicatorVisual: ReturnType['getVisualConfig']; saveIndicatorVisual: ReturnType['saveVisual']; saveAllIndicatorDefaults: ReturnType['saveAllIndicatorDefaults']; indicatorSettingsRevision: number; handleLoginSuccess: (res: LoginResponse) => void; handleLogout: () => void; /** 차트로 이동하는 App 레벨 콜백 */ goToMarketChart: (market: string) => void; /** 차트 화면 슬롯 (lazy ChartWorkspaceRoot) */ chartSlot: React.ReactNode; }) { const isMobile = useIsMobile(); const { symbol, indicators, refreshPaperAccount, handlePaperOrderFilled, paperRefreshKey, mode, marketTickers, settingsPageProps, } = useChartWorkspaceContext(); const paperTradingEnabled = appDefaults.paperTradingEnabled ?? true; const paperAutoTradeEnabled = appDefaults.paperAutoTradeEnabled ?? false; const handleFullscreen = () => { if (!document.fullscreenElement) document.documentElement.requestFullscreen().catch(() => {}); else document.exitFullscreen().catch(() => {}); }; return (
setAppDownloadOpen(true)} authUser={authUser} guestMode={guestMode} menuPermissions={menuPermissions} onLoginClick={() => setLoginOpen(true)} onLogout={handleLogout} tradeAlertPopupPosition={appDefaults.tradeAlertPopupPosition} tradeAlertPopupLayout={appDefaults.tradeAlertPopupLayout} tradeAlertPopupGridCols={appDefaults.tradeAlertPopupGridCols} onTradeAlertPopupPosition={v => saveAppDef({ tradeAlertPopupPosition: v })} onTradeAlertPopupLayout={v => saveAppDef({ tradeAlertPopupLayout: v })} onTradeAlertPopupGridCols={v => saveAppDef({ tradeAlertPopupGridCols: v })} onFullscreen={handleFullscreen} /> setLoginOpen(false)} onSuccess={handleLoginSuccess} /> {appDownloadOpen && ( setAppDownloadOpen(false)} /> )} {menuPage === 'dashboard' && ( }> { goToMarketChart(m); setMenuPage('chart'); }} /> )} {menuPage === 'strategy' && ( formalLogin ? ( }> ) : )} {menuPage === 'strategy-editor' && ( formalLogin ? ( }> ) : )} {menuPage === 'backtest' && ( formalLogin ? ( }> ) : )} {menuPage === 'analysis-report' && ( formalLogin ? ( }> ) : )} {menuPage === 'paper' && ( formalLogin ? ( }> { if (!formalLogin) { requireFormalLogin(); return; } setSettingsInitialCategory('trading'); setMenuPage('settings'); }} onGoChart={m => { goToMarketChart(m); setMenuPage('chart'); }} /> ) : ( ) )} {menuPage === 'virtual' && ( formalLogin ? ( }> saveAppDef({ paperAutoTradeEnabled: v })} onPaperOrderFilled={handlePaperOrderFilled} /> ) : ( ) )} {menuPage === 'trend-search' && ( }> )} {menuPage === 'verification-board' && ( }> setVerificationFocusIssueId(null)} /> )} {menuPage === 'notifications' && canMenu('notifications') && ( formalLogin ? ( }> { goToMarketChart(market); setMenuPage('chart'); }} /> ) : ( ) )} {menuPage === 'settings' && canMenu('settings') && ( }> saveAppDef({ btAutoPopup: v })} btShowPrice={appDefaults.btShowPrice} onBtShowPrice={v => saveAppDef({ btShowPrice: v })} liveMarket={symbol} tradeAlertPopup={appDefaults.tradeAlertPopup} onTradeAlertPopup={v => saveAppDef({ tradeAlertPopup: v })} tradeAlertSoundEnabled={appDefaults.tradeAlertSoundEnabled} onTradeAlertSoundEnabled={v => saveAppDef({ tradeAlertSoundEnabled: v })} tradeAlertSound={appDefaults.tradeAlertSound} onTradeAlertSound={v => saveAppDef({ tradeAlertSound: v })} tradeAlertPopupPosition={appDefaults.tradeAlertPopupPosition} onTradeAlertPopupPosition={v => saveAppDef({ tradeAlertPopupPosition: v })} tradeAlertPopupLayout={appDefaults.tradeAlertPopupLayout} onTradeAlertPopupLayout={v => saveAppDef({ tradeAlertPopupLayout: v })} tradeAlertPopupGridCols={appDefaults.tradeAlertPopupGridCols} onTradeAlertPopupGridCols={v => saveAppDef({ tradeAlertPopupGridCols: v })} getIndicatorParams={getIndicatorParams} saveIndicatorParams={saveIndicatorParams} getIndicatorVisual={getIndicatorVisual} saveIndicatorVisual={saveIndicatorVisual} saveAllIndicatorDefaults={saveAllIndicatorDefaults} indicatorSettingsRevision={indicatorSettingsRevision} paperTradingEnabled={paperTradingEnabled} onPaperTradingEnabled={v => saveAppDef({ paperTradingEnabled: v })} paperInitialCapital={appDefaults.paperInitialCapital} onPaperInitialCapital={v => saveAppDef({ paperInitialCapital: v })} paperFeeRatePct={appDefaults.paperFeeRatePct} onPaperFeeRatePct={v => saveAppDef({ paperFeeRatePct: v })} paperSlippagePct={appDefaults.paperSlippagePct} onPaperSlippagePct={v => saveAppDef({ paperSlippagePct: v })} paperMinOrderKrw={appDefaults.paperMinOrderKrw} onPaperMinOrderKrw={v => saveAppDef({ paperMinOrderKrw: v })} paperAutoTradeEnabled={appDefaults.paperAutoTradeEnabled} onPaperAutoTradeEnabled={v => saveAppDef({ paperAutoTradeEnabled: v })} paperAutoTradeBudgetPct={appDefaults.paperAutoTradeBudgetPct} onPaperAutoTradeBudgetPct={v => saveAppDef({ paperAutoTradeBudgetPct: v })} paperOrderFillMode={appDefaults.paperOrderFillMode} onPaperOrderFillMode={v => saveAppDef({ paperOrderFillMode: v })} paperOrderAutoCancelPct={appDefaults.paperOrderAutoCancelPct} onPaperOrderAutoCancelPct={v => saveAppDef({ paperOrderAutoCancelPct: v })} virtualTargetMaxCount={appDefaults.virtualTargetMaxCount} onVirtualTargetMaxCount={v => saveAppDef({ virtualTargetMaxCount: clampVirtualTargetMax(v) })} trendSearchSettings={appDefaults.trendSearchSettings} onTrendSearchSettingsChange={s => saveAppDef({ trendSearchSettings: resolveTrendSearchAppSettings(s), })} tradingMode={appDefaults.tradingMode} onTradingMode={v => saveAppDef({ tradingMode: v })} liveAutoTradeEnabled={appDefaults.liveAutoTradeEnabled} onLiveAutoTradeEnabled={v => saveAppDef({ liveAutoTradeEnabled: v })} hasUpbitKeys={appDefaults.hasUpbitKeys} upbitAccessKeyMasked={appDefaults.upbitAccessKeyMasked} onUpbitKeys={(access, secret) => { const patch: Parameters[0] = {}; const a = access.trim(); const s = secret.trim(); const isMasked = (v: string) => v.startsWith('····') || v.startsWith('****'); if (a && !isMasked(a)) patch.upbitAccessKey = a; if (s && !isMasked(s)) patch.upbitSecretKey = s; if (Object.keys(patch).length) saveAppDef(patch); }} chartRealtimeSource={appDefaults.chartRealtimeSource} onChartRealtimeSource={v => saveAppDef({ chartRealtimeSource: v })} liveAutoTradeBudgetPct={appDefaults.liveAutoTradeBudgetPct} onLiveAutoTradeBudgetPct={v => saveAppDef({ liveAutoTradeBudgetPct: v })} fcmPushEnabled={appDefaults.fcmPushEnabled} onFcmPushEnabled={v => saveAppDef({ fcmPushEnabled: v })} verificationIssueNotify={appDefaults.verificationIssueNotify} onVerificationIssueNotify={v => saveAppDef({ verificationIssueNotify: v })} onFcmTest={async () => { const { sendFcmTest } = await import('./utils/backendApi'); const r = await sendFcmTest(); window.alert(r?.available ? 'FCM 테스트 요청을 보냈습니다.' : 'FCM 서버가 비활성 상태입니다.'); }} initialCategory={settingsInitialCategory} {...settingsPageProps} /> )} {chartSlot} { setVerificationFocusIssueId(issueId); guardedSetMenuPage('verification-board'); }} tradingMode={appDefaults.tradingMode} hasUpbitKeys={appDefaults.hasUpbitKeys} paperTradingEnabled={paperTradingEnabled} liveAutoTradeBudgetPct={appDefaults.liveAutoTradeBudgetPct} paperAutoTradeBudgetPct={appDefaults.paperAutoTradeBudgetPct} tradeAlertPopupPosition={appDefaults.tradeAlertPopupPosition} tradeAlertPopupLayout={appDefaults.tradeAlertPopupLayout} tradeAlertPopupGridCols={appDefaults.tradeAlertPopupGridCols} onOrderDone={() => { refreshPaperAccount(symbol); handlePaperOrderFilled(); }} />
); } function App() { const [sessionKey, setSessionKey] = useState(0); const [appEntered, setAppEnteredState] = useState(() => isAppEntered()); const [authUser, setAuthUser] = useState(() => isAppEntered() ? getAuthSession() : null, ); const [guestMode, setGuestMode] = useState(() => isAppEntered() && !getAuthSession(), ); const formalLogin = isFormalLogin(authUser, guestMode); const [loginOpen, setLoginOpen] = useState(false); const requireFormalLogin = useCallback(() => { window.alert(FORMAL_LOGIN_REQUIRED_MSG); setLoginOpen(true); }, []); const [appDownloadOpen, setAppDownloadOpen] = useState(false); const { defaults: appDefaults, isLoaded: appSettingsLoaded, save: saveAppDef } = useAppSettings(sessionKey); const { permissions: menuPermissions, isLoaded: menuPermsLoaded, can: canMenu } = useMenuPermissions(sessionKey); const [theme, setTheme] = useState(loadLocalTheme); const [menuPage, setMenuPage] = useState(loadLastMenu); const [settingsInitialCategory, setSettingsInitialCategory] = useState(); const [verificationFocusIssueId, setVerificationFocusIssueId] = useState(null); // 차트 lazy mount 관련 상태: 처음 chart 메뉴 진입 시 마운트, 이후 유지 const [chartEverActivated, setChartEverActivated] = useState(() => loadLastMenu() === 'chart'); const [pendingChartMarket, setPendingChartMarket] = useState(null); // menuPage 변경 시 localStorage 저장 + chart 활성화 추적 const setMenuPagePersist = useCallback((page: MenuPage) => { saveLastMenu(page); setMenuPage(page); if (page === 'chart') setChartEverActivated(true); }, []); // 외부(대시보드·알림 등)에서 특정 마켓으로 차트 이동 요청 const handleGoToMarketChart = useCallback((market: string) => { setPendingChartMarket(market); setMenuPagePersist('chart'); setChartEverActivated(true); }, [setMenuPagePersist]); useEffect(() => { if (menuPage === 'chart') setChartEverActivated(true); }, [menuPage]); // 지표 설정: 차트·전략·알림 관련 페이지 첫 진입 시 로드 (이후 캐시 유지) const INDICATOR_SETTINGS_PAGES: MenuPage[] = ['chart', 'strategy-editor', 'notifications', 'strategy', 'backtest', 'analysis-report', 'settings']; const [indicatorSettingsEnabled, setIndicatorSettingsEnabled] = useState( () => INDICATOR_SETTINGS_PAGES.includes(menuPage), ); useEffect(() => { if (!indicatorSettingsEnabled && INDICATOR_SETTINGS_PAGES.includes(menuPage)) { setIndicatorSettingsEnabled(true); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [menuPage, indicatorSettingsEnabled]); const { getParams: getIndicatorParams, saveParams: saveIndicatorParams, getVisualConfig: getIndicatorVisual, saveVisual: saveIndicatorVisual, saveAllIndicatorDefaults, settingsRevision: indicatorSettingsRevision, } = useIndicatorSettings(sessionKey, indicatorSettingsEnabled); const getParams = getIndicatorParams; const saveParams = saveIndicatorParams; const getVisualConfig = getIndicatorVisual; const saveVisual = saveIndicatorVisual; useEffect(() => { if (menuPage !== 'settings') setSettingsInitialCategory(undefined); }, [menuPage]); const enterMainApp = useCallback(() => { setAppEntered(); setAppEnteredState(true); }, []); const handleLoginSuccess = useCallback((res: LoginResponse) => { const session: AuthSession = { userId: res.userId, username: res.username, displayName: res.displayName, role: normalizeRole(res.role), }; setAuthSession(session); setAuthUser(session); setGuestMode(false); clearAdminUnlock(); invalidateAppSettingsCache(); invalidateIndicatorSettingsCache(); invalidateMenuPermissionsCache(); setSessionKey(k => k + 1); enterMainApp(); }, [enterMainApp]); const handleGuestEnter = useCallback(() => { clearAuthSession(); setAuthUser(null); setGuestMode(true); clearAdminUnlock(); invalidateAppSettingsCache(); invalidateIndicatorSettingsCache(); invalidateMenuPermissionsCache(); setSessionKey(k => k + 1); enterMainApp(); }, [enterMainApp]); const handleLogout = useCallback(() => { clearAuthSession(); setAuthUser(null); setGuestMode(false); clearAdminUnlock(); invalidateAppSettingsCache(); invalidateIndicatorSettingsCache(); invalidateMenuPermissionsCache(); setSessionKey(k => k + 1); clearAppEntered(); setAppEnteredState(false); setLoginOpen(false); }, []); const guardedSetMenuPage = useCallback((page: MenuPage) => { if (!formalLogin && isTradingMenuPage(page)) { requireFormalLogin(); return; } if (page === 'notifications' && !canMenu('notifications')) return; if (page !== 'notifications' && !canMenu(page)) { setMenuPagePersist(firstAllowedTopMenu(menuPermissions)); return; } setMenuPagePersist(page); }, [canMenu, menuPermissions, formalLogin, requireFormalLogin, setMenuPagePersist]); useEffect(() => { if (!menuPermsLoaded) return; if (menuPage === 'notifications') { if (!canMenu('notifications')) setMenuPagePersist(firstAllowedTopMenu(menuPermissions)); return; } if (!canMenu(menuPage)) { setMenuPagePersist(firstAllowedTopMenu(menuPermissions)); } }, [menuPage, menuPermsLoaded, menuPermissions, canMenu, setMenuPagePersist]); const handleThemeToggle = useCallback(() => { setTheme(t => { const next: Theme = t === 'dark' ? 'blue' : t === 'blue' ? 'light' : 'dark'; saveLocalTheme(next); return next; }); }, []); useEffect(() => { syncDocumentTheme(theme); }, [theme]); useEffect(() => { if (!appSettingsLoaded) return; setTheme(migrateDbThemeToLocalIfNeeded(getAppSettingsCache().defaultTheme)); }, [appSettingsLoaded]); const openNotificationsPage = useCallback( () => guardedSetMenuPage('notifications'), [guardedSetMenuPage], ); if (!appEntered) { return ( ); } // 차트 슬롯: 처음 chart 페이지 진입 시 마운트, 이후 visible 토글 const chartSlot = chartEverActivated ? ( setPendingChartMarket(null)} menuPage={menuPage} sessionKey={sessionKey} formalLogin={formalLogin} requireFormalLogin={requireFormalLogin} theme={theme} onThemeToggle={handleThemeToggle} appDefaults={appDefaults as unknown as import('./utils/backendApi').AppSettingsDto} appSettingsLoaded={appSettingsLoaded} saveAppDef={saveAppDef} getParams={getParams} saveParams={saveParams} getVisualConfig={getVisualConfig} saveVisual={saveVisual} saveAllIndicatorDefaults={saveAllIndicatorDefaults} indicatorSettingsRevision={indicatorSettingsRevision} onOpenNotificationsPage={openNotificationsPage} onOpenAppDownload={() => setAppDownloadOpen(true)} /> ) : null; return ( ); } export default App;