"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
// 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} />
{
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' }}
/>
{data.map((entry, index) => (
|
))}
{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;
const overdueProjects = getOverdueProjects(projects);
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 (
{/* KPIs principales */}
}
highlight
/>
}
/>
}
/>
0 ? "Proyectos retrasados" : "Todo en orden"}
icon={ 0 ? "text-red-500" : "text-green-500"}`} />}
/>
{/* Graficos: Estado + Progreso */}
{/* Presupuesto por proyecto */}
b.budget - a.budget)}
title="Presupuesto vs Gastado por Proyecto"
maxItems={8}
/>
{/* Alertas: retrasados y proximos a vencer */}
{(overdueProjects.length > 0 || upcomingDeadlines.length > 0) && (
{overdueProjects.length > 0 && (
Retrasados ({overdueProjects.length})
{overdueProjects.slice(0, 4).map(p => {
const daysOverdue = Math.abs(getDaysRemaining(p.endDate));
return (
);
})}
)}
{upcomingDeadlines.length > 0 && (
Proximos a vencer ({upcomingDeadlines.length})
{upcomingDeadlines.slice(0, 4).map(p => {
const days = getDaysRemaining(p.endDate);
return (
);
})}
)}
)}
);
}
// ==================== 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] },
];
return (
{/* KPIs */}
} highlight />
} />
} />
} />
{/* Gráficos */}
b.budget - a.budget)} title="Presupuesto vs Gastado" maxItems={6} />
{/* 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
);
})}
)}
);
}
// ==================== 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);
// 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 (
{/* 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 ? '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'}`}>
0 ? 'bg-red-100 dark:bg-red-900/50' : 'bg-muted'}`}>
0 ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground/50'}`} />
0 ? 'text-red-700 dark:text-red-400' : 'text-muted-foreground'}`}>Excedieron Presupuesto
0 ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground/50'}`}>{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
{/* 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);
return (
{/* KPIs */}
} highlight />
} />
} />
} />
{/* Gráficos */}
b.budget - a.budget)} title="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}
)}
);
}