1113 lines
48 KiB
TypeScript
1113 lines
48 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-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>
|
|
);
|
|
}
|