initial commit

This commit is contained in:
2026-01-31 22:34:57 +09:00
commit f1301de543
875 changed files with 196598 additions and 0 deletions

242
pages/Discovery.tsx Normal file
View File

@@ -0,0 +1,242 @@
import React, { useState, useMemo, useEffect } from 'react';
import {
Trophy, Flame, Users, Search, Info, MessageSquare, Sparkles, Zap,
Save, EyeOff, Eye, RefreshCw, FileText, StickyNote, History as HistoryIcon,
ArrowUpRight, ArrowDownRight
} from 'lucide-react';
import { StockItem, MarketType, OrderType, ApiSettings, TradeOrder } from '../types';
import StockDetailModal from '../components/StockDetailModal';
import TradeModal from '../components/TradeModal';
import { FilterChip, TabButton } from '../components/CommonUI';
import { StockRow } from '../components/StockRow';
import { AiService } from '../services/aiService';
interface DiscoveryProps {
stocks: StockItem[];
orders: TradeOrder[];
onUpdateStock: (code: string, updates: Partial<StockItem>) => void;
settings: ApiSettings;
}
const Discovery: React.FC<DiscoveryProps> = ({ stocks, orders, onUpdateStock, settings }) => {
const [activeTab, setActiveTab] = useState<'realtime' | 'category' | 'investor'>('realtime');
const [marketFilter, setMarketFilter] = useState<'all' | 'domestic' | 'overseas'>('all');
const [sortType, setSortType] = useState<'value' | 'volume' | 'gain' | 'loss'>('value');
const [selectedStockCode, setSelectedStockCode] = useState<string>(stocks[0]?.code || '');
const [detailStock, setDetailStock] = useState<StockItem | null>(null);
const [tradeContext, setTradeContext] = useState<{ stock: StockItem, type: OrderType } | null>(null);
const selectedStock = useMemo(() => {
return stocks.find(s => s.code === selectedStockCode) || null;
}, [stocks, selectedStockCode]);
const stockOrders = useMemo(() => {
return orders.filter(o => o.stockCode === selectedStockCode).sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
}, [orders, selectedStockCode]);
const [memo, setMemo] = useState('');
const [isAnalyzing, setIsAnalyzing] = useState(false);
useEffect(() => {
if (selectedStock) {
setMemo(selectedStock.memo || '');
}
}, [selectedStockCode, stocks]);
const enrichedStocks = useMemo(() => {
return stocks.filter(s => !s.isHidden).map((s) => ({
...s,
tradingValue: (s.volume * s.price) / (s.market === MarketType.DOMESTIC ? 100000000 : 1000000),
buyRatio: 50 + Math.floor(Math.random() * 45),
sellRatio: 100 - (50 + Math.floor(Math.random() * 45))
})).filter(s => {
if (marketFilter === 'domestic') return s.market === MarketType.DOMESTIC;
if (marketFilter === 'overseas') return s.market === MarketType.OVERSEAS;
return true;
}).sort((a, b) => {
if (sortType === 'value') return (b.tradingValue || 0) - (a.tradingValue || 0);
if (sortType === 'volume') return b.volume - a.volume;
if (sortType === 'gain') return b.changePercent - a.changePercent;
if (sortType === 'loss') return a.changePercent - b.changePercent;
return 0;
});
}, [stocks, marketFilter, sortType]);
const handleSaveMemo = () => {
if (selectedStock) {
onUpdateStock(selectedStock.code, { memo });
alert('메모가 저장되었습니다.');
}
};
const handleToggleHide = () => {
if (selectedStock) {
const newHidden = !selectedStock.isHidden;
onUpdateStock(selectedStock.code, { isHidden: newHidden });
if (newHidden) {
alert(`${selectedStock.name} 종목이 숨김 처리되었습니다.`);
const next = enrichedStocks.find(s => s.code !== selectedStock.code);
if (next) setSelectedStockCode(next.code);
}
}
};
const handleGenerateAnalysis = async () => {
if (!selectedStock) return;
const config = settings.aiConfigs.find(c => c.id === settings.preferredStockAiId) || settings.aiConfigs[0];
if (!config) return;
setIsAnalyzing(true);
try {
const prompt = `주식 전문가로서 ${selectedStock.name}(${selectedStock.code}) 리포트를 작성해줘.`;
const result = await AiService.analyzeNewsSentiment(config, [prompt]);
onUpdateStock(selectedStock.code, { aiAnalysis: result });
} catch (e) {
alert('AI 분석 생성 실패');
} finally {
setIsAnalyzing(false);
}
};
return (
<div className="max-w-[1500px] mx-auto flex flex-col lg:flex-row gap-6 animate-in fade-in duration-700 pb-10">
<div className="flex-1 space-y-6">
<div className="flex items-center gap-8 border-b border-slate-100 pb-1 overflow-x-auto scrollbar-hide">
<TabButton active={activeTab === 'realtime'} onClick={() => setActiveTab('realtime')} icon={<Trophy size={18}/>} label="실시간 차트" />
<TabButton active={activeTab === 'category'} onClick={() => setActiveTab('category')} icon={<Flame size={18}/>} label="인기 테마" />
<TabButton active={activeTab === 'investor'} onClick={() => setActiveTab('investor')} icon={<Users size={18}/>} label="투자자 동향" />
</div>
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex gap-1.5">
<FilterChip active={marketFilter === 'all'} onClick={() => setMarketFilter('all')} label="전체" />
<FilterChip active={marketFilter === 'domestic'} onClick={() => setMarketFilter('domestic')} label="국내" />
<FilterChip active={marketFilter === 'overseas'} onClick={() => setMarketFilter('overseas')} label="해외" />
</div>
<div className="flex gap-1.5 bg-slate-100 p-1 rounded-xl">
<FilterChip active={sortType === 'value'} onClick={() => setSortType('value')} label="거래대금" />
<FilterChip active={sortType === 'gain'} onClick={() => setSortType('gain')} label="급상승" />
</div>
</div>
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
<table className="w-full text-left">
<thead>
<tr className="text-[10px] font-black text-slate-400 uppercase tracking-widest border-b bg-slate-50/50">
<th className="pl-6 py-4 w-12 text-center"></th>
<th className="px-4 py-4"></th>
<th className="px-4 py-4 text-right"></th>
<th className="px-4 py-4 text-right"></th>
<th className="px-4 py-4 text-right w-36">/ </th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{enrichedStocks.map((stock, idx) => (
<StockRow
key={stock.code}
stock={stock}
rank={idx + 1}
showRank={true}
showRatioBar={true}
onClick={() => setSelectedStockCode(stock.code)}
/>
))}
</tbody>
</table>
</div>
</div>
<div className="w-full lg:w-[380px]">
{selectedStock && (
<div className="bg-white p-6 rounded-3xl shadow-lg border border-slate-100 flex flex-col gap-6 sticky top-24 max-h-[88vh] overflow-y-auto scrollbar-hide">
<div className="flex justify-between items-start">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-slate-900 rounded-xl flex items-center justify-center text-white shadow-md text-sm">{selectedStock.name[0]}</div>
<div>
<h4 className="text-lg font-black text-slate-900 italic tracking-tighter cursor-pointer hover:text-blue-600 leading-tight" onClick={() => setDetailStock(selectedStock)}>{selectedStock.name}</h4>
<p className="text-[11px] font-black text-slate-400">{selectedStock.code}</p>
</div>
</div>
<button onClick={handleToggleHide} className={`p-2 rounded-xl transition-all ${selectedStock.isHidden ? 'bg-rose-50 text-rose-500' : 'bg-slate-50 text-slate-400 hover:text-rose-500'}`}>
{selectedStock.isHidden ? <Eye size={18} /> : <EyeOff size={18} />}
</button>
</div>
<div className="flex gap-3">
<button onClick={() => setTradeContext({stock: selectedStock, type: OrderType.BUY})} className="flex-1 py-3 bg-rose-500 text-white rounded-xl font-black text-sm shadow-md flex items-center justify-center gap-1.5"></button>
<button onClick={() => setTradeContext({stock: selectedStock, type: OrderType.SELL})} className="flex-1 py-3 bg-blue-600 text-white rounded-xl font-black text-sm shadow-md flex items-center justify-center gap-1.5"></button>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 text-[13px] font-black text-slate-800">
<StickyNote size={14} className="text-amber-500" />
</div>
<button onClick={handleSaveMemo} className="text-[10px] font-black text-blue-600 hover:underline"></button>
</div>
<textarea
className="w-full h-20 p-4 bg-slate-50 border border-slate-100 rounded-2xl text-[12px] text-slate-600 font-medium resize-none focus:bg-white outline-none transition-all"
placeholder="메모를 입력하세요..."
value={memo}
onChange={(e) => setMemo(e.target.value)}
/>
</div>
<div className="space-y-3 pt-4 border-t border-slate-50">
<div className="flex items-center gap-1.5 text-[13px] font-black text-slate-800">
<HistoryIcon size={14} className="text-blue-500" />
</div>
<div className="space-y-2">
{stockOrders.length > 0 ? (
stockOrders.slice(0, 2).map((order) => (
<div key={order.id} className="p-3 bg-slate-50/50 border border-slate-100 rounded-xl flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`p-1.5 rounded-lg ${order.type === OrderType.BUY ? 'bg-rose-50 text-rose-500' : 'bg-blue-50 text-blue-500'}`}>
{order.type === OrderType.BUY ? <ArrowDownRight size={12} /> : <ArrowUpRight size={12} />}
</div>
<div>
<p className="text-[11px] font-black text-slate-800">{order.type === OrderType.BUY ? '매수' : '매도'} {order.quantity}</p>
<p className="text-[9px] text-slate-400 font-bold">{new Date(order.timestamp).toLocaleDateString()}</p>
</div>
</div>
<p className="text-[11px] font-black text-slate-900">{order.price.toLocaleString()}</p>
</div>
))
) : (
<p className="text-[10px] font-black text-slate-300 text-center py-4 italic"> </p>
)}
</div>
</div>
<div className="space-y-3 pt-4 border-t border-slate-50">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 text-[13px] font-black text-slate-800">
<Sparkles size={14} className="text-purple-500" /> AI
</div>
<button onClick={handleGenerateAnalysis} disabled={isAnalyzing} className="p-1.5 bg-purple-50 text-purple-600 rounded-lg">
<RefreshCw size={12} className={isAnalyzing ? 'animate-spin' : ''} />
</button>
</div>
{selectedStock.aiAnalysis ? (
<div className="bg-slate-900 p-4 rounded-2xl text-slate-100 text-[11px] leading-relaxed font-medium italic">
{selectedStock.aiAnalysis}
</div>
) : (
<div className="p-6 border border-dashed border-slate-100 rounded-2xl flex flex-col items-center gap-2 opacity-30 text-center">
<p className="text-[10px] font-black uppercase"> </p>
</div>
)}
</div>
</div>
)}
</div>
{detailStock && <StockDetailModal stock={detailStock} onClose={() => setDetailStock(null)} />}
{tradeContext && <TradeModal stock={tradeContext.stock} type={tradeContext.type} onClose={() => setTradeContext(null)} onExecute={async (o) => alert('주문 예약됨')} />}
</div>
);
};
export default Discovery;