"use client"; import { useMemo } from "react"; import { BriefcaseBusiness, Gauge, Sparkles } from "lucide-react"; import { useShallow } from "zustand/react/shallow"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ActivitySection } from "@/features/dashboard/components/ActivitySection"; import { DashboardAccessGate } from "@/features/dashboard/components/DashboardAccessGate"; import { DashboardSkeleton } from "@/features/dashboard/components/DashboardSkeleton"; import { HoldingsList } from "@/features/dashboard/components/HoldingsList"; import { MarketHubSection } from "@/features/dashboard/components/MarketHubSection"; import { MarketSummary } from "@/features/dashboard/components/MarketSummary"; import { StatusHeader } from "@/features/dashboard/components/StatusHeader"; import { StockDetailPreview } from "@/features/dashboard/components/StockDetailPreview"; import { useDashboardData } from "@/features/dashboard/hooks/use-dashboard-data"; import { useMarketRealtime } from "@/features/dashboard/hooks/use-market-realtime"; import { useKisWebSocketStore } from "@/features/kis-realtime/stores/kisWebSocketStore"; import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store"; import { useHoldingsRealtime } from "@/features/dashboard/hooks/use-holdings-realtime"; import type { DashboardBalanceSummary, DashboardHoldingItem, DashboardMarketIndexItem, } from "@/features/dashboard/types/dashboard.types"; import type { KisRealtimeStockTick } from "@/features/dashboard/utils/kis-stock-realtime.utils"; /** * @file DashboardContainer.tsx * @description 대시보드 메인 레이아웃 및 데이터 통합 관리 컴포넌트 * @remarks * - [레이어] Components / Container * - [사용자 행동] 대시보드 진입 -> 전체 자산/시장 지수/보유 종목 확인 -> 특정 종목 선택 상세 확인 * - [데이터 흐름] API(REST/WS) -> Hooks(useDashboardData, useMarketRealtime, useHoldingsRealtime) -> UI 병합 -> 하위 컴포넌트 전파 * - [연관 파일] use-dashboard-data.ts, use-holdings-realtime.ts, StatusHeader.tsx, HoldingsList.tsx * @author jihoon87.lee */ export function DashboardContainer() { // [Store] KIS 런타임 설정 상태 (인증 여부, 접속 계좌, 웹소켓 정보 등) const { verifiedCredentials, isKisVerified, isKisProfileVerified, verifiedAccountNo, _hasHydrated, wsApprovalKey, wsUrl, } = useKisRuntimeStore( useShallow((state) => ({ verifiedCredentials: state.verifiedCredentials, isKisVerified: state.isKisVerified, isKisProfileVerified: state.isKisProfileVerified, verifiedAccountNo: state.verifiedAccountNo, _hasHydrated: state._hasHydrated, wsApprovalKey: state.wsApprovalKey, wsUrl: state.wsUrl, })), ); // KIS 접근 가능 여부 판단 const canAccess = isKisVerified && Boolean(verifiedCredentials); // [Hooks] 기본적인 대시보드 데이터(잔고, 지수, 활동내역) 조회 및 선택 상태 관리 // @see use-dashboard-data.ts - 초기 데이터 로딩 및 폴링 처리 const { activity, balance, indices: initialIndices, selectedSymbol, setSelectedSymbol, isLoading, isRefreshing, activityError, balanceError, indicesError, marketHub, marketHubError, lastUpdatedAt, refresh, } = useDashboardData(canAccess ? verifiedCredentials : null); // [Hooks] 시장 지수(코스피/코스닥) 실시간 웹소켓 데이터 구독 // @see use-market-realtime.ts - 웹소켓 연결 및 지수 파싱 const { realtimeIndices, isConnected: isWsConnected } = useMarketRealtime( verifiedCredentials, isKisVerified, ); // [Hooks] 보유 종목 실시간 시세 웹소켓 데이터 구독 // @see use-holdings-realtime.ts - 보유 종목 리스트 기반 시세 업데이트 const { realtimeData: realtimeHoldings } = useHoldingsRealtime( balance?.holdings ?? [], ); const reconnectWebSocket = useKisWebSocketStore((state) => state.reconnect); // [Step 1] REST API로 가져온 기본 지수 정보와 실시간 웹소켓 시세 병합 const indices = useMemo(() => { if (initialIndices.length === 0) { return buildRealtimeOnlyIndices(realtimeIndices); } return initialIndices.map((item) => { const realtime = realtimeIndices[item.code]; if (!realtime) return item; return { ...item, price: realtime.price, change: realtime.change, changeRate: realtime.changeRate, }; }); }, [initialIndices, realtimeIndices]); // [Step 2] 초기 잔고 데이터와 실시간 보유 종목 시세를 병합하여 손익 재계산 const mergedHoldings = useMemo( () => mergeHoldingsWithRealtime(balance?.holdings ?? [], realtimeHoldings), [balance?.holdings, realtimeHoldings], ); const isKisRestConnected = Boolean( (balance && !balanceError) || (initialIndices.length > 0 && !indicesError) || (activity && !activityError), ); const hasRealtimeStreaming = Object.keys(realtimeIndices).length > 0 || Object.keys(realtimeHoldings).length > 0; const isRealtimePending = Boolean( wsApprovalKey && wsUrl && isWsConnected && !hasRealtimeStreaming, ); const effectiveIndicesError = indices.length === 0 ? indicesError : null; const restStatusLabel = isKisRestConnected ? "REST 정상" : "REST 점검 필요"; const realtimeStatusLabel = isWsConnected ? isRealtimePending ? "실시간 대기중" : "실시간 수신중" : "실시간 미연결"; const profileStatusLabel = isKisProfileVerified ? "계좌 인증 완료" : "계좌 인증 필요"; const indicesWarning = indices.length > 0 && indicesError ? "지수 API 재요청 중입니다. 화면 값은 실시간/최근 성공 데이터입니다." : null; /** * 대시보드 수동 새로고침 시 REST 조회 + 웹소켓 재연결을 함께 수행합니다. * @remarks UI 흐름: StatusHeader/각 카드 다시 불러오기 버튼 -> handleRefreshAll -> REST 재조회 + WS 완전 종료 후 재연결 * @see features/dashboard/components/StatusHeader.tsx 상단 다시 불러오기 버튼 * @see features/kis-realtime/stores/kisWebSocketStore.ts reconnect */ const handleRefreshAll = async () => { await Promise.allSettled([ refresh(), reconnectWebSocket({ refreshApproval: false }), ]); }; /** * 실시간 보유종목 데이터를 기반으로 전체 자산 요약을 계산합니다. * @returns 실시간 요약 데이터 (총자산, 손익, 평가금액 등) */ const mergedSummary = useMemo( () => buildRealtimeSummary(balance?.summary ?? null, mergedHoldings), [balance?.summary, mergedHoldings], ); // [Step 3] 실시간 병합 데이터에서 현재 선택된 종목 정보를 추출 // @see StockDetailPreview.tsx - 선택된 종목의 상세 정보 표시 const realtimeSelectedHolding = useMemo(() => { if (!selectedSymbol || mergedHoldings.length === 0) return null; return ( mergedHoldings.find((item) => item.symbol === selectedSymbol) ?? null ); }, [mergedHoldings, selectedSymbol]); // 하이드레이션 이전에는 로딩 스피너 표시 if (!_hasHydrated) { return (
); } // KIS 인증이 되지 않은 경우 접근 제한 게이트 표시 if (!canAccess) { return ; } // 데이터 로딩 중이며 아직 데이터가 없는 경우 스켈레톤 표시 if (isLoading && !balance && indices.length === 0) { return ; } return (

