대시보드 분리
This commit is contained in:
115
frontend/src/components/ChartPanel.tsx
Normal file
115
frontend/src/components/ChartPanel.tsx
Normal 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
|
||||
79
frontend/src/components/JournalPanel.tsx
Normal file
79
frontend/src/components/JournalPanel.tsx
Normal 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
|
||||
98
frontend/src/components/OrderbookPanel.tsx
Normal file
98
frontend/src/components/OrderbookPanel.tsx
Normal 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
|
||||
105
frontend/src/components/PortfolioPanel.tsx
Normal file
105
frontend/src/components/PortfolioPanel.tsx
Normal 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
|
||||
133
frontend/src/components/RecommendationPanel.tsx
Normal file
133
frontend/src/components/RecommendationPanel.tsx
Normal 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
|
||||
Reference in New Issue
Block a user