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

308
pages/AiInsights.tsx Normal file
View File

@@ -0,0 +1,308 @@
import React, { useState, useEffect } from 'react';
import {
BrainCircuit,
Sparkles,
RefreshCw,
Search,
TrendingUp,
MessageSquareQuote,
Zap,
ChevronRight,
Target,
BarChart4,
Cpu,
ShieldCheck,
AlertCircle
} from 'lucide-react';
import { ApiSettings, StockItem, NewsItem, AiConfig } from '../types';
import { NaverService } from '../services/naverService';
import { AiService } from '../services/aiService';
interface AiInsightsProps {
stocks: StockItem[];
settings: ApiSettings;
onUpdateSettings: (settings: ApiSettings) => void;
}
const AiInsights: React.FC<AiInsightsProps> = ({ stocks, settings, onUpdateSettings }) => {
// 뉴스 분석 상태
const [news, setNews] = useState<NewsItem[]>([]);
const [isNewsLoading, setIsNewsLoading] = useState(false);
const [selectedNewsAiId, setSelectedNewsAiId] = useState<string>(settings.preferredNewsAiId || settings.aiConfigs[0]?.id || 'none');
const [newsAnalysis, setNewsAnalysis] = useState<string | null>(null);
const [isNewsAnalyzing, setIsNewsAnalyzing] = useState(false);
// 종목 분석 상태
const [search, setSearch] = useState('');
const [selectedStock, setSelectedStock] = useState<StockItem | null>(null);
const [selectedStockAiId, setSelectedStockAiId] = useState<string>(settings.preferredStockAiId || settings.aiConfigs[0]?.id || 'none');
const [stockAnalysis, setStockAnalysis] = useState<string | null>(null);
const [isStockAnalyzing, setIsStockAnalyzing] = useState(false);
const naverService = new NaverService(settings);
useEffect(() => {
fetchInitialNews();
}, []);
const fetchInitialNews = async () => {
setIsNewsLoading(true);
if (settings.useNaverNews) {
const result = await naverService.fetchNews("주식 시장 전망");
setNews(result);
}
setIsNewsLoading(false);
};
const handleNewsAnalysis = async () => {
const config = settings.aiConfigs.find(c => c.id === selectedNewsAiId);
if (!config) return;
setIsNewsAnalyzing(true);
const headlines = news.map(n => n.title);
try {
const result = await AiService.analyzeNewsSentiment(config, headlines);
setNewsAnalysis(result);
// 선호 엔진 업데이트
onUpdateSettings({ ...settings, preferredNewsAiId: selectedNewsAiId });
} catch (e) {
setNewsAnalysis("분석 실패: API 설정을 확인해주세요.");
} finally {
setIsNewsAnalyzing(false);
}
};
const handleStockAnalysis = async () => {
if (!selectedStock) return;
const config = settings.aiConfigs.find(c => c.id === selectedStockAiId);
if (!config) return;
setIsStockAnalyzing(true);
const context = `종목명: ${selectedStock.name}, 현재가: ${selectedStock.price}, 등락률: ${selectedStock.changePercent}%, PER: ${selectedStock.per}, PBR: ${selectedStock.pbr}, ROE: ${selectedStock.roe}%`;
const prompt = `주식 전문가로서 다음 데이터를 바탕으로 ${selectedStock.name}에 대한 매수/매도 의견과 향후 일주일간의 대응 전략을 아주 구체적으로 제안해 주세요. 데이터: ${context}`;
try {
// AiService에 범용 호출 메서드가 없으므로 analyzeNewsSentiment를 차용하거나 직접 fetch 가능
// 여기서는 규칙에 따라 aiService의 call 메카니즘을 활용하도록 시뮬레이션
const result = await AiService.analyzeNewsSentiment(config, [prompt]);
setStockAnalysis(result);
onUpdateSettings({ ...settings, preferredStockAiId: selectedStockAiId });
} catch (e) {
setStockAnalysis("분석 실패: API 연결을 확인하세요.");
} finally {
setIsStockAnalyzing(false);
}
};
const filteredStocks = stocks.filter(s =>
s.name.includes(search) || s.code.toLowerCase().includes(search.toLowerCase())
).slice(0, 5);
return (
<div className="max-w-7xl mx-auto space-y-12 pb-32 animate-in fade-in duration-700">
{/* Header Banner */}
<div className="relative bg-slate-900 rounded-[4rem] p-16 overflow-hidden border border-white/5 shadow-2xl">
<div className="absolute top-0 right-0 w-[500px] h-[500px] bg-gradient-to-br from-blue-600/20 to-purple-600/20 rounded-full blur-[100px] -mr-64 -mt-64"></div>
<div className="relative z-10 flex flex-col md:flex-row items-center justify-between gap-10">
<div className="space-y-6 max-w-2xl">
<div className="flex items-center gap-4">
<div className="p-4 bg-blue-600 text-white rounded-[1.5rem] shadow-lg shadow-blue-500/30">
<BrainCircuit size={40} />
</div>
<h2 className="text-4xl font-black text-white italic tracking-tighter">AI INTELLIGENCE CENTER</h2>
</div>
<p className="text-lg text-slate-400 font-medium leading-relaxed">
AI <br/>
.
</p>
<div className="flex items-center gap-6 pt-4">
<div className="flex items-center gap-2 px-5 py-2.5 bg-white/5 rounded-full border border-white/10">
<ShieldCheck className="text-emerald-500" size={18} />
<span className="text-[11px] font-black text-slate-300 uppercase tracking-widest">Secure AI Tunneling</span>
</div>
<div className="flex items-center gap-2 px-5 py-2.5 bg-white/5 rounded-full border border-white/10">
<Cpu className="text-blue-400" size={18} />
<span className="text-[11px] font-black text-slate-300 uppercase tracking-widest">{settings.aiConfigs.length} Engines Connected</span>
</div>
</div>
</div>
<div className="hidden lg:block relative">
<div className="w-64 h-64 bg-slate-800 rounded-[3rem] border border-white/5 flex items-center justify-center animate-pulse">
<Sparkles size={80} className="text-blue-500/40" />
</div>
<div className="absolute -top-4 -right-4 p-4 bg-blue-600 rounded-2xl shadow-xl">
<Target size={24} className="text-white" />
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
{/* News Intelligence Panel */}
<div className="space-y-8">
<div className="bg-white p-12 rounded-[3.5rem] shadow-sm border border-slate-100 h-full flex flex-col">
<div className="flex justify-between items-start mb-10">
<div className="flex items-center gap-5">
<div className="p-4 bg-emerald-50 text-emerald-600 rounded-2xl">
<TrendingUp size={28} />
</div>
<div>
<h3 className="text-2xl font-black text-slate-900 tracking-tight">MARKET SENTIMENT</h3>
<p className="text-[11px] text-slate-400 font-black uppercase tracking-widest mt-1"> </p>
</div>
</div>
<div className="flex items-center gap-3">
<select
value={selectedNewsAiId}
onChange={(e) => setSelectedNewsAiId(e.target.value)}
className="p-3.5 bg-slate-50 border border-slate-100 rounded-xl text-xs font-black outline-none focus:border-blue-500 transition-all cursor-pointer"
>
{settings.aiConfigs.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<button
onClick={handleNewsAnalysis}
disabled={isNewsAnalyzing || news.length === 0}
className="p-3.5 bg-slate-900 text-white rounded-xl hover:bg-slate-800 transition-all disabled:opacity-30"
>
{isNewsAnalyzing ? <RefreshCw size={18} className="animate-spin" /> : <Sparkles size={18} />}
</button>
</div>
</div>
<div className="flex-1 space-y-8 overflow-y-auto pr-2 scrollbar-hide">
{newsAnalysis ? (
<div className="p-8 bg-slate-900 rounded-[2.5rem] text-white shadow-2xl animate-in zoom-in-95 duration-500">
<div className="flex items-center gap-3 mb-6">
<MessageSquareQuote size={24} className="text-emerald-400" />
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400">Analysis Result</span>
</div>
<div className="text-base font-medium leading-relaxed opacity-90 whitespace-pre-wrap font-sans">
{newsAnalysis}
</div>
</div>
) : (
<div className="space-y-4 opacity-50">
{news.slice(0, 3).map((n, i) => (
<div key={i} className="p-6 bg-slate-50 rounded-2xl border border-slate-100 flex items-center justify-between">
<p className="font-bold text-slate-800 truncate pr-4">{n.title}</p>
<ChevronRight size={16} className="text-slate-300 shrink-0" />
</div>
))}
{news.length === 0 && (
<div className="py-20 text-center flex flex-col items-center gap-4">
<AlertCircle className="text-slate-200" size={48} />
<p className="text-sm font-black text-slate-300 uppercase"> <br/> .</p>
</div>
)}
</div>
)}
</div>
</div>
</div>
{/* Stock Deep Analysis Panel */}
<div className="space-y-8">
<div className="bg-white p-12 rounded-[3.5rem] shadow-sm border border-slate-100 h-full flex flex-col">
<div className="flex justify-between items-start mb-10">
<div className="flex items-center gap-5">
<div className="p-4 bg-purple-50 text-purple-600 rounded-2xl">
<BarChart4 size={28} />
</div>
<div>
<h3 className="text-2xl font-black text-slate-900 tracking-tight">STOCK DEEP DIVE</h3>
<p className="text-[11px] text-slate-400 font-black uppercase tracking-widest mt-1"> </p>
</div>
</div>
<div className="flex items-center gap-3">
<select
value={selectedStockAiId}
onChange={(e) => setSelectedStockAiId(e.target.value)}
className="p-3.5 bg-slate-50 border border-slate-100 rounded-xl text-xs font-black outline-none focus:border-blue-500 transition-all cursor-pointer"
>
{settings.aiConfigs.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
</div>
<div className="relative mb-8">
<Search className="absolute left-6 top-1/2 -translate-y-1/2 text-slate-400" size={24} />
<input
type="text"
placeholder="분석할 종목 검색..."
className="w-full pl-16 pr-6 py-5 bg-slate-50 border-2 border-transparent rounded-[2rem] focus:border-blue-500 focus:bg-white outline-none text-base font-bold text-slate-800 transition-all shadow-inner"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{search && filteredStocks.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-3 bg-white border border-slate-100 shadow-2xl rounded-[2.5rem] overflow-hidden z-[50]">
{filteredStocks.map(s => (
<div
key={s.code}
onClick={() => { setSelectedStock(s); setSearch(''); setStockAnalysis(null); }}
className="p-6 hover:bg-purple-50 cursor-pointer flex justify-between items-center border-b last:border-none border-slate-50 group"
>
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-slate-100 rounded-2xl flex items-center justify-center font-black text-slate-400 text-xs">{s.code.substring(0,2)}</div>
<div>
<p className="font-black text-slate-800 text-base">{s.name}</p>
<p className="text-[11px] font-mono text-slate-400 font-bold tracking-widest">{s.code}</p>
</div>
</div>
<ChevronRight className="text-slate-200 group-hover:text-purple-500 transition-colors" />
</div>
))}
</div>
)}
</div>
<div className="flex-1 space-y-8 overflow-y-auto pr-2 scrollbar-hide">
{selectedStock ? (
<div className="space-y-8 animate-in slide-in-from-bottom-4 duration-500">
<div className="p-8 bg-purple-50 rounded-[2.5rem] border border-purple-100 flex items-center justify-between">
<div className="flex items-center gap-5">
<div className="w-16 h-16 bg-white rounded-3xl flex items-center justify-center text-purple-600 shadow-sm">
<Zap size={32} fill="currentColor" />
</div>
<div>
<h4 className="text-2xl font-black text-slate-900 tracking-tighter italic">{selectedStock.name}</h4>
<p className="text-sm font-mono text-slate-500 font-bold">{selectedStock.code}</p>
</div>
</div>
<button
onClick={handleStockAnalysis}
disabled={isStockAnalyzing}
className="px-8 py-4 bg-purple-600 text-white rounded-2xl font-black text-xs uppercase tracking-widest hover:bg-purple-700 transition-all shadow-xl shadow-purple-200"
>
{isStockAnalyzing ? <RefreshCw className="animate-spin" size={20} /> : "전략 분석 요청"}
</button>
</div>
{stockAnalysis && (
<div className="p-10 bg-white border-2 border-purple-100 rounded-[3rem] shadow-xl animate-in fade-in zoom-in-95 duration-500 relative overflow-hidden">
<div className="absolute top-0 right-0 p-4 opacity-5">
<BrainCircuit size={120} />
</div>
<div className="relative z-10 text-slate-800 text-base font-medium leading-relaxed whitespace-pre-wrap">
{stockAnalysis}
</div>
</div>
)}
</div>
) : (
<div className="h-full flex flex-col items-center justify-center py-20 opacity-30">
<Search size={64} strokeWidth={1} className="text-slate-300 mb-6" />
<p className="text-sm font-black text-slate-400 uppercase tracking-widest"> </p>
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
};
export default AiInsights;

289
pages/AutoTrading.tsx Normal file
View File

@@ -0,0 +1,289 @@
import React, { useState } from 'react';
import { Cpu, Plus, Calendar, Zap, Trash2, Activity, Clock, LayoutGrid, Layers, X } from 'lucide-react';
import { StockItem, AutoTradeConfig, MarketType, WatchlistGroup } from '../types';
interface AutoTradingProps {
marketMode: MarketType;
stocks: StockItem[];
configs: AutoTradeConfig[];
groups: WatchlistGroup[];
onAddConfig: (config: Omit<AutoTradeConfig, 'id' | 'active'>) => void;
onToggleConfig: (id: string) => void;
onDeleteConfig: (id: string) => void;
}
const AutoTrading: React.FC<AutoTradingProps> = ({ marketMode, stocks, configs, groups, onAddConfig, onToggleConfig, onDeleteConfig }) => {
const [showAddModal, setShowAddModal] = useState(false);
const [targetType, setTargetType] = useState<'SINGLE' | 'GROUP'>('SINGLE');
const [newConfig, setNewConfig] = useState<Partial<AutoTradeConfig>>({
type: 'ACCUMULATION',
frequency: 'DAILY',
quantity: 1,
executionTime: '09:00',
specificDay: 1
});
const handleAdd = () => {
if (newConfig.type) {
if (targetType === 'SINGLE' && !newConfig.stockCode) return;
if (targetType === 'GROUP' && !newConfig.groupId) return;
const stockName = targetType === 'SINGLE'
? stocks.find(s => s.code === newConfig.stockCode)?.name || '알 수 없음'
: groups.find(g => g.id === newConfig.groupId)?.name || '알 수 없는 그룹';
onAddConfig({
stockCode: targetType === 'SINGLE' ? newConfig.stockCode : undefined,
groupId: targetType === 'GROUP' ? newConfig.groupId : undefined,
stockName: stockName,
type: newConfig.type as 'ACCUMULATION' | 'TRAILING_STOP',
quantity: newConfig.quantity || 1,
frequency: newConfig.frequency as 'DAILY' | 'WEEKLY' | 'MONTHLY',
specificDay: newConfig.specificDay,
executionTime: newConfig.executionTime || '09:00',
trailingPercent: newConfig.trailingPercent,
market: marketMode
});
setShowAddModal(false);
}
};
const getDayLabel = (config: AutoTradeConfig) => {
if (config.frequency === 'DAILY') return '매일';
if (config.frequency === 'WEEKLY') {
const days = ['일', '월', '화', '수', '목', '금', '토'];
return `매주 ${days[config.specificDay || 0]}요일`;
}
if (config.frequency === 'MONTHLY') return `매월 ${config.specificDay}`;
return '';
};
const filteredStocks = stocks.filter(s => s.market === marketMode);
const filteredGroups = groups.filter(g => g.codes.some(code => stocks.find(s => s.code === code)?.market === marketMode));
return (
<div className="space-y-12 animate-in slide-in-from-bottom-6 duration-500 pb-24">
<div className="flex justify-between items-center bg-white p-12 rounded-[4rem] shadow-sm border border-slate-100">
<div>
<h3 className="text-3xl font-black text-slate-800 uppercase tracking-tight flex items-center gap-5">
<Cpu className="text-emerald-500" size={36} /> {marketMode === MarketType.DOMESTIC ? '국내' : '해외'}
</h3>
<p className="text-base font-bold text-slate-400 uppercase tracking-widest mt-3 flex items-center gap-3">
<span className="relative flex h-3 w-3">
<span className={`animate-ping absolute inline-flex h-full w-full rounded-full opacity-75 ${configs.filter(c => c.active && c.market === marketMode).length > 0 ? 'bg-emerald-400' : 'bg-slate-300'}`}></span>
<span className={`relative inline-flex rounded-full h-3 w-3 ${configs.filter(c => c.active && c.market === marketMode).length > 0 ? 'bg-emerald-500' : 'bg-slate-400'}`}></span>
</span>
{configs.filter(c => c.active && c.market === marketMode).length}
</p>
</div>
<button
onClick={() => { setShowAddModal(true); setTargetType('SINGLE'); }}
className="bg-slate-900 text-white px-12 py-6 rounded-[2.5rem] font-black text-base uppercase tracking-widest flex items-center gap-4 hover:bg-slate-800 transition-all shadow-2xl shadow-slate-300 active:scale-95"
>
<Plus size={24} />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10">
{configs.filter(c => c.market === marketMode).map(config => (
<div key={config.id} className={`bg-white p-12 rounded-[4rem] shadow-sm border transition-all relative overflow-hidden group hover:shadow-2xl ${config.active ? 'border-emerald-200' : 'border-slate-100 grayscale-[0.5]'}`}>
<div className={`absolute top-0 left-0 w-full h-2.5 transition-colors ${config.active ? (config.groupId ? 'bg-indigo-500' : (config.type === 'ACCUMULATION' ? 'bg-blue-500' : 'bg-orange-500')) : 'bg-slate-300'}`}></div>
<div className="flex justify-between items-start mb-10">
<div className="flex items-center gap-6">
<div className={`p-5 rounded-3xl shadow-sm transition-colors ${config.active ? (config.groupId ? 'bg-indigo-50 text-indigo-600' : (config.type === 'ACCUMULATION' ? 'bg-blue-50 text-blue-600' : 'bg-orange-50 text-orange-600')) : 'bg-slate-100 text-slate-400'}`}>
{config.groupId ? <Layers size={32} /> : <Activity size={32} />}
</div>
<div>
<h4 className={`font-black text-2xl leading-none mb-2 transition-colors ${config.active ? 'text-slate-900' : 'text-slate-400'}`}>{config.stockName}</h4>
<p className="text-[12px] text-slate-400 font-mono font-bold tracking-widest uppercase">{config.groupId ? 'GROUP AGENT' : config.stockCode}</p>
</div>
</div>
{/* 활성화 토글 스위치 */}
<button
onClick={() => onToggleConfig(config.id)}
className={`relative inline-flex h-8 w-14 items-center rounded-full transition-all focus:outline-none ${config.active ? 'bg-emerald-500' : 'bg-slate-200'}`}
>
<span className={`inline-block h-5 w-5 transform rounded-full bg-white transition-transform ${config.active ? 'translate-x-7' : 'translate-x-2'}`} />
</button>
</div>
<div className="space-y-6">
<div className={`p-8 rounded-[3rem] space-y-5 border transition-colors ${config.active ? 'bg-slate-50/80 border-slate-100' : 'bg-slate-50/30 border-transparent'}`}>
<div className="flex justify-between items-center text-[12px] font-black uppercase tracking-[0.2em]">
<span className="text-slate-400"></span>
<span className={config.active ? (config.groupId ? 'text-indigo-600' : 'text-slate-600') : 'text-slate-300'}>{config.groupId ? '그룹 일괄' : '개별 자산'}</span>
</div>
<div className="flex justify-between items-center text-[12px] font-black uppercase tracking-[0.2em]">
<span className="text-slate-400"> </span>
<span className={`px-3 py-1 rounded-lg transition-colors ${config.active ? (config.type === 'ACCUMULATION' ? 'bg-blue-100 text-blue-600' : 'bg-orange-100 text-orange-600') : 'bg-slate-100 text-slate-300'}`}>
{config.type === 'ACCUMULATION' ? '적립식 매수' : 'TS 자동매매'}
</span>
</div>
<div className="flex justify-between items-center text-base font-bold">
<span className="text-slate-400 uppercase tracking-widest text-[12px]"></span>
<span className={`flex items-center gap-3 transition-colors ${config.active ? 'text-slate-700' : 'text-slate-300'}`}><Calendar size={20} className="text-slate-400" /> {getDayLabel(config)}</span>
</div>
<div className="flex justify-between items-center text-base font-bold">
<span className="text-slate-400 uppercase tracking-widest text-[12px]"></span>
<span className={`flex items-center gap-3 transition-colors ${config.active ? 'text-slate-700' : 'text-slate-300'}`}><Clock size={20} className="text-slate-400" /> {config.executionTime} / {config.quantity}</span>
</div>
</div>
<div className="pt-8 border-t border-slate-100 flex justify-between items-center">
<div className="flex items-center gap-3">
<span className={`w-3 h-3 rounded-full transition-colors ${config.active ? 'bg-emerald-500' : 'bg-slate-300'}`}></span>
<span className="text-[12px] font-black text-slate-400 uppercase tracking-widest">{config.active ? '에이전트 운용 중' : '일시 중단됨'}</span>
</div>
<button
onClick={() => onDeleteConfig(config.id)}
className="p-4 text-slate-300 hover:text-rose-500 hover:bg-rose-50 rounded-[1.5rem] transition-all"
>
<Trash2 size={24} />
</button>
</div>
</div>
</div>
))}
</div>
{/* 전략 추가 모달 (기존 동일) */}
{showAddModal && (
<div className="fixed inset-0 z-[100] bg-slate-900/60 backdrop-blur-md flex items-center justify-center p-6">
<div className="bg-white w-full max-w-2xl rounded-[4rem] p-16 shadow-2xl animate-in zoom-in-95 duration-300 border border-slate-100 overflow-hidden">
<div className="flex justify-between items-center mb-12">
<h3 className="text-4xl font-black text-slate-900 uppercase tracking-tight flex items-center gap-5">
<Zap className="text-yellow-400 fill-yellow-400" />
</h3>
<button onClick={() => setShowAddModal(false)} className="p-4 hover:bg-slate-100 rounded-full transition-colors"><X size={32} className="text-slate-400" /></button>
</div>
<div className="space-y-10">
<div className="space-y-5">
<label className="text-[12px] font-black text-slate-400 uppercase tracking-[0.2em] ml-3"> </label>
<div className="flex bg-slate-100 p-2.5 rounded-[2.5rem] shadow-inner">
<button
onClick={() => setTargetType('SINGLE')}
className={`flex-1 py-5 rounded-[2rem] text-[12px] font-black transition-all ${targetType === 'SINGLE' ? 'bg-white text-slate-900 shadow-xl' : 'text-slate-400 hover:text-slate-600'}`}
>
</button>
<button
onClick={() => setTargetType('GROUP')}
className={`flex-1 py-5 rounded-[2rem] text-[12px] font-black transition-all ${targetType === 'GROUP' ? 'bg-white text-slate-900 shadow-xl' : 'text-slate-400 hover:text-slate-600'}`}
>
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-8">
<div className="space-y-4">
<label className="text-[12px] font-black text-slate-400 uppercase tracking-[0.2em] ml-3">
{targetType === 'SINGLE' ? '자산 선택' : '그룹 선택'}
</label>
{targetType === 'SINGLE' ? (
<select
className="w-full p-6 bg-slate-50 rounded-[2rem] border-2 border-transparent focus:border-blue-500 outline-none font-bold text-slate-800 text-base shadow-sm"
onChange={(e) => setNewConfig({...newConfig, stockCode: e.target.value})}
value={newConfig.stockCode || ''}
>
<option value=""> </option>
{filteredStocks.map(s => <option key={s.code} value={s.code}>{s.name}</option>)}
</select>
) : (
<select
className="w-full p-6 bg-slate-50 rounded-[2rem] border-2 border-transparent focus:border-blue-500 outline-none font-bold text-slate-800 text-base shadow-sm"
onChange={(e) => setNewConfig({...newConfig, groupId: e.target.value})}
value={newConfig.groupId || ''}
>
<option value=""> </option>
{filteredGroups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
)}
</div>
<div className="space-y-4">
<label className="text-[12px] font-black text-slate-400 uppercase tracking-[0.2em] ml-3"> </label>
<input
type="number"
className="w-full p-6 bg-slate-50 rounded-[2rem] border-2 border-transparent focus:border-blue-500 outline-none font-black text-slate-800 text-center text-2xl shadow-sm"
value={newConfig.quantity}
onChange={(e) => setNewConfig({...newConfig, quantity: parseInt(e.target.value)})}
/>
</div>
</div>
<div className="space-y-5">
<label className="text-[12px] font-black text-slate-400 uppercase tracking-[0.2em] ml-3"> </label>
<div className="grid grid-cols-3 gap-4">
{[
{ val: 'DAILY', label: '매일' },
{ val: 'WEEKLY', label: '매주' },
{ val: 'MONTHLY', label: '매월' }
].map(freq => (
<button
key={freq.val}
onClick={() => setNewConfig({...newConfig, frequency: freq.val as any, specificDay: freq.val === 'DAILY' ? undefined : 1})}
className={`py-5 rounded-[2rem] text-[12px] font-black transition-all border-2 ${newConfig.frequency === freq.val ? 'bg-slate-900 text-white border-slate-900 shadow-2xl' : 'bg-white text-slate-400 border-slate-100 hover:border-slate-300'}`}
>
{freq.label}
</button>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-8">
{newConfig.frequency !== 'DAILY' && (
<div className="space-y-4">
<label className="text-[12px] font-black text-slate-400 uppercase tracking-[0.2em] ml-3">
{newConfig.frequency === 'WEEKLY' ? '요일' : '날짜'}
</label>
<select
className="w-full p-6 bg-slate-50 rounded-[2rem] border-2 border-transparent focus:border-blue-500 outline-none font-bold text-slate-800 shadow-sm"
value={newConfig.specificDay}
onChange={(e) => setNewConfig({...newConfig, specificDay: parseInt(e.target.value)})}
>
{newConfig.frequency === 'WEEKLY' ? (
['일', '월', '화', '수', '목', '금', '토'].map((d, i) => <option key={d} value={i}>{d}</option>)
) : (
Array.from({length: 31}, (_, i) => i + 1).map(d => <option key={d} value={d}>{d}</option>)
)}
</select>
</div>
)}
<div className="space-y-4">
<label className="text-[12px] font-black text-slate-400 uppercase tracking-[0.2em] ml-3">퀀 </label>
<input
type="time"
className="w-full p-6 bg-slate-50 rounded-[2rem] border-2 border-transparent focus:border-blue-500 outline-none font-bold text-slate-800 shadow-sm"
value={newConfig.executionTime}
onChange={(e) => setNewConfig({...newConfig, executionTime: e.target.value})}
/>
</div>
</div>
<div className="flex gap-8 pt-10">
<button
onClick={() => setShowAddModal(false)}
className="flex-1 py-6 bg-slate-100 text-slate-400 rounded-[2.5rem] font-black uppercase text-[12px] tracking-widest hover:bg-slate-200 transition-all"
>
</button>
<button
onClick={handleAdd}
className="flex-1 py-6 bg-blue-600 text-white rounded-[2.5rem] font-black uppercase text-[12px] tracking-widest hover:bg-blue-700 transition-all shadow-2xl shadow-blue-100"
>
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default AutoTrading;

195
pages/Dashboard.tsx Normal file
View File

@@ -0,0 +1,195 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
TrendingUp, Wallet, Activity, Briefcase, PieChart, Database, Zap, Timer, Trash2
} from 'lucide-react';
import { StockItem, TradeOrder, MarketType, WatchlistGroup, OrderType, AutoTradeConfig, ReservedOrder } from '../types';
import { DbService, HoldingItem } from '../services/dbService';
import StockDetailModal from '../components/StockDetailModal';
import TradeModal from '../components/TradeModal';
import { StatCard } from '../components/CommonUI';
import { StockRow } from '../components/StockRow';
interface DashboardProps {
marketMode: MarketType;
watchlistGroups: WatchlistGroup[];
stocks: StockItem[];
orders: TradeOrder[];
reservedOrders: ReservedOrder[];
autoTrades: AutoTradeConfig[];
onManualOrder: (order: Omit<TradeOrder, 'id' | 'timestamp' | 'status'>) => Promise<void>;
onAddReservedOrder: (order: Omit<ReservedOrder, 'id' | 'status' | 'createdAt'>) => Promise<void>;
onDeleteReservedOrder: (id: string) => Promise<void>;
onRefreshHoldings: () => void;
}
const Dashboard: React.FC<DashboardProps> = ({
marketMode, watchlistGroups, stocks, reservedOrders, onAddReservedOrder, onDeleteReservedOrder, onRefreshHoldings, orders
}) => {
const [holdings, setHoldings] = useState<HoldingItem[]>([]);
const [summary, setSummary] = useState({ totalAssets: 0, buyingPower: 0 });
const [activeGroupId, setActiveGroupId] = useState<string | null>(null);
const [detailStock, setDetailStock] = useState<StockItem | null>(null);
const [tradeContext, setTradeContext] = useState<{ stock: StockItem, type: OrderType } | null>(null);
const dbService = useMemo(() => new DbService(), []);
useEffect(() => {
loadData();
}, [orders, marketMode, reservedOrders]);
const activeMarketGroups = useMemo(() => {
return watchlistGroups.filter(group => group.market === marketMode);
}, [watchlistGroups, marketMode]);
useEffect(() => {
if (activeMarketGroups.length > 0) {
if (!activeGroupId || !activeMarketGroups.find(g => g.id === activeGroupId)) {
setActiveGroupId(activeMarketGroups[0].id);
}
} else {
setActiveGroupId(null);
}
}, [marketMode, activeMarketGroups, activeGroupId]);
const loadData = async () => {
const allHoldings = await dbService.getHoldings();
const filteredHoldings = allHoldings.filter(h => h.market === marketMode);
const accSummary = await dbService.getAccountSummary();
setHoldings(filteredHoldings);
setSummary(accSummary);
};
const calculatePL = (holding: HoldingItem) => {
const currentStock = stocks.find(s => s.code === holding.code);
const currentPrice = currentStock ? currentStock.price : holding.avgPrice;
const pl = (currentPrice - holding.avgPrice) * holding.quantity;
const plPercent = ((currentPrice - holding.avgPrice) / holding.avgPrice) * 100;
const value = currentPrice * holding.quantity;
return { pl, plPercent, currentPrice, value, stock: currentStock };
};
const totalLiquidationSummary = holdings.reduce((acc, h) => {
const plData = calculatePL(h);
return {
totalValue: acc.totalValue + plData.value,
totalPL: acc.totalPL + plData.pl,
totalCost: acc.totalCost + (h.avgPrice * h.quantity)
};
}, { totalValue: 0, totalPL: 0, totalCost: 0 });
const aggregatePLPercent = totalLiquidationSummary.totalCost > 0
? (totalLiquidationSummary.totalPL / totalLiquidationSummary.totalCost) * 100
: 0;
const selectedGroup = activeMarketGroups.find(g => g.id === activeGroupId) || activeMarketGroups[0];
return (
<div className="space-y-12 animate-in fade-in duration-500 pb-20">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
<StatCard title={`총 자산 (${marketMode === MarketType.DOMESTIC ? '원' : '달러'})`} value={`${marketMode === MarketType.DOMESTIC ? '₩' : '$'} ${summary.totalAssets.toLocaleString()}`} change="+4.2%" isUp={true} icon={<Wallet className="text-blue-500" />} />
<StatCard title="총 평가손익" value={`${marketMode === MarketType.DOMESTIC ? '₩' : '$'} ${totalLiquidationSummary.totalPL.toLocaleString()}`} change={`${aggregatePLPercent.toFixed(2)}%`} isUp={totalLiquidationSummary.totalPL >= 0} icon={<TrendingUp className={totalLiquidationSummary.totalPL >= 0 ? "text-emerald-500" : "text-rose-500"} />} />
<StatCard title="보유 종목수" value={`${holdings.length}`} change="마켓 필터" isUp={true} icon={<Briefcase className="text-orange-500" />} />
<StatCard title="예수금" value={`${marketMode === MarketType.DOMESTIC ? '₩' : '$'} ${summary.buyingPower.toLocaleString()}`} change="인출 가능" isUp={true} icon={<Activity className="text-purple-500" />} />
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-10">
<div className="bg-white p-10 rounded-[3.5rem] shadow-sm border border-slate-100 flex flex-col h-[800px] lg:col-span-1">
<div className="flex justify-between items-center mb-8">
<h3 className="text-2xl font-black text-slate-800 flex items-center gap-3 uppercase tracking-tighter">
<PieChart size={28} className="text-blue-600" />
</h3>
</div>
<div className="flex gap-3 mb-10 overflow-x-auto pb-4 scrollbar-hide">
{activeMarketGroups.map(group => (
<button key={group.id} onClick={() => setActiveGroupId(group.id)} className={`relative px-7 py-3 rounded-full font-black text-[12px] transition-all border-2 whitespace-nowrap ${activeGroupId === group.id ? 'bg-white border-blue-500 text-blue-600 shadow-md' : 'bg-transparent border-slate-100 text-slate-400'}`}>
{group.name}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto pr-2 scrollbar-hide">
<table className="w-full">
<tbody className="divide-y divide-slate-50">
{selectedGroup?.codes.map(code => stocks.find(s => s.code === code)).filter(s => s?.market === marketMode).map(stock => {
if (!stock) return null;
return (
<StockRow
key={stock.code}
stock={stock}
showActions={true}
onTrade={(type) => setTradeContext({ stock, type })}
onClick={() => setDetailStock(stock)}
/>
);
})}
</tbody>
</table>
</div>
</div>
<div className="lg:col-span-2 space-y-10">
<div className="bg-white p-10 rounded-[3.5rem] shadow-sm border border-slate-100 flex flex-col h-[450px]">
<div className="flex justify-between items-center mb-8">
<h3 className="text-2xl font-black text-slate-800 flex items-center gap-3 tracking-tighter">
<Database size={28} className="text-emerald-600" />
</h3>
</div>
<div className="overflow-x-auto flex-1 scrollbar-hide">
<table className="w-full text-left">
<thead>
<tr className="text-[11px] font-black text-slate-400 uppercase tracking-[0.25em] border-b">
<th className="pb-5 px-6"></th>
<th className="pb-5 px-6 text-right"></th>
<th className="pb-5 px-6 text-right"> (%)</th>
<th className="pb-5 px-6 text-right"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{holdings.map(holding => {
const { pl, plPercent, stock } = calculatePL(holding);
if (!stock) return null;
return (
<StockRow
key={holding.code}
stock={stock}
showPL={{ pl, percent: plPercent }}
showActions={true}
onTrade={(type) => setTradeContext({ stock, type })}
onClick={() => setDetailStock(stock)}
/>
);
})}
</tbody>
</table>
</div>
</div>
<div className="bg-white p-10 rounded-[3.5rem] shadow-sm border border-slate-100 flex flex-col h-[350px] overflow-hidden">
<h3 className="text-2xl font-black text-slate-800 flex items-center gap-3 mb-8">
<Timer size={28} className="text-blue-600" />
</h3>
<div className="flex-1 overflow-y-auto space-y-4 scrollbar-hide">
{reservedOrders.filter(o => o.market === marketMode).map(order => (
<div key={order.id} className="bg-slate-50 p-6 rounded-[2.5rem] border border-slate-100 flex justify-between items-center group">
<div className="flex items-center gap-5">
<div className={`p-4 rounded-2xl ${order.type === OrderType.BUY ? 'bg-rose-50 text-rose-500' : 'bg-blue-50 text-blue-600'}`}><Zap size={20} fill="currentColor" /></div>
<div><p className="font-black text-lg text-slate-800">{order.stockName}</p></div>
</div>
<button onClick={() => onDeleteReservedOrder(order.id)} className="p-3 bg-white hover:bg-rose-50 rounded-2xl text-slate-300 hover:text-rose-500 transition-all shadow-sm">
<Trash2 size={22} />
</button>
</div>
))}
</div>
</div>
</div>
</div>
{detailStock && <StockDetailModal stock={detailStock} onClose={() => setDetailStock(null)} />}
{tradeContext && <TradeModal stock={tradeContext.stock} type={tradeContext.type} onClose={() => setTradeContext(null)} onExecute={onAddReservedOrder} />}
</div>
);
};
export default Dashboard;

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;

81
pages/History.tsx Normal file
View File

@@ -0,0 +1,81 @@
import React from 'react';
import { History, CheckCircle, Clock, XCircle, FileText } from 'lucide-react';
import { TradeOrder } from '../types';
interface HistoryPageProps {
orders: TradeOrder[];
}
const HistoryPage: React.FC<HistoryPageProps> = ({ orders }) => {
return (
<div className="space-y-10 animate-in fade-in duration-500">
<div className="bg-white rounded-[3.5rem] shadow-sm border border-slate-100 overflow-hidden">
<div className="p-8 border-b border-slate-50 flex justify-between items-center bg-slate-50/30">
<h3 className="text-2xl font-black text-slate-800 flex items-center gap-3 uppercase tracking-widest">
<History size={24} className="text-blue-600" />
</h3>
<button className="text-[11px] font-black text-slate-400 hover:text-slate-600 flex items-center gap-2 uppercase tracking-widest">
<FileText size={18} /> (CSV)
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-slate-50/50">
<tr className="text-left text-[11px] font-black text-slate-400 uppercase tracking-widest border-b">
<th className="px-10 py-6"> 퀀 </th>
<th className="px-10 py-6"></th>
<th className="px-10 py-6"></th>
<th className="px-10 py-6"> </th>
<th className="px-10 py-6"> </th>
<th className="px-10 py-6"> </th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{orders.map(order => (
<tr key={order.id} className="hover:bg-slate-50 transition-colors">
<td className="px-10 py-6 text-sm font-mono text-slate-500">
{order.timestamp.toLocaleString()}
</td>
<td className="px-10 py-6">
<div className="flex flex-col">
<span className="font-black text-slate-800 text-base">{order.stockName}</span>
<span className="text-[11px] font-mono text-slate-400 uppercase tracking-widest">{order.stockCode}</span>
</div>
</td>
<td className="px-10 py-6">
<span className={`px-3 py-1.5 rounded-lg text-[11px] font-black tracking-widest ${order.type === 'BUY' ? 'bg-red-50 text-red-600' : 'bg-blue-50 text-blue-600'}`}>
{order.type === 'BUY' ? '매수' : '매도'}
</span>
</td>
<td className="px-10 py-6 text-base font-black text-slate-700">
{order.quantity} UNIT
</td>
<td className="px-10 py-6 text-base font-mono font-bold text-slate-600">
{order.price.toLocaleString()}
</td>
<td className="px-10 py-6">
<div className="flex items-center gap-2.5">
<CheckCircle size={18} className="text-emerald-500" />
<span className="text-sm font-black text-slate-800 uppercase tracking-tighter"> </span>
</div>
</td>
</tr>
))}
{orders.length === 0 && (
<tr>
<td colSpan={6} className="px-10 py-32 text-center text-slate-400 italic text-base">
.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
};
export default HistoryPage;

186
pages/News.tsx Normal file
View File

@@ -0,0 +1,186 @@
import React, { useState, useEffect } from 'react';
import { Newspaper, ExternalLink, RefreshCw, Bookmark, Search, AlertCircle, Sparkles, X, MessageSquareQuote } from 'lucide-react';
import { NewsItem, ApiSettings } from '../types';
import { NaverService } from '../services/naverService';
import { AiService } from '../services/aiService';
interface NewsProps {
settings: ApiSettings;
}
const MOCK_NEWS: NewsItem[] = [
{
title: "BatchuKis 마켓 브리핑: AI 트레이딩의 미래",
description: "딥러닝 알고리즘을 활용한 자동매매 시장이 국내에서도 빠르게 성장하고 있습니다. 개인 투자자들의 접근성이 확대되며...",
link: "#",
pubDate: new Date().toISOString()
},
{
title: "KIS API 연동 가이드: 보안 설정과 데이터 암호화",
description: "한국투자증권 오픈 API를 활용한 안전한 트레이딩 환경 구축을 위해 사용자는 반드시 앱키 노출에 주의해야 하며...",
link: "#",
pubDate: new Date().toISOString()
}
];
const News: React.FC<NewsProps> = ({ settings }) => {
const [loading, setLoading] = useState(false);
const [news, setNews] = useState<NewsItem[]>(MOCK_NEWS);
const [filter, setFilter] = useState('');
// AI 분석 관련 상태
const [analysisResult, setAnalysisResult] = useState<string | null>(null);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const naverService = new NaverService(settings);
useEffect(() => {
if (settings.useNaverNews) {
handleRefresh();
}
}, [settings.useNaverNews]);
const handleRefresh = async () => {
setLoading(true);
if (settings.useNaverNews) {
const fetchedNews = await naverService.fetchNews();
if (fetchedNews.length > 0) {
setNews([...fetchedNews, ...MOCK_NEWS]);
}
}
setTimeout(() => setLoading(false), 800);
};
const handleAnalyze = async () => {
const aiId = settings.preferredNewsAiId;
if (!aiId) {
alert("설정 페이지에서 '뉴스 분석 엔진'을 먼저 지정해주세요.");
return;
}
const config = settings.aiConfigs.find(c => c.id === aiId);
if (!config) {
alert("지정된 AI 엔진 설정을 찾을 수 없습니다.");
return;
}
setIsAnalyzing(true);
setAnalysisResult(null);
const headlines = news.slice(0, 10).map(n => n.title);
try {
const result = await AiService.analyzeNewsSentiment(config, headlines);
setAnalysisResult(result);
} catch (e) {
setAnalysisResult("AI 분석 중 오류가 발생했습니다. 설정을 확인해 주세요.");
} finally {
setIsAnalyzing(false);
}
};
const filteredNews = news.filter(n =>
n.title.toLowerCase().includes(filter.toLowerCase()) ||
n.description.toLowerCase().includes(filter.toLowerCase())
);
return (
<div className="space-y-12 max-w-6xl mx-auto animate-in slide-in-from-right-4 duration-500 pb-20">
{/* 뉴스 스크랩 비활성 알림 */}
{!settings.useNaverNews && (
<div className="bg-amber-50 border border-amber-100 p-8 rounded-[2.5rem] flex items-start gap-5 shadow-sm">
<AlertCircle className="text-amber-500 shrink-0" size={28} />
<div>
<h5 className="font-bold text-amber-900 mb-2 text-lg"> </h5>
<p className="text-base text-amber-700"> . Naver Client ID를 .</p>
</div>
</div>
)}
{/* 분석 결과 리포트 */}
{analysisResult && (
<div className="bg-white p-10 rounded-[3rem] shadow-xl border-l-8 border-l-blue-600 animate-in fade-in zoom-in duration-300 relative group">
<button onClick={() => setAnalysisResult(null)} className="absolute top-6 right-6 p-2 hover:bg-slate-50 rounded-full text-slate-300 hover:text-slate-600 transition-all"><X size={20}/></button>
<div className="flex items-start gap-6">
<div className="p-4 bg-blue-50 text-blue-600 rounded-2xl">
<MessageSquareQuote size={28} />
</div>
<div className="space-y-4 pr-10">
<h4 className="text-[11px] font-black text-blue-600 uppercase tracking-[0.2em]">AI Intelligence Report</h4>
<div className="text-slate-800 text-lg font-bold leading-relaxed whitespace-pre-wrap">
{analysisResult}
</div>
</div>
</div>
</div>
)}
{/* 툴바: 검색 및 실행 버튼 */}
<div className="flex flex-col md:flex-row gap-8 items-center justify-between">
<div className="relative w-full md:flex-1">
<Search className="absolute left-6 top-1/2 -translate-y-1/2 text-slate-400" size={24} />
<input
type="text"
placeholder="뉴스 검색..."
className="w-full pl-16 pr-6 py-5 bg-white border border-slate-200 rounded-[2rem] shadow-sm focus:ring-4 focus:ring-blue-50 outline-none text-base font-bold text-slate-800"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</div>
<div className="flex gap-4 w-full md:w-auto">
<button
onClick={handleAnalyze}
disabled={isAnalyzing || news.length === 0}
className={`flex-1 md:shrink-0 px-8 py-5 bg-blue-600 text-white rounded-[2rem] shadow-xl font-black text-[12px] uppercase tracking-widest hover:bg-blue-700 flex items-center justify-center gap-4 transition-all active:scale-95 disabled:opacity-50`}
>
<Sparkles size={22} className={isAnalyzing ? 'animate-pulse' : ''} />
{isAnalyzing ? 'AI 분석 중...' : 'AI 브리핑'}
</button>
<button
onClick={handleRefresh}
disabled={loading}
className={`flex-1 md:shrink-0 px-8 py-5 bg-slate-900 text-white rounded-[2rem] shadow-xl font-black text-[12px] uppercase tracking-widest hover:bg-slate-800 flex items-center justify-center gap-4 transition-all active:scale-95 ${loading ? 'opacity-50' : ''}`}
>
<RefreshCw size={22} className={loading ? 'animate-spin' : ''} />
</button>
</div>
</div>
{/* 뉴스 리스트 */}
<div className="space-y-8">
{filteredNews.map((item, idx) => (
<article key={idx} className="bg-white p-10 rounded-[3.5rem] shadow-sm border border-slate-100 flex flex-col md:flex-row gap-10 hover:shadow-2xl transition-all group">
<div className="w-20 h-20 bg-slate-50 rounded-3xl flex items-center justify-center flex-shrink-0 text-slate-900 group-hover:bg-slate-900 group-hover:text-white transition-all shadow-sm">
<Newspaper size={40} />
</div>
<div className="flex-1 space-y-4">
<div className="flex justify-between items-start">
<span className="text-[11px] font-black text-blue-600 uppercase tracking-[0.2em] bg-blue-50 px-4 py-1.5 rounded-full">Financial Market</span>
<span className="text-sm text-slate-400 font-bold">{new Date(item.pubDate).toLocaleDateString('ko-KR')}</span>
</div>
<h3 className="text-2xl font-black text-slate-900 leading-tight group-hover:text-blue-600 transition-colors">
{item.title}
</h3>
<p className="text-slate-500 text-base font-medium leading-relaxed line-clamp-2 opacity-80">
{item.description}
</p>
<div className="pt-6 flex items-center gap-8">
<a href={item.link} target="_blank" rel="noopener noreferrer" className="text-[12px] font-black uppercase text-slate-900 hover:text-blue-600 flex items-center gap-2.5 tracking-tighter transition-colors">
<ExternalLink size={18} />
</a>
<button className="text-slate-300 hover:text-amber-500 transition-colors">
<Bookmark size={22} />
</button>
</div>
</div>
</article>
))}
</div>
</div>
);
};
export default News;

264
pages/Settings.tsx Normal file
View File

@@ -0,0 +1,264 @@
import React, { useState } from 'react';
import { Save, Key, Shield, MessageCircle, Globe, Check, Cpu, Zap, Plus, Trash2, Edit3, X, BarChart4, Newspaper, Scale, PlusCircle, MinusCircle } from 'lucide-react';
import { ApiSettings, AiConfig } from '../types';
import { InputGroup, ToggleButton } from '../components/CommonUI';
interface SettingsProps {
settings: ApiSettings;
onSave: (settings: ApiSettings) => void;
}
const Settings: React.FC<SettingsProps> = ({ settings, onSave }) => {
const [formData, setFormData] = useState<ApiSettings>(settings);
const [isSaved, setIsSaved] = useState(false);
const [showAiModal, setShowAiModal] = useState(false);
const [editingAi, setEditingAi] = useState<Partial<AiConfig> | null>(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave(formData);
setIsSaved(true);
setTimeout(() => setIsSaved(false), 3000);
};
const toggleService = (field: keyof Pick<ApiSettings, 'useTelegram' | 'useNaverNews'>) => {
setFormData(prev => ({ ...prev, [field]: !prev[field] }));
};
const handleAddAi = () => {
setEditingAi({
id: 'ai_' + Date.now(),
name: '',
providerType: 'gemini',
modelName: 'gemini-3-flash-preview',
baseUrl: ''
});
setShowAiModal(true);
};
const handleSaveAi = () => {
if (!editingAi?.name) return;
const newConfigs = [...formData.aiConfigs];
const index = newConfigs.findIndex(c => c.id === editingAi.id);
if (index > -1) {
newConfigs[index] = editingAi as AiConfig;
} else {
newConfigs.push(editingAi as AiConfig);
}
setFormData({ ...formData, aiConfigs: newConfigs });
setShowAiModal(false);
setEditingAi(null);
};
const handleDeleteAi = (id: string) => {
setFormData({ ...formData, aiConfigs: formData.aiConfigs.filter(c => c.id !== id) });
};
return (
<div className="max-w-5xl space-y-10 animate-in fade-in duration-500 pb-20 mx-auto">
<div className="bg-white p-12 rounded-[3.5rem] shadow-sm border border-slate-100">
<form onSubmit={handleSubmit} className="space-y-14">
{/* KIS API Section */}
<section>
<div className="flex items-center justify-between mb-10">
<h4 className="text-[12px] font-black text-slate-400 uppercase tracking-[0.25em] flex items-center gap-3">
<Key size={20} /> KIS API
</h4>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<InputGroup label="앱 키" value={formData.appKey} onChange={(v) => setFormData({...formData, appKey: v})} type="password" placeholder="App Key" />
<InputGroup label="비밀 키" value={formData.appSecret} onChange={(v) => setFormData({...formData, appSecret: v})} type="password" placeholder="Secret Key" />
<div className="md:col-span-2">
<InputGroup label="계좌 번호" value={formData.accountNumber} onChange={(v) => setFormData({...formData, accountNumber: v})} placeholder="예: 50061234-01" />
</div>
</div>
</section>
{/* AI 분석 자동화 설정 섹션 */}
<section className="bg-blue-50/20 p-10 rounded-[2.5rem] border border-blue-100">
<div className="flex items-center gap-4 mb-10">
<div className="p-3 bg-blue-600 text-white rounded-2xl shadow-lg shadow-blue-100">
<Zap size={24} />
</div>
<div>
<h4 className="text-[12px] font-black text-slate-800 uppercase tracking-raw-2 mt-0.5">AI </h4>
<p className="text-[10px] text-slate-400 font-bold uppercase tracking-widest mt-0.5"> </p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="space-y-3">
<label className="text-[11px] font-black text-slate-400 uppercase tracking-widest block pl-1 flex items-center gap-2">
<Newspaper size={14} className="text-blue-500" />
</label>
<select
className="w-full p-4 bg-white border border-slate-200 rounded-[1.2rem] focus:border-blue-500 outline-none transition-all font-bold text-slate-800 shadow-sm"
value={formData.preferredNewsAiId || ''}
onChange={(e) => setFormData({...formData, preferredNewsAiId: e.target.value})}
>
<option value=""> </option>
{formData.aiConfigs.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div className="space-y-3">
<label className="text-[11px] font-black text-slate-400 uppercase tracking-widest block pl-1 flex items-center gap-2">
<BarChart4 size={14} className="text-purple-500" />
</label>
<select
className="w-full p-4 bg-white border border-slate-200 rounded-[1.2rem] focus:border-blue-500 outline-none transition-all font-bold text-slate-800 shadow-sm"
value={formData.preferredStockAiId || ''}
onChange={(e) => setFormData({...formData, preferredStockAiId: e.target.value})}
>
<option value=""> </option>
{formData.aiConfigs.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div className="space-y-3">
<label className="text-[11px] font-black text-slate-400 uppercase tracking-widest block pl-1 flex items-center gap-2">
<Scale size={14} className="text-amber-500" />
</label>
<select
className="w-full p-4 bg-white border border-slate-200 rounded-[1.2rem] focus:border-blue-500 outline-none transition-all font-bold text-slate-800 shadow-sm"
value={formData.preferredNewsJudgementAiId || ''}
onChange={(e) => setFormData({...formData, preferredNewsJudgementAiId: e.target.value})}
>
<option value=""> </option>
{formData.aiConfigs.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div className="space-y-3">
<label className="text-[11px] font-black text-slate-400 uppercase tracking-widest block pl-1 flex items-center gap-2">
<PlusCircle size={14} className="text-rose-500" />
</label>
<select
className="w-full p-4 bg-white border border-slate-200 rounded-[1.2rem] focus:border-blue-500 outline-none transition-all font-bold text-slate-800 shadow-sm"
value={formData.preferredAutoBuyAiId || ''}
onChange={(e) => setFormData({...formData, preferredAutoBuyAiId: e.target.value})}
>
<option value=""> </option>
{formData.aiConfigs.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div className="space-y-3 md:col-span-2">
<label className="text-[11px] font-black text-slate-400 uppercase tracking-widest block pl-1 flex items-center gap-2">
<MinusCircle size={14} className="text-blue-600" />
</label>
<select
className="w-full p-4 bg-white border border-slate-200 rounded-[1.2rem] focus:border-blue-500 outline-none transition-all font-bold text-slate-800 shadow-sm"
value={formData.preferredAutoSellAiId || ''}
onChange={(e) => setFormData({...formData, preferredAutoSellAiId: e.target.value})}
>
<option value=""> </option>
{formData.aiConfigs.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
</div>
</section>
{/* Telegram Notification Section */}
<section className="bg-slate-50 p-10 rounded-[2.5rem] border border-slate-100">
<div className="flex items-center justify-between mb-10">
<div className="flex items-center gap-4">
<div className={`p-3 rounded-2xl ${formData.useTelegram ? 'bg-blue-100 text-blue-600' : 'bg-slate-200 text-slate-400'}`}>
<MessageCircle size={24} />
</div>
<div>
<h4 className="text-[12px] font-black text-slate-800 uppercase tracking-[0.2em]"> </h4>
<p className="text-[10px] text-slate-400 font-bold uppercase tracking-widest mt-0.5"> </p>
</div>
</div>
<ToggleButton active={formData.useTelegram} onClick={() => toggleService('useTelegram')} />
</div>
{formData.useTelegram && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 animate-in slide-in-from-top-4 duration-300">
<InputGroup label="봇 토큰" value={formData.telegramToken} onChange={(v) => setFormData({...formData, telegramToken: v})} placeholder="Bot API Token" />
<InputGroup label="채팅 ID" value={formData.telegramChatId} onChange={(v) => setFormData({...formData, telegramChatId: v})} placeholder="Chat ID" />
</div>
)}
</section>
{/* Naver News API Section */}
<section className="bg-slate-50 p-10 rounded-[2.5rem] border border-slate-100">
<div className="flex items-center justify-between mb-10">
<div className="flex items-center gap-4">
<div className={`p-3 rounded-2xl ${formData.useNaverNews ? 'bg-emerald-100 text-emerald-600' : 'bg-slate-200 text-slate-400'}`}>
<Globe size={24} />
</div>
<div>
<h4 className="text-[12px] font-black text-slate-800 uppercase tracking-[0.2em]"> </h4>
<p className="text-[10px] text-slate-400 font-bold uppercase tracking-widest mt-0.5"> </p>
</div>
</div>
<ToggleButton active={formData.useNaverNews} onClick={() => toggleService('useNaverNews')} />
</div>
{formData.useNaverNews && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 animate-in slide-in-from-top-4 duration-300">
<InputGroup label="Client ID" value={formData.naverClientId} onChange={(v) => setFormData({...formData, naverClientId: v})} placeholder="Naver Client ID" />
<InputGroup label="Client Secret" value={formData.naverClientSecret} onChange={(v) => setFormData({...formData, naverClientSecret: v})} type="password" placeholder="Naver Client Secret" />
</div>
)}
</section>
<div className="pt-10 border-t border-slate-100 flex flex-col sm:flex-row items-center justify-between gap-8">
<div className="flex items-center gap-4 text-slate-400 text-sm font-medium bg-slate-50 px-6 py-4 rounded-2xl border border-slate-100">
<Shield size={22} className="text-emerald-500" />
</div>
<button
type="submit"
className={`w-full sm:w-auto px-16 py-6 rounded-3xl font-black uppercase text-sm tracking-widest shadow-2xl transition-all flex items-center justify-center gap-4 ${isSaved ? 'bg-emerald-500 text-white shadow-emerald-200 scale-95' : 'bg-slate-900 text-white hover:bg-slate-800 shadow-slate-300 active:scale-95'}`}
>
{isSaved ? <><Check size={24} /> </> : <><Save size={24} /> </>}
</button>
</div>
</form>
</div>
{/* AI Engine Modal */}
{showAiModal && (
<div className="fixed inset-0 z-[150] bg-slate-900/60 backdrop-blur-sm flex items-center justify-center p-6">
<div className="bg-white w-full max-w-lg rounded-[3rem] p-10 shadow-2xl animate-in zoom-in-95 duration-200 border border-slate-100">
<div className="flex justify-between items-center mb-10">
<h3 className="text-2xl font-black text-slate-900 flex items-center gap-3 uppercase tracking-tight">
<Cpu className="text-blue-600" /> AI
</h3>
<button onClick={() => setShowAiModal(false)} className="p-2 hover:bg-slate-100 rounded-full transition-colors"><X size={28} className="text-slate-400" /></button>
</div>
<div className="space-y-6">
<InputGroup label="엔진 식별 이름" value={editingAi?.name || ''} onChange={(v) => setEditingAi({...editingAi, name: v})} placeholder="예: 구글 고성능 모델, Ollama Llama3" />
<div className="space-y-3">
<label className="text-[11px] font-black text-slate-400 uppercase tracking-widest ml-1"> </label>
<div className="flex bg-slate-100 p-1.5 rounded-2xl">
<button type="button" onClick={() => setEditingAi({...editingAi, providerType: 'gemini'})} className={`flex-1 py-3 rounded-xl text-[11px] font-black transition-all ${editingAi?.providerType === 'gemini' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}>Gemini</button>
<button type="button" onClick={() => setEditingAi({...editingAi, providerType: 'openai-compatible'})} className={`flex-1 py-3 rounded-xl text-[11px] font-black transition-all ${editingAi?.providerType === 'openai-compatible' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}>Ollama / OpenAI</button>
</div>
</div>
<InputGroup label="모델명" value={editingAi?.modelName || ''} onChange={(v) => setEditingAi({...editingAi, modelName: v})} placeholder={editingAi?.providerType === 'gemini' ? 'gemini-3-flash-preview' : 'llama3'} />
{editingAi?.providerType === 'openai-compatible' && (
<InputGroup label="베이스 URL (API End-point)" value={editingAi?.baseUrl || ''} onChange={(v) => setEditingAi({...editingAi, baseUrl: v})} placeholder="http://localhost:11434/v1" />
)}
<button
type="button"
onClick={handleSaveAi}
className="w-full py-5 bg-blue-600 text-white rounded-[1.5rem] font-black uppercase text-[12px] tracking-widest hover:bg-blue-700 transition-all shadow-xl shadow-blue-100 mt-6"
>
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default Settings;

93
pages/Stocks.tsx Normal file
View File

@@ -0,0 +1,93 @@
import React, { useState, useMemo } from 'react';
import { Search, Filter, ArrowUpDown, RotateCw } from 'lucide-react';
import { StockItem, MarketType, OrderType } from '../types';
import StockDetailModal from '../components/StockDetailModal';
import TradeModal from '../components/TradeModal';
import { StockRow } from '../components/StockRow';
interface StocksProps {
marketMode: MarketType;
stocks: StockItem[];
onTrade: (stock: StockItem, type: OrderType) => void;
onAddToWatchlist: (stock: StockItem) => void;
watchlistCodes: string[];
onSync: () => Promise<void>;
onAddReservedOrder?: (order: any) => Promise<void>;
}
const Stocks: React.FC<StocksProps> = ({ marketMode, stocks, onAddToWatchlist, watchlistCodes, onSync }) => {
const [search, setSearch] = useState('');
const [sortField, setSortField] = useState<keyof StockItem>('aiScoreBuy');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const [detailStock, setDetailStock] = useState<StockItem | null>(null);
const [tradeContext, setTradeContext] = useState<{ stock: StockItem, type: OrderType } | null>(null);
const filteredStocks = useMemo(() => {
let result = stocks.filter(s =>
s.market === marketMode &&
(s.name.includes(search) || s.code.toLowerCase().includes(search.toLowerCase()))
);
result.sort((a, b) => {
const valA = a[sortField] || 0;
const valB = b[sortField] || 0;
return sortOrder === 'asc' ? (valA > valB ? 1 : -1) : (valB > valA ? 1 : -1);
});
return result;
}, [stocks, search, marketMode, sortField, sortOrder]);
const handleSort = (field: keyof StockItem) => {
if (sortField === field) setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
else { setSortField(field); setSortOrder('desc'); }
};
return (
<div className="space-y-10 animate-in fade-in duration-500 pb-20">
<div className="bg-white p-10 rounded-[3.5rem] shadow-sm border border-slate-100 flex flex-col lg:flex-row gap-8 items-center justify-between">
<div className="flex items-center gap-6">
<div className="p-5 bg-blue-50 text-blue-600 rounded-3xl"><Filter size={28} /></div>
<div><h3 className="text-3xl font-black text-slate-900 tracking-tight"> </h3></div>
</div>
<div className="flex gap-4">
<input type="text" placeholder="검색..." className="p-4 bg-slate-50 rounded-2xl outline-none border-2 border-transparent focus:border-blue-500" value={search} onChange={(e) => setSearch(e.target.value)} />
<button onClick={onSync} className="p-4 bg-slate-900 text-white rounded-2xl flex items-center gap-2"><RotateCw size={18} /> </button>
</div>
</div>
<div className="bg-white rounded-[3.5rem] shadow-sm border border-slate-100 overflow-hidden">
<table className="w-full text-left">
<thead className="bg-slate-50 text-[11px] font-black text-slate-400 uppercase tracking-widest">
<tr>
<th className="px-8 py-6 cursor-pointer" onClick={() => handleSort('name')}> <ArrowUpDown size={12} className="inline" /></th>
<th className="px-8 py-6 cursor-pointer" onClick={() => handleSort('price')}> <ArrowUpDown size={12} className="inline" /></th>
<th className="px-8 py-6">AI </th>
<th className="px-8 py-6 text-right"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{filteredStocks.map(stock => {
const isWatch = watchlistCodes.includes(stock.code);
return (
<StockRow
key={stock.code}
stock={stock}
isWatchlisted={isWatch}
showActions={true}
onTrade={(type) => setTradeContext({ stock, type })}
onToggleWatchlist={() => onAddToWatchlist(stock)}
onClick={() => setDetailStock(stock)}
/>
);
})}
</tbody>
</table>
</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 Stocks;

89
pages/Trading.tsx Normal file
View File

@@ -0,0 +1,89 @@
import React, { useState, useMemo, useEffect } from 'react';
import { Search, ArrowRightLeft, ShieldCheck, Wallet, History, TrendingUp, Info } from 'lucide-react';
import { StockItem, OrderType, MarketType, TradeOrder } from '../types';
interface TradingProps {
marketMode: MarketType;
stocks: StockItem[];
onOrder: (order: Omit<TradeOrder, 'id' | 'timestamp' | 'status'>) => void;
}
const Trading: React.FC<TradingProps> = ({ marketMode, stocks, onOrder }) => {
const [search, setSearch] = useState('');
const [selectedStock, setSelectedStock] = useState<StockItem | null>(null);
const [orderAmount, setOrderAmount] = useState<number>(1);
const [orderType, setOrderType] = useState<OrderType>(OrderType.BUY);
// 모드 변경 시 수량 초기화
useEffect(() => {
setOrderAmount(1);
}, [orderType]);
// 시장 변경 시 선택 해제
useEffect(() => {
setSelectedStock(null);
}, [marketMode]);
const filteredStocks = useMemo(() => {
return stocks.filter(s =>
s.market === marketMode &&
!s.isHidden &&
(s.name.includes(search) || s.code.toLowerCase().includes(search.toLowerCase()))
);
}, [stocks, search, marketMode]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!selectedStock) return;
const confirmMsg = `${selectedStock.name} 종목을 ${orderAmount}${orderType === OrderType.BUY ? '매수' : '매도'}하시겠습니까?`;
if(window.confirm(confirmMsg)) {
onOrder({
stockCode: selectedStock.code,
stockName: selectedStock.name,
type: orderType,
price: selectedStock.price,
quantity: orderAmount
});
}
};
const isBuyMode = orderType === OrderType.BUY;
return (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-10 animate-in fade-in duration-500 pb-20 max-w-[1600px] mx-auto">
{/* Left: Inventory List */}
<div className="lg:col-span-7 space-y-8">
<div className="bg-white p-10 rounded-[3.5rem] shadow-sm border border-slate-100 flex flex-col h-[750px]">
<div className="flex flex-col sm:flex-row gap-8 justify-between items-center mb-10">
<div>
<h3 className="text-2xl font-black text-slate-800 uppercase tracking-tight flex items-center gap-3">
<Wallet size={24} className="text-blue-600" /> {marketMode === MarketType.DOMESTIC ? '국내' : '해외'}
</h3>
</div>
<div className="relative w-full sm:w-[350px]">
<Search size={20} className="absolute left-6 top-1/2 -translate-y-1/2 text-slate-400" />
<input
type="text"
placeholder="종목명 또는 코드 검색..."
className="w-full pl-14 pr-6 py-4 bg-slate-50 border-2 border-transparent rounded-[1.8rem] focus:border-blue-500 focus:bg-white outline-none text-sm font-bold shadow-inner transition-all"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
<div className="flex-1 overflow-y-auto pr-2 scrollbar-hide space-y-4">
{filteredStocks.map(stock => (
<div
key={stock.code}
onClick={() => setSelectedStock(stock)}
className={`p-6 rounded-[2.5rem] border-2 transition-all cursor-pointer group flex items-center justify-between ${selectedStock?.code === stock.code ? 'border-blue-500 bg-blue-50/20 shadow-lg' : 'border-transparent bg-slate-50/60 hover:bg-white hover:border-slate-200'}`}
>
<div className="flex items-center gap-5">
<div className="w-12 h-12 rounded-2xl bg-slate-900 flex items-center justify-center font-black text-white text-[12px] uppercase">
{stock.name[0]}
</div>
<div>
<h4 className="font-black text-slate

View File

@@ -0,0 +1,216 @@
import React, { useState, useMemo, useEffect } from 'react';
import { Star, Plus, Trash2, Search, X, FolderPlus, ArrowRight, LayoutGrid, Globe, Edit3, Save, GripVertical } from 'lucide-react';
import { StockItem, WatchlistGroup, MarketType } from '../types';
import { DbService } from '../services/dbService';
import { StockRow } from '../components/StockRow';
interface WatchlistManagementProps {
marketMode: MarketType;
stocks: StockItem[];
groups: WatchlistGroup[];
onRefresh: () => void;
}
const WatchlistManagement: React.FC<WatchlistManagementProps> = ({ marketMode, stocks, groups, onRefresh }) => {
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const [showAddGroupModal, setShowAddGroupModal] = useState(false);
const [showRenameModal, setShowRenameModal] = useState(false);
const [editingGroup, setEditingGroup] = useState<WatchlistGroup | null>(null);
const [newGroupName, setNewGroupName] = useState('');
const [renameValue, setRenameValue] = useState('');
const [stockSearch, setStockSearch] = useState('');
const [draggedItemIndex, setDraggedItemIndex] = useState<number | null>(null);
const dbService = useMemo(() => new DbService(), []);
const filteredGroups = useMemo(() => {
return groups.filter(g => g.market === marketMode);
}, [groups, marketMode]);
useEffect(() => {
if (filteredGroups.length > 0) {
if (!selectedGroupId || !filteredGroups.find(g => g.id === selectedGroupId)) {
setSelectedGroupId(filteredGroups[0].id);
}
} else {
setSelectedGroupId(null);
}
}, [marketMode, filteredGroups, selectedGroupId]);
const selectedGroup = useMemo(() =>
filteredGroups.find(g => g.id === selectedGroupId),
[filteredGroups, selectedGroupId]
);
const filteredSearchStocks = useMemo(() => {
if (!stockSearch) return [];
return stocks.filter(s =>
s.market === marketMode &&
(s.name.includes(stockSearch) || s.code.toLowerCase().includes(stockSearch.toLowerCase())) &&
!(selectedGroup?.codes.includes(s.code))
).slice(0, 5);
}, [stocks, stockSearch, marketMode, selectedGroup]);
const handleAddGroup = async () => {
if (!newGroupName.trim()) return;
const newGroup: WatchlistGroup = {
id: 'grp_' + Date.now(),
name: newGroupName,
codes: [],
market: marketMode
};
await dbService.saveWatchlistGroup(newGroup);
setNewGroupName('');
setShowAddGroupModal(false);
onRefresh();
setSelectedGroupId(newGroup.id);
};
const handleRenameGroup = async () => {
if (!editingGroup || !renameValue.trim()) return;
const updated = { ...editingGroup, name: renameValue };
await dbService.updateWatchlistGroup(updated);
setEditingGroup(null);
setRenameValue('');
setShowRenameModal(false);
onRefresh();
};
const handleDeleteGroup = async (id: string) => {
if (!confirm('그룹을 삭제하시겠습니까?')) return;
await dbService.deleteWatchlistGroup(id);
onRefresh();
if (selectedGroupId === id) setSelectedGroupId(null);
};
const handleAddStockToGroup = async (code: string) => {
if (!selectedGroup) return;
const updated = { ...selectedGroup, codes: [...selectedGroup.codes, code] };
await dbService.updateWatchlistGroup(updated);
setStockSearch('');
onRefresh();
};
const handleRemoveStockFromGroup = async (code: string) => {
if (!selectedGroup) return;
const stock = stocks.find(s => s.code === code);
const stockName = stock ? stock.name : code;
if (!confirm(`'${stockName}' 종목을 그룹에서 삭제하시겠습니까?`)) return;
const updated = { ...selectedGroup, codes: selectedGroup.codes.filter(c => c !== code) };
await dbService.updateWatchlistGroup(updated);
onRefresh();
};
const onDragStart = (e: React.DragEvent, index: number) => {
setDraggedItemIndex(index);
e.dataTransfer.effectAllowed = 'move';
};
const onDrop = async (e: React.DragEvent, targetIndex: number) => {
if (!selectedGroup || draggedItemIndex === null || draggedItemIndex === targetIndex) return;
const newCodes = [...selectedGroup.codes];
const [movedItem] = newCodes.splice(draggedItemIndex, 1);
newCodes.splice(targetIndex, 0, movedItem);
const updated = { ...selectedGroup, codes: newCodes };
await dbService.updateWatchlistGroup(updated);
setDraggedItemIndex(null);
onRefresh();
};
return (
<div className="grid grid-cols-1 lg:grid-cols-4 gap-10 animate-in fade-in duration-500 pb-20">
<div className="lg:col-span-1 bg-white p-10 rounded-[3.5rem] shadow-sm border border-slate-100 flex flex-col h-[750px]">
<div className="flex justify-between items-center mb-10">
<h3 className="text-2xl font-black text-slate-800 flex items-center gap-3 uppercase tracking-tighter">
<Star size={24} className="text-blue-600" /> {marketMode === MarketType.DOMESTIC ? '국내' : '해외'}
</h3>
</div>
<button onClick={() => setShowAddGroupModal(true)} className="w-full py-5 bg-slate-900 text-white rounded-[2rem] font-black text-[12px] uppercase tracking-widest flex items-center justify-center gap-3 mb-8 shadow-xl shadow-slate-200 hover:bg-slate-800 transition-all active:scale-95"><FolderPlus size={18} /> </button>
<div className="flex-1 overflow-y-auto pr-2 space-y-4 scrollbar-hide">
{filteredGroups.map(group => (
<div key={group.id} onClick={() => setSelectedGroupId(group.id)} className={`p-6 rounded-[2rem] border-2 transition-all cursor-pointer group flex justify-between items-center ${selectedGroupId === group.id ? 'border-blue-500 bg-blue-50/20' : 'border-transparent bg-slate-50/70 hover:bg-white hover:border-slate-200'}`}>
<div className="flex-1 min-w-0 pr-2">
<p className={`font-black text-base truncate ${selectedGroupId === group.id ? 'text-blue-600' : 'text-slate-800'}`}>{group.name}</p>
<p className="text-[11px] font-bold text-slate-400 uppercase tracking-widest mt-1">{group.codes.length} </p>
</div>
<div className="flex items-center gap-1">
<button onClick={(e) => { e.stopPropagation(); setEditingGroup(group); setRenameValue(group.name); setShowRenameModal(true); }} className="p-2 opacity-0 group-hover:opacity-100 hover:bg-blue-50 text-slate-300 hover:text-blue-500 rounded-xl transition-all"><Edit3 size={16} /></button>
<button onClick={(e) => { e.stopPropagation(); handleDeleteGroup(group.id); }} className="p-2 opacity-0 group-hover:opacity-100 hover:bg-rose-50 text-slate-300 hover:text-rose-500 rounded-xl transition-all"><Trash2 size={16} /></button>
</div>
</div>
))}
</div>
</div>
<div className="lg:col-span-3 space-y-10">
<div className="bg-white p-12 rounded-[4rem] shadow-sm border border-slate-100 flex flex-col h-[750px]">
{selectedGroup ? (
<>
<div className="flex justify-between items-center mb-12">
<div>
<h3 className="text-3xl font-black text-slate-900 italic tracking-tighter uppercase mb-3">{selectedGroup.name}</h3>
<div className="flex items-center gap-3">
<span className="text-[11px] font-black text-slate-400 tracking-widest bg-slate-100 px-4 py-1.5 rounded-full"> </span>
</div>
</div>
<div className="relative w-80">
<Search className="absolute left-5 top-1/2 -translate-y-1/2 text-slate-400" size={20} />
<input type="text" placeholder="종목 추가..." className="w-full pl-14 pr-6 py-4 bg-slate-50 border-2 border-transparent rounded-[1.8rem] focus:border-blue-500 focus:bg-white outline-none text-sm font-bold shadow-inner" value={stockSearch} onChange={(e) => setStockSearch(e.target.value)} />
{filteredSearchStocks.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-3 bg-white border border-slate-100 shadow-2xl rounded-[2.5rem] overflow-hidden z-[50]">
{filteredSearchStocks.map(s => (
<div key={s.code} onClick={() => handleAddStockToGroup(s.code)} className="p-5 hover:bg-blue-50 cursor-pointer flex justify-between items-center border-b last:border-none border-slate-50">
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-slate-100 rounded-xl flex items-center justify-center font-black text-slate-400 text-[10px]">{s.code.substring(0,2)}</div>
<p className="font-black text-slate-800 text-sm">{s.name}</p>
</div>
<Plus size={18} className="text-blue-600" />
</div>
))}
</div>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto pr-3 scrollbar-hide">
<table className="w-full text-left">
<tbody className="divide-y divide-slate-50">
{selectedGroup.codes.map((code, idx) => {
const stock = stocks.find(s => s.code === code);
if (!stock) return null;
return (
<StockRow
key={code}
stock={stock}
onTrade={() => {}}
onToggleWatchlist={() => handleRemoveStockFromGroup(code)}
isWatchlisted={true}
/>
);
})}
</tbody>
</table>
</div>
</>
) : (
<div className="flex flex-col items-center justify-center h-full opacity-20"><Star size={80} strokeWidth={1} /><p className="text-lg font-black uppercase tracking-widest mt-6"> </p></div>
)}
</div>
</div>
{/* 모달은 기존 코드 유지 (생략 가능하나 유저 요청에 따라 전체 포함) */}
{showAddGroupModal && (
<div className="fixed inset-0 z-[150] bg-slate-900/70 backdrop-blur-md flex items-center justify-center p-6">
<div className="bg-white w-full max-w-lg rounded-[3.5rem] p-12 shadow-2xl border border-slate-200">
<div className="flex justify-between items-center mb-10"><h3 className="text-2xl font-black text-slate-900 uppercase tracking-tight"> </h3><button onClick={() => setShowAddGroupModal(false)}><X size={28} className="text-slate-400" /></button></div>
<input type="text" className="w-full p-6 bg-slate-50 border-2 border-transparent focus:border-blue-500 rounded-3xl font-black text-lg" placeholder="그룹 명칭" value={newGroupName} onChange={(e) => setNewGroupName(e.target.value)} />
<button onClick={handleAddGroup} className="w-full py-5 bg-blue-600 text-white rounded-[2rem] font-black mt-8"> </button>
</div>
</div>
)}
</div>
);
};
export default WatchlistManagement;