대시보드 분리

This commit is contained in:
2026-02-24 16:39:04 +09:00
parent 3e19862c02
commit 1c07a59352
8 changed files with 654 additions and 449 deletions

View File

@@ -0,0 +1,115 @@
import React from 'react'
import { Brain, RefreshCw } from 'lucide-react'
import { TradingChart } from './TradingChart'
const formatTime = (isoStr?: string) => {
if (!isoStr) return null
const d = new Date(isoStr)
return d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZone: 'Asia/Seoul' })
}
interface ChartPanelProps {
chartData: any[] | null | undefined
chartLoading: boolean
selectedStock: string
chartPeriod: 'D' | 'W' | 'M'
onPeriodChange: (period: 'D' | 'W' | 'M') => void
stockName: string
}
const ChartPanel = React.memo(({
chartData,
chartLoading,
selectedStock,
chartPeriod,
onPeriodChange,
stockName,
}: ChartPanelProps) => {
return (
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 rounded-xl p-1 shadow-lg h-[45%] flex flex-col min-h-0">
<div className="px-5 py-3 flex justify-between items-center border-b border-gray-700/30">
<div>
<h2 className="text-lg font-bold flex items-center gap-3 text-white">
{stockName || '종목을 선택해주세요'}
{selectedStock && (
<span className="text-xs font-normal text-gray-500 font-mono bg-gray-800 px-2 py-0.5 rounded">
{selectedStock}
</span>
)}
</h2>
</div>
<div className="flex bg-gray-900/50 rounded-lg p-1 border border-gray-700/50 scale-90">
{(['D', 'W', 'M'] as const).map((p) => (
<button
key={p}
onClick={() => onPeriodChange(p)}
className={`px-3 py-1 text-xs font-medium rounded-md transition-all ${chartPeriod === p
? 'bg-blue-600 text-white shadow-lg shadow-blue-900/20'
: 'text-gray-400 hover:text-white hover:bg-gray-800'
}`}
>
{p === 'D' ? '일봉' : p === 'W' ? '주봉' : '월봉'}
</button>
))}
</div>
</div>
<div className="flex-1 relative bg-[#0b0e14] rounded-b-lg overflow-hidden">
{chartLoading ? (
<div className="absolute inset-0 flex items-center justify-center text-gray-500 bg-[#0b0e14]">
<div className="flex flex-col items-center gap-2">
<RefreshCw className="animate-spin w-6 h-6 text-blue-500" />
<span className="text-sm">차트 로딩중...</span>
</div>
</div>
) : chartData && chartData.length > 0 ? (
<TradingChart data={chartData} />
) : (
<div className="absolute inset-0 flex items-center justify-center text-gray-600 text-sm">
데이터가 없습니다.
</div>
)}
</div>
</div>
)
})
ChartPanel.displayName = 'ChartPanel'
// AI 분석 패널 — ChartPanel과 같은 파일에 배치 (center column 분리용)
interface AiAnalysisPanelProps {
aiAnalysisData: { analysis: string; data: any } | null | undefined
}
export const AiAnalysisPanel = React.memo(({ aiAnalysisData }: AiAnalysisPanelProps) => {
const aiAnalysisText = aiAnalysisData?.analysis || null
return (
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 rounded-xl p-4 shadow-lg flex flex-col">
<div className="flex justify-between items-center mb-2">
<h3 className="font-bold flex items-center gap-2 text-purple-400 text-sm">
<Brain size={16} />
AI 투자 분석
</h3>
{aiAnalysisData?.data?.analyzed_at && (
<span className="text-[10px] text-gray-500 font-mono">
{formatTime(aiAnalysisData.data.analyzed_at)}
</span>
)}
</div>
<div className="bg-gray-900/50 rounded-lg p-3 text-gray-300 text-sm leading-relaxed overflow-y-auto custom-scrollbar flex-1 border border-gray-700/30 whitespace-pre-wrap">
{aiAnalysisText ? (
typeof aiAnalysisText === 'string'
? aiAnalysisText
: JSON.stringify(aiAnalysisText, null, 2)
) : (
<div className="h-full flex items-center justify-center text-gray-600 text-center text-xs">
<p>종목을 선택하면<br />AI 투자 분석을 표시합니다.</p>
</div>
)}
</div>
</div>
)
})
AiAnalysisPanel.displayName = 'AiAnalysisPanel'
export default ChartPanel

View File

