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