feat: bank forceasts
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
useAllDeskConfigs, useAllSpecialistReports,
|
||||
useUpdateDeskConfig, useCreateSpecialistReport,
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
ChevronRight, Star, Link, ExternalLink, RefreshCw,
|
||||
AlertCircle, Calendar, BookOpen,
|
||||
TrendingUp, TrendingDown, Minus, BarChart2, FlaskConical,
|
||||
ArrowUp, ArrowDown, Activity,
|
||||
ArrowUp, ArrowDown, Activity, Landmark, Play, Send,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
@@ -308,6 +308,247 @@ function SurpriseInput({ report, onSave }: {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Bank Forecasts Panel ───────────────────────────────────────────────────────
|
||||
|
||||
interface BankSource { id: string; name: string; url: string; active: number; last_scraped: string | null; notes: string }
|
||||
interface BankForecast { id: number; source_id: string; bank_name: string; series_id: string; event_name: string; event_date: string; forecast_value: number | null; unit: string; confidence: string; snippet: string; extracted_at: string }
|
||||
interface Consensus { series_id: string; event_name: string; event_date: string; consensus: number; bank_count: number; min_forecast: number; max_forecast: number }
|
||||
|
||||
function BankForecastsPanel() {
|
||||
const [sources, setSources] = useState<BankSource[]>([])
|
||||
const [forecasts, setForecasts] = useState<BankForecast[]>([])
|
||||
const [consensus, setConsensus] = useState<Consensus[]>([])
|
||||
const [scraping, setScraping] = useState(false)
|
||||
const [pushing, setPushing] = useState(false)
|
||||
const [editId, setEditId] = useState<string | null>(null)
|
||||
const [editUrl, setEditUrl] = useState('')
|
||||
const [editNotes, setEditNotes] = useState('')
|
||||
const [scrapeResults, setScrapeResults] = useState<any[]>([])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [s, f, c] = await Promise.all([
|
||||
fetch('/api/specialist-desks/bank-forecasts/sources').then(r => r.json()),
|
||||
fetch('/api/specialist-desks/bank-forecasts').then(r => r.json()),
|
||||
fetch('/api/specialist-desks/bank-forecasts/consensus').then(r => r.json()),
|
||||
])
|
||||
setSources(Array.isArray(s) ? s : [])
|
||||
setForecasts(Array.isArray(f) ? f : [])
|
||||
setConsensus(Array.isArray(c) ? c : [])
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const saveSource = async (src: BankSource) => {
|
||||
await fetch(`/api/specialist-desks/bank-forecasts/sources/${src.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: src.name, url: editUrl, active: !!src.active, notes: editNotes }),
|
||||
})
|
||||
setEditId(null)
|
||||
load()
|
||||
}
|
||||
|
||||
const scrapeAll = async () => {
|
||||
setScraping(true); setScrapeResults([])
|
||||
try {
|
||||
const r = await fetch('/api/specialist-desks/bank-forecasts/scrape', { method: 'POST' }).then(x => x.json())
|
||||
setScrapeResults(r.results ?? [])
|
||||
load()
|
||||
} finally { setScraping(false) }
|
||||
}
|
||||
|
||||
const pushConsensus = async () => {
|
||||
setPushing(true)
|
||||
try {
|
||||
await fetch('/api/specialist-desks/bank-forecasts/push-consensus', { method: 'POST' })
|
||||
} finally { setPushing(false) }
|
||||
}
|
||||
|
||||
// Group forecasts by (event_date, series_id) for display
|
||||
const grouped = forecasts.reduce<Record<string, BankForecast[]>>((acc, f) => {
|
||||
const key = `${f.event_date}|${f.series_id}`
|
||||
if (!acc[key]) acc[key] = []
|
||||
acc[key].push(f)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-slate-100 flex items-center gap-2">
|
||||
<Landmark className="w-5 h-5 text-amber-400" />
|
||||
Bank Forecasts
|
||||
</h1>
|
||||
<p className="text-xs text-slate-500 mt-0.5">Prévisions publiques scrappées — consensus maison</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={scrapeAll} disabled={scraping}
|
||||
className="flex items-center gap-1.5 text-xs bg-amber-900/30 hover:bg-amber-900/50 border border-amber-700/40 text-amber-300 px-3 py-2 rounded transition-colors disabled:opacity-50">
|
||||
<Play className="w-3.5 h-3.5" /> {scraping ? 'Scraping…' : 'Scrape all'}
|
||||
</button>
|
||||
<button onClick={pushConsensus} disabled={pushing}
|
||||
className="flex items-center gap-1.5 text-xs bg-emerald-900/30 hover:bg-emerald-900/50 border border-emerald-700/40 text-emerald-300 px-3 py-2 rounded transition-colors disabled:opacity-50">
|
||||
<Send className="w-3.5 h-3.5" /> {pushing ? '…' : '→ macro_series_log'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sources config */}
|
||||
<div className="bg-dark-800 border border-slate-700/40 rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-2.5 border-b border-slate-700/40 text-xs font-bold text-slate-400 uppercase tracking-wide">
|
||||
Sources ({sources.filter(s => s.active).length} actives)
|
||||
</div>
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-[10px] text-slate-500 border-b border-slate-700/30">
|
||||
<th className="text-left px-4 py-2">Banque</th>
|
||||
<th className="text-left px-4 py-2">URL</th>
|
||||
<th className="text-left px-4 py-2">Dernier scrape</th>
|
||||
<th className="px-4 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sources.map(src => (
|
||||
<tr key={src.id} className="border-b border-slate-700/20 hover:bg-dark-700/30">
|
||||
<td className="px-4 py-2 font-medium text-slate-200">{src.name}</td>
|
||||
<td className="px-4 py-2 max-w-xs">
|
||||
{editId === src.id ? (
|
||||
<input value={editUrl} onChange={e => setEditUrl(e.target.value)}
|
||||
className="w-full bg-dark-700 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 font-mono"
|
||||
placeholder="https://..." />
|
||||
) : (
|
||||
<span className={clsx('font-mono truncate block', src.url ? 'text-cyan-400' : 'text-slate-700 italic')}>
|
||||
{src.url || 'non configurée'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-slate-600 tabular-nums">
|
||||
{src.last_scraped ? src.last_scraped.slice(0, 16) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{editId === src.id ? (
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => saveSource(src)}
|
||||
className="text-emerald-400 hover:text-emerald-200 px-2 py-0.5 border border-emerald-800/40 rounded text-[10px]">
|
||||
Sauver
|
||||
</button>
|
||||
<button onClick={() => setEditId(null)}
|
||||
className="text-slate-500 hover:text-slate-300 px-2 py-0.5 border border-slate-700/40 rounded text-[10px]">
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => { setEditId(src.id); setEditUrl(src.url); setEditNotes(src.notes) }}
|
||||
className="text-slate-600 hover:text-slate-300 transition-colors">
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Scrape results */}
|
||||
{scrapeResults.length > 0 && (
|
||||
<div className="bg-dark-800 border border-slate-700/40 rounded-lg p-4">
|
||||
<div className="text-xs font-bold text-slate-400 uppercase tracking-wide mb-2">Résultats scrape</div>
|
||||
<div className="space-y-1">
|
||||
{scrapeResults.map((r, i) => (
|
||||
<div key={i} className="flex gap-3 text-xs font-mono">
|
||||
<span className={clsx('w-32 truncate', r.fetched ? 'text-slate-300' : 'text-red-400')}>{r.name}</span>
|
||||
<span className="text-slate-500">fetched={r.fetched ? '✓' : '✗'}</span>
|
||||
<span className="text-emerald-500">saved={r.saved}</span>
|
||||
{r.error && <span className="text-red-400 truncate">{r.error}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Consensus table */}
|
||||
{consensus.length > 0 && (
|
||||
<div className="bg-dark-800 border border-slate-700/40 rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-2.5 border-b border-slate-700/40 text-xs font-bold text-slate-400 uppercase tracking-wide">
|
||||
Consensus maison ({consensus.length} events)
|
||||
</div>
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-[10px] text-slate-500 border-b border-slate-700/30">
|
||||
<th className="text-left px-4 py-2">Release</th>
|
||||
<th className="text-left px-4 py-2">Série</th>
|
||||
<th className="text-right px-4 py-2">Consensus</th>
|
||||
<th className="text-right px-4 py-2">Range</th>
|
||||
<th className="text-right px-4 py-2">Banques</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{consensus.map((c, i) => (
|
||||
<tr key={i} className="border-b border-slate-700/20 hover:bg-dark-700/30">
|
||||
<td className="px-4 py-2 tabular-nums text-slate-400">{c.event_date}</td>
|
||||
<td className="px-4 py-2 text-slate-200">{c.event_name || c.series_id}</td>
|
||||
<td className="px-4 py-2 text-right font-mono font-bold text-amber-300">{c.consensus.toFixed(2)}</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-slate-500">
|
||||
{c.min_forecast.toFixed(2)} – {c.max_forecast.toFixed(2)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right text-slate-400">{c.bank_count}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail by event */}
|
||||
{Object.entries(grouped).length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs font-bold text-slate-400 uppercase tracking-wide">Détail par événement</div>
|
||||
{Object.entries(grouped).sort(([a], [b]) => b.localeCompare(a)).map(([key, items]) => {
|
||||
const [date, series] = key.split('|')
|
||||
return (
|
||||
<div key={key} className="bg-dark-800 border border-slate-700/40 rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-2 border-b border-slate-700/30 flex items-center gap-3">
|
||||
<span className="text-xs font-mono text-slate-400">{date}</span>
|
||||
<span className="text-xs font-bold text-slate-200">{items[0].event_name || series}</span>
|
||||
<span className="text-[10px] text-slate-600">{series}</span>
|
||||
</div>
|
||||
<table className="w-full text-xs">
|
||||
<tbody>
|
||||
{items.map((f, i) => (
|
||||
<tr key={i} className="border-b border-slate-800/60 last:border-0">
|
||||
<td className="px-4 py-1.5 text-slate-400 w-36">{f.bank_name}</td>
|
||||
<td className="px-4 py-1.5 font-mono font-bold text-amber-300">
|
||||
{f.forecast_value != null ? f.forecast_value.toFixed(2) : '—'} {f.unit}
|
||||
</td>
|
||||
<td className={clsx('px-4 py-1.5 text-[10px]',
|
||||
f.confidence === 'high' ? 'text-emerald-600' : f.confidence === 'low' ? 'text-red-700' : 'text-slate-600')}>
|
||||
{f.confidence}
|
||||
</td>
|
||||
<td className="px-4 py-1.5 text-slate-600 italic max-w-xs truncate" title={f.snippet}>{f.snippet}</td>
|
||||
<td className="px-4 py-1.5 text-slate-700 tabular-nums text-[10px]">{f.extracted_at.slice(0, 16)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Object.entries(grouped).length === 0 && consensus.length === 0 && !scraping && (
|
||||
<div className="text-center py-16 text-slate-600">
|
||||
<Landmark className="w-10 h-10 mx-auto mb-3 opacity-20" />
|
||||
<p className="text-sm">Aucune prévision bancaire</p>
|
||||
<p className="text-xs mt-1">Configurez les URLs des banques puis cliquez "Scrape all"</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main page ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function SpecialistDesks() {
|
||||
@@ -335,7 +576,7 @@ export default function SpecialistDesks() {
|
||||
.filter(Boolean) as DeskConfig[]
|
||||
|
||||
const [activeDeskAc, setActiveDeskAc] = useState<string>(DESK_ORDER[0])
|
||||
const [activeTab, setActiveTab] = useState<'config' | 'reports' | 'all-reports' | 'cot' | 'curves'>('config')
|
||||
const [activeTab, setActiveTab] = useState<'config' | 'reports' | 'all-reports' | 'cot' | 'curves' | 'bank-forecasts'>('config')
|
||||
|
||||
// Desk edit state
|
||||
const [editFund, setEditFund] = useState('')
|
||||
@@ -472,6 +713,17 @@ export default function SpecialistDesks() {
|
||||
<Activity className="w-3.5 h-3.5" />
|
||||
Forward Curves
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('bank-forecasts')}
|
||||
className={clsx(
|
||||
'px-4 py-3 flex items-center gap-2 border-t border-slate-700/40 transition-colors text-xs',
|
||||
activeTab === 'bank-forecasts'
|
||||
? 'bg-amber-900/30 text-amber-300'
|
||||
: 'text-slate-500 hover:text-slate-300'
|
||||
)}>
|
||||
<Landmark className="w-3.5 h-3.5" />
|
||||
Bank Forecasts
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Right: desk detail / all reports ──────────────────────────────── */}
|
||||
@@ -1056,6 +1308,9 @@ export default function SpecialistDesks() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* ══ BANK FORECASTS VIEW ════════════════════════════════════════════ */}
|
||||
{activeTab === 'bank-forecasts' && <BankForecastsPanel />}
|
||||
|
||||
{/* ── Report modal ─────────────────────────────────────────────────────── */}
|
||||
{reportModal && (
|
||||
<ReportModal
|
||||
|
||||
Reference in New Issue
Block a user