@@ -0,0 +1,79 @@
import React from 'react'
import { Activity } from 'lucide-react'
interface JournalPanelProps {
journals: any[] | undefined
onResetPending: () => void
}
const JournalPanel = React.memo(({ journals, onResetPending }: JournalPanelProps) => {
return (
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 rounded-xl overflow-hidden flex flex-col h-1/2 shadow-lg">
<div className="flex flex-col h-full p-3">
<div className="flex flex-col h-full min-h-0">
<div className="flex items-center justify-between mb-2 flex-shrink-0">
<h3 className="font-bold flex items-center gap-2 text-green-400 text-sm">
<Activity size={16} />
매매일지
</h3>
<button
onClick={onResetPending}
className="px-2 py-1 text-[10px] bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded border border-red-500/30 transition-colors"
title="미체결 주문 정리"
>
미체결 정리
</button>
</div>
<div className="space-y-2 overflow-y-auto custom-scrollbar flex-1">
{journals && journals.length > 0 ? (
journals.map((journal: any) => {
const journalDate = new Date(journal.journal_date || journal.created_at)
const dateStr = journalDate.toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit', timeZone: 'Asia/Seoul' })
const timeStr = journalDate.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'Asia/Seoul' })
const isGoodMood = journal.mood === '좋음' || journal.mood === 'good' || journal.mood === 'positive'
const isBadMood = journal.mood === '나쁨' || journal.mood === 'bad' || journal.mood === 'negative'
const tags: string[] = Array.isArray(journal.tags) ? journal.tags : []
const hasProfitTag = tags.some((t: string) => t.includes('익절') || t.includes('profit'))
const hasLossTag = tags.some((t: string) => t.includes('손절') || t.includes('loss'))
return (
<div key={journal.id} className="p-3 hover:bg-gray-700/30 rounded-lg transition-colors bg-gray-900/30 border border-gray-700/30">
<div className="flex justify-between items-start mb-1.5">
<div className="flex items-center gap-2">
<span className="text-base">
{isGoodMood ? '😊' : isBadMood ? '😔' : '📝'}
</span>
<span className="font-bold text-gray-200 text-sm">{journal.stock_name || journal.stock_code}</span>
{hasProfitTag && <span className="px-1.5 py-0.5 text-[9px] rounded bg-green-500/20 text-green-400">익절</span>}
{hasLossTag && <span className="px-1.5 py-0.5 text-[9px] rounded bg-red-500/20 text-red-400">손절</span>}
</div>
<span className="text-[10px] text-gray-500 font-mono">{dateStr} {timeStr}</span>
</div>
<div className="text-[11px] text-gray-300 leading-relaxed line-clamp-3 whitespace-pre-line">
{journal.content}
</div>
{tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{tags.slice(0, 4).map((tag: string, i: number) => (
<span key={i} className="px-1.5 py-0.5 text-[9px] rounded bg-gray-700/50 text-gray-400">#{tag}</span>
))}
</div>
)}
</div>
)
})
) : (
<div className="h-full flex flex-col items-center justify-center text-gray-600 text-xs gap-1">
<span>매매일지 없음</span>
<span className="text-[10px] text-gray-700">매도 체결 후 자동으로 생성됩니다</span>
</div>
)}
</div>
</div>
</div>
</div>
)
})
JournalPanel.displayName = 'JournalPanel'
export default JournalPanel

View File

@@ -0,0 +1,98 @@
import React from 'react'
import { Activity } from 'lucide-react'
interface OrderbookPanelProps {
orderbook: any | null | undefined
}
const OrderbookPanel = React.memo(({ orderbook }: OrderbookPanelProps) => {
return (
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 rounded-xl p-4 shadow-lg flex flex-col">
<h3 className="font-bold flex items-center gap-2 mb-3 text-yellow-400 text-sm">
<Activity size={16} />
실시간 호가
</h3>
<div className="overflow-y-auto custom-scrollbar flex-1">
{orderbook ? (() => {
const currentPrice = orderbook.current_price
const priceUnit =
currentPrice < 1000 ? 1
: currentPrice < 5000 ? 5
: currentPrice < 10000 ? 10
: currentPrice < 50000 ? 50
: currentPrice < 100000 ? 100
: currentPrice < 500000 ? 500
: 1000
const steps = 10
const sequentialAsks: { price: number; volume: number }[] = []
for (let i = steps; i >= 1; i--) {
const price = currentPrice + priceUnit * i
const existingAsk = orderbook.asks?.find((ask: any) => ask.price === price)
sequentialAsks.push({ price, volume: existingAsk ? existingAsk.volume : 0 })
}
const sequentialBids: { price: number; volume: number }[] = []
for (let i = 1; i <= steps; i++) {
const price = currentPrice - priceUnit * i
const existingBid = orderbook.bids?.find((bid: any) => bid.price === price)
sequentialBids.push({ price, volume: existingBid ? existingBid.volume : 0 })
}
return (
<div className="space-y-2">
<div className="space-y-0.5">
{sequentialAsks.map((ask, i) => (
<div key={`ask-${i}`} className="grid grid-cols-3 items-center gap-2 py-1 px-2">
<div className="text-xs text-gray-300 font-medium text-right font-mono">
{ask.volume > 0 ? ask.volume.toLocaleString() : '-'}
</div>
<div className="text-sm font-bold text-red-400 font-mono text-center">
{ask.price.toLocaleString()}
</div>
<div></div>
</div>
))}
</div>
<div className="py-3 bg-gradient-to-r from-red-900/20 via-red-800/30 to-red-900/20 rounded-lg border-2 border-red-500/30 my-2">
<div className="flex items-center justify-center gap-4 px-4">
<div className="text-2xl font-bold text-red-400 font-mono">
{currentPrice?.toLocaleString()}
</div>
{orderbook.current_volume > 0 && (
<div className="text-xs text-gray-400 font-mono">
({orderbook.current_volume.toLocaleString()})
</div>
)}
</div>
<div className="text-xs text-red-300 mt-1 text-center">연속 호가창</div>
</div>
<div className="space-y-0.5">
{sequentialBids.map((bid, i) => (
<div key={`bid-${i}`} className="grid grid-cols-3 items-center gap-2 py-1 px-2">
<div></div>
<div className="text-sm font-bold text-blue-400 font-mono text-center">
{bid.price.toLocaleString()}
</div>
<div className="text-xs text-gray-300 font-medium text-left font-mono">
{bid.volume > 0 ? bid.volume.toLocaleString() : '-'}
</div>
</div>
))}
</div>
</div>
)
})() : (
<div className="h-full flex items-center justify-center text-gray-600 text-xs text-center">
종목을 선택하면 호가 정보가 표시됩니다.
</div>
)}
</div>
</div>
)
})
OrderbookPanel.displayName = 'OrderbookPanel'
export default OrderbookPanel

View File

