trello_fake/components/statistics/statistics-page.tsx

1113 lines
48 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-gray-200 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-gray-600">{title}</CardTitle>
{icon}
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-gray-900">{value}</div>
{subtitle && <p className="text-xs text-gray-500 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-gray-200">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600">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-gray-900">{total}</p>
<p className="text-xs text-gray-500">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-gray-600">{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-gray-200">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600">{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-gray-900">{centerValue}</p>
{centerLabel && <p className="text-xs text-gray-500">{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-gray-600">{entry.name}</span>
</div>
))}
</div>
</CardContent>
</Card>
);
}
// 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 (
<Card className="border-gray-200">
<CardHeader className="pb-0">
<CardTitle className="text-sm font-medium text-gray-600">{title}</CardTitle>
</CardHeader>
<CardContent>
<div className="h-40 relative">
<ResponsiveContainer width="100%" height="100%">
<RadialBarChart
cx="50%"
cy="50%"
innerRadius="60%"
outerRadius="90%"
data={data}
startAngle={180}
endAngle={0}
>
<RadialBar
background={{ fill: "#f3f4f6" }}
dataKey="value"
cornerRadius={10}
/>
</RadialBarChart>
</ResponsiveContainer>
<div className="absolute inset-0 flex items-center justify-center pointer-events-none" style={{ marginTop: "-15px" }}>
<div className="text-center">
<p className="text-2xl font-bold text-gray-900">{value}%</p>
{subtitle && <p className="text-xs text-gray-500">{subtitle}</p>}
</div>
</div>
</div>
</CardContent>
</Card>
);
}
// 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-gray-200">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600">{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>
);
}
// 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 (
<Card className="border-gray-200">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600">{title}</CardTitle>
</CardHeader>
<CardContent>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} margin={{ top: 10, right: 10, left: -10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="name" fontSize={11} />
<YAxis tickFormatter={formatter} fontSize={11} />
<Tooltip
formatter={(value) => [formatter ? formatter(Number(value) || 0) : (value ?? 0), '']}
contentStyle={{ fontSize: '12px' }}
/>
<Bar dataKey="value" radius={[4, 4, 0, 0]}>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color || COLORS.primary} />
))}
</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-gray-200">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600">{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: "#f3f4f6" }}
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;
// 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 (
<div className="space-y-6">
{/* KPIs principales */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<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={`Media: ${formatCurrency(stats.avgBudget)}`} icon={<DollarSign className="w-4 h-4 text-green-500" />} />
<StatCard title="Progreso Medio" value={`${stats.avgProgress}%`} subtitle={`${stats.budgetConsumed}% presupuesto usado`} icon={<TrendingUp className="w-4 h-4 text-blue-500" />} />
<StatCard title="Clientes" value={stats.uniqueClients} subtitle={`${stats.avgDuration} días duración media`} icon={<Users className="w-4 h-4 text-indigo-500" />} />
</div>
{/* Alertas de fechas */}
{(overdueProjects.length > 0 || upcomingDeadlines.length > 0) && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{overdueProjects.length > 0 && (
<Card className="border-red-200 bg-red-50">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-red-700 flex items-center gap-2">
<AlertCircle className="w-4 h-4" /> Proyectos Retrasados
</p>
<p className="text-3xl font-bold text-red-600 mt-2">{overdueProjects.length}</p>
<p className="text-xs text-red-600">Fecha límite superada</p>
</div>
<div className="text-right">
{overdueProjects.slice(0, 2).map(p => (
<p key={p.id} className="text-xs text-red-500 truncate max-w-32">{p.name}</p>
))}
</div>
</div>
</CardContent>
</Card>
)}
{upcomingDeadlines.length > 0 && (
<Card className="border-amber-200 bg-amber-50">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-amber-700 flex items-center gap-2">
<Clock className="w-4 h-4" /> Próximos a Vencer
</p>
<p className="text-3xl font-bold text-amber-600 mt-2">{upcomingDeadlines.length}</p>
<p className="text-xs text-amber-600">En los próximos 14 días</p>
</div>
<div className="text-right">
{upcomingDeadlines.slice(0, 2).map(p => (
<p key={p.id} className="text-xs text-amber-600 truncate max-w-32">
{p.name} ({getDaysRemaining(p.endDate)}d)
</p>
))}
</div>
</div>
</CardContent>
</Card>
)}
</div>
)}
{/* Gráficos principales */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<StatusPieChart borradores={borradores} abiertos={abiertos} cerrados={cerrados} />
<ProgressDonut data={progressData} title="Distribución por Progreso" centerValue={`${stats.avgProgress}%`} centerLabel="Promedio" />
<RadialMetric value={stats.budgetConsumed} title="Consumo de Presupuesto" subtitle={formatCurrency(stats.totalSpent)} color={stats.budgetConsumed > 80 ? COLORS.danger : COLORS.primary} />
</div>
{/* Comparativas */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<ComparisonBarChart data={budgetByStatus} title="Presupuesto por Estado" formatter={(v) => formatCurrency(v)} />
<MultiRadialChart data={topProjects} title="Progreso de Top 5 Proyectos" valueLabel="%" />
</div>
{/* Timeline de fechas - Próximos vencimientos y retrasados */}
{(upcomingDeadlines.length > 0 || overdueProjects.length > 0) && (
<Card className="border-gray-200">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
<Calendar className="w-4 h-4" /> Timeline de Proyectos
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Proyectos retrasados */}
{overdueProjects.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-red-600 uppercase tracking-wide mb-2 flex items-center gap-1">
<AlertCircle className="w-3 h-3" /> Retrasados ({overdueProjects.length})
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
{overdueProjects.slice(0, 6).map((p, index) => {
const daysOverdue = Math.abs(getDaysRemaining(p.endDate));
return (
<div key={p.id} className="flex items-center gap-3 p-3 bg-red-50 border border-red-100 rounded-lg">
<div className="w-2 h-2 rounded-full bg-red-500" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-800 truncate">{p.name}</p>
<p className="text-xs text-gray-500">{p.progress}% completado</p>
</div>
<Badge variant="outline" className="border-red-300 text-red-600 text-xs">
-{daysOverdue}d
</Badge>
</div>
);
})}
</div>
</div>
)}
{/* Próximos vencimientos */}
{upcomingDeadlines.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-amber-600 uppercase tracking-wide mb-2 flex items-center gap-1">
<Clock className="w-3 h-3" /> Próximos 14 días ({upcomingDeadlines.length})
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
{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-gray-50 border border-gray-100 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-gray-800 truncate">{p.name}</p>
<p className="text-xs text-gray-500">{p.progress}% completado</p>
</div>
<Badge
variant="outline"
className={`text-xs ${
days <= 3 ? 'border-red-300 text-red-600' :
days <= 7 ? 'border-amber-300 text-amber-600' :
'border-gray-300 text-gray-600'
}`}
>
{days}d
</Badge>
</div>
);
})}
</div>
</div>
)}
{/* Resumen de fechas */}
<div className="pt-3 border-t border-gray-100">
<div className="flex flex-wrap gap-4 text-xs text-gray-500">
<span className="flex items-center gap-1">
<div className="w-2 h-2 rounded-full bg-red-500" />
Retrasados: {overdueProjects.length}
</span>
<span className="flex items-center gap-1">
<div className="w-2 h-2 rounded-full bg-amber-500" />
Vencen en 7 días: {upcomingDeadlines.filter(p => getDaysRemaining(p.endDate) <= 7).length}
</span>
<span className="flex items-center gap-1">
<div className="w-2 h-2 rounded-full bg-green-500" />
En tiempo: {projects.filter(p => p.status === "1" && getDaysRemaining(p.endDate) > 14).length}
</span>
</div>
</div>
</div>
</CardContent>
</Card>
)}
{/* Presupuesto detallado */}
<BudgetBarChart projects={[...projects].sort((a, b) => b.budget - a.budget)} title="Presupuesto vs Gastado por Proyecto" maxItems={6} />
</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-gray-300 mb-4" />
<h3 className="text-xl font-semibold text-gray-700">No hay proyectos activos</h3>
<p className="text-gray-500 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] },
];
// 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 (
<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" />} />
<StatCard title="Clientes Activos" value={stats.uniqueClients} subtitle="Con proyectos en curso" icon={<Users className="w-4 h-4 text-indigo-500" />} />
</div>
{/* Alertas */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className={`border-red-200 ${overdueProjects.length > 0 ? 'bg-red-50' : 'bg-gray-50 border-gray-200'}`}>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className={`text-sm font-medium flex items-center gap-2 ${overdueProjects.length > 0 ? 'text-red-700' : 'text-gray-500'}`}>
<AlertCircle className="w-4 h-4" /> Retrasados
</p>
<p className={`text-3xl font-bold mt-2 ${overdueProjects.length > 0 ? 'text-red-600' : 'text-gray-400'}`}>{overdueProjects.length}</p>
<p className={`text-xs ${overdueProjects.length > 0 ? 'text-red-600' : 'text-gray-400'}`}>Fecha superada</p>
</div>
</div>
</CardContent>
</Card>
<Card className={`border-orange-200 ${needsAttention > 0 ? 'bg-orange-50' : 'bg-gray-50 border-gray-200'}`}>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className={`text-sm font-medium flex items-center gap-2 ${needsAttention > 0 ? 'text-orange-700' : 'text-gray-500'}`}>
<Clock className="w-4 h-4" /> Requieren Atención
</p>
<p className={`text-3xl font-bold mt-2 ${needsAttention > 0 ? 'text-orange-600' : 'text-gray-400'}`}>{needsAttention}</p>
<p className={`text-xs ${needsAttention > 0 ? 'text-orange-600' : 'text-gray-400'}`}>Menos del 25%</p>
</div>
</div>
</CardContent>
</Card>
<Card className="border-green-200 bg-green-50">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-green-700 flex items-center gap-2">
<CheckCircle2 className="w-4 h-4" /> Próximos a Completar
</p>
<p className="text-3xl font-bold text-green-600 mt-2">{nearCompletion}</p>
<p className="text-xs text-green-600">75% o más</p>
</div>
</div>
</CardContent>
</Card>
</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" />
<MultiRadialChart data={topByProgress} title="Top 5 Proyectos por Progreso" valueLabel="%" />
</div>
{/* Próximos vencimientos */}
{upcomingDeadlines.length > 0 && (
<Card className="border-gray-200">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600 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-gray-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-gray-800 truncate">{p.name}</p>
<p className="text-xs text-gray-500">{p.progress}% completado</p>
</div>
<Badge variant="outline" className={days <= 3 ? 'border-red-300 text-red-600' : days <= 7 ? 'border-amber-300 text-amber-600' : 'border-gray-300'}>
{days}d
</Badge>
</div>
);
})}
</div>
</CardContent>
</Card>
)}
{/* Presupuesto */}
<BudgetBarChart projects={activeProjects.sort((a, b) => b.budget - a.budget)} title="Presupuesto vs Gastado por Proyecto" maxItems={6} />
</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-gray-300 mb-4" />
<h3 className="text-xl font-semibold text-gray-700">No hay proyectos cerrados</h3>
<p className="text-gray-500 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);
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 (
<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 bg-gradient-to-br from-green-50 to-emerald-50">
<CardContent className="py-6">
<div className="flex items-center gap-6">
<div className="p-4 bg-green-100 rounded-full">
<CheckCircle2 className="w-8 h-8 text-green-600" />
</div>
<div>
<p className="text-sm font-medium text-green-700">Dentro del Presupuesto</p>
<p className="text-4xl font-bold text-green-600">{withinBudget}</p>
<p className="text-sm text-green-600">{stats.total > 0 ? Math.round((withinBudget / stats.total) * 100) : 0}% de los cerrados</p>
</div>
</div>
</CardContent>
</Card>
<Card className={`border-red-200 ${overBudget > 0 ? 'bg-gradient-to-br from-red-50 to-orange-50' : 'bg-gray-50 border-gray-200'}`}>
<CardContent className="py-6">
<div className="flex items-center gap-6">
<div className={`p-4 rounded-full ${overBudget > 0 ? 'bg-red-100' : 'bg-gray-100'}`}>
<AlertCircle className={`w-8 h-8 ${overBudget > 0 ? 'text-red-600' : 'text-gray-400'}`} />
</div>
<div>
<p className={`text-sm font-medium ${overBudget > 0 ? 'text-red-700' : 'text-gray-500'}`}>Excedieron Presupuesto</p>
<p className={`text-4xl font-bold ${overBudget > 0 ? 'text-red-600' : 'text-gray-400'}`}>{overBudget}</p>
<p className={`text-sm ${overBudget > 0 ? 'text-red-600' : 'text-gray-400'}`}>{stats.total > 0 ? Math.round((overBudget / stats.total) * 100) : 0}% de los cerrados</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Gráficos */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<ProgressDonut data={budgetComplianceData} title="Cumplimiento Presupuestario" centerValue={`${stats.total > 0 ? Math.round((withinBudget / stats.total) * 100) : 0}%`} centerLabel="Éxito" />
<ProgressDonut data={completionData} title="Estado de Completitud" centerValue={fullyCompleted} centerLabel="Completos" />
<RadialMetric value={Math.min(100, 100 - stats.budgetConsumed)} title="Eficiencia Global" subtitle="ahorro promedio" color={COLORS.success} />
</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-gray-300 mb-4" />
<h3 className="text-xl font-semibold text-gray-700">No hay proyectos completados</h3>
<p className="text-gray-500 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);
// 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 (
<div className="space-y-6">
{/* Banner de éxito */}
<Card className="border-green-300 bg-gradient-to-r from-green-500 to-emerald-600 text-white">
<CardContent className="py-8">
<div className="flex items-center justify-center gap-8 md:gap-16 flex-wrap">
<div className="text-center">
<p className="text-5xl font-bold">{stats.total}</p>
<p className="text-sm text-green-100 mt-2">Proyectos al 100%</p>
</div>
<div className="h-16 w-px bg-green-400 hidden md:block" />
<div className="text-center">
<p className="text-5xl font-bold">{successRate}%</p>
<p className="text-sm text-green-100 mt-2">Tasa de éxito</p>
</div>
<div className="h-16 w-px bg-green-400 hidden md:block" />
<div className="text-center">
<p className="text-5xl font-bold">{formatCurrency(savings)}</p>
<p className="text-sm text-green-100 mt-2">Ahorro total</p>
</div>
</div>
</CardContent>
</Card>
{/* KPIs */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard title="Presupuesto Total" value={formatCurrency(stats.totalBudget)} subtitle="Gestionado" icon={<DollarSign className="w-4 h-4 text-green-500" />} />
<StatCard title="Total Gastado" value={formatCurrency(stats.totalSpent)} subtitle={`${stats.budgetConsumed}% usado`} icon={<PiggyBank className="w-4 h-4 text-blue-500" />} />
<StatCard title="Dentro Presupuesto" value={withinBudget} subtitle={`de ${stats.total} completados`} icon={<CheckCircle2 className="w-4 h-4 text-green-500" />} />
<StatCard title="Clientes Satisfechos" value={stats.uniqueClients} subtitle="Proyectos entregados" icon={<Users className="w-4 h-4 text-indigo-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" />
<ComparisonBarChart data={budgetData} title="Presupuesto por Proyecto Completado" formatter={(v) => formatCurrency(v)} />
</div>
{/* Detalle de presupuesto */}
<BudgetBarChart projects={completedProjects.sort((a, b) => b.budget - a.budget)} title="Detalle: Presupuesto vs Gastado" maxItems={6} />
</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-gray-200">
<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-gray-200">
<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-gradient-to-br from-gray-50 to-gray-100 flex items-center justify-center">
<div className="text-center">
<AlertCircle className="w-16 h-16 text-red-400 mx-auto mb-4" />
<p className="text-red-600 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-gradient-to-br from-gray-50 to-gray-100">
<header className="bg-white border-b border-gray-200 sticky top-0 z-10">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 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-gray-900">Estadisticas</h1>
<p className="text-sm text-gray-500">Analisis de {projects.length} proyectos en Dolibarr</p>
</div>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 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>
);
}