You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
797 lines
36 KiB
TypeScript
797 lines
36 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import Layout from '@/components/Layout';
|
|
import NewsSocialDisplay from '@/components/NewsSocialDisplay';
|
|
import {
|
|
Search,
|
|
TrendingUp,
|
|
BarChart3,
|
|
Building,
|
|
Calendar,
|
|
DollarSign,
|
|
Percent,
|
|
Users,
|
|
AlertCircle,
|
|
RefreshCw,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
TrendingDown,
|
|
Activity,
|
|
PieChart,
|
|
LineChart,
|
|
Eye,
|
|
BarChart,
|
|
Target,
|
|
Layers,
|
|
Shield,
|
|
Award,
|
|
Briefcase,
|
|
Calculator
|
|
} from 'lucide-react';
|
|
import {
|
|
stockApi,
|
|
Company,
|
|
FinancialData,
|
|
PriceData,
|
|
formatCurrency,
|
|
formatNumber,
|
|
formatPercentage,
|
|
formatDate
|
|
} from '@/lib/api';
|
|
import dynamic from 'next/dynamic';
|
|
const Plot = dynamic(() => import('react-plotly.js'), { ssr: false });
|
|
|
|
interface StockDetailPageProps {}
|
|
|
|
type ChartPeriod = '1D' | '5D' | '1M' | '3M' | '6M' | '1Y' | '2Y' | '5Y' | '10Y';
|
|
|
|
const StockDetailPage: React.FC<StockDetailPageProps> = () => {
|
|
const [ticker, setTicker] = useState<string>('');
|
|
const [searchTicker, setSearchTicker] = useState<string>('');
|
|
const [company, setCompany] = useState<Company | null>(null);
|
|
const [financialData, setFinancialData] = useState<FinancialData[]>([]);
|
|
const [priceData, setPriceData] = useState<PriceData[]>([]);
|
|
const [loading, setLoading] = useState<boolean>(false);
|
|
const [error, setError] = useState<string>('');
|
|
const [showAllFinancialData, setShowAllFinancialData] = useState<boolean>(false);
|
|
const [periodType, setPeriodType] = useState<'quarterly' | 'annual' | 'all'>('all');
|
|
const [chartPeriod, setChartPeriod] = useState<ChartPeriod>('6M');
|
|
const [chartType, setChartType] = useState<'line' | 'candlestick'>('line');
|
|
|
|
const searchStock = async () => {
|
|
if (!searchTicker.trim()) {
|
|
setError('종목 코드를 입력해주세요');
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
setError('');
|
|
setTicker(searchTicker.toUpperCase());
|
|
|
|
try {
|
|
// Get date range for price data based on chart selection
|
|
const getDateRangeForChart = (period: ChartPeriod): { start_date: string; end_date: string } => {
|
|
const today = new Date();
|
|
const endDate = today.toISOString().split('T')[0];
|
|
let startDate: Date;
|
|
|
|
switch (period) {
|
|
case '1D':
|
|
startDate = new Date(today.getTime() - 1 * 24 * 60 * 60 * 1000);
|
|
break;
|
|
case '5D':
|
|
startDate = new Date(today.getTime() - 5 * 24 * 60 * 60 * 1000);
|
|
break;
|
|
case '1M':
|
|
startDate = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000);
|
|
break;
|
|
case '3M':
|
|
startDate = new Date(today.getTime() - 90 * 24 * 60 * 60 * 1000);
|
|
break;
|
|
case '6M':
|
|
startDate = new Date(today.getTime() - 180 * 24 * 60 * 60 * 1000);
|
|
break;
|
|
case '1Y':
|
|
startDate = new Date(today.getTime() - 365 * 24 * 60 * 60 * 1000);
|
|
break;
|
|
case '2Y':
|
|
startDate = new Date(today.getTime() - 2 * 365 * 24 * 60 * 60 * 1000);
|
|
break;
|
|
case '5Y':
|
|
startDate = new Date(today.getTime() - 5 * 365 * 24 * 60 * 60 * 1000);
|
|
break;
|
|
case '10Y':
|
|
startDate = new Date(today.getTime() - 10 * 365 * 24 * 60 * 60 * 1000);
|
|
break;
|
|
default:
|
|
startDate = new Date(today.getTime() - 180 * 24 * 60 * 60 * 1000); // Default to 6M
|
|
}
|
|
|
|
return {
|
|
start_date: startDate.toISOString().split('T')[0],
|
|
end_date: endDate
|
|
};
|
|
};
|
|
|
|
const { start_date, end_date } = getDateRangeForChart(chartPeriod);
|
|
|
|
// 재무 데이터와 주가 데이터를 동시에 가져오기
|
|
const [financialResponse, priceResponse] = await Promise.allSettled([
|
|
stockApi.getFinancialData({
|
|
ticker: searchTicker.toUpperCase(),
|
|
start_date: '2020-01-01',
|
|
end_date: new Date().toISOString().split('T')[0],
|
|
period_type: periodType,
|
|
include_metrics: true
|
|
}),
|
|
stockApi.getPriceData({
|
|
ticker: searchTicker.toUpperCase(),
|
|
start_date,
|
|
end_date,
|
|
interval: '1d'
|
|
})
|
|
]);
|
|
|
|
// 재무 데이터 처리
|
|
if (financialResponse.status === 'fulfilled') {
|
|
console.log('🔍 Debug: Financial data loaded', {
|
|
company: financialResponse.value.company,
|
|
dataCount: financialResponse.value.financial_data?.length,
|
|
firstRecord: financialResponse.value.financial_data?.[0],
|
|
lastRecord: financialResponse.value.financial_data?.[financialResponse.value.financial_data.length - 1]
|
|
});
|
|
setCompany(financialResponse.value.company);
|
|
setFinancialData(financialResponse.value.financial_data);
|
|
}
|
|
|
|
// 주가 데이터 처리
|
|
if (priceResponse.status === 'fulfilled') {
|
|
console.log('🔍 Debug: Price data loaded', {
|
|
ticker: priceResponse.value.ticker,
|
|
dataCount: priceResponse.value.data?.length,
|
|
firstRecord: priceResponse.value.data?.[0],
|
|
lastRecord: priceResponse.value.data?.[priceResponse.value.data?.length - 1]
|
|
});
|
|
setPriceData(priceResponse.value.data || []);
|
|
}
|
|
|
|
// 둘 다 실패한 경우에만 에러 표시
|
|
if (financialResponse.status === 'rejected' && priceResponse.status === 'rejected') {
|
|
setError(`${searchTicker} 종목에 대한 데이터를 찾을 수 없습니다.`);
|
|
}
|
|
|
|
} catch (err: any) {
|
|
setError(err.message || '데이터를 불러오는 중 오류가 발생했습니다.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleKeyPress = (e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter') {
|
|
searchStock();
|
|
}
|
|
};
|
|
|
|
// 차트 기간이 변경될 때 데이터 다시 로드
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally only re-fetch when chartPeriod changes
|
|
useEffect(() => {
|
|
if (ticker) {
|
|
searchStock();
|
|
}
|
|
}, [chartPeriod]);
|
|
|
|
// Helper function to format chart data
|
|
const formatChartData = () => {
|
|
if (!priceData || priceData.length === 0) return [];
|
|
|
|
// Sort by date ascending (oldest to newest) for proper chronological display
|
|
const sortedData = [...priceData].sort((a, b) =>
|
|
new Date(a.date).getTime() - new Date(b.date).getTime()
|
|
);
|
|
|
|
return sortedData.map(item => ({
|
|
date: formatDate(item.date, 'MM/dd'),
|
|
price: item.close,
|
|
volume: item.volume || 0,
|
|
high: item.high,
|
|
low: item.low,
|
|
open: item.open,
|
|
}));
|
|
};
|
|
|
|
// Calculate price change from previous to latest price
|
|
const getPriceChange = () => {
|
|
if (!priceData || priceData.length < 2) return { change: 0, changePercent: 0 };
|
|
|
|
// API returns data in ascending order, so latest is last, previous is second to last
|
|
const latest = priceData[priceData.length - 1];
|
|
const previous = priceData[priceData.length - 2];
|
|
|
|
const change = latest.close - previous.close;
|
|
const changePercent = (change / previous.close) * 100;
|
|
|
|
return { change, changePercent };
|
|
};
|
|
|
|
// Calculate 52-week range
|
|
const get52WeekRange = () => {
|
|
if (!priceData || priceData.length === 0) return { low: 0, high: 0 };
|
|
|
|
const prices = priceData.map(p => p.close);
|
|
return {
|
|
low: Math.min(...prices),
|
|
high: Math.max(...prices)
|
|
};
|
|
};
|
|
|
|
// Display financial data with most recent first
|
|
const displayedFinancialData = showAllFinancialData
|
|
? [...financialData].reverse()
|
|
: [...(financialData || [])].reverse().slice(0, 8);
|
|
|
|
const getLatestPrice = () => {
|
|
if (!priceData || priceData.length === 0) {
|
|
console.log('🔍 Debug: No price data available', { priceData });
|
|
return null;
|
|
}
|
|
// API returns data in ascending order (oldest first), so get the last element for most recent
|
|
const latest = priceData[priceData.length - 1];
|
|
console.log('🔍 Debug: Latest price data', { latest, totalRecords: priceData.length });
|
|
return latest;
|
|
};
|
|
|
|
const getLatestFinancials = () => {
|
|
if (!financialData || financialData.length === 0) {
|
|
console.log('🔍 Debug: No financial data available', { financialData });
|
|
return null;
|
|
}
|
|
// Get the most recent financial data (last element if sorted ascending)
|
|
const latest = financialData[financialData.length - 1];
|
|
console.log('🔍 Debug: Latest financial data', { latest, totalRecords: financialData.length });
|
|
return latest;
|
|
};
|
|
|
|
const latestPrice = getLatestPrice();
|
|
const latestFinancials = getLatestFinancials();
|
|
const chartData = formatChartData();
|
|
const priceChange = getPriceChange();
|
|
const weekRange = get52WeekRange();
|
|
|
|
const chartPeriods: ChartPeriod[] = ['1D', '5D', '1M', '3M', '6M', '1Y', '2Y', '5Y', '10Y'];
|
|
|
|
return (
|
|
<Layout title="Stock Analysis - Stock Oracle">
|
|
<div className="space-y-6">
|
|
{/* 검색 섹션 */}
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
<h1 className="text-2xl font-bold text-gray-900 mb-4 flex items-center">
|
|
<Search className="h-6 w-6 mr-2 text-blue-600" />
|
|
종목 상세 조회
|
|
</h1>
|
|
|
|
<div className="flex gap-4">
|
|
<div className="flex-1">
|
|
<label htmlFor="ticker" className="block text-sm font-medium text-gray-700 mb-2">
|
|
종목 코드 (예: AAPL, TSLA, MSFT)
|
|
</label>
|
|
<input
|
|
type="text"
|
|
id="ticker"
|
|
value={searchTicker}
|
|
onChange={(e) => setSearchTicker(e.target.value.toUpperCase())}
|
|
onKeyPress={handleKeyPress}
|
|
placeholder="종목 코드를 입력하세요"
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
기간 유형
|
|
</label>
|
|
<select
|
|
value={periodType}
|
|
onChange={(e) => setPeriodType(e.target.value as 'quarterly' | 'annual' | 'all')}
|
|
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
|
>
|
|
<option value="all">전체</option>
|
|
<option value="quarterly">분기별</option>
|
|
<option value="annual">연간</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div className="flex items-end">
|
|
<button
|
|
onClick={searchStock}
|
|
disabled={loading}
|
|
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
|
>
|
|
{loading ? (
|
|
<RefreshCw className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Search className="h-4 w-4" />
|
|
)}
|
|
조회
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="mt-4 p-4 bg-red-50 border border-red-200 rounded-lg flex items-center gap-2">
|
|
<AlertCircle className="h-5 w-5 text-red-500" />
|
|
<span className="text-red-700">{error}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Professional Stock Analysis Dashboard */}
|
|
{company && (
|
|
<div className="space-y-6">
|
|
{/* Premium Company Header */}
|
|
<div className="bg-gradient-to-r from-slate-900 to-slate-800 rounded-xl shadow-xl text-white p-8">
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<div className="p-2 bg-white/10 rounded-lg">
|
|
<Building className="h-8 w-8 text-white" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-4xl font-bold">{company.name}</h1>
|
|
<div className="flex items-center gap-3 mt-1">
|
|
<span className="text-2xl font-mono text-blue-300">{company.ticker}</span>
|
|
{company.sector && (
|
|
<span className="text-slate-300 text-lg">• {company.sector}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Live Price Display */}
|
|
{latestPrice && (
|
|
<div className="flex items-center gap-6 mt-4">
|
|
<div className="text-5xl font-bold font-mono">
|
|
{formatCurrency(latestPrice.close)}
|
|
</div>
|
|
<div className={`flex items-center gap-2 px-4 py-2 rounded-lg text-lg font-semibold ${
|
|
priceChange.change >= 0
|
|
? 'bg-green-500/20 text-green-300'
|
|
: 'bg-red-500/20 text-red-300'
|
|
}`}>
|
|
{priceChange.change >= 0 ? <TrendingUp className="h-5 w-5" /> : <TrendingDown className="h-5 w-5" />}
|
|
<span>
|
|
{priceChange.change >= 0 ? '+' : ''}{priceChange.change.toFixed(2)}
|
|
({priceChange.changePercent >= 0 ? '+' : ''}{priceChange.changePercent.toFixed(2)}%)
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Market Status */}
|
|
<div className="text-right">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<div className="h-3 w-3 bg-green-400 rounded-full animate-pulse"></div>
|
|
<span className="text-green-300 font-medium">Live Data</span>
|
|
</div>
|
|
{latestPrice && (
|
|
<div className="text-slate-300 text-sm">
|
|
Last Updated: {formatDate(latestPrice.date)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Key Performance Indicators Grid */}
|
|
{(latestPrice || latestFinancials) && (
|
|
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
|
{latestPrice && (
|
|
<>
|
|
<div className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<Target className="h-5 w-5 text-indigo-500" />
|
|
<span className="text-xs text-gray-500 uppercase tracking-wide">52W High</span>
|
|
</div>
|
|
<div className="text-xl font-bold text-gray-900">{formatCurrency(weekRange.high)}</div>
|
|
</div>
|
|
|
|
<div className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<TrendingDown className="h-5 w-5 text-red-500" />
|
|
<span className="text-xs text-gray-500 uppercase tracking-wide">52W Low</span>
|
|
</div>
|
|
<div className="text-xl font-bold text-gray-900">{formatCurrency(weekRange.low)}</div>
|
|
</div>
|
|
|
|
<div className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<Activity className="h-5 w-5 text-purple-500" />
|
|
<span className="text-xs text-gray-500 uppercase tracking-wide">Volume</span>
|
|
</div>
|
|
<div className="text-xl font-bold text-gray-900">{formatNumber(latestPrice.volume)}</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{latestFinancials && (
|
|
<>
|
|
<div className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<Calculator className="h-5 w-5 text-blue-500" />
|
|
<span className="text-xs text-gray-500 uppercase tracking-wide">P/E Ratio</span>
|
|
</div>
|
|
<div className="text-xl font-bold text-gray-900">
|
|
{latestFinancials.eps && latestPrice ?
|
|
(latestPrice.close / latestFinancials.eps).toFixed(2) : 'N/A'
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<Award className="h-5 w-5 text-green-500" />
|
|
<span className="text-xs text-gray-500 uppercase tracking-wide">ROE</span>
|
|
</div>
|
|
<div className="text-xl font-bold text-gray-900">{formatPercentage(latestFinancials.roe)}</div>
|
|
</div>
|
|
|
|
<div className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<Percent className="h-5 w-5 text-orange-500" />
|
|
<span className="text-xs text-gray-500 uppercase tracking-wide">Margin</span>
|
|
</div>
|
|
<div className="text-xl font-bold text-gray-900">{formatPercentage(latestFinancials.net_margin)}</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Main Dashboard Grid */}
|
|
<div className="grid lg:grid-cols-3 gap-6">
|
|
|
|
{/* Chart Section - Takes 2/3 width */}
|
|
<div className="lg:col-span-2 space-y-6">
|
|
{/* Stock Chart */}
|
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 bg-blue-50 rounded-lg">
|
|
<BarChart className="h-6 w-6 text-blue-600" />
|
|
</div>
|
|
<h2 className="text-xl font-bold text-gray-900">주가 차트</h2>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-4">
|
|
{/* Period Selector */}
|
|
<div className="flex bg-gray-50 rounded-lg p-1">
|
|
{chartPeriods.map((period) => (
|
|
<button
|
|
key={period}
|
|
onClick={() => setChartPeriod(period)}
|
|
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-all ${
|
|
chartPeriod === period
|
|
? 'bg-white text-blue-600 shadow-sm ring-1 ring-blue-200'
|
|
: 'text-gray-600 hover:text-gray-900'
|
|
}`}
|
|
>
|
|
{period}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Chart Display */}
|
|
<div className="bg-gray-50 rounded-xl p-4 border">
|
|
<Plot
|
|
data={[
|
|
{
|
|
x: chartData.map((d:any) => d.date),
|
|
y: chartData.map((d:any) => d.price),
|
|
type: 'scatter',
|
|
mode: 'lines',
|
|
name: '주가',
|
|
line: { color: '#10b981', width: 3 },
|
|
yaxis: 'y1',
|
|
},
|
|
{
|
|
x: chartData.map((d:any) => d.date),
|
|
y: chartData.map((d:any) => d.volume),
|
|
type: 'bar',
|
|
name: '거래량',
|
|
marker: { color: 'rgba(59,130,246,0.4)' },
|
|
yaxis: 'y2',
|
|
},
|
|
]}
|
|
layout={{
|
|
autosize: true,
|
|
height: 400,
|
|
margin: { t: 20, r: 20, b: 40, l: 40 },
|
|
paper_bgcolor: 'rgba(0,0,0,0)',
|
|
plot_bgcolor: 'rgba(0,0,0,0)',
|
|
xaxis: { title: '날짜', tickfont: { size: 12 } },
|
|
yaxis: { title: '주가', titlefont: { size: 12 }, tickprefix: '$' },
|
|
yaxis2: {
|
|
title: '거래량',
|
|
overlaying: 'y',
|
|
side: 'right',
|
|
showgrid: false,
|
|
},
|
|
legend: { orientation: 'h', y: -0.2 },
|
|
}}
|
|
style={{ width: '100%', height: '100%' }}
|
|
config={{ displayModeBar: false, responsive: true }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Financial Trend Chart */}
|
|
{financialData.length > 1 && (
|
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
|
<div className="flex items-center gap-3 mb-6">
|
|
<div className="p-2 bg-green-50 rounded-lg">
|
|
<TrendingUp className="h-6 w-6 text-green-600" />
|
|
</div>
|
|
<h2 className="text-xl font-bold text-gray-900">재무 트렌드</h2>
|
|
</div>
|
|
<div className="bg-gray-50 rounded-xl p-4 border">
|
|
<Plot
|
|
data={(() => {
|
|
const sorted = [...financialData]
|
|
.slice(0, 8)
|
|
.sort((a, b) => new Date(a.period_date).getTime() - new Date(b.period_date).getTime());
|
|
const x = sorted.map((d) => formatDate(d.period_date, 'MM/yy'));
|
|
return [
|
|
{
|
|
x,
|
|
y: sorted.map((d) => d.revenue ?? null),
|
|
type: 'scatter',
|
|
mode: 'lines+markers',
|
|
name: '매출액',
|
|
line: { color: '#3b82f6', width: 3 },
|
|
marker: { color: '#3b82f6' },
|
|
},
|
|
{
|
|
x,
|
|
y: sorted.map((d) => d.net_income ?? null),
|
|
type: 'scatter',
|
|
mode: 'lines+markers',
|
|
name: '순이익',
|
|
line: { color: '#10b981', width: 3 },
|
|
marker: { color: '#10b981' },
|
|
},
|
|
];
|
|
})()}
|
|
layout={{
|
|
autosize: true,
|
|
height: 300,
|
|
margin: { t: 10, r: 20, b: 40, l: 50 },
|
|
paper_bgcolor: 'rgba(0,0,0,0)',
|
|
plot_bgcolor: 'rgba(0,0,0,0)',
|
|
xaxis: { title: '기간', tickfont: { size: 12 } },
|
|
yaxis: { title: '금액', tickprefix: '$' },
|
|
legend: { orientation: 'h', y: -0.2 },
|
|
}}
|
|
style={{ width: '100%', height: '100%' }}
|
|
config={{ displayModeBar: false, responsive: true }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Sidebar - Takes 1/3 width */}
|
|
<div className="space-y-6">
|
|
{/* Company Overview */}
|
|
{company.business_description && (
|
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<div className="p-2 bg-purple-50 rounded-lg">
|
|
<Briefcase className="h-5 w-5 text-purple-600" />
|
|
</div>
|
|
<h3 className="text-lg font-bold text-gray-900">회사 개요</h3>
|
|
</div>
|
|
<p className="text-gray-700 leading-relaxed text-sm">{company.business_description}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Key Financial Metrics */}
|
|
{latestFinancials && (
|
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<div className="p-2 bg-blue-50 rounded-lg">
|
|
<BarChart3 className="h-5 w-5 text-blue-600" />
|
|
</div>
|
|
<h3 className="text-lg font-bold text-gray-900">주요 재무 지표</h3>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<div className="flex justify-between items-center p-3 bg-blue-50 rounded-lg">
|
|
<span className="text-blue-700 font-medium">매출액</span>
|
|
<span className="font-bold text-blue-900">{formatCurrency(latestFinancials.revenue)}</span>
|
|
</div>
|
|
<div className="flex justify-between items-center p-3 bg-green-50 rounded-lg">
|
|
<span className="text-green-700 font-medium">순이익</span>
|
|
<span className="font-bold text-green-900">{formatCurrency(latestFinancials.net_income)}</span>
|
|
</div>
|
|
<div className="flex justify-between items-center p-3 bg-purple-50 rounded-lg">
|
|
<span className="text-purple-700 font-medium">총 자산</span>
|
|
<span className="font-bold text-purple-900">{formatCurrency(latestFinancials.total_assets)}</span>
|
|
</div>
|
|
<div className="flex justify-between items-center p-3 bg-orange-50 rounded-lg">
|
|
<span className="text-orange-700 font-medium">EPS</span>
|
|
<span className="font-bold text-orange-900">
|
|
{latestFinancials.eps ? `$${latestFinancials.eps.toFixed(2)}` : 'N/A'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Price Statistics */}
|
|
{latestPrice && (
|
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<div className="p-2 bg-indigo-50 rounded-lg">
|
|
<DollarSign className="h-5 w-5 text-indigo-600" />
|
|
</div>
|
|
<h3 className="text-lg font-bold text-gray-900">주가 정보</h3>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-600">시가</span>
|
|
<span className="font-semibold">{formatCurrency(latestPrice.open)}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-600">고가</span>
|
|
<span className="font-semibold">{formatCurrency(latestPrice.high)}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-600">저가</span>
|
|
<span className="font-semibold">{formatCurrency(latestPrice.low)}</span>
|
|
</div>
|
|
<div className="flex justify-between border-t pt-3">
|
|
<span className="text-gray-600">거래량</span>
|
|
<span className="font-semibold">{formatNumber(latestPrice.volume)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Financial Ratios */}
|
|
{latestFinancials && (
|
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<div className="p-2 bg-emerald-50 rounded-lg">
|
|
<Calculator className="h-5 w-5 text-emerald-600" />
|
|
</div>
|
|
<h3 className="text-lg font-bold text-gray-900">재무 비율</h3>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-600">ROA</span>
|
|
<span className="font-semibold">{formatPercentage(latestFinancials.roa)}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-600">순이익률</span>
|
|
<span className="font-semibold">{formatPercentage(latestFinancials.net_margin)}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-600">부채비율</span>
|
|
<span className="font-semibold">
|
|
{latestFinancials.total_debt && latestFinancials.total_assets ?
|
|
formatPercentage((latestFinancials.total_debt / latestFinancials.total_assets) * 100) : 'N/A'
|
|
}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Financial Data Table */}
|
|
{financialData && financialData.length > 0 && (
|
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 bg-slate-50 rounded-lg">
|
|
<Layers className="h-6 w-6 text-slate-600" />
|
|
</div>
|
|
<h2 className="text-xl font-bold text-gray-900">재무 데이터 히스토리</h2>
|
|
<span className="bg-gray-100 text-gray-600 px-3 py-1 rounded-full text-sm font-medium">
|
|
{financialData.length}개 기록
|
|
</span>
|
|
</div>
|
|
|
|
{financialData.length > 8 && (
|
|
<button
|
|
onClick={() => setShowAllFinancialData(!showAllFinancialData)}
|
|
className="flex items-center gap-2 text-blue-600 hover:text-blue-800 font-medium"
|
|
>
|
|
{showAllFinancialData ? (
|
|
<>접기 <ChevronUp className="h-4 w-4" /></>
|
|
) : (
|
|
<>모두 보기 <ChevronDown className="h-4 w-4" /></>
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="overflow-x-auto">
|
|
<table className="min-w-full divide-y divide-gray-200">
|
|
<thead className="bg-gray-50">
|
|
<tr>
|
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">날짜</th>
|
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">유형</th>
|
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">매출액</th>
|
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">순이익</th>
|
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">EPS</th>
|
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">ROE</th>
|
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">순이익률</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white divide-y divide-gray-100">
|
|
{displayedFinancialData.map((data, index) => (
|
|
<tr key={index} className="hover:bg-gray-50 transition-colors">
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
|
{formatDate(data.period_date)}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<span className={`px-3 py-1 text-xs font-semibold rounded-full ${
|
|
data.period_type === 'quarterly'
|
|
? 'bg-blue-100 text-blue-800 border border-blue-200'
|
|
: 'bg-green-100 text-green-800 border border-green-200'
|
|
}`}>
|
|
{data.period_type === 'quarterly' ? '분기' : '연간'}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-semibold text-gray-900">
|
|
{formatCurrency(data.revenue)}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-semibold text-gray-900">
|
|
{formatCurrency(data.net_income)}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-semibold text-gray-900">
|
|
{data.eps ? `$${data.eps.toFixed(2)}` : 'N/A'}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-semibold text-gray-900">
|
|
{formatPercentage(data.roe)}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-semibold text-gray-900">
|
|
{formatPercentage(data.net_margin)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 뉴스 및 소셜 미디어 섹션 */}
|
|
{ticker && (
|
|
<NewsSocialDisplay ticker={ticker} />
|
|
)}
|
|
|
|
{/* 데이터 없음 메시지 */}
|
|
{ticker && !loading && !company && (!financialData || financialData.length === 0) && (!priceData || priceData.length === 0) && (
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-8 text-center">
|
|
<AlertCircle className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
|
<h3 className="text-lg font-medium text-gray-900 mb-2">데이터를 찾을 수 없습니다</h3>
|
|
<p className="text-gray-500">
|
|
"{ticker}" 종목에 대한 저장된 데이터가 없습니다. 다른 종목 코드를 시도해보세요.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Layout>
|
|
);
|
|
};
|
|
|
|
export default StockDetailPage; |