feat: add data explorer frontend page for viewing collected data
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
aa3e2d40d2
commit
4afd01c947
384
frontend/src/app/admin/data/explorer/page.tsx
Normal file
384
frontend/src/app/admin/data/explorer/page.tsx
Normal file
@ -0,0 +1,384 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { DashboardLayout } from '@/components/layout/dashboard-layout';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
type Tab = 'stocks' | 'etfs' | 'sectors' | 'valuations';
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface StockItem {
|
||||
ticker: string;
|
||||
name: string;
|
||||
market: string;
|
||||
close_price: number | null;
|
||||
market_cap: number | null;
|
||||
}
|
||||
|
||||
interface ETFItem {
|
||||
ticker: string;
|
||||
name: string;
|
||||
asset_class: string;
|
||||
market: string;
|
||||
expense_ratio: number | null;
|
||||
}
|
||||
|
||||
interface SectorItem {
|
||||
ticker: string;
|
||||
company_name: string;
|
||||
sector_code: string;
|
||||
sector_name: string;
|
||||
}
|
||||
|
||||
interface ValuationItem {
|
||||
ticker: string;
|
||||
base_date: string;
|
||||
per: number | null;
|
||||
pbr: number | null;
|
||||
psr: number | null;
|
||||
pcr: number | null;
|
||||
dividend_yield: number | null;
|
||||
}
|
||||
|
||||
interface PricePoint {
|
||||
date: string;
|
||||
close: number;
|
||||
open?: number;
|
||||
high?: number;
|
||||
low?: number;
|
||||
volume?: number;
|
||||
}
|
||||
|
||||
export default function DataExplorerPage() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tab, setTab] = useState<Tab>('stocks');
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [data, setData] = useState<PaginatedResponse<unknown> | null>(null);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
|
||||
// Price chart state
|
||||
const [selectedTicker, setSelectedTicker] = useState<string | null>(null);
|
||||
const [priceType, setPriceType] = useState<'stock' | 'etf'>('stock');
|
||||
const [prices, setPrices] = useState<PricePoint[]>([]);
|
||||
const [priceLoading, setPriceLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
try {
|
||||
await api.getCurrentUser();
|
||||
} catch {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
init();
|
||||
}, [router]);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setFetching(true);
|
||||
try {
|
||||
const params = new URLSearchParams({ page: String(page), size: '50' });
|
||||
if (search) params.set('search', search);
|
||||
|
||||
const endpoint = `/api/data/${tab}?${params}`;
|
||||
const result = await api.get<PaginatedResponse<unknown>>(endpoint);
|
||||
setData(result);
|
||||
} catch {
|
||||
setData(null);
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
}, [tab, page, search]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading) fetchData();
|
||||
}, [loading, fetchData]);
|
||||
|
||||
const handleTabChange = (newTab: Tab) => {
|
||||
setTab(newTab);
|
||||
setPage(1);
|
||||
setSearch('');
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
setPage(1);
|
||||
fetchData();
|
||||
};
|
||||
|
||||
const viewPrices = async (ticker: string, type: 'stock' | 'etf') => {
|
||||
setSelectedTicker(ticker);
|
||||
setPriceType(type);
|
||||
setPriceLoading(true);
|
||||
try {
|
||||
const endpoint = type === 'stock'
|
||||
? `/api/data/stocks/${ticker}/prices`
|
||||
: `/api/data/etfs/${ticker}/prices`;
|
||||
const result = await api.get<PricePoint[]>(endpoint);
|
||||
setPrices(result);
|
||||
} catch {
|
||||
setPrices([]);
|
||||
} finally {
|
||||
setPriceLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatNumber = (v: number | null) =>
|
||||
v !== null && v !== undefined ? v.toLocaleString('ko-KR') : '-';
|
||||
|
||||
const totalPages = data ? Math.ceil(data.total / data.size) : 0;
|
||||
|
||||
if (loading) return null;
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-foreground">수집 데이터 탐색</h1>
|
||||
<Button variant="outline" onClick={() => router.push('/admin/data')}>
|
||||
수집 관리
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
{([
|
||||
['stocks', '주식'],
|
||||
['etfs', 'ETF'],
|
||||
['sectors', '섹터'],
|
||||
['valuations', '밸류에이션'],
|
||||
] as [Tab, string][]).map(([key, label]) => (
|
||||
<Button
|
||||
key={key}
|
||||
variant={tab === key ? 'default' : 'outline'}
|
||||
onClick={() => handleTabChange(key)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<Input
|
||||
placeholder="검색 (종목코드, 이름...)"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<Button onClick={handleSearch} variant="outline">검색</Button>
|
||||
</div>
|
||||
|
||||
{/* Data Table */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{tab === 'stocks' && '주식 마스터'}
|
||||
{tab === 'etfs' && 'ETF 마스터'}
|
||||
{tab === 'sectors' && '섹터 분류'}
|
||||
{tab === 'valuations' && '밸류에이션'}
|
||||
{data && ` (${data.total}건)`}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
{tab === 'stocks' && (
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">종목코드</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">종목명</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">시장</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">종가</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">시가총액</th>
|
||||
<th scope="col" className="px-4 py-3 text-center text-sm font-medium text-muted-foreground">가격</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{(data?.items as StockItem[] || []).map((s) => (
|
||||
<tr key={s.ticker}>
|
||||
<td className="px-4 py-3 text-sm font-mono">{s.ticker}</td>
|
||||
<td className="px-4 py-3 text-sm">{s.name}</td>
|
||||
<td className="px-4 py-3 text-sm">{s.market}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatNumber(s.close_price)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{s.market_cap ? (s.market_cap / 100000000).toLocaleString('ko-KR', { maximumFractionDigits: 0 }) + '억' : '-'}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<button className="text-primary text-sm hover:underline" onClick={() => viewPrices(s.ticker, 'stock')}>차트</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{tab === 'etfs' && (
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">종목코드</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">종목명</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">자산유형</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">보수율</th>
|
||||
<th scope="col" className="px-4 py-3 text-center text-sm font-medium text-muted-foreground">가격</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{(data?.items as ETFItem[] || []).map((e) => (
|
||||
<tr key={e.ticker}>
|
||||
<td className="px-4 py-3 text-sm font-mono">{e.ticker}</td>
|
||||
<td className="px-4 py-3 text-sm">{e.name}</td>
|
||||
<td className="px-4 py-3 text-sm">{e.asset_class}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{e.expense_ratio !== null ? `${(e.expense_ratio * 100).toFixed(2)}%` : '-'}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<button className="text-primary text-sm hover:underline" onClick={() => viewPrices(e.ticker, 'etf')}>차트</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{tab === 'sectors' && (
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">종목코드</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">회사명</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">섹터코드</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">섹터명</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{(data?.items as SectorItem[] || []).map((s) => (
|
||||
<tr key={s.ticker}>
|
||||
<td className="px-4 py-3 text-sm font-mono">{s.ticker}</td>
|
||||
<td className="px-4 py-3 text-sm">{s.company_name}</td>
|
||||
<td className="px-4 py-3 text-sm">{s.sector_code}</td>
|
||||
<td className="px-4 py-3 text-sm">{s.sector_name}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{tab === 'valuations' && (
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">종목코드</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">기준일</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">PER</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">PBR</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">배당수익률</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{(data?.items as ValuationItem[] || []).map((v, i) => (
|
||||
<tr key={`${v.ticker}-${v.base_date}-${i}`}>
|
||||
<td className="px-4 py-3 text-sm font-mono">{v.ticker}</td>
|
||||
<td className="px-4 py-3 text-sm">{v.base_date}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{v.per?.toFixed(2) ?? '-'}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{v.pbr?.toFixed(2) ?? '-'}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{v.dividend_yield ? `${v.dividend_yield.toFixed(2)}%` : '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{(!data || data.items.length === 0) && !fetching && (
|
||||
<div className="px-4 py-8 text-center text-muted-foreground">데이터가 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-border">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{data?.total}건 중 {((page - 1) * 50) + 1}-{Math.min(page * 50, data?.total ?? 0)}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||||
이전
|
||||
</Button>
|
||||
<span className="text-sm py-1">{page} / {totalPages}</span>
|
||||
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage(page + 1)}>
|
||||
다음
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Price Chart / Table */}
|
||||
{selectedTicker && (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>{selectedTicker} 가격 데이터 ({prices.length}건)</CardTitle>
|
||||
<button
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
onClick={() => { setSelectedTicker(null); setPrices([]); }}
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{priceLoading ? (
|
||||
<div className="px-4 py-8 text-center text-muted-foreground">로딩 중...</div>
|
||||
) : prices.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-muted-foreground">가격 데이터가 없습니다.</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto max-h-96 overflow-y-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted sticky top-0">
|
||||
<tr>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">날짜</th>
|
||||
{priceType === 'stock' && (
|
||||
<>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">시가</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">고가</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">저가</th>
|
||||
</>
|
||||
)}
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">종가</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">거래량</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{[...prices].reverse().map((p) => (
|
||||
<tr key={p.date}>
|
||||
<td className="px-4 py-2 text-sm">{p.date}</td>
|
||||
{priceType === 'stock' && (
|
||||
<>
|
||||
<td className="px-4 py-2 text-sm text-right">{formatNumber(p.open ?? null)}</td>
|
||||
<td className="px-4 py-2 text-sm text-right">{formatNumber(p.high ?? null)}</td>
|
||||
<td className="px-4 py-2 text-sm text-right">{formatNumber(p.low ?? null)}</td>
|
||||
</>
|
||||
)}
|
||||
<td className="px-4 py-2 text-sm text-right">{formatNumber(p.close)}</td>
|
||||
<td className="px-4 py-2 text-sm text-right">{formatNumber(p.volume ?? null)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user