@@ -0,0 +1,105 @@
import React from 'react'
import { Activity } from 'lucide-react'
interface PortfolioPanelProps {
portfolio: any[] | undefined
livePrices: Record<string, number> | undefined
selectedStock: string
onSelectStock: (code: string) => void
onRefetchAiAnalysis: () => void
onSell: (stockCode: string, stockName: string, quantity: number) => Promise<void>
}
const PortfolioPanel = React.memo(({
portfolio,
livePrices,
selectedStock,
onSelectStock,
onRefetchAiAnalysis,
onSell,
}: PortfolioPanelProps) => {
return (
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 rounded-xl overflow-hidden flex flex-col h-full shadow-lg">
<div className="p-3 border-b border-gray-700/50 flex justify-between items-center bg-[#1c222c]">
<h3 className="font-semibold flex items-center gap-2 text-gray-200 text-sm">
<Activity className="w-4 h-4 text-blue-400" />
보유 종목
</h3>
<span className="text-xs bg-blue-500/20 text-blue-300 px-2 py-0.5 rounded-full">
{portfolio?.length || 0}
</span>
</div>
<div className="divide-y divide-gray-700/50 overflow-y-auto custom-scrollbar flex-1">
{portfolio && portfolio.length > 0 ? (
portfolio.map((stock: any) => {
const currentPrice = livePrices?.[stock.stock_code] ?? Number(stock.current_price || 0)
const avgPrice = Number(stock.average_price || 0)
const quantity = Number(stock.quantity || 0)
const totalValue = currentPrice * quantity
const profitLossRate = avgPrice > 0
? ((currentPrice - avgPrice) / avgPrice) * 100
: Number(stock.profit_loss_rate || 0)
const handleSellClick = async (e: React.MouseEvent) => {
e.stopPropagation()
if (!confirm(`${stock.stock_name} 전량(${quantity}주)을 시장가로 매도하시겠습니까?`)) return
try {
await onSell(stock.stock_code, stock.stock_name, quantity)
} catch (error: any) {
alert(`매도 실패: ${error.response?.data?.detail || error.message}`)
}
}
return (
<div
key={stock.stock_code}
className={`w-full p-3 hover:bg-gray-700/30 transition-all duration-200 group ${selectedStock === stock.stock_code ? 'bg-blue-500/10 border-l-2 border-blue-500' : 'border-l-2 border-transparent'}`}
>
<div
onClick={() => { onSelectStock(stock.stock_code); onRefetchAiAnalysis() }}
className="cursor-pointer"
>
<div className="flex justify-between items-start mb-1">
<span className={`font-bold text-sm ${selectedStock === stock.stock_code ? 'text-blue-400' : 'text-gray-200'}`}>
{stock.stock_name}
</span>
<span className={`text-xs font-bold ${profitLossRate > 0 ? 'text-red-400' : 'text-blue-400'}`}>
{profitLossRate > 0 ? '+' : ''}{profitLossRate.toFixed(2)}%
</span>
</div>
<div className="flex justify-between items-center text-[11px] text-gray-400 mb-0.5">
<span className="font-mono">{stock.stock_code}</span>
<span>{quantity}주</span>
</div>
<div className="flex justify-between items-center text-[11px]">
<span className="text-gray-500">{avgPrice.toLocaleString()}원</span>
<span className="text-white font-medium">{currentPrice.toLocaleString()}원</span>
</div>
<div className="flex justify-between items-center text-[11px] mt-0.5">
<span className="text-gray-500">평가금액</span>
<span className={`font-medium ${profitLossRate > 0 ? 'text-red-400' : 'text-blue-400'}`}>
{totalValue.toLocaleString()}원
</span>
</div>
</div>
<button
onClick={handleSellClick}
className="w-full mt-2 px-2 py-1 text-[10px] bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 rounded border border-blue-500/30 transition-colors font-bold"
>
🔵 전량 청산 (시장가)
</button>
</div>
)
})
) : (
<div className="h-full flex flex-col items-center justify-center text-gray-500 p-4 text-center text-xs">
<p>보유 중인 종목이 없습니다.</p>
</div>
)}
</div>
</div>
)
})
PortfolioPanel.displayName = 'PortfolioPanel'
export default PortfolioPanel

View File

