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:
zephyrdark 2026-02-11 23:34:50 +09:00
parent aa3e2d40d2
commit 4afd01c947

View 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>
);
}