initial commit
This commit is contained in:
183
components/StockDetailModal.tsx
Normal file
183
components/StockDetailModal.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
X, TrendingUp, BarChart4, PieChart, Info, Building2, Layers, RotateCw
|
||||
} from 'lucide-react';
|
||||
import { StockItem, MarketType, StockTick } from '../types';
|
||||
import { DbService } from '../services/dbService';
|
||||
|
||||
interface StockDetailModalProps {
|
||||
stock: StockItem;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const StockDetailModal: React.FC<StockDetailModalProps> = ({ stock, onClose }) => {
|
||||
const [ticks, setTicks] = useState<StockTick[]>([]);
|
||||
const dbService = useMemo(() => new DbService(), []);
|
||||
|
||||
useEffect(() => {
|
||||
const refreshTicks = async () => {
|
||||
const history = await dbService.getStockTicks(stock.code);
|
||||
setTicks(history);
|
||||
};
|
||||
|
||||
refreshTicks();
|
||||
const interval = setInterval(refreshTicks, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [stock.code, dbService]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[300] bg-slate-900/40 backdrop-blur-xl flex items-center justify-center p-6 animate-in fade-in duration-300">
|
||||
<div className="bg-white w-full max-w-7xl rounded-[4rem] h-[85vh] shadow-2xl flex flex-col overflow-hidden border border-slate-100 animate-in slide-in-from-bottom-10">
|
||||
{/* Header */}
|
||||
<div className="px-16 py-10 flex justify-between items-center border-b border-slate-50">
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="w-20 h-20 bg-slate-900 rounded-[2rem] flex items-center justify-center text-white shadow-xl">
|
||||
<BarChart4 size={40} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-4 mb-2">
|
||||
<h2 className="text-4xl font-black text-slate-900 tracking-tighter italic">{stock.name}</h2>
|
||||
<span className={`px-4 py-1 rounded-full text-[10px] font-black uppercase tracking-[0.2em] ${stock.market === MarketType.DOMESTIC ? 'bg-red-50 text-red-500' : 'bg-blue-50 text-blue-600'}`}>
|
||||
{stock.market}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-base text-slate-400 font-mono font-bold tracking-[0.3em] uppercase">{stock.code}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="text-right">
|
||||
<p className="text-5xl font-black text-slate-900 tracking-tighter mb-2">
|
||||
{stock.market === MarketType.DOMESTIC ? stock.price.toLocaleString() : `$${stock.price}`}
|
||||
</p>
|
||||
<p className={`text-xl font-bold ${stock.changePercent >= 0 ? 'text-rose-500' : 'text-blue-600'} flex items-center justify-end gap-2`}>
|
||||
{stock.changePercent >= 0 ? <TrendingUp size={24}/> : <TrendingUp size={24} className="rotate-180" />}
|
||||
{Math.abs(stock.changePercent)}%
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-4 bg-slate-50 hover:bg-slate-100 text-slate-400 rounded-full transition-all">
|
||||
<X size={32} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Left: Chart */}
|
||||
<div className="flex-[1.5] p-16 border-r border-slate-50 overflow-hidden flex flex-col">
|
||||
<div className="flex items-center justify-between mb-10">
|
||||
<h4 className="text-[12px] font-black text-slate-400 uppercase tracking-[0.3em] flex items-center gap-3">
|
||||
<PieChart size={20} className="text-blue-500" /> 시계열 시세 차트 (실시간)
|
||||
</h4>
|
||||
<span className="text-[10px] bg-slate-900 text-white px-4 py-1.5 rounded-lg font-black uppercase tracking-widest flex items-center gap-2">
|
||||
<span className="w-2 h-2 bg-emerald-500 rounded-full animate-pulse"></span> 데이터 수집 중
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 relative bg-slate-50/50 rounded-[3rem] border border-slate-100 overflow-hidden group">
|
||||
{ticks.length > 0 ? (
|
||||
<div className="w-full h-full p-10">
|
||||
<SimpleLineChart data={ticks} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center text-slate-300 gap-6">
|
||||
<RotateCw size={48} className="animate-spin" />
|
||||
<p className="text-sm font-black uppercase tracking-widest">분 단위 시세 데이터 수집 중...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Info */}
|
||||
<div className="flex-1 p-16 overflow-y-auto scrollbar-hide space-y-12">
|
||||
<section>
|
||||
<h4 className="text-[12px] font-black text-slate-400 uppercase tracking-[0.3em] mb-8 flex items-center gap-3">
|
||||
<Layers size={20} className="text-blue-500" /> 연관 테마 분석
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{stock.themes?.map(theme => (
|
||||
<span key={theme} className="px-6 py-3 bg-blue-50 text-blue-600 rounded-2xl text-[12px] font-black uppercase tracking-widest shadow-sm border border-blue-100">
|
||||
#{theme}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h4 className="text-[12px] font-black text-slate-400 uppercase tracking-[0.3em] mb-8 flex items-center gap-3">
|
||||
<Building2 size={20} className="text-purple-500" /> 기업 분석 핵심 지표
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<DataCard label="PER" value={stock.per} desc="Price Earning Ratio" />
|
||||
<DataCard label="PBR" value={stock.pbr} desc="Price Book-value Ratio" />
|
||||
<DataCard label="ROE" value={stock.roe} suffix="%" desc="Return On Equity" />
|
||||
<DataCard label="시가총액" value={stock.marketCap} suffix="억" desc="Market Cap" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-slate-900 p-10 rounded-[2.5rem] shadow-2xl">
|
||||
<h4 className="text-[11px] font-black text-blue-400 uppercase tracking-[0.25em] mb-6">AI Strategy Insight</h4>
|
||||
<div className="space-y-6">
|
||||
<ScoreBar label="BUY" score={stock.aiScoreBuy} color="blue" />
|
||||
<ScoreBar label="SELL" score={stock.aiScoreSell} color="rose" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Helpers ---
|
||||
const SimpleLineChart: React.FC<{ data: StockTick[] }> = ({ data }) => {
|
||||
const prices = data.map(d => d.price);
|
||||
const min = Math.min(...prices) * 0.9995;
|
||||
const max = Math.max(...prices) * 1.0005;
|
||||
const range = max - min || 1;
|
||||
const width = 800;
|
||||
const height = 400;
|
||||
|
||||
if (data.length <= 1) return <div className="flex items-center justify-center h-full text-slate-400 uppercase font-black text-xs tracking-widest">데이터 수집 부족</div>;
|
||||
|
||||
const points = data.map((d, i) => {
|
||||
const x = (i / (data.length - 1)) * width;
|
||||
const y = height - ((d.price - min) / range) * height;
|
||||
return `${x},${y}`;
|
||||
}).join(' ');
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-full overflow-visible">
|
||||
<polyline fill="none" stroke="#3b82f6" strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" points={points} />
|
||||
<polygon points={`0,${height} ${points} ${width},${height}`} fill="url(#grad-dash)" />
|
||||
<circle cx={width} cy={height - ((data[data.length-1].price - min) / range) * height} r="6" fill="#3b82f6" className="animate-pulse" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const DataCard: React.FC<{ label: string, value?: number, suffix?: string, desc: string }> = ({ label, value, suffix = '', desc }) => (
|
||||
<div className="bg-white border border-slate-100 p-8 rounded-[2rem] shadow-sm hover:border-blue-100 transition-all group">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{label}</span>
|
||||
<Info size={14} className="text-slate-200 group-hover:text-blue-400 transition-colors" />
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1 mb-1">
|
||||
<span className="text-2xl font-black text-slate-900 leading-none">{value !== undefined ? value.toLocaleString() : 'N/A'}</span>
|
||||
<span className="text-[12px] font-black text-slate-400">{suffix}</span>
|
||||
</div>
|
||||
<p className="text-[9px] font-bold text-slate-300 uppercase tracking-widest">{desc}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ScoreBar: React.FC<{ label: string, score: number, color: 'rose' | 'blue' }> = ({ label, score, color }) => (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between items-center text-[10px] font-black">
|
||||
<span className={color === 'rose' ? 'text-rose-500' : 'text-blue-600'}>{label}</span>
|
||||
<span className="text-slate-400">{score}pt</span>
|
||||
</div>
|
||||
<div className="h-2 w-full bg-white/10 rounded-full overflow-hidden">
|
||||
<div className={`h-full transition-all duration-1000 ${color === 'rose' ? 'bg-rose-500' : 'bg-blue-600'}`} style={{ width: `${score}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default StockDetailModal;
|
||||
Reference in New Issue
Block a user