AndesOps-AI / frontend /components /Dashboard.tsx
Á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>
);
}