@@ -0,0 +1,133 @@
import React from 'react'
import { Brain, RefreshCw } from 'lucide-react'
const formatTime = (isoStr?: string) => {
if (!isoStr) return null
const d = new Date(isoStr)
return d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZone: 'Asia/Seoul' })
}
interface RecommendationPanelProps {
recommendations: any | undefined
isError: boolean
onRefetch: () => void
selectedStock: string
onSelectStock: (code: string) => void
onRefetchAiAnalysis: () => void
heldStockCodes: Set<string>
pendingBuyStockCodes: Set<string>
livePrices: Record<string, number> | undefined
}
const RecommendationPanel = React.memo(({
recommendations,
isError,
onRefetch,
selectedStock,
onSelectStock,
onRefetchAiAnalysis,
heldStockCodes,
pendingBuyStockCodes,
livePrices,
}: RecommendationPanelProps) => {
return (
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 rounded-xl overflow-hidden flex flex-col h-1/2 shadow-lg">
<div className="p-3 border-b border-gray-700/50 flex justify-between items-center bg-[#1c222c]">
<h3 className="font-semibold flex items-center gap-2 text-gray-200 text-sm">
<Brain className="w-4 h-4 text-purple-400" />
분석 종목
</h3>
<div className="flex items-center gap-2">
{recommendations?.batch_time && (
<span className="text-[10px] text-gray-500 font-mono">
{formatTime(recommendations.batch_time)}
</span>
)}
<span className="text-xs bg-purple-500/20 text-purple-300 px-2 py-0.5 rounded-full">
{recommendations?.analyzed_stocks || 0}
</span>
<button
onClick={onRefetch}
className="p-1 hover:bg-gray-700/50 rounded transition-colors"
title="분석 종목 새로고침"
>
<RefreshCw className="w-3 h-3 text-gray-400 hover:text-purple-400" />
</button>
</div>
</div>
<div className="divide-y divide-gray-700/50 overflow-y-auto custom-scrollbar flex-1">
{isError ? (
<div className="h-full flex flex-col items-center justify-center text-red-500 text-xs p-4 text-center">
<p>⚠️ API 오류</p>
<p className="text-[10px] text-gray-500 mt-1">백엔드 로그 확인 필요</p>
<button onClick={onRefetch} className="mt-2 px-2 py-1 bg-red-500/20 text-red-400 rounded text-[10px]">재시도</button>
</div>
) : recommendations === undefined ? (
<div className="h-full flex flex-col items-center justify-center text-gray-500 text-xs p-4">
<RefreshCw className="w-6 h-6 animate-spin mb-2 text-purple-400" />
<p>분석 종목 로딩 중...</p>
<p className="text-[10px] text-gray-600 mt-1">최초 로딩 시 10초 정도 소요됩니다</p>
</div>
) : (recommendations?.analyzed_stock_list || []).length > 0 ? (
(recommendations.analyzed_stock_list as any[]).map((stock: any, idx: number) => {
const isRecommended = (recommendations?.recommendations || []).some((r: any) => r.stock_code === stock.stock_code)
const isHeld = heldStockCodes.has(stock.stock_code)
const isPending = pendingBuyStockCodes.has(stock.stock_code)
const changeRate = stock.change_rate || 0
return (
<button
key={`${stock.stock_code}-${idx}`}
onClick={() => { onSelectStock(stock.stock_code); onRefetchAiAnalysis() }}
className={`w-full p-3 text-left hover:bg-gray-700/30 transition-all duration-200 ${selectedStock === stock.stock_code ? 'bg-purple-500/10 border-l-2 border-purple-500' : 'border-l-2 border-transparent'}`}
>
<div className="flex justify-between items-start mb-1">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-0.5">
<span className="font-bold text-gray-200 text-xs truncate">{stock.stock_name}</span>
<span className="text-[9px] text-gray-500 flex-shrink-0">({stock.stock_code})</span>
</div>
{stock.signals && stock.signals.length > 0 && (
<div className="text-[9px] text-gray-500 truncate">
{stock.signals.slice(0, 2).join(' · ')}
</div>
)}
</div>
<div className="flex flex-col items-end gap-0.5 flex-shrink-0 ml-1">
{isPending ? (
<span className="text-[9px] text-orange-400 border border-orange-500/20 bg-orange-500/10 px-1 py-0.5 rounded flex items-center gap-0.5">
<RefreshCw className="w-2 h-2 animate-spin" />매수중
</span>
) : isHeld ? (
<span className="text-[9px] text-blue-400 border border-blue-500/20 bg-blue-500/10 px-1 py-0.5 rounded">보유중</span>
) : isRecommended ? (
<span className="text-[9px] text-green-400 border border-green-500/20 bg-green-500/10 px-1 py-0.5 rounded">매수신호</span>
) : null}
<span className={`text-[9px] font-bold ${changeRate > 0 ? 'text-red-400' : changeRate < 0 ? 'text-blue-400' : 'text-gray-500'}`}>
{changeRate > 0 ? '+' : ''}{changeRate.toFixed(2)}%
</span>
</div>
</div>
<div className="flex justify-between items-center text-[9px] text-gray-500 mt-0.5">
<span>{Math.round(livePrices?.[stock.stock_code] ?? stock.current_price ?? 0).toLocaleString()}원</span>
<span className="flex gap-2">
{stock.rsi != null && <span>RSI {stock.rsi.toFixed(0)}</span>}
{stock.stochastic_k != null && <span>K {stock.stochastic_k.toFixed(0)}</span>}
{stock.score > 0 && <span className="text-purple-400">{stock.score}점</span>}
</span>
</div>
</button>
)
})
) : (
<div className="h-full flex flex-col items-center justify-center text-gray-500 p-4 text-center text-xs">
<p>배치 분석 데이터가 없습니다.</p>
<p className="text-[10px] text-gray-600 mt-1">make batch-test 실행 필요</p>
</div>
)}
</div>
</div>
)
})
RecommendationPanel.displayName = 'RecommendationPanel'
export default RecommendationPanel

View File