TRADING OVERVIEW

시장과 내 자산을 한 화면에서 빠르게 확인하세요

핵심 지표를 상단에 모으고, 시장 흐름과 자산 상태를 탭으로 분리했습니다.

시장 인사이트 내 자산
{ void handleRefreshAll(); }} /> { void handleRefreshAll(); }} />
{ void handleRefreshAll(); }} />
{ void handleRefreshAll(); }} /> { void handleRefreshAll(); }} onSelect={setSelectedSymbol} />
); } function TopStatusPill({ label, value, ok, warn = false, }: { label: string; value: string; ok: boolean; warn?: boolean; }) { const toneClass = ok ? warn ? "border-amber-300/70 bg-amber-50/70 text-amber-700 dark:border-amber-700/50 dark:bg-amber-950/30 dark:text-amber-300" : "border-emerald-300/70 bg-emerald-50/70 text-emerald-700 dark:border-emerald-700/50 dark:bg-emerald-950/30 dark:text-emerald-300" : "border-red-300/70 bg-red-50/70 text-red-700 dark:border-red-700/50 dark:bg-red-950/30 dark:text-red-300"; return (

{label}

{value}

); } /** * @description REST 지수 데이터가 없을 때 실시간 수신값만으로 코스피/코스닥 표시 데이터를 구성합니다. * @param realtimeIndices 실시간 지수 맵 * @returns 화면 렌더링용 지수 배열 * @remarks UI 흐름: DashboardContainer -> buildRealtimeOnlyIndices -> MarketSummary 렌더링 * @see features/dashboard/hooks/use-market-realtime.ts 실시간 지수 수신 훅 */ function buildRealtimeOnlyIndices( realtimeIndices: Record, ) { const baseItems: DashboardMarketIndexItem[] = [ { market: "KOSPI", code: "0001", name: "코스피", price: 0, change: 0, changeRate: 0 }, { market: "KOSDAQ", code: "1001", name: "코스닥", price: 0, change: 0, changeRate: 0 }, ]; return baseItems .map((item) => { const realtime = realtimeIndices[item.code]; if (!realtime) return null; return { ...item, price: realtime.price, change: realtime.change, changeRate: realtime.changeRate, } satisfies DashboardMarketIndexItem; }) .filter((item): item is DashboardMarketIndexItem => Boolean(item)); } /** * @description 보유종목 리스트에 실시간 체결가를 병합해 현재가/평가금액/손익을 재계산합니다. * @param holdings REST 기준 보유종목 * @param realtimeHoldings 종목별 실시간 체결 데이터 * @returns 병합된 보유종목 리스트 * @remarks UI 흐름: DashboardContainer -> mergeHoldingsWithRealtime -> HoldingsList/StockDetailPreview 반영 * @see features/dashboard/hooks/use-holdings-realtime.ts 보유종목 실시간 체결 구독 */ function mergeHoldingsWithRealtime( holdings: DashboardHoldingItem[], realtimeHoldings: Record, ) { if (holdings.length === 0 || Object.keys(realtimeHoldings).length === 0) { return holdings; } return holdings.map((item) => { const tick = realtimeHoldings[item.symbol]; if (!tick) return item; const currentPrice = tick.currentPrice; const purchaseAmount = item.averagePrice * item.quantity; const evaluationAmount = currentPrice * item.quantity; const profitLoss = evaluationAmount - purchaseAmount; const profitRate = purchaseAmount > 0 ? (profitLoss / purchaseAmount) * 100 : 0; return { ...item, currentPrice, evaluationAmount, profitLoss, profitRate, }; }); } /** * @description 실시간 보유종목 기준으로 대시보드 요약(총자산/손익)을 일관되게 재계산합니다. * @param summary REST API 요약 값 * @param holdings 실시간 병합된 보유종목 * @returns 재계산된 요약 값 * @remarks UI 흐름: DashboardContainer -> buildRealtimeSummary -> StatusHeader 카드 반영 * @see features/dashboard/components/StatusHeader.tsx 상단 요약 렌더링 */ function buildRealtimeSummary( summary: DashboardBalanceSummary | null, holdings: DashboardHoldingItem[], ) { if (!summary) return null; if (holdings.length === 0) return summary; const evaluationAmount = holdings.reduce( (total, item) => total + item.evaluationAmount, 0, ); const purchaseAmount = holdings.reduce( (total, item) => total + item.averagePrice * item.quantity, 0, ); const totalProfitLoss = evaluationAmount - purchaseAmount; const totalProfitRate = purchaseAmount > 0 ? (totalProfitLoss / purchaseAmount) * 100 : 0; const evaluationDelta = evaluationAmount - summary.evaluationAmount; const baseTotalAmount = summary.apiReportedNetAssetAmount > 0 ? summary.apiReportedNetAssetAmount : summary.totalAmount; // 실시간은 "기준 순자산 + 평가금 증감분"으로만 반영합니다. const totalAmount = Math.max(baseTotalAmount + evaluationDelta, 0); const netAssetAmount = totalAmount; const cashBalance = Math.max(totalAmount - evaluationAmount, 0); return { ...summary, totalAmount, netAssetAmount, cashBalance, evaluationAmount, purchaseAmount, totalProfitLoss, totalProfitRate, } satisfies DashboardBalanceSummary; }