feat: add KJB signal dashboard frontend page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
zephyrdark 2026-02-19 15:19:54 +09:00
parent 5268d1fa60
commit 2d1983efff
2 changed files with 362 additions and 0 deletions

View File

@ -0,0 +1,360 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { DashboardLayout } from '@/components/layout/dashboard-layout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { api } from '@/lib/api';
import { Radio, History, RefreshCw, ArrowUpCircle, ArrowDownCircle, MinusCircle } from 'lucide-react';
interface Signal {
id: number;
date: string;
ticker: string;
name: string | null;
signal_type: string;
entry_price: number | null;
target_price: number | null;
stop_loss_price: number | null;
reason: string | null;
status: string;
created_at: string;
}
const signalTypeConfig: Record<string, { label: string; style: string; icon: typeof ArrowUpCircle }> = {
buy: {
label: '매수',
style: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
icon: ArrowUpCircle,
},
sell: {
label: '매도',
style: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
icon: ArrowDownCircle,
},
partial_sell: {
label: '부분매도',
style: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',
icon: MinusCircle,
},
};
const statusConfig: Record<string, { label: string; style: string }> = {
active: {
label: '활성',
style: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
},
executed: {
label: '실행됨',
style: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
},
expired: {
label: '만료',
style: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
},
};
const formatPrice = (value: number | null | undefined) => {
if (value === null || value === undefined) return '-';
return new Intl.NumberFormat('ko-KR').format(value);
};
export default function SignalsPage() {
const router = useRouter();
const [loading, setLoading] = useState(true);
const [todaySignals, setTodaySignals] = useState<Signal[]>([]);
const [historySignals, setHistorySignals] = useState<Signal[]>([]);
const [showHistory, setShowHistory] = useState(false);
const [refreshing, setRefreshing] = useState(false);
// History filter state
const [filterTicker, setFilterTicker] = useState('');
const [filterStartDate, setFilterStartDate] = useState('');
const [filterEndDate, setFilterEndDate] = useState('');
useEffect(() => {
const init = async () => {
try {
await api.getCurrentUser();
await fetchTodaySignals();
} catch {
router.push('/login');
} finally {
setLoading(false);
}
};
init();
}, [router]);
const fetchTodaySignals = async () => {
try {
const data = await api.get<Signal[]>('/api/signal/kjb/today');
setTodaySignals(data);
} catch (err) {
console.error('Failed to fetch today signals:', err);
}
};
const fetchHistorySignals = async () => {
try {
const params = new URLSearchParams();
if (filterStartDate) params.set('start_date', filterStartDate);
if (filterEndDate) params.set('end_date', filterEndDate);
if (filterTicker) params.set('ticker', filterTicker);
const query = params.toString();
const url = `/api/signal/kjb/history${query ? `?${query}` : ''}`;
const data = await api.get<Signal[]>(url);
setHistorySignals(data);
} catch (err) {
console.error('Failed to fetch signal history:', err);
}
};
const handleRefresh = async () => {
setRefreshing(true);
try {
if (showHistory) {
await fetchHistorySignals();
} else {
await fetchTodaySignals();
}
} finally {
setRefreshing(false);
}
};
const handleShowHistory = async () => {
if (!showHistory && historySignals.length === 0) {
await fetchHistorySignals();
}
setShowHistory(!showHistory);
};
const handleFilterSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await fetchHistorySignals();
};
const renderSignalTable = (signals: Signal[]) => (
<div className="overflow-x-auto">
<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-center 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-left 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">
{signals.map((signal) => {
const typeConf = signalTypeConfig[signal.signal_type] || {
label: signal.signal_type,
style: 'bg-muted',
icon: MinusCircle,
};
const statConf = statusConfig[signal.status] || {
label: signal.status,
style: 'bg-muted',
};
const TypeIcon = typeConf.icon;
return (
<tr key={signal.id} className="hover:bg-muted/50">
<td className="px-4 py-3 text-sm">{signal.date}</td>
<td className="px-4 py-3 text-sm font-mono">{signal.ticker}</td>
<td className="px-4 py-3 text-sm">{signal.name || '-'}</td>
<td className="px-4 py-3 text-center">
<Badge className={typeConf.style}>
<TypeIcon className="h-3 w-3 mr-1" />
{typeConf.label}
</Badge>
</td>
<td className="px-4 py-3 text-sm text-right font-mono">{formatPrice(signal.entry_price)}</td>
<td className="px-4 py-3 text-sm text-right font-mono text-green-600">{formatPrice(signal.target_price)}</td>
<td className="px-4 py-3 text-sm text-right font-mono text-red-600">{formatPrice(signal.stop_loss_price)}</td>
<td className="px-4 py-3 text-sm max-w-xs truncate" title={signal.reason || ''}>
{signal.reason || '-'}
</td>
<td className="px-4 py-3 text-center">
<Badge className={statConf.style}>{statConf.label}</Badge>
</td>
</tr>
);
})}
{signals.length === 0 && (
<tr>
<td colSpan={9} className="px-4 py-8 text-center text-muted-foreground">
.
</td>
</tr>
)}
</tbody>
</table>
</div>
);
if (loading) {
return (
<DashboardLayout>
<div className="space-y-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-[400px]" />
</div>
</DashboardLayout>
);
}
return (
<DashboardLayout>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">KJB </h1>
<p className="mt-1 text-muted-foreground">
KJB
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={handleRefresh}
disabled={refreshing}
>
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
</Button>
<Button
variant="outline"
onClick={handleShowHistory}
>
{showHistory ? (
<>
<Radio className="mr-2 h-4 w-4" />
</>
) : (
<>
<History className="mr-2 h-4 w-4" />
</>
)}
</Button>
</div>
</div>
{/* Summary Cards */}
{!showHistory && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<ArrowUpCircle className="h-4 w-4 text-green-600" />
<span className="text-xs font-medium"> </span>
</div>
<p className="text-2xl font-bold">
{todaySignals.filter((s) => s.signal_type === 'buy').length}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<ArrowDownCircle className="h-4 w-4 text-red-600" />
<span className="text-xs font-medium"> </span>
</div>
<p className="text-2xl font-bold">
{todaySignals.filter((s) => s.signal_type === 'sell').length}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<MinusCircle className="h-4 w-4 text-orange-600" />
<span className="text-xs font-medium"> </span>
</div>
<p className="text-2xl font-bold">
{todaySignals.filter((s) => s.signal_type === 'partial_sell').length}
</p>
</CardContent>
</Card>
</div>
)}
{showHistory ? (
<div className="space-y-4">
{/* History Filters */}
<Card>
<CardContent className="p-4">
<form onSubmit={handleFilterSubmit} className="flex flex-wrap items-end gap-4">
<div className="space-y-2">
<Label htmlFor="filter-start-date"></Label>
<Input
id="filter-start-date"
type="date"
value={filterStartDate}
onChange={(e) => setFilterStartDate(e.target.value)}
className="w-40"
/>
</div>
<div className="space-y-2">
<Label htmlFor="filter-end-date"></Label>
<Input
id="filter-end-date"
type="date"
value={filterEndDate}
onChange={(e) => setFilterEndDate(e.target.value)}
className="w-40"
/>
</div>
<div className="space-y-2">
<Label htmlFor="filter-ticker"></Label>
<Input
id="filter-ticker"
type="text"
placeholder="예: 005930"
value={filterTicker}
onChange={(e) => setFilterTicker(e.target.value)}
className="w-36"
/>
</div>
<Button type="submit" variant="outline">
</Button>
</form>
</CardContent>
</Card>
{/* History Table */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent className="p-0">
{renderSignalTable(historySignals)}
</CardContent>
</Card>
</div>
) : (
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent className="p-0">
{renderSignalTable(todaySignals)}
</CardContent>
</Card>
)}
</DashboardLayout>
);
}

View File

@ -10,6 +10,7 @@ import {
FlaskConical, FlaskConical,
Database, Database,
Search, Search,
Radio,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
} from 'lucide-react'; } from 'lucide-react';
@ -28,6 +29,7 @@ const navItems = [
{ href: '/portfolio', label: '포트폴리오', icon: Briefcase }, { href: '/portfolio', label: '포트폴리오', icon: Briefcase },
{ href: '/strategy', label: '전략', icon: TrendingUp }, { href: '/strategy', label: '전략', icon: TrendingUp },
{ href: '/backtest', label: '백테스트', icon: FlaskConical }, { href: '/backtest', label: '백테스트', icon: FlaskConical },
{ href: '/signals', label: '매매 신호', icon: Radio },
{ href: '/admin/data', label: '데이터 수집', icon: Database }, { href: '/admin/data', label: '데이터 수집', icon: Database },
{ href: '/admin/data/explorer', label: '데이터 탐색', icon: Search }, { href: '/admin/data/explorer', label: '데이터 탐색', icon: Search },
]; ];