@@ -1,18 +1,16 @@
import { useEffect, useState, useRef, useMemo } from 'react'
import { useEffect, useState, useRef, useMemo, useCallback } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import api from '../lib/api'
import { useWebSocket } from '../hooks/useWebSocket'
import { PortfolioSummary } from '../components/PortfolioSummary'
import { TradingChart } from '../components/TradingChart'
import { Activity, Brain, RefreshCw } from 'lucide-react'
import PortfolioPanel from '../components/PortfolioPanel'
import ChartPanel, { AiAnalysisPanel } from '../components/ChartPanel'
import OrderbookPanel from '../components/OrderbookPanel'
import RecommendationPanel from '../components/RecommendationPanel'
import JournalPanel from '../components/JournalPanel'
import { Activity } from 'lucide-react'
import './Dashboard.css'
const formatTime = (isoStr?: string) => {
if (!isoStr) return null;
const d = new Date(isoStr);
return d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZone: 'Asia/Seoul' });
};
// 마켓 운영 시간 체크 (한국장 + 미국장)
const checkMarketOpen = () => {
const now = new Date();
@@ -45,10 +43,11 @@ const Dashboard = () => {
const isMarketOpen = checkMarketOpen();
// ✅ FIX 1-b: 종목 선택 시 localStorage에 저장 (재진입 시 Waterfall 제거)
const handleSelectStock = (code: string) => {
// useCallback으로 안정화 → React.memo 패널에 전달 시 불필요한 리렌더 방지
const handleSelectStock = useCallback((code: string) => {
setSelectedStock(code)
localStorage.setItem('selectedStock', code)
}
}, [])
// --- Queries ---
@@ -220,15 +219,40 @@ const Dashboard = () => {
staleTime: 5000,
})
const heldStockCodes = new Set((portfolio || []).map((s: any) => s.stock_code))
const pendingBuyStockCodes = new Set(
(tradeHistory || [])
.filter((t: any) => t.status === 'PENDING' && t.trade_type === 'BUY')
.map((t: any) => t.stock_code)
// useMemo로 Set 안정화 → React.memo 패널에 전달 시 레퍼런스 유지
const heldStockCodes = useMemo(
() => new Set((portfolio || []).map((s: any) => s.stock_code as string)),
[portfolio]
)
const resetPendingTrades = async () => {
const pendingBuyStockCodes = useMemo(
() => new Set(
(tradeHistory || [])
.filter((t: any) => t.status === 'PENDING' && t.trade_type === 'BUY')
.map((t: any) => t.stock_code as string)
),
[tradeHistory]
)
// 차트 헤더용 종목명 (useMemo로 안정화)
const stockName = useMemo(() => {
return (
portfolio?.find((s: any) => s.stock_code === selectedStock)?.stock_name ||
ruleBasedRecommendations?.recommendations_by_rule?.flatMap((r: any) => r.stocks).find((s: any) => s.stock_code === selectedStock)?.stock_name ||
selectedStock ||
''
)
}, [portfolio, ruleBasedRecommendations, selectedStock])
// useCallback으로 안정화 → 패널 리렌더 방지
const handleSell = useCallback(async (stockCode: string, stockName: string, quantity: number) => {
await api.post('/portfolio/sell', { stock_code: stockCode, quantity, price: null })
queryClient.invalidateQueries({ queryKey: ['portfolio'] })
queryClient.invalidateQueries({ queryKey: ['trade-history'] })
console.log(`✅ ${stockName} 매도 주문 완료`)
}, [queryClient])
const resetPendingTrades = useCallback(async () => {
try {
const response = await api.delete('/trades/pending');
queryClient.invalidateQueries({ queryKey: ['trade-history'] });
@@ -236,7 +260,7 @@ const Dashboard = () => {
} catch (error: any) {
console.error('미체결 주문 정리 실패:', error);
}
};
}, [queryClient])
const { data: aiAnalysisData, refetch: refetchAiAnalysis } = useQuery({
// ✅ FIX 3: orderbook?.stock_name 제거 → orderbook 로드 시 이중 fetch 방지
@@ -323,7 +347,14 @@ const Dashboard = () => {
staleTime: 30000,
});
const aiAnalysisText = aiAnalysisData?.analysis || null;
// useCallback으로 안정화 → 패널에 전달 시 매 렌더마다 새 함수 생성 방지
const stableRefetchAiAnalysis = useCallback(() => {
refetchAiAnalysis()
}, [refetchAiAnalysis])
const stableRefetchRecommendations = useCallback(() => {
refetchRecommendations()
}, [refetchRecommendations])
// 초기 종목 선택 (localStorage에 없을 때만 동작)
useEffect(() => {
@@ -337,7 +368,7 @@ const Dashboard = () => {
}
}
}
}, [portfolio, ruleBasedRecommendations, selectedStock])
}, [portfolio, ruleBasedRecommendations, selectedStock, handleSelectStock])
return (
<div className="min-h-screen bg-[#0f1115] text-gray-100 font-sans flex flex-col h-screen overflow-hidden">
@@ -375,428 +406,54 @@ const Dashboard = () => {
{/* [Left Column] 보유 종목 */}
<div className="col-span-2 flex flex-col gap-4 h-full overflow-hidden">
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 rounded-xl overflow-hidden flex flex-col h-full shadow-lg">
<div className="p-3 border-b border-gray-700/50 flex justify-between items-center bg-[#1c222c]">
<h3 className="font-semibold flex items-center gap-2 text-gray-200 text-sm">
<Activity className="w-4 h-4 text-blue-400" />
보유 종목
</h3>
<span className="text-xs bg-blue-500/20 text-blue-300 px-2 py-0.5 rounded-full">
{portfolio?.length || 0}
</span>
</div>
<div className="divide-y divide-gray-700/50 overflow-y-auto custom-scrollbar flex-1">
{portfolio && portfolio.length > 0 ? (
portfolio.map((stock: any) => {
const currentPrice = livePrices?.[stock.stock_code] ?? Number(stock.current_price || 0);
const avgPrice = Number(stock.average_price || 0);
const quantity = Number(stock.quantity || 0);
const totalValue = currentPrice * quantity;
const profitLossRate = avgPrice > 0
? ((currentPrice - avgPrice) / avgPrice) * 100
: Number(stock.profit_loss_rate || 0);
const handleSell = async (e: React.MouseEvent) => {
e.stopPropagation();
if (!confirm(`${stock.stock_name} 전량(${quantity}주)을 시장가로 매도하시겠습니까?`)) {
return;
}
try {
await api.post('/portfolio/sell', {
stock_code: stock.stock_code,
quantity: quantity,
price: null
});
queryClient.invalidateQueries({ queryKey: ['portfolio'] });
queryClient.invalidateQueries({ queryKey: ['trade-history'] });
console.log(`✅ ${stock.stock_name} 매도 주문 완료`);
} catch (error: any) {
console.error('매도 실패:', error);
alert(`매도 실패: ${error.response?.data?.detail || error.message}`);
}
};
return (
<div
key={stock.stock_code}
className={`w-full p-3 hover:bg-gray-700/30 transition-all duration-200 group ${selectedStock === stock.stock_code ? 'bg-blue-500/10 border-l-2 border-blue-500' : 'border-l-2 border-transparent'}`}
>
<div
onClick={() => {
handleSelectStock(stock.stock_code);
refetchAiAnalysis();
}}
className="cursor-pointer"
>
<div className="flex justify-between items-start mb-1">
<span className={`font-bold text-sm ${selectedStock === stock.stock_code ? 'text-blue-400' : 'text-gray-200'}`}>
{stock.stock_name}
</span>
<span className={`text-xs font-bold ${profitLossRate > 0 ? 'text-red-400' : 'text-blue-400'}`}>
{profitLossRate > 0 ? '+' : ''}{profitLossRate.toFixed(2)}%
</span>
</div>
<div className="flex justify-between items-center text-[11px] text-gray-400 mb-0.5">
<span className="font-mono">{stock.stock_code}</span>
<span>{quantity}주</span>
</div>
<div className="flex justify-between items-center text-[11px]">
<span className="text-gray-500">{avgPrice.toLocaleString()}원</span>
<span className="text-white font-medium">{currentPrice.toLocaleString()}원</span>
</div>
<div className="flex justify-between items-center text-[11px] mt-0.5">
<span className="text-gray-500">평가금액</span>
<span className={`font-medium ${profitLossRate > 0 ? 'text-red-400' : 'text-blue-400'}`}>
{totalValue.toLocaleString()}원
</span>
</div>
</div>
<button
onClick={handleSell}
className="w-full mt-2 px-2 py-1 text-[10px] bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 rounded border border-blue-500/30 transition-colors font-bold"
>
🔵 전량 청산 (시장가)
</button>
</div>
);
})
) : (
<div className="h-full flex flex-col items-center justify-center text-gray-500 p-4 text-center text-xs">
<p>보유 중인 종목이 없습니다.</p>
</div>
)}
</div>
</div>
<PortfolioPanel
portfolio={portfolio}
livePrices={livePrices}
selectedStock={selectedStock}
onSelectStock={handleSelectStock}
onRefetchAiAnalysis={stableRefetchAiAnalysis}
onSell={handleSell}
/>
</div>
{/* [Center Column] 차트 & 트레이딩 정보 */}
<div className="col-span-7 flex flex-col h-full gap-4 overflow-hidden">
{/* 차트 섹션 */}
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 rounded-xl p-1 shadow-lg h-[45%] flex flex-col min-h-0">
<div className="px-5 py-3 flex justify-between items-center border-b border-gray-700/30">
<div>
<h2 className="text-lg font-bold flex items-center gap-3 text-white">
{portfolio?.find((s: any) => s.stock_code === selectedStock)?.stock_name ||
ruleBasedRecommendations?.recommendations_by_rule?.flatMap((r: any) => r.stocks).find((s: any) => s.stock_code === selectedStock)?.stock_name ||
selectedStock || '종목을 선택해주세요'}
{selectedStock && <span className="text-xs font-normal text-gray-500 font-mono bg-gray-800 px-2 py-0.5 rounded">{selectedStock}</span>}
</h2>
</div>
<div className="flex bg-gray-900/50 rounded-lg p-1 border border-gray-700/50 scale-90">
{['D', 'W', 'M'].map((p) => (
<button
key={p}
onClick={() => setChartPeriod(p as any)}
className={`px-3 py-1 text-xs font-medium rounded-md transition-all ${chartPeriod === p ? 'bg-blue-600 text-white shadow-lg shadow-blue-900/20' : 'text-gray-400 hover:text-white hover:bg-gray-800'
}`}
>
{p === 'D' ? '일봉' : p === 'W' ? '주봉' : '월봉'}
</button>
))}
</div>
</div>
<ChartPanel
chartData={chartData}
chartLoading={chartLoading}
selectedStock={selectedStock}
chartPeriod={chartPeriod}
onPeriodChange={setChartPeriod}
stockName={stockName}
/>
<div className="flex-1 relative bg-[#0b0e14] rounded-b-lg overflow-hidden">
{chartLoading ? (
<div className="absolute inset-0 flex items-center justify-center text-gray-500 bg-[#0b0e14]">
<div className="flex flex-col items-center gap-2">
<RefreshCw className="animate-spin w-6 h-6 text-blue-500" />
<span className="text-sm">차트 로딩중...</span>
</div>
</div>
) : chartData && chartData.length > 0 ? (
<TradingChart data={chartData} />
) : (
<div className="absolute inset-0 flex items-center justify-center text-gray-600 text-sm">
데이터가 없습니다.
</div>
)}
</div>
</div>
{/* 하단: AI 투자 분석 & 호가창 */}
{/* 하단: AI 투자 분석 & 호가창
- AiAnalysisPanel: selectedStock 변경 시에만 리렌더
- OrderbookPanel: 5초마다 독립 리렌더 (다른 패널 영향 없음) */}
<div className="flex-1 grid grid-cols-2 gap-4 min-h-0">
{/* AI 투자 분석 */}
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 rounded-xl p-4 shadow-lg flex flex-col">
<div className="flex justify-between items-center mb-2">
<h3 className="font-bold flex items-center gap-2 text-purple-400 text-sm">
<Brain size={16} />
AI 투자 분석
</h3>
{aiAnalysisData?.data?.analyzed_at && (
<span className="text-[10px] text-gray-500 font-mono">
{formatTime(aiAnalysisData.data.analyzed_at)}
</span>
)}
</div>
<div className="bg-gray-900/50 rounded-lg p-3 text-gray-300 text-sm leading-relaxed overflow-y-auto custom-scrollbar flex-1 border border-gray-700/30 whitespace-pre-wrap">
{aiAnalysisText ? (
typeof aiAnalysisText === 'string'
? aiAnalysisText
: JSON.stringify(aiAnalysisText, null, 2)
) : (
<div className="h-full flex items-center justify-center text-gray-600 text-center text-xs">
<p>종목을 선택하면<br />AI 투자 분석을 표시합니다.</p>
</div>
)}
</div>
</div>
{/* 호가창 */}
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 rounded-xl p-4 shadow-lg flex flex-col">
<h3 className="font-bold flex items-center gap-2 mb-3 text-yellow-400 text-sm">
<Activity size={16} />
실시간 호가
</h3>
<div className="overflow-y-auto custom-scrollbar flex-1">
{orderbook ? (() => {
const currentPrice = orderbook.current_price;
const priceUnit =
currentPrice < 1000 ? 1
: currentPrice < 5000 ? 5
: currentPrice < 10000 ? 10
: currentPrice < 50000 ? 50
: currentPrice < 100000 ? 100
: currentPrice < 500000 ? 500
: 1000;
const steps = 10;
const sequentialAsks = [];
for (let i = steps; i >= 1; i--) {
const price = currentPrice + (priceUnit * i);
const existingAsk = orderbook.asks?.find((ask: any) => ask.price === price);
sequentialAsks.push({ price, volume: existingAsk ? existingAsk.volume : 0 });
}
const sequentialBids = [];
for (let i = 1; i <= steps; i++) {
const price = currentPrice - (priceUnit * i);
const existingBid = orderbook.bids?.find((bid: any) => bid.price === price);
sequentialBids.push({ price, volume: existingBid ? existingBid.volume : 0 });
}
return (
<div className="space-y-2">
<div className="space-y-0.5">
{sequentialAsks.map((ask: any, i: number) => (
<div key={`ask-${i}`} className="grid grid-cols-3 items-center gap-2 py-1 px-2">
<div className="text-xs text-gray-300 font-medium text-right font-mono">
{ask.volume > 0 ? ask.volume.toLocaleString() : '-'}
</div>
<div className="text-sm font-bold text-red-400 font-mono text-center">
{ask.price.toLocaleString()}
</div>
<div></div>
</div>
))}
</div>
<div className="py-3 bg-gradient-to-r from-red-900/20 via-red-800/30 to-red-900/20 rounded-lg border-2 border-red-500/30 my-2">
<div className="flex items-center justify-center gap-4 px-4">
<div className="text-2xl font-bold text-red-400 font-mono">
{currentPrice?.toLocaleString()}
</div>
{orderbook.current_volume > 0 && (
<div className="text-xs text-gray-400 font-mono">
({orderbook.current_volume.toLocaleString()})
</div>
)}
</div>
<div className="text-xs text-red-300 mt-1 text-center">연속 호가창</div>
</div>
<div className="space-y-0.5">
{sequentialBids.map((bid: any, i: number) => (
<div key={`bid-${i}`} className="grid grid-cols-3 items-center gap-2 py-1 px-2">
<div></div>
<div className="text-sm font-bold text-blue-400 font-mono text-center">
{bid.price.toLocaleString()}
</div>
<div className="text-xs text-gray-300 font-medium text-left font-mono">
{bid.volume > 0 ? bid.volume.toLocaleString() : '-'}
</div>
</div>
))}
</div>
</div>
);
})() : (
<div className="h-full flex items-center justify-center text-gray-600 text-xs text-center">
종목을 선택하면 호가 정보가 표시됩니다.
</div>
)}
</div>
</div>
<AiAnalysisPanel aiAnalysisData={aiAnalysisData} />
<OrderbookPanel orderbook={orderbook} />
</div>
</div>
{/* [Right Column] 분석 종목 & 매매일지 */}
<div className="col-span-3 flex flex-col gap-4 h-full overflow-hidden">
{/* 분석 종목 */}
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 rounded-xl overflow-hidden flex flex-col h-1/2 shadow-lg">
<div className="p-3 border-b border-gray-700/50 flex justify-between items-center bg-[#1c222c]">
<h3 className="font-semibold flex items-center gap-2 text-gray-200 text-sm">
<Brain className="w-4 h-4 text-purple-400" />
분석 종목
</h3>
<div className="flex items-center gap-2">
{ruleBasedRecommendations?.batch_time && (
<span className="text-[10px] text-gray-500 font-mono">
{formatTime(ruleBasedRecommendations.batch_time)}
</span>
)}
<span className="text-xs bg-purple-500/20 text-purple-300 px-2 py-0.5 rounded-full">
{ruleBasedRecommendations?.analyzed_stocks || 0}
</span>
<button
onClick={() => refetchRecommendations()}
className="p-1 hover:bg-gray-700/50 rounded transition-colors"
title="분석 종목 새로고침"
>
<RefreshCw className="w-3 h-3 text-gray-400 hover:text-purple-400" />
</button>
</div>
</div>
<div className="divide-y divide-gray-700/50 overflow-y-auto custom-scrollbar flex-1">
{isRecommendationsError ? (
<div className="h-full flex flex-col items-center justify-center text-red-500 text-xs p-4 text-center">
<p>⚠️ API 오류</p>
<p className="text-[10px] text-gray-500 mt-1">백엔드 로그 확인 필요</p>
<button onClick={() => refetchRecommendations()} className="mt-2 px-2 py-1 bg-red-500/20 text-red-400 rounded text-[10px]">재시도</button>
</div>
) : ruleBasedRecommendations === undefined ? (
<div className="h-full flex flex-col items-center justify-center text-gray-500 text-xs p-4">
<RefreshCw className="w-6 h-6 animate-spin mb-2 text-purple-400" />
<p>분석 종목 로딩 중...</p>
<p className="text-[10px] text-gray-600 mt-1">최초 로딩 시 10초 정도 소요됩니다</p>
</div>
) : (ruleBasedRecommendations?.analyzed_stock_list || []).length > 0 ? (
(ruleBasedRecommendations.analyzed_stock_list as any[]).map((stock: any, idx: number) => {
const isRecommended = (ruleBasedRecommendations?.recommendations || []).some((r: any) => r.stock_code === stock.stock_code)
const isHeld = heldStockCodes.has(stock.stock_code)
const isPending = pendingBuyStockCodes.has(stock.stock_code)
const changeRate = stock.change_rate || 0
return (
<button
key={`${stock.stock_code}-${idx}`}
onClick={() => {
handleSelectStock(stock.stock_code);
refetchAiAnalysis();
}}
className={`w-full p-3 text-left hover:bg-gray-700/30 transition-all duration-200 ${selectedStock === stock.stock_code ? 'bg-purple-500/10 border-l-2 border-purple-500' : 'border-l-2 border-transparent'}`}
>
<div className="flex justify-between items-start mb-1">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-0.5">
<span className="font-bold text-gray-200 text-xs truncate">{stock.stock_name}</span>
<span className="text-[9px] text-gray-500 flex-shrink-0">({stock.stock_code})</span>
</div>
{stock.signals && stock.signals.length > 0 && (
<div className="text-[9px] text-gray-500 truncate">
{stock.signals.slice(0, 2).join(' · ')}
</div>
)}
</div>
<div className="flex flex-col items-end gap-0.5 flex-shrink-0 ml-1">
{isPending ? (
<span className="text-[9px] text-orange-400 border border-orange-500/20 bg-orange-500/10 px-1 py-0.5 rounded flex items-center gap-0.5">
<RefreshCw className="w-2 h-2 animate-spin" />매수중
</span>
) : isHeld ? (
<span className="text-[9px] text-blue-400 border border-blue-500/20 bg-blue-500/10 px-1 py-0.5 rounded">보유중</span>
) : isRecommended ? (
<span className="text-[9px] text-green-400 border border-green-500/20 bg-green-500/10 px-1 py-0.5 rounded">매수신호</span>
) : null}
<span className={`text-[9px] font-bold ${changeRate > 0 ? 'text-red-400' : changeRate < 0 ? 'text-blue-400' : 'text-gray-500'}`}>
{changeRate > 0 ? '+' : ''}{changeRate.toFixed(2)}%
</span>
</div>
</div>
<div className="flex justify-between items-center text-[9px] text-gray-500 mt-0.5">
<span>{Math.round(livePrices?.[stock.stock_code] ?? stock.current_price ?? 0).toLocaleString()}원</span>
<span className="flex gap-2">
{stock.rsi != null && <span>RSI {stock.rsi.toFixed(0)}</span>}
{stock.stochastic_k != null && <span>K {stock.stochastic_k.toFixed(0)}</span>}
{stock.score > 0 && <span className="text-purple-400">{stock.score}점</span>}
</span>
</div>
</button>
)
})
) : (
<div className="h-full flex flex-col items-center justify-center text-gray-500 p-4 text-center text-xs">
<p>배치 분석 데이터가 없습니다.</p>
<p className="text-[10px] text-gray-600 mt-1">make batch-test 실행 필요</p>
</div>
)}
</div>
</div>
{/* 매매일지 */}
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 rounded-xl overflow-hidden flex flex-col h-1/2 shadow-lg">
<div className="flex flex-col h-full p-3">
<div className="flex flex-col h-full min-h-0">
<div className="flex items-center justify-between mb-2 flex-shrink-0">
<h3 className="font-bold flex items-center gap-2 text-green-400 text-sm">
<Activity size={16} />
매매일지
</h3>
<button
onClick={resetPendingTrades}
className="px-2 py-1 text-[10px] bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded border border-red-500/30 transition-colors"
title="미체결 주문 정리"
>
미체결 정리
</button>
</div>
<div className="space-y-2 overflow-y-auto custom-scrollbar flex-1">
{journals && journals.length > 0 ? (
journals.map((journal: any) => {
const journalDate = new Date(journal.journal_date || journal.created_at);
const dateStr = journalDate.toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit', timeZone: 'Asia/Seoul' });
const timeStr = journalDate.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'Asia/Seoul' });
const isGoodMood = journal.mood === '좋음' || journal.mood === 'good' || journal.mood === 'positive';
const isBadMood = journal.mood === '나쁨' || journal.mood === 'bad' || journal.mood === 'negative';
const tags: string[] = Array.isArray(journal.tags) ? journal.tags : [];
const hasProfitTag = tags.some((t: string) => t.includes('익절') || t.includes('profit'));
const hasLossTag = tags.some((t: string) => t.includes('손절') || t.includes('loss'));
return (
<div key={journal.id} className="p-3 hover:bg-gray-700/30 rounded-lg transition-colors bg-gray-900/30 border border-gray-700/30">
<div className="flex justify-between items-start mb-1.5">
<div className="flex items-center gap-2">
<span className="text-base">
{isGoodMood ? '😊' : isBadMood ? '😔' : '📝'}
</span>
<span className="font-bold text-gray-200 text-sm">{journal.stock_name || journal.stock_code}</span>
{hasProfitTag && <span className="px-1.5 py-0.5 text-[9px] rounded bg-green-500/20 text-green-400">익절</span>}
{hasLossTag && <span className="px-1.5 py-0.5 text-[9px] rounded bg-red-500/20 text-red-400">손절</span>}
</div>
<span className="text-[10px] text-gray-500 font-mono">{dateStr} {timeStr}</span>
</div>
<div className="text-[11px] text-gray-300 leading-relaxed line-clamp-3 whitespace-pre-line">
{journal.content}
</div>
{tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{tags.slice(0, 4).map((tag: string, i: number) => (
<span key={i} className="px-1.5 py-0.5 text-[9px] rounded bg-gray-700/50 text-gray-400">#{tag}</span>
))}
</div>
)}
</div>
);
})
) : (
<div className="h-full flex flex-col items-center justify-center text-gray-600 text-xs gap-1">
<span>매매일지 없음</span>
<span className="text-[10px] text-gray-700">매도 체결 후 자동으로 생성됩니다</span>
</div>
)}
</div>
</div>
</div>
</div>
<RecommendationPanel
recommendations={ruleBasedRecommendations}
isError={isRecommendationsError}
onRefetch={stableRefetchRecommendations}
selectedStock={selectedStock}
onSelectStock={handleSelectStock}
onRefetchAiAnalysis={stableRefetchAiAnalysis}
heldStockCodes={heldStockCodes}
pendingBuyStockCodes={pendingBuyStockCodes}
livePrices={livePrices}
/>
<JournalPanel
journals={journals}
onResetPending={resetPendingTrades}
/>
</div>
</div>
</main>