Álvaro Valenzuela Valdes
fix: make recommendation engine robust and prioritize recommendations in dashboard
d372f15 | import StatCard from "./StatCard"; | |
| import { Tender } from "../lib/types"; | |
| import { useEffect, useMemo, useState } from "react"; | |
| import BrandLoader from "./BrandLoader"; | |
| import { searchTenders, fetchDbStatus, syncDatabase, fetchRecommendations } from "../lib/api"; | |
| import { translations, Language } from "../lib/translations"; | |
| type Props = { | |
| tendersFound: number; | |
| recommendedOpportunities: number; | |
| highRiskItems: number; | |
| reportsGenerated: number; | |
| followedTendersCount: number; | |
| tenders: Tender[]; | |
| onFilterClick?: (type: "sector" | "region", value: string) => void; | |
| onTenderClick?: (tender: Tender) => void; | |
| lang: Language; | |
| }; | |
| export default function Dashboard({ | |
| tendersFound, | |
| recommendedOpportunities, | |
| highRiskItems, | |
| reportsGenerated, | |
| followedTendersCount, | |
| tenders, | |
| onFilterClick, | |
| onTenderClick, | |
| lang | |
| }: Props) { | |
| const t = translations[lang]; | |
| const [isSyncing, setIsSyncing] = useState(false); | |
| const [dbStatus, setDbStatus] = useState<any>(null); | |
| const [recommendations, setRecommendations] = useState<Tender[]>([]); | |
| const [loadingRecs, setLoadingRecs] = useState(true); | |
| useEffect(() => { | |
| async function loadRecs() { | |
| setLoadingRecs(true); | |
| const recs = await fetchRecommendations(); | |
| setRecommendations(recs); | |
| setLoadingRecs(false); | |
| } | |
| loadRecs(); | |
| }, []); | |
| useEffect(() => { | |
| async function loadStatus() { | |
| const status = await fetchDbStatus(); | |
| setDbStatus(status); | |
| } | |
| loadStatus(); | |
| }, [tenders]); | |
| const handleGlobalSync = async () => { | |
| setIsSyncing(true); | |
| try { | |
| await syncDatabase(); | |
| await new Promise(r => setTimeout(r, 1500)); | |
| window.location.reload(); | |
| } catch (e) { | |
| console.error(e); | |
| } finally { | |
| setIsSyncing(false); | |
| } | |
| }; | |
| const sectorDistribution = useMemo(() => { | |
| const counts: Record<string, number> = {}; | |
| tenders.forEach(t => { | |
| const sector = t.sector || "General"; | |
| counts[sector] = (counts[sector] || 0) + 1; | |
| }); | |
| return Object.entries(counts) | |
| .sort((a, b) => b[1] - a[1]) | |
| .slice(0, 5); | |
| }, [tenders]); | |
| const regionDistribution = useMemo(() => { | |
| const counts: Record<string, number> = {}; | |
| tenders.forEach(t => { | |
| const region = t.region || "Sin Región"; | |
| counts[region] = (counts[region] || 0) + 1; | |
| }); | |
| return Object.entries(counts) | |
| .sort((a, b) => b[1] - a[1]) | |
| .slice(0, 5); | |
| }, [tenders]); | |
| const deadlineStatus = useMemo(() => { | |
| const now = new Date(); | |
| const status = { | |
| urgent: 0, | |
| near: 0, | |
| far: 0 | |
| }; | |
| tenders.forEach(t => { | |
| if (!t.closing_date) return; | |
| const closing = new Date(t.closing_date); | |
| const diffDays = Math.ceil((closing.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); | |
| if (diffDays < 7) status.urgent++; | |
| else if (diffDays < 21) status.near++; | |
| else status.far++; | |
| }); | |
| return status; | |
| }, [tenders]); | |
| const totalAmount = useMemo(() => { | |
| return tenders.reduce((acc, t) => acc + (t.estimated_amount || 0), 0); | |
| }, [tenders]); | |
| const formatAmount = (amount: number) => { | |
| if (amount >= 1_000_000_000) { | |
| return `$${(amount / 1_000_000_000).toFixed(1)}B`; | |
| } | |
| if (amount >= 1_000_000) { | |
| return `$${(amount / 1_000_000).toFixed(1)}M`; | |
| } | |
| return new Intl.NumberFormat("es-CL", { | |
| style: "currency", | |
| currency: "CLP", | |
| maximumFractionDigits: 0 | |
| }).format(amount); | |
| }; | |
| return ( | |
| <div className="space-y-8"> | |
| {isSyncing && <BrandLoader />} | |
| <div className="flex items-center justify-between gap-4"> | |
| <div> | |
| <p className="text-sm uppercase tracking-[0.35em] text-cyan/80">{t.resumenEjecutivo}</p> | |
| <h2 className="mt-3 text-4xl font-semibold text-white">AndesOps AI</h2> | |
| <p className="mt-4 max-w-2xl text-slate-300"> | |
| {t.andesOpsDesc} | |
| </p> | |
| </div> | |
| <button | |
| onClick={handleGlobalSync} | |
| className="group relative flex items-center gap-3 overflow-hidden rounded-2xl bg-cyan px-6 py-4 font-bold text-slate-950 transition hover:bg-sky hover:scale-[1.02] active:scale-[0.98]" | |
| > | |
| <span className="relative z-10">{t.syncPipeline}</span> | |
| <span className="text-xl group-hover:rotate-180 transition-transform duration-700">🔄</span> | |
| <div className="absolute inset-0 bg-gradient-to-r from-white/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" /> | |
| </button> | |
| </div> | |
| <div className="grid gap-5 md:grid-cols-2 xl:grid-cols-4"> | |
| <StatCard title={t.tendersFound} value={dbStatus?.total_records || tendersFound} subtitle={t.activeOpps} /> | |
| <StatCard title={t.recommended} value={recommendedOpportunities} subtitle="Fit score > 80%" /> | |
| <StatCard title={t.highRisk} value={highRiskItems} subtitle="Riesgos críticos" /> | |
| <StatCard title="Portfolio" value={followedTendersCount} subtitle="Opps. guardadas" /> | |
| <StatCard title={t.totalPipeline} value={formatAmount(totalAmount)} subtitle="Monto total proyectado" /> | |
| </div> | |
| <div className="grid gap-6 lg:grid-cols-3"> | |
| {/* Sector Distribution */} | |
| <div className="rounded-3xl border border-slate-800 bg-slate-950/80 p-6"> | |
| <h3 className="text-sm uppercase tracking-widest text-slate-400 mb-6 font-semibold">{t.sectors}</h3> | |
| <div className="space-y-4"> | |
| {sectorDistribution.length > 0 ? ( | |
| sectorDistribution.map(([sector, count]) => ( | |
| <button | |
| key={sector} | |
| onClick={() => onFilterClick?.("sector", sector)} | |
| className="w-full text-left group/item focus:outline-none" | |
| > | |
| <div className="flex justify-between text-xs mb-1.5"> | |
| <span className="text-slate-300 group-hover/item:text-cyan transition-colors">{sector}</span> | |
| <span className="text-cyan font-semibold opacity-60 group-hover/item:opacity-100">{count}</span> | |
| </div> | |
| <div className="h-2 w-full bg-slate-900 rounded-full overflow-hidden border border-white/5"> | |
| <div | |
| className="h-full bg-cyan transition-all duration-700 group-hover/item:brightness-125" | |
| style={{ width: `${(count / tenders.length) * 100}%` }} | |
| /> | |
| </div> | |
| </button> | |
| )) | |
| ) : ( | |
| <p className="text-slate-500 text-xs italic">Sin datos disponibles.</p> | |
| )} | |
| </div> | |
| </div> | |
| {/* Region Distribution */} | |
| <div className="rounded-3xl border border-slate-800 bg-slate-950/80 p-6"> | |
| <h3 className="text-sm uppercase tracking-widest text-slate-400 mb-6 font-semibold">{t.regionalDist}</h3> | |
| <div className="space-y-4"> | |
| {regionDistribution.length > 0 ? ( | |
| regionDistribution.map(([region, count]) => ( | |
| <button | |
| key={region} | |
| onClick={() => onFilterClick?.("region", region)} | |
| className="w-full text-left group/item focus:outline-none" | |
| > | |
| <div className="flex justify-between text-xs mb-1.5"> | |
| <span className="text-slate-300 group-hover/item:text-sky transition-colors">{region}</span> | |
| <span className="text-sky font-semibold opacity-60 group-hover/item:opacity-100">{count}</span> | |
| </div> | |
| <div className="h-2 w-full bg-slate-900 rounded-full overflow-hidden border border-white/5"> | |
| <div | |
| className="h-full bg-sky transition-all duration-700 group-hover/item:brightness-125" | |
| style={{ width: `${(count / tenders.length) * 100}%` }} | |
| /> | |
| </div> | |
| </button> | |
| )) | |
| ) : ( | |
| <p className="text-slate-500 text-xs italic">Sin datos disponibles.</p> | |
| )} | |
| </div> | |
| </div> | |
| {/* Deadline Status - Enhanced Visual */} | |
| <div className="rounded-3xl border border-white/5 bg-slate-950/80 p-8 relative overflow-hidden group hover:border-purple-500/20 transition-all duration-500"> | |
| <div className="absolute -right-10 -bottom-10 w-40 h-40 bg-purple-500/5 blur-3xl group-hover:bg-purple-500/10 transition-colors" /> | |
| <h3 className="text-[10px] uppercase tracking-[0.2em] text-slate-500 mb-8 font-black">{t.deadlines}</h3> | |
| <div className="flex items-center gap-10"> | |
| <div className="relative w-32 h-32 flex items-center justify-center"> | |
| {/* Complex Radial Background with Multiple Segments via CSS Gradients */} | |
| <div | |
| className="absolute inset-0 rounded-full border-[10px] border-white/5 shadow-inner" | |
| style={{ | |
| background: `conic-gradient( | |
| #ef4444 0% ${((deadlineStatus.urgent / Math.max(1, tenders.length)) * 100).toFixed(1)}%, | |
| #f59e0b ${((deadlineStatus.urgent / Math.max(1, tenders.length)) * 100).toFixed(1)}% ${(((deadlineStatus.urgent + deadlineStatus.near) / Math.max(1, tenders.length)) * 100).toFixed(1)}%, | |
| #22c55e ${(((deadlineStatus.urgent + deadlineStatus.near) / Math.max(1, tenders.length)) * 100).toFixed(1)}% 100% | |
| )`, | |
| maskImage: 'radial-gradient(transparent 58%, black 60%)', | |
| WebkitMaskImage: 'radial-gradient(transparent 58%, black 60%)' | |
| }} | |
| /> | |
| <div className="text-center"> | |
| <div className="text-3xl font-black text-white">{tenders.length}</div> | |
| <div className="text-[8px] font-bold text-slate-500 uppercase tracking-tighter">Total</div> | |
| </div> | |
| </div> | |
| <div className="flex-1 space-y-4"> | |
| <div className="flex items-center justify-between group/row"> | |
| <div className="flex items-center gap-2"> | |
| <div className="w-1.5 h-1.5 rounded-full bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.5)]" /> | |
| <span className="text-[11px] text-slate-400 group-hover/row:text-red-400 transition-colors">Urgent</span> | |
| </div> | |
| <span className="text-xs font-bold text-white">{deadlineStatus.urgent}</span> | |
| </div> | |
| <div className="flex items-center justify-between group/row"> | |
| <div className="flex items-center gap-2"> | |
| <div className="w-1.5 h-1.5 rounded-full bg-amber-500 shadow-[0_0_8px_rgba(245,158,11,0.5)]" /> | |
| <span className="text-[11px] text-slate-400 group-hover/row:text-amber-400 transition-colors">Near</span> | |
| </div> | |
| <span className="text-xs font-bold text-white">{deadlineStatus.near}</span> | |
| </div> | |
| <div className="flex items-center justify-between group/row"> | |
| <div className="flex items-center gap-2"> | |
| <div className="w-1.5 h-1.5 rounded-full bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.5)]" /> | |
| <span className="text-[11px] text-slate-400 group-hover/row:text-green-400 transition-colors">Safe</span> | |
| </div> | |
| <span className="text-xs font-bold text-white">{deadlineStatus.far}</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Database Status Table (New) */} | |
| <div className="rounded-3xl border border-slate-800 bg-slate-950/80 p-6 flex flex-col justify-between"> | |
| <div> | |
| <h3 className="text-sm uppercase tracking-widest text-slate-400 mb-6 font-semibold">{t.integrityMonitor}</h3> | |
| <div className="overflow-hidden rounded-2xl border border-slate-800 bg-slate-900/30"> | |
| <table className="w-full text-left text-[10px]"> | |
| <thead className="bg-slate-800/50 text-slate-500 uppercase font-bold"> | |
| <tr> | |
| <th className="px-4 py-2">Organismo Local</th> | |
| <th className="px-4 py-2 text-right">Qty</th> | |
| </tr> | |
| </thead> | |
| <tbody className="divide-y divide-slate-800/50"> | |
| {dbStatus?.top_buyers?.map((b: any, i: number) => ( | |
| <tr key={i} className="hover:bg-slate-800/30"> | |
| <td className="px-4 py-3 text-slate-300 truncate max-w-[120px]">{b.name}</td> | |
| <td className="px-4 py-3 text-right text-cyan font-mono">{b.count}</td> | |
| </tr> | |
| ))} | |
| {!dbStatus?.top_buyers?.length && ( | |
| <tr> | |
| <td colSpan={2} className="px-4 py-6 text-center text-slate-600 italic">No local data found.</td> | |
| </tr> | |
| )} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| <div className="mt-6 pt-6 border-t border-slate-800/50"> | |
| <div className="flex justify-between items-center text-[10px]"> | |
| <span className="text-slate-500 font-bold uppercase tracking-tighter">Total Local Tenders:</span> | |
| <span className="text-white font-mono">{dbStatus?.total_records || 0}</span> | |
| </div> | |
| <div className="flex justify-between items-center text-[10px] mt-2"> | |
| <span className="text-slate-500 font-bold uppercase tracking-tighter">Last Pulse:</span> | |
| <span className="text-cyan font-mono">{dbStatus?.last_sync ? new Date(dbStatus.last_sync).toLocaleTimeString() : 'Never'}</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="rounded-3xl border border-slate-800 bg-slate-950/80 p-6 relative overflow-hidden group"> | |
| <div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-20 transition-opacity"> | |
| <span className="text-4xl">🤖</span> | |
| </div> | |
| <h3 className="text-sm uppercase tracking-widest text-indigo-400 mb-6 font-black flex items-center gap-2"> | |
| <span className="w-2 h-2 rounded-full bg-indigo-500 animate-pulse" /> | |
| IA Recommendations for your Company | |
| </h3> | |
| <div className="space-y-3"> | |
| {(recommendations.length > 0 || tenders.length > 0) ? ( | |
| (recommendations.length > 0 ? recommendations : tenders).slice(0, 6).map((t) => ( | |
| // ... existing map logic ... | |
| <div | |
| key={t.code} | |
| onClick={() => onTenderClick?.(t)} | |
| className="flex items-center justify-between p-4 rounded-2xl bg-slate-900/40 border border-slate-800/50 hover:bg-slate-900/60 transition group cursor-pointer" | |
| > | |
| <div className="flex items-center gap-4 overflow-hidden"> | |
| <div className="h-10 w-10 flex-shrink-0 rounded-full bg-slate-800 flex items-center justify-center text-cyan group-hover:scale-110 transition"> | |
| {t.sector?.charAt(0) || "T"} | |
| </div> | |
| <div className="overflow-hidden"> | |
| <div className="text-sm font-medium text-white truncate max-w-[200px] md:max-w-[400px]">{t.name}</div> | |
| <div className="text-xs text-slate-500 truncate">{t.buyer}</div> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-6 text-right"> | |
| <div className="hidden lg:block min-w-[100px]"> | |
| <div className="text-[10px] uppercase font-bold text-slate-500">Región</div> | |
| <div className="text-xs text-slate-300">{t.region || "N/A"}</div> | |
| </div> | |
| <div className="min-w-[80px]"> | |
| <div className="text-[10px] uppercase font-bold text-slate-500">Código</div> | |
| <div className="text-xs font-mono text-cyan">{t.code}</div> | |
| </div> | |
| <button | |
| className="px-4 py-2 rounded-xl bg-cyan/10 border border-cyan/20 text-[10px] font-black text-cyan uppercase tracking-widest group-hover:bg-cyan group-hover:text-black transition-all" | |
| > | |
| View | |
| </button> | |
| </div> | |
| </div> | |
| )) | |
| ) : ( | |
| <div className="flex flex-col items-center justify-center py-20 text-center"> | |
| <div className="w-16 h-16 rounded-full bg-slate-900 flex items-center justify-center mb-4 text-2xl">📡</div> | |
| <p className="text-slate-500 text-sm italic mb-6 max-w-xs"> | |
| No local data found yet. Sync with Mercado Público to feed the Intelligence Pipeline. | |
| </p> | |
| <button | |
| onClick={handleGlobalSync} | |
| className="group relative px-8 py-3 rounded-2xl bg-cyan text-slate-950 font-bold hover:bg-sky transition-all active:scale-95 flex items-center gap-2" | |
| > | |
| <span>📥 Sync Real Data Now</span> | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |