886 lines
33 KiB
TypeScript
886 lines
33 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import { useState, useEffect } from "react";
|
||
|
|
import {
|
||
|
|
LayoutGrid,
|
||
|
|
TrendingUp,
|
||
|
|
CheckCircle2,
|
||
|
|
DollarSign,
|
||
|
|
Clock,
|
||
|
|
Calendar,
|
||
|
|
Users,
|
||
|
|
Target,
|
||
|
|
PiggyBank,
|
||
|
|
FileText,
|
||
|
|
AlertCircle,
|
||
|
|
BarChart3,
|
||
|
|
} from "lucide-react";
|
||
|
|
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, ProjectStatus, STATUS_CONFIG } from "@/types/project";
|
||
|
|
|
||
|
|
// Componente para una card de estadística individual
|
||
|
|
interface StatCardProps {
|
||
|
|
title: string;
|
||
|
|
value: string | number;
|
||
|
|
subtitle?: string;
|
||
|
|
icon: React.ReactNode;
|
||
|
|
trend?: {
|
||
|
|
value: number;
|
||
|
|
label: string;
|
||
|
|
positive?: boolean;
|
||
|
|
};
|
||
|
|
highlight?: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
function StatCard({ title, value, subtitle, icon, trend, 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>}
|
||
|
|
{trend && (
|
||
|
|
<div className={`flex items-center gap-1 mt-2 text-xs ${trend.positive ? "text-green-600" : "text-red-600"}`}>
|
||
|
|
<span>{trend.positive ? "+" : ""}{trend.value}%</span>
|
||
|
|
<span className="text-gray-500">{trend.label}</span>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Componente para mostrar la distribución por estado
|
||
|
|
interface StatusDistributionProps {
|
||
|
|
projects: Project[];
|
||
|
|
}
|
||
|
|
|
||
|
|
function StatusDistribution({ projects }: StatusDistributionProps) {
|
||
|
|
const statusCounts: Record<ProjectStatus, number> = {
|
||
|
|
"0": projects.filter((p) => p.status === "0").length,
|
||
|
|
"1": projects.filter((p) => p.status === "1").length,
|
||
|
|
"2": projects.filter((p) => p.status === "2").length,
|
||
|
|
};
|
||
|
|
|
||
|
|
const total = projects.length;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Card className="border-gray-200">
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
|
||
|
|
<BarChart3 className="w-4 h-4" />
|
||
|
|
Distribucion por Estado
|
||
|
|
</CardTitle>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent className="space-y-4">
|
||
|
|
{(Object.keys(statusCounts) as ProjectStatus[]).map((status) => {
|
||
|
|
const count = statusCounts[status];
|
||
|
|
const percentage = total > 0 ? Math.round((count / total) * 100) : 0;
|
||
|
|
const config = STATUS_CONFIG[status];
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div key={status} className="space-y-2">
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<Badge variant="outline" className={config.getColorClasses()}>
|
||
|
|
{config.label}
|
||
|
|
</Badge>
|
||
|
|
<span className="text-sm font-medium text-gray-700">
|
||
|
|
{count} ({percentage}%)
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
<div className="w-full bg-gray-100 rounded-full h-2">
|
||
|
|
<div
|
||
|
|
className={`h-2 rounded-full transition-all ${
|
||
|
|
status === "0" ? "bg-gray-400" : status === "1" ? "bg-blue-500" : "bg-green-500"
|
||
|
|
}`}
|
||
|
|
style={{ width: `${percentage}%` }}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Componente para estadísticas de presupuesto
|
||
|
|
interface BudgetStatsProps {
|
||
|
|
projects: Project[];
|
||
|
|
title?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
function BudgetStats({ projects, title = "Resumen de Presupuesto" }: BudgetStatsProps) {
|
||
|
|
const totalBudget = projects.reduce((acc, p) => acc + p.budget, 0);
|
||
|
|
const totalSpent = projects.reduce((acc, p) => acc + p.spent, 0);
|
||
|
|
const remaining = totalBudget - totalSpent;
|
||
|
|
const spentPercentage = totalBudget > 0 ? Math.round((totalSpent / totalBudget) * 100) : 0;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Card className="border-gray-200">
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
|
||
|
|
<PiggyBank className="w-4 h-4" />
|
||
|
|
{title}
|
||
|
|
</CardTitle>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent className="space-y-4">
|
||
|
|
<div className="grid grid-cols-3 gap-4 text-center">
|
||
|
|
<div>
|
||
|
|
<p className="text-2xl font-bold text-gray-900">
|
||
|
|
{totalBudget >= 1000 ? `${(totalBudget / 1000).toFixed(0)}k` : totalBudget.toLocaleString("es-ES")}
|
||
|
|
</p>
|
||
|
|
<p className="text-xs text-gray-500">Presupuesto Total</p>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<p className="text-2xl font-bold text-blue-600">
|
||
|
|
{totalSpent >= 1000 ? `${(totalSpent / 1000).toFixed(0)}k` : totalSpent.toLocaleString("es-ES")}
|
||
|
|
</p>
|
||
|
|
<p className="text-xs text-gray-500">Gastado</p>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<p className={`text-2xl font-bold ${remaining >= 0 ? "text-green-600" : "text-red-600"}`}>
|
||
|
|
{remaining >= 1000 ? `${(remaining / 1000).toFixed(0)}k` : remaining.toLocaleString("es-ES")}
|
||
|
|
</p>
|
||
|
|
<p className="text-xs text-gray-500">Restante</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div className="space-y-2">
|
||
|
|
<div className="flex justify-between text-sm">
|
||
|
|
<span className="text-gray-600">Consumido</span>
|
||
|
|
<span className="font-medium">{spentPercentage}%</span>
|
||
|
|
</div>
|
||
|
|
<div className="w-full bg-gray-100 rounded-full h-3">
|
||
|
|
<div
|
||
|
|
className={`h-3 rounded-full transition-all ${
|
||
|
|
spentPercentage > 90 ? "bg-red-500" : spentPercentage > 70 ? "bg-yellow-500" : "bg-blue-500"
|
||
|
|
}`}
|
||
|
|
style={{ width: `${Math.min(spentPercentage, 100)}%` }}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Funciones helper para calcular estadísticas
|
||
|
|
function calculateStats(projects: Project[]) {
|
||
|
|
const total = projects.length;
|
||
|
|
|
||
|
|
if (total === 0) {
|
||
|
|
return {
|
||
|
|
total: 0,
|
||
|
|
avgProgress: 0,
|
||
|
|
totalBudget: 0,
|
||
|
|
totalSpent: 0,
|
||
|
|
avgBudget: 0,
|
||
|
|
budgetConsumed: 0,
|
||
|
|
minBudget: 0,
|
||
|
|
maxBudget: 0,
|
||
|
|
uniqueClients: 0,
|
||
|
|
oldestProject: null as string | null,
|
||
|
|
newestProject: null as string | null,
|
||
|
|
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 budgets = projects.map((p) => p.budget);
|
||
|
|
const minBudget = Math.min(...budgets);
|
||
|
|
const maxBudget = Math.max(...budgets);
|
||
|
|
|
||
|
|
const uniqueClients = new Set(projects.map((p) => p.client)).size;
|
||
|
|
|
||
|
|
const dates = projects.map((p) => new Date(p.startDate).getTime()).filter((d) => !isNaN(d));
|
||
|
|
const oldestProject = dates.length > 0 ? new Date(Math.min(...dates)).toLocaleDateString("es-ES") : null;
|
||
|
|
const newestProject = dates.length > 0 ? new Date(Math.max(...dates)).toLocaleDateString("es-ES") : null;
|
||
|
|
|
||
|
|
// Calcular duracion promedio en dias
|
||
|
|
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,
|
||
|
|
minBudget,
|
||
|
|
maxBudget,
|
||
|
|
uniqueClients,
|
||
|
|
oldestProject,
|
||
|
|
newestProject,
|
||
|
|
avgDuration,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// Panel de estadísticas para "Todos los proyectos"
|
||
|
|
interface AllProjectsStatsProps {
|
||
|
|
projects: Project[];
|
||
|
|
}
|
||
|
|
|
||
|
|
function AllProjectsStats({ projects }: AllProjectsStatsProps) {
|
||
|
|
const stats = calculateStats(projects);
|
||
|
|
const activeProjects = projects.filter((p) => p.status !== "2" && p.progress < 100);
|
||
|
|
const completedProjects = projects.filter((p) => p.status === "2" || p.progress >= 100);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-6">
|
||
|
|
{/* Cards principales */}
|
||
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||
|
|
<StatCard
|
||
|
|
title="Total Proyectos"
|
||
|
|
value={stats.total}
|
||
|
|
subtitle="En toda la plataforma"
|
||
|
|
icon={<LayoutGrid className="w-4 h-4 text-gray-400" />}
|
||
|
|
highlight
|
||
|
|
/>
|
||
|
|
<StatCard
|
||
|
|
title="Proyectos Activos"
|
||
|
|
value={activeProjects.length}
|
||
|
|
subtitle={`${stats.total > 0 ? Math.round((activeProjects.length / stats.total) * 100) : 0}% del total`}
|
||
|
|
icon={<TrendingUp className="w-4 h-4 text-blue-500" />}
|
||
|
|
/>
|
||
|
|
<StatCard
|
||
|
|
title="Proyectos Completados"
|
||
|
|
value={completedProjects.length}
|
||
|
|
subtitle={`${stats.total > 0 ? Math.round((completedProjects.length / stats.total) * 100) : 0}% del total`}
|
||
|
|
icon={<CheckCircle2 className="w-4 h-4 text-green-500" />}
|
||
|
|
/>
|
||
|
|
<StatCard
|
||
|
|
title="Progreso Medio"
|
||
|
|
value={`${stats.avgProgress}%`}
|
||
|
|
subtitle="De todos los proyectos"
|
||
|
|
icon={<Clock className="w-4 h-4 text-purple-500" />}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Segunda fila: Presupuesto */}
|
||
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||
|
|
<StatCard
|
||
|
|
title="Presupuesto Total"
|
||
|
|
value={`${(stats.totalBudget / 1000).toFixed(0)}k`}
|
||
|
|
subtitle="Suma de todos los proyectos"
|
||
|
|
icon={<DollarSign className="w-4 h-4 text-green-500" />}
|
||
|
|
/>
|
||
|
|
<StatCard
|
||
|
|
title="Gastado Total"
|
||
|
|
value={`${(stats.totalSpent / 1000).toFixed(0)}k`}
|
||
|
|
subtitle={`${stats.budgetConsumed}% del presupuesto`}
|
||
|
|
icon={<PiggyBank className="w-4 h-4 text-orange-500" />}
|
||
|
|
/>
|
||
|
|
<StatCard
|
||
|
|
title="Presupuesto Promedio"
|
||
|
|
value={`${(stats.avgBudget / 1000).toFixed(1)}k`}
|
||
|
|
subtitle="Por proyecto"
|
||
|
|
icon={<Target className="w-4 h-4 text-blue-500" />}
|
||
|
|
/>
|
||
|
|
<StatCard
|
||
|
|
title="Clientes Unicos"
|
||
|
|
value={stats.uniqueClients}
|
||
|
|
subtitle="Con proyectos activos"
|
||
|
|
icon={<Users className="w-4 h-4 text-indigo-500" />}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Tercera fila: Fechas y distribucion */}
|
||
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||
|
|
<StatCard
|
||
|
|
title="Duracion Promedio"
|
||
|
|
value={`${stats.avgDuration} dias`}
|
||
|
|
subtitle="Por proyecto"
|
||
|
|
icon={<Calendar className="w-4 h-4 text-teal-500" />}
|
||
|
|
/>
|
||
|
|
<StatCard
|
||
|
|
title="Proyecto mas Antiguo"
|
||
|
|
value={stats.oldestProject || "N/A"}
|
||
|
|
subtitle="Fecha de inicio"
|
||
|
|
icon={<FileText className="w-4 h-4 text-gray-500" />}
|
||
|
|
/>
|
||
|
|
<StatCard
|
||
|
|
title="Proyecto mas Reciente"
|
||
|
|
value={stats.newestProject || "N/A"}
|
||
|
|
subtitle="Fecha de inicio"
|
||
|
|
icon={<FileText className="w-4 h-4 text-blue-500" />}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Cuarta fila: Graficos/Distribuciones */}
|
||
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||
|
|
<StatusDistribution projects={projects} />
|
||
|
|
<BudgetStats projects={projects} />
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Info adicional: Rango de presupuestos */}
|
||
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
|
|
<Card className="border-gray-200">
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
|
||
|
|
<DollarSign className="w-4 h-4" />
|
||
|
|
Rango de Presupuestos
|
||
|
|
</CardTitle>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<div className="text-center">
|
||
|
|
<p className="text-sm text-gray-500">Minimo</p>
|
||
|
|
<p className="text-xl font-bold text-gray-900">{stats.minBudget.toLocaleString("es-ES")}</p>
|
||
|
|
</div>
|
||
|
|
<div className="flex-1 mx-4 h-px bg-gradient-to-r from-red-300 via-yellow-300 to-green-300" />
|
||
|
|
<div className="text-center">
|
||
|
|
<p className="text-sm text-gray-500">Maximo</p>
|
||
|
|
<p className="text-xl font-bold text-gray-900">{stats.maxBudget.toLocaleString("es-ES")}</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
<Card className="border-gray-200">
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
|
||
|
|
<AlertCircle className="w-4 h-4" />
|
||
|
|
Proyectos por Progreso
|
||
|
|
</CardTitle>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
<div className="grid grid-cols-4 gap-2 text-center">
|
||
|
|
<div>
|
||
|
|
<p className="text-lg font-bold text-red-600">
|
||
|
|
{projects.filter((p) => p.progress < 25).length}
|
||
|
|
</p>
|
||
|
|
<p className="text-xs text-gray-500">0-25%</p>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<p className="text-lg font-bold text-orange-600">
|
||
|
|
{projects.filter((p) => p.progress >= 25 && p.progress < 50).length}
|
||
|
|
</p>
|
||
|
|
<p className="text-xs text-gray-500">25-50%</p>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<p className="text-lg font-bold text-yellow-600">
|
||
|
|
{projects.filter((p) => p.progress >= 50 && p.progress < 75).length}
|
||
|
|
</p>
|
||
|
|
<p className="text-xs text-gray-500">50-75%</p>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<p className="text-lg font-bold text-green-600">
|
||
|
|
{projects.filter((p) => p.progress >= 75).length}
|
||
|
|
</p>
|
||
|
|
<p className="text-xs text-gray-500">75-100%</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Panel de estadísticas para "Proyectos Activos"
|
||
|
|
interface ActiveProjectsStatsProps {
|
||
|
|
projects: Project[];
|
||
|
|
}
|
||
|
|
|
||
|
|
function ActiveProjectsStats({ projects }: ActiveProjectsStatsProps) {
|
||
|
|
// Filtrar solo proyectos activos (no cerrados y no completados al 100%)
|
||
|
|
const activeProjects = projects.filter((p) => p.status !== "2" && p.progress < 100);
|
||
|
|
const stats = calculateStats(activeProjects);
|
||
|
|
|
||
|
|
if (activeProjects.length === 0) {
|
||
|
|
return (
|
||
|
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||
|
|
<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 estan completados o cerrados</p>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Proyectos que necesitan atencion (bajo progreso o alto consumo de presupuesto)
|
||
|
|
const needsAttention = activeProjects.filter((p) => {
|
||
|
|
const spentPercentage = p.budget > 0 ? (p.spent / p.budget) * 100 : 0;
|
||
|
|
return p.progress < 25 || spentPercentage > 80;
|
||
|
|
});
|
||
|
|
|
||
|
|
// Proyectos proximos a terminar
|
||
|
|
const nearCompletion = activeProjects.filter((p) => p.progress >= 75);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-6">
|
||
|
|
{/* Cards principales */}
|
||
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||
|
|
<StatCard
|
||
|
|
title="Proyectos Activos"
|
||
|
|
value={stats.total}
|
||
|
|
subtitle="En progreso actualmente"
|
||
|
|
icon={<TrendingUp className="w-4 h-4 text-blue-500" />}
|
||
|
|
highlight
|
||
|
|
/>
|
||
|
|
<StatCard
|
||
|
|
title="Progreso Promedio"
|
||
|
|
value={`${stats.avgProgress}%`}
|
||
|
|
subtitle="De proyectos activos"
|
||
|
|
icon={<Clock className="w-4 h-4 text-purple-500" />}
|
||
|
|
/>
|
||
|
|
<StatCard
|
||
|
|
title="Presupuesto Activo"
|
||
|
|
value={`${(stats.totalBudget / 1000).toFixed(0)}k`}
|
||
|
|
subtitle="En proyectos en curso"
|
||
|
|
icon={<DollarSign className="w-4 h-4 text-green-500" />}
|
||
|
|
/>
|
||
|
|
<StatCard
|
||
|
|
title="Presupuesto Consumido"
|
||
|
|
value={`${stats.budgetConsumed}%`}
|
||
|
|
subtitle={`${(stats.totalSpent / 1000).toFixed(0)}k gastado`}
|
||
|
|
icon={<PiggyBank className="w-4 h-4 text-orange-500" />}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Alertas */}
|
||
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
|
|
<Card className="border-orange-200 bg-orange-50">
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle className="text-sm font-medium text-orange-700 flex items-center gap-2">
|
||
|
|
<AlertCircle className="w-4 h-4" />
|
||
|
|
Requieren Atencion
|
||
|
|
</CardTitle>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
<p className="text-3xl font-bold text-orange-600">{needsAttention.length}</p>
|
||
|
|
<p className="text-xs text-orange-600 mt-1">
|
||
|
|
Proyectos con bajo progreso o alto consumo de presupuesto
|
||
|
|
</p>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
<Card className="border-green-200 bg-green-50">
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle className="text-sm font-medium text-green-700 flex items-center gap-2">
|
||
|
|
<CheckCircle2 className="w-4 h-4" />
|
||
|
|
Proximos a Completar
|
||
|
|
</CardTitle>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
<p className="text-3xl font-bold text-green-600">{nearCompletion.length}</p>
|
||
|
|
<p className="text-xs text-green-600 mt-1">
|
||
|
|
Proyectos con 75% o mas de progreso
|
||
|
|
</p>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Estadísticas adicionales */}
|
||
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
|
|
<StatCard
|
||
|
|
title="Clientes Activos"
|
||
|
|
value={stats.uniqueClients}
|
||
|
|
subtitle="Con proyectos en curso"
|
||
|
|
icon={<Users className="w-4 h-4 text-indigo-500" />}
|
||
|
|
/>
|
||
|
|
<StatCard
|
||
|
|
title="Duracion Promedio"
|
||
|
|
value={`${stats.avgDuration} dias`}
|
||
|
|
subtitle="De proyectos activos"
|
||
|
|
icon={<Calendar className="w-4 h-4 text-teal-500" />}
|
||
|
|
/>
|
||
|
|
<StatCard
|
||
|
|
title="Presupuesto Promedio"
|
||
|
|
value={`${(stats.avgBudget / 1000).toFixed(1)}k`}
|
||
|
|
subtitle="Por proyecto activo"
|
||
|
|
icon={<Target className="w-4 h-4 text-blue-500" />}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Distribucion y Budget */}
|
||
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||
|
|
<StatusDistribution projects={activeProjects} />
|
||
|
|
<BudgetStats projects={activeProjects} title="Presupuesto de Proyectos Activos" />
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Proyectos por progreso */}
|
||
|
|
<Card className="border-gray-200">
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
|
||
|
|
<BarChart3 className="w-4 h-4" />
|
||
|
|
Distribucion por Progreso
|
||
|
|
</CardTitle>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
<div className="grid grid-cols-4 gap-4 text-center">
|
||
|
|
<div className="p-4 bg-red-50 rounded-lg">
|
||
|
|
<p className="text-2xl font-bold text-red-600">
|
||
|
|
{activeProjects.filter((p) => p.progress < 25).length}
|
||
|
|
</p>
|
||
|
|
<p className="text-xs text-red-600 mt-1">Inicio (0-25%)</p>
|
||
|
|
</div>
|
||
|
|
<div className="p-4 bg-orange-50 rounded-lg">
|
||
|
|
<p className="text-2xl font-bold text-orange-600">
|
||
|
|
{activeProjects.filter((p) => p.progress >= 25 && p.progress < 50).length}
|
||
|
|
</p>
|
||
|
|
<p className="text-xs text-orange-600 mt-1">En curso (25-50%)</p>
|
||
|
|
</div>
|
||
|
|
<div className="p-4 bg-yellow-50 rounded-lg">
|
||
|
|
<p className="text-2xl font-bold text-yellow-600">
|
||
|
|
{activeProjects.filter((p) => p.progress >= 50 && p.progress < 75).length}
|
||
|
|
</p>
|
||
|
|
<p className="text-xs text-yellow-600 mt-1">Avanzado (50-75%)</p>
|
||
|
|
</div>
|
||
|
|
<div className="p-4 bg-green-50 rounded-lg">
|
||
|
|
<p className="text-2xl font-bold text-green-600">
|
||
|
|
{activeProjects.filter((p) => p.progress >= 75).length}
|
||
|
|
</p>
|
||
|
|
<p className="text-xs text-green-600 mt-1">Casi listo (75-99%)</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Panel de estadísticas para "Proyectos Completados"
|
||
|
|
interface CompletedProjectsStatsProps {
|
||
|
|
projects: Project[];
|
||
|
|
}
|
||
|
|
|
||
|
|
function CompletedProjectsStats({ projects }: CompletedProjectsStatsProps) {
|
||
|
|
// Filtrar proyectos completados (cerrados o 100% progreso)
|
||
|
|
const completedProjects = projects.filter((p) => p.status === "2" || p.progress >= 100);
|
||
|
|
const stats = calculateStats(completedProjects);
|
||
|
|
|
||
|
|
if (completedProjects.length === 0) {
|
||
|
|
return (
|
||
|
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||
|
|
<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">Aun no se ha completado ningun proyecto</p>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Proyectos que terminaron dentro del presupuesto
|
||
|
|
const withinBudget = completedProjects.filter((p) => p.spent <= p.budget);
|
||
|
|
const overBudget = completedProjects.filter((p) => p.spent > p.budget);
|
||
|
|
|
||
|
|
// Eficiencia promedio (presupuesto restante / presupuesto total)
|
||
|
|
const avgEfficiency =
|
||
|
|
completedProjects.length > 0
|
||
|
|
? Math.round(
|
||
|
|
completedProjects.reduce((acc, p) => {
|
||
|
|
if (p.budget === 0) return acc;
|
||
|
|
return acc + ((p.budget - p.spent) / p.budget) * 100;
|
||
|
|
}, 0) / completedProjects.length
|
||
|
|
)
|
||
|
|
: 0;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-6">
|
||
|
|
{/* Cards principales */}
|
||
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||
|
|
<StatCard
|
||
|
|
title="Proyectos Completados"
|
||
|
|
value={stats.total}
|
||
|
|
subtitle="Finalizados exitosamente"
|
||
|
|
icon={<CheckCircle2 className="w-4 h-4 text-green-500" />}
|
||
|
|
highlight
|
||
|
|
/>
|
||
|
|
<StatCard
|
||
|
|
title="Presupuesto Gestionado"
|
||
|
|
value={`${(stats.totalBudget / 1000).toFixed(0)}k`}
|
||
|
|
subtitle="En proyectos completados"
|
||
|
|
icon={<DollarSign className="w-4 h-4 text-green-500" />}
|
||
|
|
/>
|
||
|
|
<StatCard
|
||
|
|
title="Total Gastado"
|
||
|
|
value={`${(stats.totalSpent / 1000).toFixed(0)}k`}
|
||
|
|
subtitle={`${stats.budgetConsumed}% del presupuesto`}
|
||
|
|
icon={<PiggyBank className="w-4 h-4 text-blue-500" />}
|
||
|
|
/>
|
||
|
|
<StatCard
|
||
|
|
title="Eficiencia Promedio"
|
||
|
|
value={`${Math.max(0, avgEfficiency)}%`}
|
||
|
|
subtitle="Presupuesto ahorrado"
|
||
|
|
icon={<Target className="w-4 h-4 text-purple-500" />}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Cumplimiento de presupuesto */}
|
||
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
|
|
<Card className="border-green-200 bg-green-50">
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle className="text-sm font-medium text-green-700 flex items-center gap-2">
|
||
|
|
<CheckCircle2 className="w-4 h-4" />
|
||
|
|
Dentro del Presupuesto
|
||
|
|
</CardTitle>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
<p className="text-3xl font-bold text-green-600">{withinBudget.length}</p>
|
||
|
|
<p className="text-xs text-green-600 mt-1">
|
||
|
|
{stats.total > 0 ? Math.round((withinBudget.length / stats.total) * 100) : 0}% de los proyectos completados
|
||
|
|
</p>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
<Card className="border-red-200 bg-red-50">
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle className="text-sm font-medium text-red-700 flex items-center gap-2">
|
||
|
|
<AlertCircle className="w-4 h-4" />
|
||
|
|
Excedieron Presupuesto
|
||
|
|
</CardTitle>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
<p className="text-3xl font-bold text-red-600">{overBudget.length}</p>
|
||
|
|
<p className="text-xs text-red-600 mt-1">
|
||
|
|
{stats.total > 0 ? Math.round((overBudget.length / stats.total) * 100) : 0}% de los proyectos completados
|
||
|
|
</p>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Estadísticas adicionales */}
|
||
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
|
|
<StatCard
|
||
|
|
title="Clientes Atendidos"
|
||
|
|
value={stats.uniqueClients}
|
||
|
|
subtitle="Con proyectos completados"
|
||
|
|
icon={<Users className="w-4 h-4 text-indigo-500" />}
|
||
|
|
/>
|
||
|
|
<StatCard
|
||
|
|
title="Duracion Promedio"
|
||
|
|
value={`${stats.avgDuration} dias`}
|
||
|
|
subtitle="Por proyecto completado"
|
||
|
|
icon={<Calendar className="w-4 h-4 text-teal-500" />}
|
||
|
|
/>
|
||
|
|
<StatCard
|
||
|
|
title="Presupuesto Promedio"
|
||
|
|
value={`${(stats.avgBudget / 1000).toFixed(1)}k`}
|
||
|
|
subtitle="Por proyecto completado"
|
||
|
|
icon={<Target className="w-4 h-4 text-blue-500" />}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Distribucion y Budget */}
|
||
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||
|
|
<BudgetStats projects={completedProjects} title="Resumen Final de Presupuesto" />
|
||
|
|
|
||
|
|
<Card className="border-gray-200">
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
|
||
|
|
<DollarSign className="w-4 h-4" />
|
||
|
|
Rango de Presupuestos Completados
|
||
|
|
</CardTitle>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<div className="text-center">
|
||
|
|
<p className="text-sm text-gray-500">Minimo</p>
|
||
|
|
<p className="text-xl font-bold text-gray-900">{stats.minBudget.toLocaleString("es-ES")}</p>
|
||
|
|
</div>
|
||
|
|
<div className="flex-1 mx-4 h-px bg-gradient-to-r from-green-300 to-green-500" />
|
||
|
|
<div className="text-center">
|
||
|
|
<p className="text-sm text-gray-500">Maximo</p>
|
||
|
|
<p className="text-xl font-bold text-gray-900">{stats.maxBudget.toLocaleString("es-ES")}</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Timeline */}
|
||
|
|
<Card className="border-gray-200">
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
|
||
|
|
<Calendar className="w-4 h-4" />
|
||
|
|
Rango de Fechas
|
||
|
|
</CardTitle>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<div className="text-center">
|
||
|
|
<p className="text-sm text-gray-500">Primer Proyecto</p>
|
||
|
|
<p className="text-lg font-bold text-gray-900">{stats.oldestProject || "N/A"}</p>
|
||
|
|
</div>
|
||
|
|
<div className="flex-1 mx-4 flex items-center justify-center">
|
||
|
|
<div className="h-px w-full bg-gradient-to-r from-blue-300 to-purple-500" />
|
||
|
|
</div>
|
||
|
|
<div className="text-center">
|
||
|
|
<p className="text-sm text-gray-500">Ultimo Proyecto</p>
|
||
|
|
<p className="text-lg font-bold text-gray-900">{stats.newestProject || "N/A"}</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Componente de skeleton para carga
|
||
|
|
function StatisticsSkeleton() {
|
||
|
|
return (
|
||
|
|
<div className="space-y-6">
|
||
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
||
|
|
<Card key={i} className="border-gray-200">
|
||
|
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||
|
|
<Skeleton className="h-4 w-24" />
|
||
|
|
<Skeleton className="h-4 w-4" />
|
||
|
|
</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 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
||
|
|
<Card key={i} className="border-gray-200">
|
||
|
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||
|
|
<Skeleton className="h-4 w-24" />
|
||
|
|
<Skeleton className="h-4 w-4" />
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
<Skeleton className="h-8 w-16 mb-2" />
|
||
|
|
<Skeleton className="h-3 w-32" />
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Componente principal de la página
|
||
|
|
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 transition-colors"
|
||
|
|
>
|
||
|
|
Reintentar
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
const activeCount = projects.filter((p) => p.status !== "2" && p.progress < 100).length;
|
||
|
|
const completedCount = projects.filter((p) => p.status === "2" || p.progress >= 100).length;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">
|
||
|
|
{/* Header */}
|
||
|
|
<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">
|
||
|
|
<div className="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 detallado de {projects.length} proyectos
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</header>
|
||
|
|
|
||
|
|
{/* Main content */}
|
||
|
|
<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-3 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="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="completados">
|
||
|
|
<CompletedProjectsStats projects={projects} />
|
||
|
|
</TabsContent>
|
||
|
|
</Tabs>
|
||
|
|
)}
|
||
|
|
</main>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|