891 lines
37 KiB
TypeScript
891 lines
37 KiB
TypeScript
"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;
|
|
|
|
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 (
|
|
<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={`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>
|
|
|
|
{/* 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 && (
|
|
<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 && (
|
|
<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" />} />
|
|
<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" />
|
|
<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);
|
|
|
|
// 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 */}
|
|
<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" />
|
|
<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">
|
|
<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" />
|
|
<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>
|
|
);
|
|
}
|