..
This commit is contained in:
143
components/StockMasterRow.tsx
Normal file
143
components/StockMasterRow.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Zap, ShoppingCart, Star, TrendingUp, TrendingDown } from 'lucide-react';
|
||||||
|
import { StockItem, MarketType, OrderType } from '../types';
|
||||||
|
|
||||||
|
interface StockMasterRowProps {
|
||||||
|
stock: StockItem;
|
||||||
|
rank?: number;
|
||||||
|
isWatchlisted?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
onTrade?: (type: OrderType) => void;
|
||||||
|
onToggleWatchlist?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StockMasterRow: React.FC<StockMasterRowProps> = ({
|
||||||
|
stock, rank, isWatchlisted, onClick, onTrade, onToggleWatchlist
|
||||||
|
}) => {
|
||||||
|
const formatValue = (val?: number) => {
|
||||||
|
if (val === undefined) return '-';
|
||||||
|
return stock.market === MarketType.DOMESTIC ? val.toLocaleString() : '$' + val;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatVolume = (vol: number) => {
|
||||||
|
if (vol >= 1000000) return (vol / 1000000).toFixed(1) + 'M';
|
||||||
|
if (vol >= 1000) return (vol / 1000).toFixed(1) + 'K';
|
||||||
|
return vol.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
onClick={onClick}
|
||||||
|
className="group cursor-pointer transition-colors hover:bg-blue-50/40 border-b border-slate-50 last:border-0"
|
||||||
|
>
|
||||||
|
{/* 1. 번호 (순위) */}
|
||||||
|
<td className="px-5 py-4 font-mono font-bold text-slate-300 group-hover:text-blue-500 text-[11px]">
|
||||||
|
{rank !== undefined ? rank.toString().padStart(2, '0') : '-'}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* 2. 종목 기본 정보 */}
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-slate-100 flex items-center justify-center text-slate-600 text-[10px] font-black group-hover:bg-slate-900 group-hover:text-white transition-all">
|
||||||
|
{stock.name[0]}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col min-w-0">
|
||||||
|
<span className="font-black text-slate-800 text-[13px] truncate tracking-tight">{stock.name}</span>
|
||||||
|
<span className="text-[10px] text-slate-400 font-mono font-bold">{stock.code}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* 3. 현재가 및 등락 */}
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<div className="flex flex-col items-end">
|
||||||
|
<span className="font-mono font-black text-slate-900 text-[13px]">{formatValue(stock.price)}</span>
|
||||||
|
<div className={`flex items-center gap-1 text-[10px] font-bold ${stock.changePercent >= 0 ? 'text-rose-500' : 'text-blue-600'}`}>
|
||||||
|
{stock.changePercent >= 0 ? <TrendingUp size={10} /> : <TrendingDown size={10} />}
|
||||||
|
{Math.abs(stock.changePercent)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* 4. 시가 / 고가 / 저가 */}
|
||||||
|
<td className="px-5 py-4 text-right">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[10px] text-slate-400 font-bold">시: {formatValue(stock.openPrice)}</span>
|
||||||
|
<div className="flex gap-2 justify-end text-[10px] font-bold">
|
||||||
|
<span className="text-rose-400">고: {formatValue(stock.highPrice)}</span>
|
||||||
|
<span className="text-blue-400">저: {formatValue(stock.lowPrice)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* 5. 거래량 및 거래대금 */}
|
||||||
|
<td className="px-5 py-4 text-right">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[11px] font-black text-slate-700 font-mono">{formatVolume(stock.volume)}</span>
|
||||||
|
{stock.tradingValue && (
|
||||||
|
<span className="text-[9px] text-slate-400 font-bold uppercase tracking-tighter">
|
||||||
|
{stock.market === MarketType.DOMESTIC ? (stock.tradingValue / 100000000).toFixed(1) + '억' : '$' + (stock.tradingValue / 1000000).toFixed(1) + 'M'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* 6. 기업 건강상태 (Fundamental) */}
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex justify-between items-center gap-2">
|
||||||
|
<span className="text-[9px] font-black text-slate-400 uppercase">PER/PBR</span>
|
||||||
|
<span className="text-[11px] font-mono font-bold text-slate-700">{stock.per || '-'} / {stock.pbr || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center gap-2">
|
||||||
|
<span className="text-[9px] font-black text-slate-400 uppercase">ROE/DY</span>
|
||||||
|
<span className="text-[11px] font-mono font-bold text-slate-700">{stock.roe || '-'}% / {stock.dividendYield || '-'}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* 7. AI 스코어 (매수/매도) */}
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<div className="flex items-center justify-end gap-3">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span className="text-[8px] font-black text-rose-300 uppercase">Buy</span>
|
||||||
|
<span className="text-[14px] font-black text-rose-500 font-mono italic">{stock.aiScoreBuy}</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-[1px] h-6 bg-slate-100" />
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span className="text-[8px] font-black text-blue-300 uppercase">Sell</span>
|
||||||
|
<span className="text-[14px] font-black text-blue-500 font-mono italic">{stock.aiScoreSell}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* 7. 액션 버튼 */}
|
||||||
|
<td className="px-5 py-4 text-right">
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onToggleWatchlist?.(); }}
|
||||||
|
className={`p-2 rounded-xl transition-all ${isWatchlisted ? 'bg-amber-50 text-amber-500' : 'bg-slate-50 text-slate-300 hover:text-amber-500'}`}
|
||||||
|
>
|
||||||
|
<Star size={14} fill={isWatchlisted ? "currentColor" : "none"} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="w-[1px] h-4 bg-slate-200 self-center mx-1" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onTrade?.(OrderType.BUY); }}
|
||||||
|
className="px-3 py-1.5 bg-slate-900 text-white rounded-xl hover:bg-rose-600 transition-all text-[11px] font-black flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Zap size={10} fill="currentColor" /> 매수
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onTrade?.(OrderType.SELL); }}
|
||||||
|
className="px-3 py-1.5 border-2 border-slate-900 text-slate-900 rounded-xl hover:bg-blue-600 hover:border-blue-600 hover:text-white transition-all text-[11px] font-black flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<ShoppingCart size={10} /> 매도
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -14,36 +14,42 @@ export const MOCK_STOCKS: StockItem[] = [
|
|||||||
code: '005930', name: '삼성전자', price: 73200, change: 800, changePercent: 1.1, market: MarketType.DOMESTIC, volume: 15234000,
|
code: '005930', name: '삼성전자', price: 73200, change: 800, changePercent: 1.1, market: MarketType.DOMESTIC, volume: 15234000,
|
||||||
per: 15.2, pbr: 1.1, roe: 12.5, marketCap: 4370000, dividendYield: 2.1,
|
per: 15.2, pbr: 1.1, roe: 12.5, marketCap: 4370000, dividendYield: 2.1,
|
||||||
aiScoreBuy: 85, aiScoreSell: 20,
|
aiScoreBuy: 85, aiScoreSell: 20,
|
||||||
|
openPrice: 72500, highPrice: 73500, lowPrice: 72400,
|
||||||
themes: ['반도체', 'AI/인공지능', '스마트폰', 'HBM']
|
themes: ['반도체', 'AI/인공지능', '스마트폰', 'HBM']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: '000660', name: 'SK하이닉스', price: 124500, change: -1200, changePercent: -0.96, market: MarketType.DOMESTIC, volume: 2100000,
|
code: '000660', name: 'SK하이닉스', price: 124500, change: -1200, changePercent: -0.96, market: MarketType.DOMESTIC, volume: 2100000,
|
||||||
per: 22.4, pbr: 1.5, roe: 8.2, marketCap: 906000, dividendYield: 1.2,
|
per: 22.4, pbr: 1.5, roe: 8.2, marketCap: 906000, dividendYield: 1.2,
|
||||||
aiScoreBuy: 65, aiScoreSell: 45,
|
aiScoreBuy: 65, aiScoreSell: 45,
|
||||||
|
openPrice: 126000, highPrice: 126500, lowPrice: 124000,
|
||||||
themes: ['반도체', 'HBM', '엔비디아 관련주']
|
themes: ['반도체', 'HBM', '엔비디아 관련주']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: '035420', name: 'NAVER', price: 215000, change: 4500, changePercent: 2.14, market: MarketType.DOMESTIC, volume: 850000,
|
code: '035420', name: 'NAVER', price: 215000, change: 4500, changePercent: 2.14, market: MarketType.DOMESTIC, volume: 850000,
|
||||||
per: 35.1, pbr: 2.3, roe: 15.8, marketCap: 352000, dividendYield: 0.5,
|
per: 35.1, pbr: 2.3, roe: 15.8, marketCap: 352000, dividendYield: 0.5,
|
||||||
aiScoreBuy: 72, aiScoreSell: 30,
|
aiScoreBuy: 72, aiScoreSell: 30,
|
||||||
|
openPrice: 211000, highPrice: 216500, lowPrice: 210500,
|
||||||
themes: ['플랫폼', '생성형AI', '광고/커머스']
|
themes: ['플랫폼', '생성형AI', '광고/커머스']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: 'AAPL', name: 'Apple Inc.', price: 189.43, change: 1.25, changePercent: 0.66, market: MarketType.OVERSEAS, volume: 45000000,
|
code: 'AAPL', name: 'Apple Inc.', price: 189.43, change: 1.25, changePercent: 0.66, market: MarketType.OVERSEAS, volume: 45000000,
|
||||||
per: 31.5, pbr: 48.2, roe: 160.1, marketCap: 3020000, dividendYield: 0.5,
|
per: 31.5, pbr: 48.2, roe: 160.1, marketCap: 3020000, dividendYield: 0.5,
|
||||||
aiScoreBuy: 90, aiScoreSell: 15,
|
aiScoreBuy: 90, aiScoreSell: 15,
|
||||||
|
openPrice: 188.50, highPrice: 190.20, lowPrice: 188.10,
|
||||||
themes: ['빅테크', '스마트폰', '자율주행']
|
themes: ['빅테크', '스마트폰', '자율주행']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: 'TSLA', name: 'Tesla Inc.', price: 234.12, change: -4.50, changePercent: -1.89, market: MarketType.OVERSEAS, volume: 110000000,
|
code: 'TSLA', name: 'Tesla Inc.', price: 234.12, change: -4.50, changePercent: -1.89, market: MarketType.OVERSEAS, volume: 110000000,
|
||||||
per: 78.2, pbr: 15.1, roe: 22.4, marketCap: 745000, dividendYield: 0,
|
per: 78.2, pbr: 15.1, roe: 22.4, marketCap: 745000, dividendYield: 0,
|
||||||
aiScoreBuy: 40, aiScoreSell: 75,
|
aiScoreBuy: 40, aiScoreSell: 75,
|
||||||
|
openPrice: 238.10, highPrice: 239.50, lowPrice: 233.80,
|
||||||
themes: ['전기차', '자율주행', '에너지저장장치']
|
themes: ['전기차', '자율주행', '에너지저장장치']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: 'NVDA', name: 'NVIDIA Corp.', price: 485.12, change: 12.30, changePercent: 2.6, market: MarketType.OVERSEAS, volume: 32000000,
|
code: 'NVDA', name: 'NVIDIA Corp.', price: 485.12, change: 12.30, changePercent: 2.6, market: MarketType.OVERSEAS, volume: 32000000,
|
||||||
per: 65.4, pbr: 35.2, roe: 91.5, marketCap: 1200000, dividendYield: 0.1,
|
per: 65.4, pbr: 35.2, roe: 91.5, marketCap: 1200000, dividendYield: 0.1,
|
||||||
aiScoreBuy: 95, aiScoreSell: 10,
|
aiScoreBuy: 95, aiScoreSell: 10,
|
||||||
|
openPrice: 480.00, highPrice: 488.50, lowPrice: 479.20,
|
||||||
themes: ['반도체', 'AI/인공지능', '데이터센터']
|
themes: ['반도체', 'AI/인공지능', '데이터센터']
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -124,14 +124,6 @@ const Dashboard: React.FC<DashboardProps> = ({
|
|||||||
{/* 1. 지수 및 환율 바 */}
|
{/* 1. 지수 및 환율 바 */}
|
||||||
<IndexBar />
|
<IndexBar />
|
||||||
|
|
||||||
{/* 2. 자산 현황 요약 */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
<StatCard title={`총 자산 (${marketMode === MarketType.DOMESTIC ? '원' : '달러'})`} value={`${marketMode === MarketType.DOMESTIC ? '₩' : '$'} ${summary.totalAssets.toLocaleString()}`} change="+4.2%" isUp={true} icon={<Wallet />} />
|
|
||||||
<StatCard title="총 평가손익" value={`${marketMode === MarketType.DOMESTIC ? '₩' : '$'} ${totalLiquidationSummary.totalPL.toLocaleString()}`} change={`${aggregatePLPercent.toFixed(2)}%`} isUp={totalLiquidationSummary.totalPL >= 0} icon={<TrendingUp />} />
|
|
||||||
<StatCard title="보유 종목수" value={`${holdings.length}개`} change="마켓 필터" isUp={true} icon={<Briefcase />} />
|
|
||||||
<StatCard title="예수금" value={`${marketMode === MarketType.DOMESTIC ? '₩' : '$'} ${summary.buyingPower.toLocaleString()}`} change="인출 가능" isUp={true} icon={<Activity />} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<div className="bg-white p-5 rounded-2xl shadow-sm border border-slate-100 flex flex-col h-[650px] lg:col-span-1">
|
<div className="bg-white p-5 rounded-2xl shadow-sm border border-slate-100 flex flex-col h-[650px] lg:col-span-1">
|
||||||
<div className="flex justify-between items-center mb-5">
|
<div className="flex justify-between items-center mb-5">
|
||||||
|
|||||||
@@ -19,15 +19,34 @@ interface DiscoveryProps {
|
|||||||
settings: ApiSettings;
|
settings: ApiSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DISCOVERY_CATEGORIES = [
|
||||||
|
{ id: 'trading_value', name: '거래대금 상위', icon: <Flame size={16} /> },
|
||||||
|
{ id: 'gainers', name: '급상승 종목', icon: <Zap size={16} /> },
|
||||||
|
{ id: 'continuous_rise', name: '연속 상승세', icon: <Trophy size={16} />, badge: '인기' },
|
||||||
|
{ id: 'undervalued_growth', name: '저평가 성장주', icon: <Sparkles size={16} />, badge: '인기' },
|
||||||
|
{ id: 'cheap_value', name: '아직 저렴한 가치주', icon: <Sparkles size={16} /> },
|
||||||
|
{ id: 'stable_dividends', name: '꾸준한 배당주', icon: <Sparkles size={16} />, badge: '인기' },
|
||||||
|
{ id: 'profitable_companies', name: '돈 잘버는 회사 찾기', icon: <Sparkles size={16} /> },
|
||||||
|
{ id: 'undervalued_recovery', name: '저평가 탈출', icon: <Sparkles size={16} /> },
|
||||||
|
{ id: 'future_dividend_kings', name: '미래의 배당왕 찾기', icon: <Sparkles size={16} /> },
|
||||||
|
{ id: 'growth_prospects', name: '성장 기대주', icon: <Sparkles size={16} /> },
|
||||||
|
{ id: 'buy_at_cheap', name: '싼값에 매수', icon: <Sparkles size={16} /> },
|
||||||
|
{ id: 'high_yield_undervalued', name: '고수익 저평가', icon: <Sparkles size={16} /> },
|
||||||
|
{ id: 'popular_growth', name: '인기 성장주', icon: <Sparkles size={16} /> },
|
||||||
|
];
|
||||||
|
|
||||||
const Discovery: React.FC<DiscoveryProps> = ({ stocks, orders, onUpdateStock, settings }) => {
|
const Discovery: React.FC<DiscoveryProps> = ({ stocks, orders, onUpdateStock, settings }) => {
|
||||||
const [activeTab, setActiveTab] = useState<'realtime' | 'category' | 'investor'>('realtime');
|
const [activeCategoryId, setActiveCategoryId] = useState<string>('trading_value');
|
||||||
const [marketFilter, setMarketFilter] = useState<'all' | 'domestic' | 'overseas'>('all');
|
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 [selectedStockCode, setSelectedStockCode] = useState<string>(stocks[0]?.code || '');
|
||||||
|
|
||||||
const [detailStock, setDetailStock] = useState<StockItem | null>(null);
|
const [detailStock, setDetailStock] = useState<StockItem | null>(null);
|
||||||
const [tradeContext, setTradeContext] = useState<{ stock: StockItem, type: OrderType } | null>(null);
|
const [tradeContext, setTradeContext] = useState<{ stock: StockItem, type: OrderType } | null>(null);
|
||||||
|
|
||||||
|
const activeCategory = useMemo(() =>
|
||||||
|
DISCOVERY_CATEGORIES.find(c => c.id === activeCategoryId) || DISCOVERY_CATEGORIES[0]
|
||||||
|
, [activeCategoryId]);
|
||||||
|
|
||||||
const selectedStock = useMemo(() => {
|
const selectedStock = useMemo(() => {
|
||||||
return stocks.find(s => s.code === selectedStockCode) || null;
|
return stocks.find(s => s.code === selectedStockCode) || null;
|
||||||
}, [stocks, selectedStockCode]);
|
}, [stocks, selectedStockCode]);
|
||||||
@@ -56,13 +75,11 @@ const Discovery: React.FC<DiscoveryProps> = ({ stocks, orders, onUpdateStock, se
|
|||||||
if (marketFilter === 'overseas') return s.market === MarketType.OVERSEAS;
|
if (marketFilter === 'overseas') return s.market === MarketType.OVERSEAS;
|
||||||
return true;
|
return true;
|
||||||
}).sort((a, b) => {
|
}).sort((a, b) => {
|
||||||
if (sortType === 'value') return (b.tradingValue || 0) - (a.tradingValue || 0);
|
// 메뉴별 기본 정렬 (사용자가 나중에 필터링 로직 제공하기 전까지는 기존 로직 활용)
|
||||||
if (sortType === 'volume') return b.volume - a.volume;
|
if (activeCategoryId === 'gainers') return b.changePercent - a.changePercent;
|
||||||
if (sortType === 'gain') return b.changePercent - a.changePercent;
|
return (b.tradingValue || 0) - (a.tradingValue || 0);
|
||||||
if (sortType === 'loss') return a.changePercent - b.changePercent;
|
|
||||||
return 0;
|
|
||||||
});
|
});
|
||||||
}, [stocks, marketFilter, sortType]);
|
}, [stocks, marketFilter, activeCategoryId]);
|
||||||
|
|
||||||
const handleSaveMemo = () => {
|
const handleSaveMemo = () => {
|
||||||
if (selectedStock) {
|
if (selectedStock) {
|
||||||
@@ -101,24 +118,56 @@ const Discovery: React.FC<DiscoveryProps> = ({ stocks, orders, onUpdateStock, se
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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="w-full h-full flex flex-col lg:flex-row gap-6 animate-in fade-in duration-700 pb-10 pr-2">
|
||||||
<div className="flex-1 space-y-6">
|
{/* 1. 좌측 사이드바 메뉴 */}
|
||||||
<div className="flex items-center gap-8 border-b border-slate-100 pb-1 overflow-x-auto scrollbar-hide">
|
<div className="w-full lg:w-[240px] shrink-0 space-y-8">
|
||||||
<TabButton active={activeTab === 'realtime'} onClick={() => setActiveTab('realtime')} icon={<Trophy size={18}/>} label="실시간 차트" />
|
<div>
|
||||||
<TabButton active={activeTab === 'category'} onClick={() => setActiveTab('category')} icon={<Flame size={18}/>} label="인기 테마" />
|
<h3 className="text-[11px] font-black text-slate-400 uppercase tracking-widest px-4 mb-4">주식 골라보기 목록</h3>
|
||||||
<TabButton active={activeTab === 'investor'} onClick={() => setActiveTab('investor')} icon={<Users size={18}/>} label="투자자 동향" />
|
<div className="space-y-1">
|
||||||
|
<p className="text-[10px] font-black text-slate-300 px-4 mb-2 uppercase italic">내가 만든</p>
|
||||||
|
<button className="w-full flex items-center gap-3 px-4 py-2 text-blue-600 font-bold text-[13px] hover:bg-blue-50/50 rounded-xl transition-all">
|
||||||
|
<div className="w-6 h-6 rounded-lg bg-blue-50 flex items-center justify-center">+</div>
|
||||||
|
직접 만들기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
<div>
|
||||||
<div className="flex gap-1.5">
|
<p className="text-[10px] font-black text-slate-300 px-4 mb-3 uppercase italic">토스증권이 만든</p>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{DISCOVERY_CATEGORIES.map(cat => (
|
||||||
|
<button
|
||||||
|
key={cat.id}
|
||||||
|
onClick={() => setActiveCategoryId(cat.id)}
|
||||||
|
className={`w-full flex items-center justify-between px-4 py-2.5 rounded-xl transition-all group ${activeCategoryId === cat.id ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-50'}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className={`${activeCategoryId === cat.id ? 'text-blue-400' : 'text-slate-400 group-hover:text-slate-900'}`}>{cat.icon}</span>
|
||||||
|
<span className="text-[13px] font-black tracking-tight">{cat.name}</span>
|
||||||
|
</div>
|
||||||
|
{cat.badge && (
|
||||||
|
<span className={`text-[9px] px-1.5 py-0.5 rounded-md font-black italic ${activeCategoryId === cat.id ? 'bg-blue-500 text-white' : 'bg-blue-50 text-blue-500'}`}>
|
||||||
|
{cat.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2. 중앙 목록 영역 */}
|
||||||
|
<div className="flex-1 space-y-6">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-black text-slate-900 italic tracking-tighter">{activeCategory.name}</h2>
|
||||||
|
<p className="text-[11px] font-bold text-slate-400 mt-1 uppercase tracking-tight">수천 개의 주식 중 조건에 맞는 종목을 선별했습니다.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5 bg-slate-100 p-1 rounded-xl">
|
||||||
<FilterChip active={marketFilter === 'all'} onClick={() => setMarketFilter('all')} label="전체" />
|
<FilterChip active={marketFilter === 'all'} onClick={() => setMarketFilter('all')} label="전체" />
|
||||||
<FilterChip active={marketFilter === 'domestic'} onClick={() => setMarketFilter('domestic')} label="국내" />
|
<FilterChip active={marketFilter === 'domestic'} onClick={() => setMarketFilter('domestic')} label="국내" />
|
||||||
<FilterChip active={marketFilter === 'overseas'} onClick={() => setMarketFilter('overseas')} label="해외" />
|
<FilterChip active={marketFilter === 'overseas'} onClick={() => setMarketFilter('overseas')} label="해외" />
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||||||
@@ -148,25 +197,26 @@ const Discovery: React.FC<DiscoveryProps> = ({ stocks, orders, onUpdateStock, se
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full lg:w-[380px]">
|
{/* 3. 우측 상세 패널 */}
|
||||||
|
<div className="w-full lg:w-[340px] shrink-0">
|
||||||
{selectedStock && (
|
{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="bg-white p-5 rounded-2xl shadow-lg border border-slate-100 flex flex-col gap-5 sticky top-24 max-h-[85vh] overflow-y-auto scrollbar-hide">
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<div className="flex items-center gap-3">
|
<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 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>
|
<div className="min-w-0">
|
||||||
<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>
|
<h4 className="text-lg font-black text-slate-900 italic tracking-tighter cursor-pointer hover:text-blue-600 leading-tight truncate" onClick={() => setDetailStock(selectedStock)}>{selectedStock.name}</h4>
|
||||||
<p className="text-[11px] font-black text-slate-400">{selectedStock.code}</p>
|
<p className="text-[11px] font-black text-slate-400">{selectedStock.code}</p>
|
||||||
</div>
|
</div>
|
||||||
</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'}`}>
|
<button onClick={handleToggleHide} className={`p-2 rounded-xl transition-all shrink-0 ${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} />}
|
{selectedStock.isHidden ? <Eye size={18} /> : <EyeOff size={18} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<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.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 hover:bg-rose-600 transition-all">매수</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>
|
<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 hover:bg-blue-600 transition-all">매도</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -215,7 +265,7 @@ const Discovery: React.FC<DiscoveryProps> = ({ stocks, orders, onUpdateStock, se
|
|||||||
<div className="flex items-center gap-1.5 text-[13px] font-black text-slate-800">
|
<div className="flex items-center gap-1.5 text-[13px] font-black text-slate-800">
|
||||||
<Sparkles size={14} className="text-purple-500" /> AI 분석
|
<Sparkles size={14} className="text-purple-500" /> AI 분석
|
||||||
</div>
|
</div>
|
||||||
<button onClick={handleGenerateAnalysis} disabled={isAnalyzing} className="p-1.5 bg-purple-50 text-purple-600 rounded-lg">
|
<button onClick={handleGenerateAnalysis} disabled={isAnalyzing} className="p-1.5 bg-purple-50 text-purple-600 rounded-lg shrink-0">
|
||||||
<RefreshCw size={12} className={isAnalyzing ? 'animate-spin' : ''} />
|
<RefreshCw size={12} className={isAnalyzing ? 'animate-spin' : ''} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -233,6 +283,7 @@ const Discovery: React.FC<DiscoveryProps> = ({ stocks, orders, onUpdateStock, se
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 4. 모달들 */}
|
||||||
{detailStock && <StockDetailModal stock={detailStock} onClose={() => setDetailStock(null)} />}
|
{detailStock && <StockDetailModal stock={detailStock} onClose={() => setDetailStock(null)} />}
|
||||||
{tradeContext && <TradeModal stock={tradeContext.stock} type={tradeContext.type} onClose={() => setTradeContext(null)} onExecute={async (o) => alert('주문 예약됨')} />}
|
{tradeContext && <TradeModal stock={tradeContext.stock} type={tradeContext.type} onClose={() => setTradeContext(null)} onExecute={async (o) => alert('주문 예약됨')} />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
112
pages/Stocks.tsx
112
pages/Stocks.tsx
@@ -5,6 +5,7 @@ import { StockItem, MarketType, OrderType } from '../types';
|
|||||||
import StockDetailModal from '../components/StockDetailModal';
|
import StockDetailModal from '../components/StockDetailModal';
|
||||||
import TradeModal from '../components/TradeModal';
|
import TradeModal from '../components/TradeModal';
|
||||||
import { StockRow } from '../components/StockRow';
|
import { StockRow } from '../components/StockRow';
|
||||||
|
import { StockMasterRow } from '../components/StockMasterRow';
|
||||||
|
|
||||||
interface StocksProps {
|
interface StocksProps {
|
||||||
marketMode: MarketType;
|
marketMode: MarketType;
|
||||||
@@ -30,8 +31,8 @@ const Stocks: React.FC<StocksProps> = ({ marketMode, stocks, onAddToWatchlist, w
|
|||||||
(s.name.includes(search) || s.code.toLowerCase().includes(search.toLowerCase()))
|
(s.name.includes(search) || s.code.toLowerCase().includes(search.toLowerCase()))
|
||||||
);
|
);
|
||||||
result.sort((a, b) => {
|
result.sort((a, b) => {
|
||||||
const valA = a[sortField] || 0;
|
const valA = (a[sortField] as number) || 0;
|
||||||
const valB = b[sortField] || 0;
|
const valB = (b[sortField] as number) || 0;
|
||||||
return sortOrder === 'asc' ? (valA > valB ? 1 : -1) : (valB > valA ? 1 : -1);
|
return sortOrder === 'asc' ? (valA > valB ? 1 : -1) : (valB > valA ? 1 : -1);
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
@@ -43,49 +44,84 @@ const Stocks: React.FC<StocksProps> = ({ marketMode, stocks, onAddToWatchlist, w
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-10 animate-in fade-in duration-500 pb-20">
|
<div className="space-y-6 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="bg-white p-6 rounded-2xl shadow-sm border border-slate-100 flex flex-col lg:flex-row gap-6 items-center justify-between">
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-4">
|
||||||
<div className="p-5 bg-blue-50 text-blue-600 rounded-3xl"><Filter size={28} /></div>
|
<div className="p-4 bg-blue-50 text-blue-600 rounded-2xl shadow-sm"><Filter size={20} /></div>
|
||||||
<div><h3 className="text-3xl font-black text-slate-900 tracking-tight">종목 마스터</h3></div>
|
<div>
|
||||||
|
<h3 className="text-[20px] font-black text-slate-900 tracking-tighter uppercase">종목 마스터</h3>
|
||||||
|
<p className="text-[11px] font-bold text-slate-400">데이터 기반 종목 분석 및 관리</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-3 w-full lg:w-auto">
|
||||||
<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)} />
|
<div className="relative flex-1 lg:w-64">
|
||||||
<button onClick={onSync} className="p-4 bg-slate-900 text-white rounded-2xl flex items-center gap-2"><RotateCw size={18} /> 동기화</button>
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-300" size={16} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="종목명 또는 코드 검색..."
|
||||||
|
className="w-full pl-11 pr-4 py-3 bg-slate-50 rounded-xl outline-none border-2 border-transparent focus:border-blue-500 focus:bg-white transition-all text-sm font-bold"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button onClick={onSync} className="px-5 py-3 bg-slate-900 text-white rounded-xl flex items-center gap-2 hover:bg-slate-800 transition-all text-sm font-black shadow-lg">
|
||||||
|
<RotateCw size={16} /> 동기화
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white rounded-[3.5rem] shadow-sm border border-slate-100 overflow-hidden">
|
<div className="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
|
||||||
<table className="w-full text-left">
|
<div className="overflow-x-auto scrollbar-hide">
|
||||||
<thead className="bg-slate-50 text-[11px] font-black text-slate-400 uppercase tracking-widest">
|
<table className="w-full text-left border-collapse">
|
||||||
<tr>
|
<thead className="bg-slate-50/50 text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-100">
|
||||||
<th className="px-8 py-6 cursor-pointer" onClick={() => handleSort('name')}>종목 <ArrowUpDown size={12} className="inline" /></th>
|
<tr>
|
||||||
<th className="px-8 py-6 cursor-pointer" onClick={() => handleSort('price')}>현재가 <ArrowUpDown size={12} className="inline" /></th>
|
<th className="px-5 py-4 w-12 text-center">No</th>
|
||||||
<th className="px-8 py-6">AI 등락률 예탁</th>
|
<th className="px-5 py-4 cursor-pointer hover:text-blue-600 transition-colors" onClick={() => handleSort('name')}>
|
||||||
<th className="px-8 py-6 text-right">액션</th>
|
종목정보 <ArrowUpDown size={10} className="inline ml-1" />
|
||||||
</tr>
|
</th>
|
||||||
</thead>
|
<th className="px-5 py-4 text-right cursor-pointer hover:text-blue-600 transition-colors" onClick={() => handleSort('price')}>
|
||||||
<tbody className="divide-y divide-slate-50">
|
현재가 <ArrowUpDown size={10} className="inline ml-1" />
|
||||||
{filteredStocks.map(stock => {
|
</th>
|
||||||
const isWatch = watchlistCodes.includes(stock.code);
|
<th className="px-5 py-4 text-right">시가/고/저</th>
|
||||||
return (
|
<th className="px-5 py-4 text-right cursor-pointer hover:text-blue-600 transition-colors" onClick={() => handleSort('volume')}>
|
||||||
<StockRow
|
거래량 <ArrowUpDown size={10} className="inline ml-1" />
|
||||||
key={stock.code}
|
</th>
|
||||||
stock={stock}
|
<th className="px-5 py-4">기업건강 (P/R/E)</th>
|
||||||
isWatchlisted={isWatch}
|
<th className="px-5 py-4 text-center cursor-pointer hover:text-blue-600 transition-colors" onClick={() => handleSort('aiScoreBuy')}>
|
||||||
showActions={true}
|
AI SCORE <ArrowUpDown size={10} className="inline ml-1" />
|
||||||
onTrade={(type) => setTradeContext({ stock, type })}
|
</th>
|
||||||
onToggleWatchlist={() => onAddToWatchlist(stock)}
|
<th className="px-5 py-4 text-right">트레이딩 액션</th>
|
||||||
onClick={() => setDetailStock(stock)}
|
</tr>
|
||||||
/>
|
</thead>
|
||||||
);
|
<tbody className="divide-y divide-slate-50">
|
||||||
})}
|
{filteredStocks.map((stock, idx) => {
|
||||||
</tbody>
|
const isWatch = watchlistCodes.includes(stock.code);
|
||||||
</table>
|
return (
|
||||||
|
<StockMasterRow
|
||||||
|
key={stock.code}
|
||||||
|
rank={idx + 1}
|
||||||
|
stock={stock}
|
||||||
|
isWatchlisted={isWatch}
|
||||||
|
onTrade={(type) => setTradeContext({ stock, type })}
|
||||||
|
onToggleWatchlist={() => onAddToWatchlist(stock)}
|
||||||
|
onClick={() => setDetailStock(stock)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{detailStock && <StockDetailModal stock={detailStock} onClose={() => setDetailStock(null)} />}
|
{detailStock && <StockDetailModal stock={detailStock} onClose={() => setDetailStock(null)} />}
|
||||||
{tradeContext && <TradeModal stock={tradeContext.stock} type={tradeContext.type} onClose={() => setTradeContext(null)} onExecute={async (o) => alert("주문 예약됨")} />}
|
{tradeContext && (
|
||||||
|
<TradeModal
|
||||||
|
stock={tradeContext.stock}
|
||||||
|
type={tradeContext.type}
|
||||||
|
onClose={() => setTradeContext(null)}
|
||||||
|
onExecute={async (o) => alert("주문 예약됨")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
3
types.ts
3
types.ts
@@ -61,6 +61,9 @@ export interface StockItem {
|
|||||||
dividendYield?: number;
|
dividendYield?: number;
|
||||||
aiScoreBuy: number;
|
aiScoreBuy: number;
|
||||||
aiScoreSell: number;
|
aiScoreSell: number;
|
||||||
|
openPrice?: number; // 시가
|
||||||
|
highPrice?: number; // 고가
|
||||||
|
lowPrice?: number; // 저가
|
||||||
themes?: string[];
|
themes?: string[];
|
||||||
// --- New Fields ---
|
// --- New Fields ---
|
||||||
memo?: string; // 사용자 메모
|
memo?: string; // 사용자 메모
|
||||||
|
|||||||
Reference in New Issue
Block a user