140 lines
8.3 KiB
TypeScript
140 lines
8.3 KiB
TypeScript
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={() => {
|
||
if (selectedStock === stock.stock_code) {
|
||
onRefetchAiAnalysis() // 같은 종목 재클릭 → 강제 새로고침
|
||
} else {
|
||
onSelectStock(stock.stock_code) // 새 종목 → queryKey 변경으로 자동 fetch
|
||
}
|
||
}}
|
||
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
|