"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 (
{title}
{icon}
{value}
{subtitle && {subtitle}
}
);
}
// ==================== 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 (
Distribucion por Estado
{data.map((entry, index) => (
|
))}
[`${value ?? 0} proyectos`, '']}
contentStyle={{ fontSize: '12px' }}
/>
{data.map((entry) => (
{entry.name}: {entry.value}
))}
);
}
// 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 (
{title}
{data.map((entry, index) => (
|
))}
[`${value ?? 0} proyectos`, '']}
contentStyle={{ fontSize: '12px' }}
/>
{centerValue !== undefined && (
{centerValue}
{centerLabel &&
{centerLabel}
}
)}
{data.map((entry) => (
))}
);
}
// Radial Bar Chart para metricas
interface RadialMetricProps {
value: number;
title: string;
subtitle?: string;
color?: string;
}
function RadialMetric({ value, title, subtitle, color = COLORS.primary }: RadialMetricProps) {
const data = [{ name: title, value: Math.min(value, 100), fill: color }];
return (
{title}
{value}%
{subtitle &&
{subtitle}
}
);
}
// 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 (
{title}
`${(v / 1000).toFixed(0)}k`} fontSize={11} />
[`€${Number(value ?? 0).toLocaleString("es-ES")}`, name === 'presupuesto' ? 'Presupuesto' : 'Gastado']}
labelFormatter={(label, payload) => payload?.[0]?.payload?.fullName || label}
contentStyle={{ fontSize: '12px' }}
/>
{data.map((entry, index) => (
|
))}
{data.map((entry, index) => (
|
))}
);
}
// Bar Chart vertical para comparativas
interface ComparisonBarChartProps {
data: { name: string; value: number; color?: string }[];
title: string;
formatter?: (value: number) => string;
}
function ComparisonBarChart({ data, title, formatter }: ComparisonBarChartProps) {
return (
{title}
[formatter ? formatter(Number(value) || 0) : (value ?? 0), '']}
contentStyle={{ fontSize: '12px' }}
/>
{data.map((entry, index) => (
|
))}
);
}
// 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 (
{title}
[`${value ?? 0}${valueLabel}`, '']}
contentStyle={{ fontSize: '12px' }}
/>
);
}
// ==================== 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;
// Nuevas métricas
const overdueProjects = getOverdueProjects(projects);
const upcomingDeadlines = getUpcomingDeadlines(projects, 14); // próximos 14 días
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] },
];
const budgetByStatus = [
{ name: "Borrador", value: projects.filter(p => p.status === "0").reduce((a, p) => a + p.budget, 0), color: COLORS.gray },
{ name: "Abierto", value: projects.filter(p => p.status === "1").reduce((a, p) => a + p.budget, 0), color: COLORS.primary },
{ name: "Cerrado", value: projects.filter(p => p.status === "2").reduce((a, p) => a + p.budget, 0), color: COLORS.success },
];
// Top proyectos por presupuesto con colores únicos
const topProjects = [...projects]
.sort((a, b) => b.budget - a.budget)
.slice(0, 5)
.map((p, index) => ({
name: p.name.length > 12 ? p.name.substring(0, 12) + "..." : p.name,
value: p.progress,
fill: getProjectColor(index),
}));
return (
{/* KPIs principales */}
} highlight />
} />
} />
} />
{/* Alertas de fechas */}
{(overdueProjects.length > 0 || upcomingDeadlines.length > 0) && (
{overdueProjects.length > 0 && (
Proyectos Retrasados
{overdueProjects.length}
Fecha límite superada
{overdueProjects.slice(0, 2).map(p => (
{p.name}
))}
)}
{upcomingDeadlines.length > 0 && (
Próximos a Vencer
{upcomingDeadlines.length}
En los próximos 14 días
{upcomingDeadlines.slice(0, 2).map(p => (
{p.name} ({getDaysRemaining(p.endDate)}d)
))}
)}
)}
{/* Gráficos principales */}
80 ? COLORS.danger : COLORS.primary} />
{/* Comparativas */}
formatCurrency(v)} />
{/* Timeline de fechas - Próximos vencimientos y retrasados */}
{(upcomingDeadlines.length > 0 || overdueProjects.length > 0) && (
Timeline de Proyectos
{/* Proyectos retrasados */}
{overdueProjects.length > 0 && (
Retrasados ({overdueProjects.length})
{overdueProjects.slice(0, 6).map((p, index) => {
const daysOverdue = Math.abs(getDaysRemaining(p.endDate));
return (
{p.name}
{p.progress}% completado
-{daysOverdue}d
);
})}
)}
{/* Próximos vencimientos */}
{upcomingDeadlines.length > 0 && (
Próximos 14 días ({upcomingDeadlines.length})
{upcomingDeadlines.slice(0, 6).map((p, index) => {
const days = getDaysRemaining(p.endDate);
return (
{p.name}
{p.progress}% completado
{days}d
);
})}
)}
{/* Resumen de fechas */}
Retrasados: {overdueProjects.length}
Vencen en 7 días: {upcomingDeadlines.filter(p => getDaysRemaining(p.endDate) <= 7).length}
En tiempo: {projects.filter(p => p.status === "1" && getDaysRemaining(p.endDate) > 14).length}
)}
{/* Presupuesto detallado */}
b.budget - a.budget)} title="Presupuesto vs Gastado por Proyecto" maxItems={6} />
);
}
// ==================== 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 (
No hay proyectos activos
Todos los proyectos están cerrados o en borrador
);
}
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] },
];
// Top proyectos con colores únicos
const topByProgress = activeProjects
.sort((a, b) => b.progress - a.progress)
.slice(0, 5)
.map((p, index) => ({
name: p.name.length > 12 ? p.name.substring(0, 12) + "..." : p.name,
value: p.progress,
fill: getProjectColor(index),
}));
return (
{/* KPIs */}
} highlight />
} />
} />
} />
{/* Alertas */}
0 ? 'bg-red-50' : 'bg-gray-50 border-gray-200'}`}>
0 ? 'text-red-700' : 'text-gray-500'}`}>
Retrasados
0 ? 'text-red-600' : 'text-gray-400'}`}>{overdueProjects.length}
0 ? 'text-red-600' : 'text-gray-400'}`}>Fecha superada
0 ? 'bg-orange-50' : 'bg-gray-50 border-gray-200'}`}>
0 ? 'text-orange-700' : 'text-gray-500'}`}>
Requieren Atención
0 ? 'text-orange-600' : 'text-gray-400'}`}>{needsAttention}
0 ? 'text-orange-600' : 'text-gray-400'}`}>Menos del 25%
Próximos a Completar
{nearCompletion}
75% o más
{/* Gráficos */}
{/* Próximos vencimientos */}
{upcomingDeadlines.length > 0 && (
Próximos Vencimientos (14 días)
{upcomingDeadlines.slice(0, 6).map((p, index) => {
const days = getDaysRemaining(p.endDate);
return (
{p.name}
{p.progress}% completado
{days}d
);
})}
)}
{/* Presupuesto */}
b.budget - a.budget)} title="Presupuesto vs Gastado por Proyecto" maxItems={6} />
);
}
// ==================== 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 (
No hay proyectos cerrados
Aún no se ha cerrado ningún proyecto
);
}
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);
const completionData = [
{ name: "100% completado", value: fullyCompleted, color: COLORS.success },
{ name: "Cerrado parcial", value: closedProjects.length - fullyCompleted, color: COLORS.warning },
].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 (
{/* KPIs */}
} highlight />
} />
0 ? Math.round((withinBudget / stats.total) * 100) : 0}%`} subtitle={`${withinBudget} dentro de presupuesto`} icon={} />
} />
{/* Cumplimiento visual */}
Dentro del Presupuesto
{withinBudget}
{stats.total > 0 ? Math.round((withinBudget / stats.total) * 100) : 0}% de los cerrados
0 ? 'bg-gradient-to-br from-red-50 to-orange-50' : 'bg-gray-50 border-gray-200'}`}>
0 ? 'bg-red-100' : 'bg-gray-100'}`}>
0 ? 'text-red-600' : 'text-gray-400'}`} />
0 ? 'text-red-700' : 'text-gray-500'}`}>Excedieron Presupuesto
0 ? 'text-red-600' : 'text-gray-400'}`}>{overBudget}
0 ? 'text-red-600' : 'text-gray-400'}`}>{stats.total > 0 ? Math.round((overBudget / stats.total) * 100) : 0}% de los cerrados
{/* Gráficos */}
0 ? Math.round((withinBudget / stats.total) * 100) : 0}%`} centerLabel="Éxito" />
{/* Eficiencia por proyecto */}
b.budget - a.budget)} title="Presupuesto vs Gastado" maxItems={5} />
);
}
// ==================== 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 (
No hay proyectos completados
Aún no se ha completado ningún proyecto al 100%
);
}
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);
// Proyectos completados con colores únicos para el gráfico
const budgetData = completedProjects
.sort((a, b) => b.budget - a.budget)
.slice(0, 6)
.map((p, index) => ({
name: p.name.length > 15 ? p.name.substring(0, 15) + "..." : p.name,
value: p.budget,
color: getProjectColor(index),
}));
return (
{/* Banner de éxito */}
{stats.total}
Proyectos al 100%
{successRate}%
Tasa de éxito
{formatCurrency(savings)}
Ahorro total
{/* KPIs */}
} />
} />
} />
} />
{/* Gráficos */}
{/* Detalle de presupuesto */}
b.budget - a.budget)} title="Detalle: Presupuesto vs Gastado" maxItems={6} />
);
}
// ==================== SKELETON ====================
function StatisticsSkeleton() {
return (
{Array.from({ length: 4 }).map((_, i) => (
))}
{Array.from({ length: 3 }).map((_, i) => (
))}
);
}
// ==================== COMPONENTE PRINCIPAL ====================
export default function StatisticsPage() {
const [projects, setProjects] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(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 (
{error}
);
}
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 (
Estadisticas
Analisis de {projects.length} proyectos en Dolibarr
{loading ? (
) : (
Todos
{projects.length}
Activos
{activeCount}
Cerrados
{closedCount}
Completados
{completedCount}
)}
);
}