From 4afd01c9479e1131aef43cfbf8bb63a9bdfb2848 Mon Sep 17 00:00:00 2001 From: zephyrdark Date: Wed, 11 Feb 2026 23:34:50 +0900 Subject: [PATCH] feat: add data explorer frontend page for viewing collected data Co-Authored-By: Claude Opus 4.6 --- frontend/src/app/admin/data/explorer/page.tsx | 384 ++++++++++++++++++ 1 file changed, 384 insertions(+) create mode 100644 frontend/src/app/admin/data/explorer/page.tsx diff --git a/frontend/src/app/admin/data/explorer/page.tsx b/frontend/src/app/admin/data/explorer/page.tsx new file mode 100644 index 0000000..f667df0 --- /dev/null +++ b/frontend/src/app/admin/data/explorer/page.tsx @@ -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 { + 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('stocks'); + const [search, setSearch] = useState(''); + const [page, setPage] = useState(1); + const [data, setData] = useState | null>(null); + const [fetching, setFetching] = useState(false); + + // Price chart state + const [selectedTicker, setSelectedTicker] = useState(null); + const [priceType, setPriceType] = useState<'stock' | 'etf'>('stock'); + const [prices, setPrices] = useState([]); + 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>(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(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 ( + +
+

수집 데이터 탐색

+ +
+ + {/* Tabs */} +
+ {([ + ['stocks', '주식'], + ['etfs', 'ETF'], + ['sectors', '섹터'], + ['valuations', '밸류에이션'], + ] as [Tab, string][]).map(([key, label]) => ( + + ))} +
+ + {/* Search */} +
+ setSearch(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + className="max-w-sm" + /> + +
+ + {/* Data Table */} + + + + {tab === 'stocks' && '주식 마스터'} + {tab === 'etfs' && 'ETF 마스터'} + {tab === 'sectors' && '섹터 분류'} + {tab === 'valuations' && '밸류에이션'} + {data && ` (${data.total}건)`} + + + +
+ {tab === 'stocks' && ( + + + + + + + + + + + + + {(data?.items as StockItem[] || []).map((s) => ( + + + + + + + + + ))} + +
종목코드종목명시장종가시가총액가격
{s.ticker}{s.name}{s.market}{formatNumber(s.close_price)}{s.market_cap ? (s.market_cap / 100000000).toLocaleString('ko-KR', { maximumFractionDigits: 0 }) + '억' : '-'} + +
+ )} + + {tab === 'etfs' && ( + + + + + + + + + + + + {(data?.items as ETFItem[] || []).map((e) => ( + + + + + + + + ))} + +
종목코드종목명자산유형보수율가격
{e.ticker}{e.name}{e.asset_class}{e.expense_ratio !== null ? `${(e.expense_ratio * 100).toFixed(2)}%` : '-'} + +
+ )} + + {tab === 'sectors' && ( + + + + + + + + + + + {(data?.items as SectorItem[] || []).map((s) => ( + + + + + + + ))} + +
종목코드회사명섹터코드섹터명
{s.ticker}{s.company_name}{s.sector_code}{s.sector_name}
+ )} + + {tab === 'valuations' && ( + + + + + + + + + + + + {(data?.items as ValuationItem[] || []).map((v, i) => ( + + + + + + + + ))} + +
종목코드기준일PERPBR배당수익률
{v.ticker}{v.base_date}{v.per?.toFixed(2) ?? '-'}{v.pbr?.toFixed(2) ?? '-'}{v.dividend_yield ? `${v.dividend_yield.toFixed(2)}%` : '-'}
+ )} + + {(!data || data.items.length === 0) && !fetching && ( +
데이터가 없습니다.
+ )} +
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + {data?.total}건 중 {((page - 1) * 50) + 1}-{Math.min(page * 50, data?.total ?? 0)} + +
+ + {page} / {totalPages} + +
+
+ )} +
+
+ + {/* Price Chart / Table */} + {selectedTicker && ( + + + {selectedTicker} 가격 데이터 ({prices.length}건) + + + + {priceLoading ? ( +
로딩 중...
+ ) : prices.length === 0 ? ( +
가격 데이터가 없습니다.
+ ) : ( +
+ + + + + {priceType === 'stock' && ( + <> + + + + + )} + + + + + + {[...prices].reverse().map((p) => ( + + + {priceType === 'stock' && ( + <> + + + + + )} + + + + ))} + +
날짜시가고가저가종가거래량
{p.date}{formatNumber(p.open ?? null)}{formatNumber(p.high ?? null)}{formatNumber(p.low ?? null)}{formatNumber(p.close)}{formatNumber(p.volume ?? null)}
+
+ )} +
+
+ )} +
+ ); +}