feat: saxo history
This commit is contained in:
BIN
backend/data/geooptions.db-journal
Normal file
BIN
backend/data/geooptions.db-journal
Normal file
Binary file not shown.
@@ -5,7 +5,7 @@ from pydantic import BaseModel
|
||||
|
||||
from services import saxo_auth
|
||||
from services.saxo_scheduler import get_watchlist, set_watchlist
|
||||
from services.database import get_saxo_snapshots
|
||||
from services.database import get_saxo_snapshots, get_saxo_snapshot_symbols
|
||||
|
||||
router = APIRouter(prefix="/api/saxo", tags=["saxo"])
|
||||
|
||||
@@ -36,6 +36,35 @@ def update_watchlist(req: WatchlistRequest):
|
||||
return {"symbols": get_watchlist()}
|
||||
|
||||
|
||||
def _validate_symbol(symbol: str) -> dict:
|
||||
from services.saxo_client import resolve_option_root_uic, SaxoNotConnected
|
||||
try:
|
||||
uic = resolve_option_root_uic(symbol)
|
||||
return {"symbol": symbol.upper(), "valid": True, "uic": uic, "error": None}
|
||||
except SaxoNotConnected as e:
|
||||
return {"symbol": symbol.upper(), "valid": False, "uic": None, "error": str(e)}
|
||||
except Exception as e:
|
||||
return {"symbol": symbol.upper(), "valid": False, "uic": None, "error": str(e)}
|
||||
|
||||
|
||||
@router.get("/validate")
|
||||
def validate_watchlist():
|
||||
"""Checks each watchlist symbol actually resolves to a real Saxo option-root instrument."""
|
||||
return [_validate_symbol(s) for s in get_watchlist()]
|
||||
|
||||
|
||||
@router.get("/validate/{symbol}")
|
||||
def validate_symbol(symbol: str):
|
||||
return _validate_symbol(symbol)
|
||||
|
||||
|
||||
@router.get("/symbols")
|
||||
def symbols_with_history():
|
||||
"""Distinct symbols that already have recorded snapshot history (may differ from the
|
||||
current watchlist — includes ad-hoc 'snapshot now' calls and previously-tracked symbols)."""
|
||||
return get_saxo_snapshot_symbols()
|
||||
|
||||
|
||||
@router.post("/snapshot-now/{symbol}")
|
||||
def snapshot_now(symbol: str):
|
||||
from services.saxo_client import snapshot_options_chain, SaxoNotConnected
|
||||
|
||||
@@ -6116,3 +6116,13 @@ def get_saxo_snapshots(symbol: Optional[str] = None, date_from: Optional[str] =
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_saxo_snapshot_symbols() -> List[Dict[str, Any]]:
|
||||
conn = get_conn()
|
||||
rows = conn.execute("""
|
||||
SELECT symbol, COUNT(*) AS rows_count, MIN(snapshot_date) AS first_date, MAX(snapshot_date) AS last_date
|
||||
FROM saxo_option_snapshots GROUP BY symbol ORDER BY symbol
|
||||
""").fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
@@ -10,6 +10,7 @@ import Markets from './pages/Markets'
|
||||
import MacroRegime from './pages/MacroRegime'
|
||||
import OptionsLab from './pages/OptionsLab'
|
||||
import StrategyBuilder from './pages/StrategyBuilder'
|
||||
import SaxoHistory from './pages/SaxoHistory'
|
||||
import WaveletsSimulation from './pages/WaveletsSimulation'
|
||||
import Backtest from './pages/Backtest'
|
||||
import CalendarPage from './pages/CalendarPage'
|
||||
@@ -52,6 +53,7 @@ const KEEP_ALIVE_DEFS: { path: string; component: ComponentType }[] = [
|
||||
{ path: '/macro', component: MacroRegime },
|
||||
{ path: '/options', component: OptionsLab },
|
||||
{ path: '/strategy-builder', component: StrategyBuilder },
|
||||
{ path: '/saxo-history', component: SaxoHistory },
|
||||
{ path: '/wavelets-simulation', component: WaveletsSimulation },
|
||||
{ path: '/patterns', component: PatternExplorer },
|
||||
{ path: '/pattern-lab', component: PatternLab },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard, Globe, BarChart2, FlaskConical,
|
||||
History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare, Building2, Users, ScanEye, CandlestickChart, PlayCircle, Radio, Bot, Sliders, Waves, Layers
|
||||
History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare, Building2, Users, ScanEye, CandlestickChart, PlayCircle, Radio, Bot, Sliders, Waves, Layers, History as HistoryIcon
|
||||
} from 'lucide-react'
|
||||
import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi'
|
||||
import clsx from 'clsx'
|
||||
@@ -13,6 +13,7 @@ const nav = [
|
||||
{ to: '/macro', icon: Activity, label: 'Macro Regime' },
|
||||
{ to: '/options', icon: TrendingUp, label: 'Options Lab' },
|
||||
{ to: '/strategy-builder', icon: Layers, label: 'Strategy Builder' },
|
||||
{ to: '/saxo-history', icon: HistoryIcon, label: 'Saxo History' },
|
||||
{ to: '/wavelets-simulation', icon: Waves, label: 'Wavelets Simulation' },
|
||||
{ to: '/patterns', icon: Zap, label: 'Patterns' },
|
||||
{ to: '/pattern-lab', icon: FlaskConical, label: 'Pattern Lab' },
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
LayoutDashboard, Globe, BarChart2, Activity, TrendingUp, Zap, FlaskConical,
|
||||
DollarSign, BookOpen, FileBarChart, Brain, Microscope, ShieldAlert, Gauge,
|
||||
GitCompare, History, Calendar, Sliders, TrendingUp as MacroSeriesIcon,
|
||||
Building2, Users, ScanEye, Radio, Bot, PlayCircle, ScrollText, Settings, Waves, Layers,
|
||||
Building2, Users, ScanEye, Radio, Bot, PlayCircle, ScrollText, Settings, Waves, Layers, History as HistoryIcon,
|
||||
} from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
@@ -19,6 +19,7 @@ export const KEEP_ALIVE_META: KeepAliveMeta[] = [
|
||||
{ path: '/macro', label: 'Macro Regime', icon: Activity },
|
||||
{ path: '/options', label: 'Options Lab', icon: TrendingUp },
|
||||
{ path: '/strategy-builder', label: 'Strategy Builder', icon: Layers },
|
||||
{ path: '/saxo-history', label: 'Saxo History', icon: HistoryIcon },
|
||||
{ path: '/wavelets-simulation', label: 'Wavelets Sim.', icon: Waves },
|
||||
{ path: '/patterns', label: 'Patterns', icon: Zap },
|
||||
{ path: '/pattern-lab', label: 'Pattern Lab', icon: FlaskConical },
|
||||
|
||||
@@ -1714,9 +1714,27 @@ export type SaxoSnapshotRow = {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export const useSaxoHistory = (symbol?: string) =>
|
||||
export const useSaxoHistory = (symbol?: string, dateFrom?: string, dateTo?: string) =>
|
||||
useQuery<SaxoSnapshotRow[]>({
|
||||
queryKey: ['saxo-history', symbol],
|
||||
queryFn: () => api.get('/saxo/history', { params: symbol ? { symbol } : {} }).then(r => r.data),
|
||||
enabled: !!symbol,
|
||||
queryKey: ['saxo-history', symbol, dateFrom, dateTo],
|
||||
queryFn: () => api.get('/saxo/history', {
|
||||
params: { ...(symbol ? { symbol } : {}), ...(dateFrom ? { date_from: dateFrom } : {}), ...(dateTo ? { date_to: dateTo } : {}) },
|
||||
}).then(r => r.data),
|
||||
})
|
||||
|
||||
export type SaxoSymbolSummary = { symbol: string; rows_count: number; first_date: string; last_date: string }
|
||||
|
||||
export const useSaxoSymbols = () =>
|
||||
useQuery<SaxoSymbolSummary[]>({
|
||||
queryKey: ['saxo-symbols'],
|
||||
queryFn: () => api.get('/saxo/symbols').then(r => r.data),
|
||||
})
|
||||
|
||||
export type SaxoValidation = { symbol: string; valid: boolean; uic: number | null; error: string | null }
|
||||
|
||||
export const useValidateSaxoWatchlist = () =>
|
||||
useQuery<SaxoValidation[]>({
|
||||
queryKey: ['saxo-validate'],
|
||||
queryFn: () => api.get('/saxo/validate').then(r => r.data),
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useSources, useUpdateSources, useUpdateApiKeys, useConfig, useAiStatus, useAnalysisConfig, useSaveAnalysisConfig, useCycleStatus, useUpdateCycleConfig, useTriggerCycle, useCycleStepCatalog, useRiskProfiles, useUpsertProfile, useDeleteProfile, useExitDefaults, useSaveExitDefaults, useOptionsGate, useSaveOptionsGate, useTechIndicatorsConfig, useSaveTechIndicatorsConfig, useInstrumentsWatchlist, useAddWatchlistInstrument, useRemoveWatchlistInstrument, validateTicker, useSaxoStatus, useDisconnectSaxo, useSaxoWatchlist, useUpdateSaxoWatchlist, useSnapshotSaxoNow, type CycleStepDef } from '../hooks/useApi'
|
||||
import { Settings, Key, Globe, CheckCircle, XCircle, AlertCircle, Save, Eye, EyeOff, Brain, SlidersHorizontal, RefreshCw, Zap, Plus, Trash2, Pencil, X, Lock, Gauge, DollarSign, TrendingUp, ShieldAlert, DatabaseBackup, Radar, Link2, Unlink, Camera } from 'lucide-react'
|
||||
import { useSources, useUpdateSources, useUpdateApiKeys, useConfig, useAiStatus, useAnalysisConfig, useSaveAnalysisConfig, useCycleStatus, useUpdateCycleConfig, useTriggerCycle, useCycleStepCatalog, useRiskProfiles, useUpsertProfile, useDeleteProfile, useExitDefaults, useSaveExitDefaults, useOptionsGate, useSaveOptionsGate, useTechIndicatorsConfig, useSaveTechIndicatorsConfig, useInstrumentsWatchlist, useAddWatchlistInstrument, useRemoveWatchlistInstrument, validateTicker, useSaxoStatus, useDisconnectSaxo, useSaxoWatchlist, useUpdateSaxoWatchlist, useSnapshotSaxoNow, useValidateSaxoWatchlist, type CycleStepDef } from '../hooks/useApi'
|
||||
import { Settings, Key, Globe, CheckCircle, XCircle, AlertCircle, Save, Eye, EyeOff, Brain, SlidersHorizontal, RefreshCw, Zap, Plus, Trash2, Pencil, X, Lock, Gauge, DollarSign, TrendingUp, ShieldAlert, DatabaseBackup, Radar, Link2, Unlink, Camera, ShieldCheck, ExternalLink } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
const API = ''
|
||||
@@ -426,6 +427,7 @@ function SaxoConnectionCard() {
|
||||
const disconnect = useDisconnectSaxo()
|
||||
const updateWatchlist = useUpdateSaxoWatchlist()
|
||||
const snapshotNow = useSnapshotSaxoNow()
|
||||
const { data: validation, refetch: runValidation, isFetching: validating } = useValidateSaxoWatchlist()
|
||||
const [input, setInput] = useState('')
|
||||
const [snapMsg, setSnapMsg] = useState('')
|
||||
|
||||
@@ -508,8 +510,23 @@ function SaxoConnectionCard() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-500 mb-2">
|
||||
Watchlist snapshotée périodiquement (historique Date/Spot/Expiry/Strike/Bid/Ask/Mid/VolatilityPct/Greeks) :
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-xs text-slate-500">
|
||||
Watchlist snapshotée périodiquement (historique Date/Spot/Expiry/Strike/Bid/Ask/Mid/VolatilityPct/Greeks) :
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => runValidation()}
|
||||
disabled={!status?.connected || validating}
|
||||
title="Vérifie que chaque symbole résout bien vers un vrai instrument Saxo"
|
||||
className="flex items-center gap-1 text-xs text-slate-400 hover:text-slate-200 border border-slate-700/50 px-2 py-1 rounded disabled:opacity-40"
|
||||
>
|
||||
<ShieldCheck className={clsx('w-3.5 h-3.5', validating && 'animate-pulse')} /> Vérifier l'orthographe
|
||||
</button>
|
||||
<Link to="/saxo-history" className="flex items-center gap-1 text-xs text-blue-400 hover:text-blue-300">
|
||||
<ExternalLink className="w-3.5 h-3.5" /> Historique
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<input
|
||||
@@ -525,15 +542,22 @@ function SaxoConnectionCard() {
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{symbols.map(sym => (
|
||||
<span key={sym} className="badge-blue flex items-center gap-1.5">
|
||||
{sym}
|
||||
<button onClick={() => handleSnapshotNow(sym)} title="Snapshot maintenant" className="hover:text-white">
|
||||
<Camera className="w-3 h-3" />
|
||||
</button>
|
||||
<button onClick={() => removeSymbol(sym)}><X className="w-3 h-3" /></button>
|
||||
</span>
|
||||
))}
|
||||
{symbols.map(sym => {
|
||||
const check = validation?.find(v => v.symbol === sym.toUpperCase())
|
||||
return (
|
||||
<span key={sym} className={clsx('badge flex items-center gap-1.5',
|
||||
check ? (check.valid ? 'badge-green' : 'badge-red') : 'badge-blue')}
|
||||
title={check && !check.valid ? check.error ?? 'Symbole non résolu par Saxo' : undefined}
|
||||
>
|
||||
{sym}
|
||||
{check && (check.valid ? <CheckCircle className="w-3 h-3" /> : <XCircle className="w-3 h-3" />)}
|
||||
<button onClick={() => handleSnapshotNow(sym)} title="Snapshot maintenant" className="hover:text-white">
|
||||
<Camera className="w-3 h-3" />
|
||||
</button>
|
||||
<button onClick={() => removeSymbol(sym)}><X className="w-3 h-3" /></button>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{snapMsg && <div className="text-[10px] text-slate-500 mt-2">{snapMsg}</div>}
|
||||
</div>
|
||||
|
||||
153
frontend/src/pages/SaxoHistory.tsx
Normal file
153
frontend/src/pages/SaxoHistory.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { History, RefreshCw } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { useSaxoSymbols, useSaxoHistory, type SaxoSnapshotRow } from '../hooks/useApi'
|
||||
|
||||
function fmt(v: number | null | undefined, digits = 2) {
|
||||
return v == null ? '—' : v.toFixed(digits)
|
||||
}
|
||||
|
||||
function daysAgo(n: number) {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - n)
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export default function SaxoHistory() {
|
||||
const { data: symbolSummaries = [] } = useSaxoSymbols()
|
||||
const [symbol, setSymbol] = useState<string>('')
|
||||
const [dateFrom, setDateFrom] = useState(daysAgo(30))
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
|
||||
const { data: rows = [], isLoading, isFetching, refetch } = useSaxoHistory(symbol || undefined, dateFrom || undefined, dateTo || undefined)
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const groups: { date: string; rows: SaxoSnapshotRow[] }[] = []
|
||||
for (const r of rows) {
|
||||
const last = groups[groups.length - 1]
|
||||
if (last && last.date === r.snapshot_date) last.rows.push(r)
|
||||
else groups.push({ date: r.snapshot_date, rows: [r] })
|
||||
}
|
||||
return groups
|
||||
}, [rows])
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
<History className="w-5 h-5 text-blue-400" /> Saxo — Historique des snapshots
|
||||
</h1>
|
||||
<p className="text-xs text-slate-500 mt-0.5">
|
||||
Accumulé depuis la connexion Saxo — pas d'historique passé disponible via leur API, seulement ce qu'on capture nous-mêmes.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
className="flex items-center gap-1.5 text-xs border border-slate-600 text-slate-400 hover:text-slate-200 hover:border-slate-500 px-3 py-1.5 rounded transition-all disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />
|
||||
{isFetching ? 'Chargement...' : 'Rafraîchir'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card flex flex-wrap items-end gap-4">
|
||||
<div>
|
||||
<label className="stat-label block mb-1">Symbole</label>
|
||||
<select
|
||||
value={symbol}
|
||||
onChange={e => setSymbol(e.target.value)}
|
||||
className="bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white w-40"
|
||||
>
|
||||
<option value="">Tous</option>
|
||||
{symbolSummaries.map(s => <option key={s.symbol} value={s.symbol}>{s.symbol}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="stat-label block mb-1">Du</label>
|
||||
<input type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)}
|
||||
className="bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="stat-label block mb-1">Au</label>
|
||||
<input type="date" value={dateTo} onChange={e => setDateTo(e.target.value)}
|
||||
className="bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white" />
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 ml-auto">{rows.length} lignes</div>
|
||||
</div>
|
||||
|
||||
{symbolSummaries.length > 0 && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{symbolSummaries.map(s => (
|
||||
<button
|
||||
key={s.symbol}
|
||||
onClick={() => setSymbol(s.symbol === symbol ? '' : s.symbol)}
|
||||
className={clsx('card-sm text-left transition-colors', s.symbol === symbol && 'border-blue-500/60')}
|
||||
>
|
||||
<div className="text-sm font-bold text-white">{s.symbol}</div>
|
||||
<div className="text-xs text-slate-500">{s.rows_count} lignes · {s.first_date} → {s.last_date}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="card-sm text-xs text-slate-500">Chargement…</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="card-sm text-xs text-slate-500">Aucun snapshot enregistré pour ces filtres.</div>
|
||||
) : (
|
||||
<div className="card overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-slate-500 text-left sticky top-0 bg-dark-800">
|
||||
<th className="py-1 pr-3">Symbole</th>
|
||||
<th className="py-1 pr-3">Expiry</th>
|
||||
<th className="py-1 pr-3 text-right">Strike</th>
|
||||
<th className="py-1 pr-3">Type</th>
|
||||
<th className="py-1 pr-3 text-right">Spot</th>
|
||||
<th className="py-1 pr-3 text-right">Bid</th>
|
||||
<th className="py-1 pr-3 text-right">Ask</th>
|
||||
<th className="py-1 pr-3 text-right">Mid</th>
|
||||
<th className="py-1 pr-3 text-right">IV%</th>
|
||||
<th className="py-1 pr-3 text-right">Delta</th>
|
||||
<th className="py-1 pr-3 text-right">Gamma</th>
|
||||
<th className="py-1 pr-3 text-right">Theta</th>
|
||||
<th className="py-1 pr-3 text-right">Vega</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{grouped.map(group => (
|
||||
<>
|
||||
<tr key={`h-${group.date}`} className="border-t border-slate-700/40">
|
||||
<td colSpan={13} className="py-1.5 pr-3 text-slate-300 font-semibold bg-dark-700/30">{group.date}</td>
|
||||
</tr>
|
||||
{group.rows.map(r => (
|
||||
<tr key={r.id} className="border-t border-slate-800/60 hover:bg-dark-700/30">
|
||||
<td className="py-1 pr-3 text-slate-300">{r.symbol}</td>
|
||||
<td className="py-1 pr-3 text-slate-400">{r.expiry_date}</td>
|
||||
<td className="py-1 pr-3 text-right text-slate-300">{r.strike}</td>
|
||||
<td className="py-1 pr-3">
|
||||
<span className={clsx('badge', r.option_type === 'call' ? 'badge-green' : 'badge-red')}>{r.option_type}</span>
|
||||
</td>
|
||||
<td className="py-1 pr-3 text-right text-slate-400">{fmt(r.spot)}</td>
|
||||
<td className="py-1 pr-3 text-right text-slate-300">{fmt(r.bid)}</td>
|
||||
<td className="py-1 pr-3 text-right text-slate-300">{fmt(r.ask)}</td>
|
||||
<td className="py-1 pr-3 text-right text-white font-semibold">{fmt(r.mid)}</td>
|
||||
{/* Saxo's field is already named "...Pct" (MidVolatilityPct) — assumed pre-scaled, not a 0-1 fraction */}
|
||||
<td className="py-1 pr-3 text-right text-amber-400">{r.volatility_pct != null ? `${r.volatility_pct.toFixed(1)}%` : '—'}</td>
|
||||
<td className="py-1 pr-3 text-right text-slate-400">{fmt(r.delta, 4)}</td>
|
||||
<td className="py-1 pr-3 text-right text-slate-400">{fmt(r.gamma, 4)}</td>
|
||||
<td className="py-1 pr-3 text-right text-slate-400">{fmt(r.theta, 4)}</td>
|
||||
<td className="py-1 pr-3 text-right text-slate-400">{fmt(r.vega, 4)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user