trello_fake/components/statistics/statistics-page.tsx

891 lines
37 KiB
TypeScript
Raw Permalink Normal View History

"use client";
import { useState, useEffect } from "react";
import {
LayoutGrid,
TrendingUp,
CheckCircle2,
DollarSign,
Clock,
Calendar,
Users,
Target,
PiggyBank,
AlertCircle,
BarChart3,
Lock,
} from "lucide-react";
import {
PieChart,
Pie,
Cell,
ResponsiveContainer,
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
Legend,
RadialBarChart,
RadialBar,
CartesianGrid,
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { getProjects } from "@/lib/projectsService";
import { Project } from "@/types/project";
// Colores consistentes
const COLORS = {
primary: "#3b82f6",
secondary: "#8b5cf6",
success: "#22c55e",
warning: "#f59e0b",
danger: "#ef4444",
gray: "#6b7280",
orange: "#f97316",
cyan: "#06b6d4",
};
const PROGRESS_COLORS = ["#ef4444", "#f97316", "#f59e0b", "#22c55e"];
// Paleta de colores para proyectos individuales (variada y distinguible)
const PROJECT_COLORS = [
"#3b82f6", // blue
"#8b5cf6", // violet
"#06b6d4", // cyan
"#10b981", // emerald
"#f59e0b", // amber
"#ef4444", // red
"#ec4899", // pink
"#6366f1", // indigo
"#14b8a6", // teal
"#f97316", // orange
"#84cc16", // lime
"#a855f7", // purple
];
// Obtener color por índice (cíclico)
function getProjectColor(index: number): string {
return PROJECT_COLORS[index % PROJECT_COLORS.length];
}
// ==================== COMPONENTES DE CARDS ====================
interface StatCardProps {
title: string;
value: string | number;
subtitle?: string;
icon: React.ReactNode;
highlight?: boolean;
}
function StatCard({ title, value, subtitle, icon, highlight }: StatCardProps) {
return (
<Card className={`border hover:shadow-md transition-shadow ${highlight ? "ring-2 ring-blue-500/20" : ""}`}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
{icon}
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-foreground">{value}</div>
{subtitle && <p className="text-xs text-muted-foreground mt-1">{subtitle}</p>}
</CardContent>
</Card>
);
}
// ==================== COMPONENTES DE GRAFICOS ====================
// Pie Chart de distribucion por estado
interface StatusPieChartProps {
borradores: number;
abiertos: number;
cerrados: number;
}
function StatusPieChart({ borradores, abiertos, cerrados }: StatusPieChartProps) {
const data = [
{ name: "Borrador", value: borradores, color: COLORS.gray },
{ name: "Abierto", value: abiertos, color: COLORS.primary },
{ name: "Cerrado", value: cerrados, color: COLORS.success },
].filter(d => d.value > 0);
const total = borradores + abiertos + cerrados;
return (
<Card className="border">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Distribucion por Estado</CardTitle>
</CardHeader>
<CardContent>
<div className="h-64 relative">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
paddingAngle={3}
dataKey="value"
>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
formatter={(value) => [`${value ?? 0} proyectos`, '']}
contentStyle={{ fontSize: '12px' }}
/>
</PieChart>
</ResponsiveContainer>
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="text-center">
<p className="text-2xl font-bold text-foreground">{total}</p>
<p className="text-xs text-muted-foreground">Total</p>
</div>
</div>
</div>
<div className="flex justify-center gap-4 mt-2">
{data.map((entry) => (
<div key={entry.name} className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: entry.color }} />
<span className="text-xs text-muted-foreground">{entry.name}: {entry.value}</span>
</div>
))}
</div>
</CardContent>
</Card>
);
}
// Donut Chart para progreso
interface ProgressDonutProps {
data: { name: string; value: number; color: string }[];
title: string;
centerValue?: string | number;
centerLabel?: string;
}
function ProgressDonut({ data, title, centerValue, centerLabel }: ProgressDonutProps) {
return (
<Card className="border">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
</CardHeader>
<CardContent>
<div className="h-64 relative">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
innerRadius={55}
outerRadius={85}
paddingAngle={2}
dataKey="value"
>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
formatter={(value) => [`${value ?? 0} proyectos`, '']}
contentStyle={{ fontSize: '12px' }}
/>
</PieChart>
</ResponsiveContainer>
{centerValue !== undefined && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="text-center">
<p className="text-2xl font-bold text-foreground">{centerValue}</p>
{centerLabel && <p className="text-xs text-muted-foreground">{centerLabel}</p>}
</div>
</div>
)}
</div>
<div className="flex flex-wrap justify-center gap-3 mt-2">
{data.map((entry) => (
<div key={entry.name} className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: entry.color }} />
<span className="text-xs text-muted-foreground">{entry.name}</span>
</div>
))}
</div>
</CardContent>
</Card>
);
}
// Radial Bar Chart para metricas
// Bar Chart horizontal para presupuestos por proyecto
interface BudgetBarChartProps {
projects: Project[];
title: string;
maxItems?: number;
}
function BudgetBarChart({ projects, title, maxItems = 6 }: BudgetBarChartProps) {
const data = projects
.slice(0, maxItems)
.map((p, index) => ({
name: p.name.length > 15 ? p.name.substring(0, 15) + "..." : p.name,
fullName: p.name,
presupuesto: p.budget,
gastado: p.spent,
color: getProjectColor(index),
}));
return (
<Card className="border">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
</CardHeader>
<CardContent>
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} layout="vertical" margin={{ left: 10, right: 20 }}>
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
<XAxis type="number" tickFormatter={(v) => `${(v / 1000).toFixed(0)}k`} fontSize={11} />
<YAxis type="category" dataKey="name" width={100} fontSize={10} />
<Tooltip
formatter={(value, name) => {
const label = name === 'presupuesto' ? 'Presupuesto' : name === 'gastado' ? 'Gastado' : String(name);
return [`€${Number(value ?? 0).toLocaleString("es-ES")}`, label];
}}
labelFormatter={(label, payload) => payload?.[0]?.payload?.fullName || label}
contentStyle={{ fontSize: '12px' }}
/>
<Legend wrapperStyle={{ fontSize: '11px' }} />
<Bar dataKey="presupuesto" name="Presupuesto" radius={[0, 4, 4, 0]}>
{data.map((entry, index) => (
<Cell key={`cell-budget-${index}`} fill={entry.color} />
))}
</Bar>
<Bar dataKey="gastado" name="Gastado" radius={[0, 4, 4, 0]}>
{data.map((entry, index) => (
<Cell key={`cell-spent-${index}`} fill={entry.color} fillOpacity={0.5} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
);
}
// Multi Radial para comparar metricas
interface MultiRadialProps {
data: { name: string; value: number; fill: string }[];
title: string;
valueLabel?: string;
}
function MultiRadialChart({ data, title, valueLabel = "%" }: MultiRadialProps) {
// Asignar colores únicos a cada proyecto
const coloredData = data.map((item, index) => ({
...item,
fill: getProjectColor(index),
}));
return (
<Card className="border">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
</CardHeader>
<CardContent>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<RadialBarChart
cx="50%"
cy="50%"
innerRadius="20%"
outerRadius="90%"
data={coloredData}
startAngle={90}
endAngle={-270}
>
<RadialBar
background={{ fill: "hsl(var(--muted))" }}
dataKey="value"
cornerRadius={5}
/>
<Tooltip
formatter={(value) => [`${value ?? 0}${valueLabel}`, '']}
contentStyle={{ fontSize: '12px' }}
/>
<Legend
iconSize={10}
layout="vertical"
verticalAlign="middle"
align="right"
wrapperStyle={{ fontSize: "10px", right: 0 }}
/>
</RadialBarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
);
}
// ==================== FUNCIONES HELPER ====================
function calculateStats(projects: Project[]) {
const total = projects.length;
if (total === 0) {
return {
total: 0, avgProgress: 0, totalBudget: 0, totalSpent: 0,
avgBudget: 0, budgetConsumed: 0, uniqueClients: 0, avgDuration: 0,
};
}
const totalBudget = projects.reduce((acc, p) => acc + p.budget, 0);
const totalSpent = projects.reduce((acc, p) => acc + p.spent, 0);
const avgProgress = Math.round(projects.reduce((acc, p) => acc + p.progress, 0) / total);
const avgBudget = Math.round(totalBudget / total);
const budgetConsumed = totalBudget > 0 ? Math.round((totalSpent / totalBudget) * 100) : 0;
const uniqueClients = new Set(projects.map((p) => p.client)).size;
const durations = projects
.map((p) => {
const start = new Date(p.startDate).getTime();
const end = new Date(p.endDate).getTime();
if (isNaN(start) || isNaN(end)) return null;
return Math.ceil((end - start) / (1000 * 60 * 60 * 24));
})
.filter((d): d is number => d !== null && d > 0);
const avgDuration = durations.length > 0 ? Math.round(durations.reduce((a, b) => a + b, 0) / durations.length) : 0;
return { total, avgProgress, totalBudget, totalSpent, avgBudget, budgetConsumed, uniqueClients, avgDuration };
}
function formatCurrency(value: number): string {
if (value >= 1000000) return `€${(value / 1000000).toFixed(1)}M`;
if (value >= 1000) return `€${(value / 1000).toFixed(0)}k`;
return `€${value.toLocaleString("es-ES")}`;
}
// Calcular días restantes hasta fecha fin
function getDaysRemaining(endDate: string): number {
const end = new Date(endDate).getTime();
const now = new Date().getTime();
return Math.ceil((end - now) / (1000 * 60 * 60 * 24));
}
// Proyectos próximos a vencer (menos de 30 días)
function getUpcomingDeadlines(projects: Project[], days: number = 30): Project[] {
return projects
.filter(p => p.status === "1" && p.progress < 100)
.filter(p => {
const remaining = getDaysRemaining(p.endDate);
return remaining > 0 && remaining <= days;
})
.sort((a, b) => getDaysRemaining(a.endDate) - getDaysRemaining(b.endDate));
}
// Proyectos retrasados (fecha fin pasada pero no completados)
function getOverdueProjects(projects: Project[]): Project[] {
return projects
.filter(p => p.status === "1" && p.progress < 100)
.filter(p => getDaysRemaining(p.endDate) < 0)
.sort((a, b) => getDaysRemaining(a.endDate) - getDaysRemaining(b.endDate));
}
// ==================== PANEL: TODOS LOS PROYECTOS ====================
function AllProjectsStats({ projects }: { projects: Project[] }) {
const stats = calculateStats(projects);
const borradores = projects.filter(p => p.status === "0").length;
const abiertos = projects.filter(p => p.status === "1").length;
const cerrados = projects.filter(p => p.status === "2").length;
2026-02-06 19:06:25 +00:00
const overdueProjects = getOverdueProjects(projects);
2026-02-06 19:06:25 +00:00
const upcomingDeadlines = getUpcomingDeadlines(projects, 14);
const progressData = [
{ name: "0-25%", value: projects.filter(p => p.progress < 25).length, color: PROGRESS_COLORS[0] },
{ name: "25-50%", value: projects.filter(p => p.progress >= 25 && p.progress < 50).length, color: PROGRESS_COLORS[1] },
{ name: "50-75%", value: projects.filter(p => p.progress >= 50 && p.progress < 75).length, color: PROGRESS_COLORS[2] },
{ name: "75-100%", value: projects.filter(p => p.progress >= 75).length, color: PROGRESS_COLORS[3] },
];
return (
<div className="space-y-6">
{/* KPIs principales */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
2026-02-06 19:06:25 +00:00
<StatCard
title="Total Proyectos"
value={stats.total}
subtitle={`${abiertos} activos, ${cerrados} cerrados`}
icon={<LayoutGrid className="w-4 h-4 text-gray-400" />}
highlight
/>
<StatCard
title="Presupuesto Total"
value={formatCurrency(stats.totalBudget)}
subtitle={`Gastado: ${formatCurrency(stats.totalSpent)}`}
icon={<DollarSign className="w-4 h-4 text-green-500" />}
/>
<StatCard
title="Progreso Medio"
value={`${stats.avgProgress}%`}
subtitle={`${stats.uniqueClients} clientes`}
icon={<TrendingUp className="w-4 h-4 text-blue-500" />}
/>
<StatCard
title="Requieren Atencion"
value={overdueProjects.length}
subtitle={overdueProjects.length > 0 ? "Proyectos retrasados" : "Todo en orden"}
icon={<AlertCircle className={`w-4 h-4 ${overdueProjects.length > 0 ? "text-red-500" : "text-green-500"}`} />}
/>
</div>
{/* Graficos: Estado + Progreso */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<StatusPieChart borradores={borradores} abiertos={abiertos} cerrados={cerrados} />
<ProgressDonut
data={progressData}
title="Distribucion por Progreso"
centerValue={`${stats.avgProgress}%`}
centerLabel="Promedio"
/>
</div>
2026-02-06 19:06:25 +00:00
{/* Presupuesto por proyecto */}
<BudgetBarChart
projects={[...projects].sort((a, b) => b.budget - a.budget)}
title="Presupuesto vs Gastado por Proyecto"
maxItems={8}
/>
{/* Alertas: retrasados y proximos a vencer */}
{(overdueProjects.length > 0 || upcomingDeadlines.length > 0) && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{overdueProjects.length > 0 && (
2026-02-06 19:06:25 +00:00
<Card className="border-red-200 dark:border-red-900">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-red-700 dark:text-red-400 flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
Retrasados ({overdueProjects.length})
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-2">
{overdueProjects.slice(0, 4).map(p => {
const daysOverdue = Math.abs(getDaysRemaining(p.endDate));
return (
<div key={p.id} className="flex items-center justify-between gap-2 p-2 rounded-md bg-red-50 dark:bg-red-950/20">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{p.name}</p>
<p className="text-xs text-muted-foreground">{p.progress}%</p>
</div>
<Badge variant="outline" className="border-red-300 dark:border-red-700 text-red-600 dark:text-red-400 text-xs shrink-0">
-{daysOverdue}d
</Badge>
</div>
);
})}
</div>
</CardContent>
</Card>
)}
{upcomingDeadlines.length > 0 && (
2026-02-06 19:06:25 +00:00
<Card className="border-amber-200 dark:border-amber-900">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-amber-700 dark:text-amber-400 flex items-center gap-2">
<Clock className="w-4 h-4" />
Proximos a vencer ({upcomingDeadlines.length})
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-2">
{upcomingDeadlines.slice(0, 4).map(p => {
const days = getDaysRemaining(p.endDate);
return (
<div key={p.id} className="flex items-center justify-between gap-2 p-2 rounded-md bg-amber-50 dark:bg-amber-950/20">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{p.name}</p>
<p className="text-xs text-muted-foreground">{p.progress}%</p>
</div>
<Badge
variant="outline"
className={`text-xs shrink-0 ${
days <= 3
? "border-red-300 dark:border-red-700 text-red-600 dark:text-red-400"
: "border-amber-300 dark:border-amber-700 text-amber-600 dark:text-amber-400"
}`}
>
{days}d
</Badge>
</div>
);
})}
</div>
</CardContent>
</Card>
)}
</div>
)}
</div>
);
}
// ==================== PANEL: PROYECTOS ACTIVOS ====================
function ActiveProjectsStats({ projects }: { projects: Project[] }) {
const activeProjects = projects.filter(p => p.status === "1" && p.progress < 100);
const stats = calculateStats(activeProjects);
if (activeProjects.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16">
<TrendingUp className="w-16 h-16 text-muted-foreground/50 mb-4" />
<h3 className="text-xl font-semibold text-foreground">No hay proyectos activos</h3>
<p className="text-muted-foreground mt-2">Todos los proyectos están cerrados o en borrador</p>
</div>
);
}
const overdueProjects = getOverdueProjects(projects);
const upcomingDeadlines = getUpcomingDeadlines(projects, 14);
const needsAttention = activeProjects.filter(p => p.progress < 25).length;
const nearCompletion = activeProjects.filter(p => p.progress >= 75).length;
const progressData = [
{ name: "Inicio (0-25%)", value: activeProjects.filter(p => p.progress < 25).length, color: PROGRESS_COLORS[0] },
{ name: "En curso (25-50%)", value: activeProjects.filter(p => p.progress >= 25 && p.progress < 50).length, color: PROGRESS_COLORS[1] },
{ name: "Avanzado (50-75%)", value: activeProjects.filter(p => p.progress >= 50 && p.progress < 75).length, color: PROGRESS_COLORS[2] },
{ name: "Casi listo (75-99%)", value: activeProjects.filter(p => p.progress >= 75).length, color: PROGRESS_COLORS[3] },
];
return (
<div className="space-y-6">
{/* KPIs */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard title="Proyectos Activos" value={stats.total} subtitle="En progreso" icon={<TrendingUp className="w-4 h-4 text-blue-500" />} highlight />
<StatCard title="Presupuesto Activo" value={formatCurrency(stats.totalBudget)} subtitle={`${stats.budgetConsumed}% consumido`} icon={<DollarSign className="w-4 h-4 text-green-500" />} />
<StatCard title="Progreso Medio" value={`${stats.avgProgress}%`} subtitle={`${nearCompletion} casi listos`} icon={<Target className="w-4 h-4 text-purple-500" />} />
2026-02-06 19:06:25 +00:00
<StatCard title="Requieren Atención" value={overdueProjects.length + needsAttention} subtitle={`${overdueProjects.length} retrasados, ${needsAttention} < 25%`} icon={<AlertCircle className="w-4 h-4 text-red-500" />} />
</div>
{/* Gráficos */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<ProgressDonut data={progressData} title="Estado de Avance" centerValue={stats.total} centerLabel="Activos" />
2026-02-06 19:06:25 +00:00
<BudgetBarChart projects={activeProjects.sort((a, b) => b.budget - a.budget)} title="Presupuesto vs Gastado" maxItems={6} />
</div>
{/* Próximos vencimientos */}
{upcomingDeadlines.length > 0 && (
<Card className="border">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Calendar className="w-4 h-4" /> Próximos Vencimientos (14 días)
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{upcomingDeadlines.slice(0, 6).map((p, index) => {
const days = getDaysRemaining(p.endDate);
return (
<div key={p.id} className="flex items-center gap-3 p-3 bg-muted/50 rounded-lg">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: getProjectColor(index) }} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">{p.name}</p>
<p className="text-xs text-muted-foreground">{p.progress}% completado</p>
</div>
<Badge variant="outline" className={days <= 3 ? 'border-red-300 dark:border-red-700 text-red-600 dark:text-red-400' : days <= 7 ? 'border-amber-300 dark:border-amber-700 text-amber-600 dark:text-amber-400' : ''}>
{days}d
</Badge>
</div>
);
})}
</div>
</CardContent>
</Card>
)}
</div>
);
}
// ==================== PANEL: PROYECTOS CERRADOS ====================
function ClosedProjectsStats({ projects }: { projects: Project[] }) {
const closedProjects = projects.filter(p => p.status === "2");
const stats = calculateStats(closedProjects);
if (closedProjects.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16">
<Lock className="w-16 h-16 text-muted-foreground/50 mb-4" />
<h3 className="text-xl font-semibold text-foreground">No hay proyectos cerrados</h3>
<p className="text-muted-foreground mt-2">Aún no se ha cerrado ningún proyecto</p>
</div>
);
}
const withinBudget = closedProjects.filter(p => p.spent <= p.budget).length;
const overBudget = closedProjects.filter(p => p.spent > p.budget).length;
const fullyCompleted = closedProjects.filter(p => p.progress >= 100).length;
const savings = Math.max(0, stats.totalBudget - stats.totalSpent);
const budgetComplianceData = [
{ name: "Dentro presupuesto", value: withinBudget, color: COLORS.success },
{ name: "Sobre presupuesto", value: overBudget, color: COLORS.danger },
].filter(d => d.value > 0);
2026-02-06 19:06:25 +00:00
// Distribución de duración de los proyectos cerrados
const durationRanges = closedProjects.reduce(
(acc, p) => {
const start = new Date(p.startDate);
const end = new Date(p.endDate);
const months = (end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24 * 30.44);
if (months < 3) acc.short++;
else if (months < 6) acc.medium++;
else if (months < 12) acc.long++;
else acc.veryLong++;
return acc;
},
{ short: 0, medium: 0, long: 0, veryLong: 0 }
);
const durationData = [
{ name: "< 3 meses", value: durationRanges.short, color: COLORS.success },
{ name: "3-6 meses", value: durationRanges.medium, color: COLORS.primary },
{ name: "6-12 meses", value: durationRanges.long, color: COLORS.warning },
{ name: "> 12 meses", value: durationRanges.veryLong, color: COLORS.danger },
].filter(d => d.value > 0);
// Eficiencia por proyecto con colores únicos
const efficiencyData = closedProjects
.slice(0, 5)
.map((p, index) => {
const efficiency = p.budget > 0 ? Math.round(((p.budget - p.spent) / p.budget) * 100) : 0;
return {
name: p.name.length > 12 ? p.name.substring(0, 12) + "..." : p.name,
value: Math.max(0, Math.min(efficiency, 100)),
fill: getProjectColor(index),
};
});
return (
<div className="space-y-6">
{/* KPIs */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard title="Proyectos Cerrados" value={stats.total} subtitle={`${fullyCompleted} al 100%`} icon={<Lock className="w-4 h-4 text-gray-500" />} highlight />
<StatCard title="Presupuesto Gestionado" value={formatCurrency(stats.totalBudget)} subtitle={`Gastado: ${formatCurrency(stats.totalSpent)}`} icon={<DollarSign className="w-4 h-4 text-green-500" />} />
<StatCard title="Tasa de Éxito" value={`${stats.total > 0 ? Math.round((withinBudget / stats.total) * 100) : 0}%`} subtitle={`${withinBudget} dentro de presupuesto`} icon={<CheckCircle2 className="w-4 h-4 text-green-500" />} />
<StatCard title="Ahorro Total" value={formatCurrency(savings)} subtitle={`${100 - stats.budgetConsumed}% no usado`} icon={<PiggyBank className="w-4 h-4 text-emerald-500" />} />
</div>
{/* Cumplimiento visual */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card className="border-green-200 dark:border-green-900 bg-gradient-to-br from-green-50 to-emerald-50 dark:from-green-950/30 dark:to-emerald-950/30">
<CardContent className="py-6">
<div className="flex items-center gap-6">
<div className="p-4 bg-green-100 dark:bg-green-900/50 rounded-full">
<CheckCircle2 className="w-8 h-8 text-green-600 dark:text-green-400" />
</div>
<div>
<p className="text-sm font-medium text-green-700 dark:text-green-400">Dentro del Presupuesto</p>
<p className="text-4xl font-bold text-green-600 dark:text-green-400">{withinBudget}</p>
<p className="text-sm text-green-600 dark:text-green-400">{stats.total > 0 ? Math.round((withinBudget / stats.total) * 100) : 0}% de los cerrados</p>
</div>
</div>
</CardContent>
</Card>
<Card className={`${overBudget > 0 ? 'border-red-200 dark:border-red-900 bg-gradient-to-br from-red-50 to-orange-50 dark:from-red-950/30 dark:to-orange-950/30' : 'border bg-muted/30'}`}>
<CardContent className="py-6">
<div className="flex items-center gap-6">
<div className={`p-4 rounded-full ${overBudget > 0 ? 'bg-red-100 dark:bg-red-900/50' : 'bg-muted'}`}>
<AlertCircle className={`w-8 h-8 ${overBudget > 0 ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground/50'}`} />
</div>
<div>
<p className={`text-sm font-medium ${overBudget > 0 ? 'text-red-700 dark:text-red-400' : 'text-muted-foreground'}`}>Excedieron Presupuesto</p>
<p className={`text-4xl font-bold ${overBudget > 0 ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground/50'}`}>{overBudget}</p>
<p className={`text-sm ${overBudget > 0 ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground/50'}`}>{stats.total > 0 ? Math.round((overBudget / stats.total) * 100) : 0}% de los cerrados</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Gráficos */}
2026-02-06 19:06:25 +00:00
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<ProgressDonut data={budgetComplianceData} title="Cumplimiento Presupuestario" centerValue={`${stats.total > 0 ? Math.round((withinBudget / stats.total) * 100) : 0}%`} centerLabel="Éxito" />
2026-02-06 19:06:25 +00:00
<ProgressDonut data={durationData} title="Duración de Proyectos" centerValue={closedProjects.length} centerLabel="Proyectos" />
</div>
{/* Eficiencia por proyecto */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<MultiRadialChart data={efficiencyData} title="Eficiencia por Proyecto (% Ahorrado)" valueLabel="%" />
<BudgetBarChart projects={closedProjects.sort((a, b) => b.budget - a.budget)} title="Presupuesto vs Gastado" maxItems={5} />
</div>
</div>
);
}
// ==================== PANEL: PROYECTOS COMPLETADOS ====================
function CompletedProjectsStats({ projects }: { projects: Project[] }) {
const completedProjects = projects.filter(p => p.progress >= 100);
const stats = calculateStats(completedProjects);
if (completedProjects.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16">
<CheckCircle2 className="w-16 h-16 text-muted-foreground/50 mb-4" />
<h3 className="text-xl font-semibold text-foreground">No hay proyectos completados</h3>
<p className="text-muted-foreground mt-2">Aún no se ha completado ningún proyecto al 100%</p>
</div>
);
}
const withinBudget = completedProjects.filter(p => p.spent <= p.budget).length;
const savings = Math.max(0, stats.totalBudget - stats.totalSpent);
const successRate = stats.total > 0 ? Math.round((withinBudget / stats.total) * 100) : 0;
const statusData = [
{ name: "Abierto (pendiente cierre)", value: completedProjects.filter(p => p.status === "1").length, color: COLORS.primary },
{ name: "Cerrado", value: completedProjects.filter(p => p.status === "2").length, color: COLORS.success },
].filter(d => d.value > 0);
return (
<div className="space-y-6">
{/* KPIs */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
2026-02-06 19:06:25 +00:00
<StatCard title="Completados" value={stats.total} subtitle="Proyectos al 100%" icon={<CheckCircle2 className="w-4 h-4 text-green-500" />} highlight />
<StatCard title="Presupuesto Total" value={formatCurrency(stats.totalBudget)} subtitle={`Gastado: ${formatCurrency(stats.totalSpent)}`} icon={<DollarSign className="w-4 h-4 text-green-500" />} />
<StatCard title="Tasa de Éxito" value={`${successRate}%`} subtitle={`${withinBudget} dentro de presupuesto`} icon={<Target className="w-4 h-4 text-emerald-500" />} />
<StatCard title="Ahorro Total" value={formatCurrency(savings)} subtitle={`${100 - stats.budgetConsumed}% no usado`} icon={<PiggyBank className="w-4 h-4 text-blue-500" />} />
</div>
{/* Gráficos */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<ProgressDonut data={statusData} title="Estado Administrativo" centerValue={stats.total} centerLabel="Completados" />
2026-02-06 19:06:25 +00:00
<BudgetBarChart projects={completedProjects.sort((a, b) => b.budget - a.budget)} title="Presupuesto vs Gastado" maxItems={6} />
</div>
</div>
);
}
// ==================== SKELETON ====================
function StatisticsSkeleton() {
return (
<div className="space-y-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i} className="border">
<CardHeader className="pb-2"><Skeleton className="h-4 w-24" /></CardHeader>
<CardContent><Skeleton className="h-8 w-16 mb-2" /><Skeleton className="h-3 w-32" /></CardContent>
</Card>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<Card key={i} className="border">
<CardHeader className="pb-2"><Skeleton className="h-4 w-32" /></CardHeader>
<CardContent><Skeleton className="h-64 w-full" /></CardContent>
</Card>
))}
</div>
</div>
);
}
// ==================== COMPONENTE PRINCIPAL ====================
export default function StatisticsPage() {
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function loadProjects() {
try {
setLoading(true);
const data = await getProjects();
setProjects(data);
setError(null);
} catch (err) {
console.error("Error loading projects:", err);
setError("Error al cargar los proyectos");
} finally {
setLoading(false);
}
}
loadProjects();
}, []);
if (error) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center">
<AlertCircle className="w-16 h-16 text-red-400 dark:text-red-500 mx-auto mb-4" />
<p className="text-red-600 dark:text-red-400 text-lg">{error}</p>
<button onClick={() => window.location.reload()} className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
Reintentar
</button>
</div>
</div>
);
}
const activeCount = projects.filter(p => p.status === "1" && p.progress < 100).length;
const closedCount = projects.filter(p => p.status === "2").length;
const completedCount = projects.filter(p => p.progress >= 100).length;
return (
<div className="min-h-screen bg-background">
<header className="bg-card border-b sticky top-0 z-10">
<div className="px-6 py-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg">
<BarChart3 className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-foreground">Estadisticas</h1>
<p className="text-sm text-muted-foreground">Analisis de {projects.length} proyectos en Dolibarr</p>
</div>
</div>
</div>
</header>
<main className="px-6 py-8">
{loading ? (
<StatisticsSkeleton />
) : (
<Tabs defaultValue="todos" className="space-y-6">
<TabsList className="grid w-full grid-cols-4 lg:w-auto lg:inline-grid">
<TabsTrigger value="todos" className="gap-2">
<LayoutGrid className="w-4 h-4" />
<span className="hidden sm:inline">Todos</span>
<Badge variant="secondary" className="ml-1">{projects.length}</Badge>
</TabsTrigger>
<TabsTrigger value="activos" className="gap-2">
<TrendingUp className="w-4 h-4" />
<span className="hidden sm:inline">Activos</span>
<Badge variant="secondary" className="ml-1 bg-blue-100 text-blue-700">{activeCount}</Badge>
</TabsTrigger>
<TabsTrigger value="cerrados" className="gap-2">
<Lock className="w-4 h-4" />
<span className="hidden sm:inline">Cerrados</span>
<Badge variant="secondary" className="ml-1 bg-gray-100 text-gray-700">{closedCount}</Badge>
</TabsTrigger>
<TabsTrigger value="completados" className="gap-2">
<CheckCircle2 className="w-4 h-4" />
<span className="hidden sm:inline">Completados</span>
<Badge variant="secondary" className="ml-1 bg-green-100 text-green-700">{completedCount}</Badge>
</TabsTrigger>
</TabsList>
<TabsContent value="todos"><AllProjectsStats projects={projects} /></TabsContent>
<TabsContent value="activos"><ActiveProjectsStats projects={projects} /></TabsContent>
<TabsContent value="cerrados"><ClosedProjectsStats projects={projects} /></TabsContent>
<TabsContent value="completados"><CompletedProjectsStats projects={projects} /></TabsContent>
</Tabs>
)}
</main>
</div>
);
}