2026-01-30 19:56:34 +00:00
"use client" ;
import { useState , useEffect } from "react" ;
import {
LayoutGrid ,
TrendingUp ,
CheckCircle2 ,
DollarSign ,
Clock ,
Calendar ,
Users ,
Target ,
PiggyBank ,
AlertCircle ,
BarChart3 ,
2026-01-30 21:44:22 +00:00
Lock ,
2026-01-30 19:56:34 +00:00
} from "lucide-react" ;
2026-01-30 21:44:22 +00:00
import {
PieChart ,
Pie ,
Cell ,
ResponsiveContainer ,
BarChart ,
Bar ,
XAxis ,
YAxis ,
Tooltip ,
Legend ,
RadialBarChart ,
RadialBar ,
CartesianGrid ,
} from "recharts" ;
2026-01-30 19:56:34 +00:00
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" ;
2026-01-30 21:44:22 +00:00
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" ] ;
2026-01-30 19:56:34 +00:00
2026-01-30 21:44:22 +00:00
// 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 ====================
2026-01-30 19:56:34 +00:00
interface StatCardProps {
title : string ;
value : string | number ;
subtitle? : string ;
icon : React.ReactNode ;
highlight? : boolean ;
}
2026-01-30 21:44:22 +00:00
function StatCard ( { title , value , subtitle , icon , highlight } : StatCardProps ) {
2026-01-30 19:56:34 +00:00
return (
2026-01-31 12:56:51 +00:00
< Card className = { ` border hover:shadow-md transition-shadow ${ highlight ? "ring-2 ring-blue-500/20" : "" } ` } >
2026-01-30 19:56:34 +00:00
< CardHeader className = "flex flex-row items-center justify-between pb-2" >
2026-01-31 12:56:51 +00:00
< CardTitle className = "text-sm font-medium text-muted-foreground" > { title } < / CardTitle >
2026-01-30 19:56:34 +00:00
{ icon }
< / CardHeader >
< CardContent >
2026-01-31 12:56:51 +00:00
< div className = "text-3xl font-bold text-foreground" > { value } < / div >
{ subtitle && < p className = "text-xs text-muted-foreground mt-1" > { subtitle } < / p > }
2026-01-30 19:56:34 +00:00
< / CardContent >
< / Card >
) ;
}
2026-01-30 21:44:22 +00:00
// ==================== COMPONENTES DE GRAFICOS ====================
// Pie Chart de distribucion por estado
interface StatusPieChartProps {
borradores : number ;
abiertos : number ;
cerrados : number ;
2026-01-30 19:56:34 +00:00
}
2026-01-30 21:44:22 +00:00
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 ) ;
2026-01-30 19:56:34 +00:00
2026-01-30 21:44:22 +00:00
const total = borradores + abiertos + cerrados ;
2026-01-30 19:56:34 +00:00
return (
2026-01-31 12:56:51 +00:00
< Card className = "border" >
2026-01-30 21:44:22 +00:00
< CardHeader className = "pb-2" >
2026-01-31 12:56:51 +00:00
< CardTitle className = "text-sm font-medium text-muted-foreground" > Distribucion por Estado < / CardTitle >
2026-01-30 19:56:34 +00:00
< / CardHeader >
2026-01-30 21:44:22 +00:00
< 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" >
2026-01-31 12:56:51 +00:00
< p className = "text-2xl font-bold text-foreground" > { total } < / p >
< p className = "text-xs text-muted-foreground" > Total < / p >
2026-01-30 21:44:22 +00:00
< / 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 } } / >
2026-01-31 12:56:51 +00:00
< span className = "text-xs text-muted-foreground" > { entry . name } : { entry . value } < / span >
2026-01-30 21:44:22 +00:00
< / 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 (
2026-01-31 12:56:51 +00:00
< Card className = "border" >
2026-01-30 21:44:22 +00:00
< CardHeader className = "pb-2" >
2026-01-31 12:56:51 +00:00
< CardTitle className = "text-sm font-medium text-muted-foreground" > { title } < / CardTitle >
2026-01-30 21:44:22 +00:00
< / 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" >
2026-01-31 12:56:51 +00:00
< p className = "text-2xl font-bold text-foreground" > { centerValue } < / p >
{ centerLabel && < p className = "text-xs text-muted-foreground" > { centerLabel } < / p > }
2026-01-30 19:56:34 +00:00
< / div >
< / div >
2026-01-30 21:44:22 +00:00
) }
< / 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 } } / >
2026-01-31 12:56:51 +00:00
< span className = "text-xs text-muted-foreground" > { entry . name } < / span >
2026-01-30 21:44:22 +00:00
< / div >
) ) }
< / div >
2026-01-30 19:56:34 +00:00
< / CardContent >
< / Card >
) ;
}
2026-01-30 21:44:22 +00:00
// 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 (
2026-01-31 12:56:51 +00:00
< Card className = "border" >
2026-01-30 21:44:22 +00:00
< CardHeader className = "pb-2" >
2026-01-31 12:56:51 +00:00
< CardTitle className = "text-sm font-medium text-muted-foreground" > { title } < / CardTitle >
2026-01-30 21:44:22 +00:00
< / 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
2026-01-30 22:41:25 +00:00
formatter = { ( value , name ) = > {
const label = name === 'presupuesto' ? 'Presupuesto' : name === 'gastado' ? 'Gastado' : String ( name ) ;
return [ ` € ${ Number ( value ? ? 0 ) . toLocaleString ( "es-ES" ) } ` , label ] ;
} }
2026-01-30 21:44:22 +00:00
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 (
2026-01-31 12:56:51 +00:00
< Card className = "border" >
2026-01-30 21:44:22 +00:00
< CardHeader className = "pb-2" >
2026-01-31 12:56:51 +00:00
< CardTitle className = "text-sm font-medium text-muted-foreground" > { title } < / CardTitle >
2026-01-30 21:44:22 +00:00
< / 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
2026-01-31 12:56:51 +00:00
background = { { fill : "hsl(var(--muted))" } }
2026-01-30 21:44:22 +00:00
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 ====================
2026-01-30 19:56:34 +00:00
function calculateStats ( projects : Project [ ] ) {
const total = projects . length ;
if ( total === 0 ) {
return {
2026-01-30 21:44:22 +00:00
total : 0 , avgProgress : 0 , totalBudget : 0 , totalSpent : 0 ,
avgBudget : 0 , budgetConsumed : 0 , uniqueClients : 0 , avgDuration : 0 ,
2026-01-30 19:56:34 +00:00
} ;
}
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 ;
2026-01-30 21:44:22 +00:00
return { total , avgProgress , totalBudget , totalSpent , avgBudget , budgetConsumed , uniqueClients , avgDuration } ;
2026-01-30 19:56:34 +00:00
}
2026-01-30 21:44:22 +00:00
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 ) ) ;
2026-01-30 19:56:34 +00:00
}
2026-01-30 21:44:22 +00:00
// ==================== PANEL: TODOS LOS PROYECTOS ====================
function AllProjectsStats ( { projects } : { projects : Project [ ] } ) {
2026-01-30 19:56:34 +00:00
const stats = calculateStats ( projects ) ;
2026-01-30 21:44:22 +00:00
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 ;
2026-02-06 19:06:25 +00:00
2026-01-30 21:44:22 +00:00
const overdueProjects = getOverdueProjects ( projects ) ;
2026-02-06 19:06:25 +00:00
const upcomingDeadlines = getUpcomingDeadlines ( projects , 14 ) ;
2026-01-30 21:44:22 +00:00
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 ] } ,
] ;
2026-01-30 19:56:34 +00:00
return (
< div className = "space-y-6" >
2026-01-30 21:44:22 +00:00
{ /* KPIs principales */ }
< div className = "grid grid-cols-2 md:grid-cols-4 gap-4" >
2026-02-06 19:06:25 +00:00
< 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"
/ >
2026-01-30 19:56:34 +00:00
< / div >
2026-02-06 19:06:25 +00:00
{ /* 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 */ }
2026-01-30 21:44:22 +00:00
{ ( overdueProjects . length > 0 || upcomingDeadlines . length > 0 ) && (
< div className = "grid grid-cols-1 md:grid-cols-2 gap-4" >
{ overdueProjects . length > 0 && (
2026-02-06 19:06:25 +00:00
< 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 >
) ;
} ) }
2026-01-30 21:44:22 +00:00
< / div >
< / CardContent >
< / Card >
) }
{ upcomingDeadlines . length > 0 && (
2026-02-06 19:06:25 +00:00
< 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 >
) ;
} ) }
2026-01-30 21:44:22 +00:00
< / div >
< / CardContent >
< / Card >
) }
< / div >
) }
2026-01-30 19:56:34 +00:00
< / div >
) ;
}
2026-01-30 21:44:22 +00:00
// ==================== PANEL: PROYECTOS ACTIVOS ====================
function ActiveProjectsStats ( { projects } : { projects : Project [ ] } ) {
const activeProjects = projects . filter ( p = > p . status === "1" && p . progress < 100 ) ;
2026-01-30 19:56:34 +00:00
const stats = calculateStats ( activeProjects ) ;
if ( activeProjects . length === 0 ) {
return (
2026-01-30 21:44:22 +00:00
< div className = "flex flex-col items-center justify-center py-16" >
2026-01-31 12:56:51 +00:00
< 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 >
2026-01-30 19:56:34 +00:00
< / div >
) ;
}
2026-01-30 21:44:22 +00:00
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 ] } ,
] ;
2026-01-30 19:56:34 +00:00
return (
< div className = "space-y-6" >
2026-01-30 21:44:22 +00:00
{ /* 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" / > } / >
2026-02-06 19:06:25 +00:00
< 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" / > } / >
2026-01-30 19:56:34 +00:00
< / div >
2026-01-30 21:44:22 +00:00
{ /* Gráficos */ }
2026-01-30 19:56:34 +00:00
< div className = "grid grid-cols-1 lg:grid-cols-2 gap-4" >
2026-01-30 21:44:22 +00:00
< ProgressDonut data = { progressData } title = "Estado de Avance" centerValue = { stats . total } centerLabel = "Activos" / >
2026-02-06 19:06:25 +00:00
< BudgetBarChart projects = { activeProjects . sort ( ( a , b ) = > b . budget - a . budget ) } title = "Presupuesto vs Gastado" maxItems = { 6 } / >
2026-01-30 19:56:34 +00:00
< / div >
2026-01-30 21:44:22 +00:00
{ /* Próximos vencimientos */ }
{ upcomingDeadlines . length > 0 && (
2026-01-31 12:56:51 +00:00
< Card className = "border" >
2026-01-30 21:44:22 +00:00
< CardHeader className = "pb-2" >
2026-01-31 12:56:51 +00:00
< CardTitle className = "text-sm font-medium text-muted-foreground flex items-center gap-2" >
2026-01-30 21:44:22 +00:00
< 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 (
2026-01-31 12:56:51 +00:00
< div key = { p . id } className = "flex items-center gap-3 p-3 bg-muted/50 rounded-lg" >
2026-01-30 21:44:22 +00:00
< div className = "w-2 h-2 rounded-full" style = { { backgroundColor : getProjectColor ( index ) } } / >
< div className = "flex-1 min-w-0" >
2026-01-31 12:56:51 +00:00
< p className = "text-sm font-medium text-foreground truncate" > { p . name } < / p >
< p className = "text-xs text-muted-foreground" > { p . progress } % completado < / p >
2026-01-30 21:44:22 +00:00
< / div >
2026-01-31 12:56:51 +00:00
< 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' : '' } >
2026-01-30 21:44:22 +00:00
{ days } d
< / Badge >
< / div >
) ;
} ) }
2026-01-30 19:56:34 +00:00
< / div >
2026-01-30 21:44:22 +00:00
< / CardContent >
< / Card >
) }
2026-01-30 19:56:34 +00:00
< / div >
) ;
}
2026-01-30 21:44:22 +00:00
// ==================== PANEL: PROYECTOS CERRADOS ====================
function ClosedProjectsStats ( { projects } : { projects : Project [ ] } ) {
const closedProjects = projects . filter ( p = > p . status === "2" ) ;
const stats = calculateStats ( closedProjects ) ;
2026-01-30 19:56:34 +00:00
2026-01-30 21:44:22 +00:00
if ( closedProjects . length === 0 ) {
2026-01-30 19:56:34 +00:00
return (
2026-01-30 21:44:22 +00:00
< div className = "flex flex-col items-center justify-center py-16" >
2026-01-31 12:56:51 +00:00
< 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 >
2026-01-30 19:56:34 +00:00
< / div >
) ;
}
2026-01-30 21:44:22 +00:00
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 ) ;
2026-02-06 19:06:25 +00:00
// 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 } ,
2026-01-30 21:44:22 +00:00
] . 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 ) ,
} ;
} ) ;
2026-01-30 19:56:34 +00:00
return (
< div className = "space-y-6" >
2026-01-30 21:44:22 +00:00
{ /* 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" / > } / >
2026-01-30 19:56:34 +00:00
< / div >
2026-01-30 21:44:22 +00:00
{ /* Cumplimiento visual */ }
2026-01-30 19:56:34 +00:00
< div className = "grid grid-cols-1 md:grid-cols-2 gap-4" >
2026-01-31 12:56:51 +00:00
< 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" >
2026-01-30 21:44:22 +00:00
< CardContent className = "py-6" >
< div className = "flex items-center gap-6" >
2026-01-31 12:56:51 +00:00
< 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" / >
2026-01-30 21:44:22 +00:00
< / div >
< div >
2026-01-31 12:56:51 +00:00
< 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 >
2026-01-30 21:44:22 +00:00
< / div >
< / div >
2026-01-30 19:56:34 +00:00
< / CardContent >
< / Card >
2026-01-31 12:56:51 +00:00
< 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' } ` } >
2026-01-30 21:44:22 +00:00
< CardContent className = "py-6" >
< div className = "flex items-center gap-6" >
2026-01-31 12:56:51 +00:00
< 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' } ` } / >
2026-01-30 21:44:22 +00:00
< / div >
< div >
2026-01-31 12:56:51 +00:00
< 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 >
2026-01-30 21:44:22 +00:00
< / div >
< / div >
2026-01-30 19:56:34 +00:00
< / CardContent >
< / Card >
< / div >
2026-01-30 21:44:22 +00:00
{ /* Gráficos */ }
2026-02-06 19:06:25 +00:00
< div className = "grid grid-cols-1 lg:grid-cols-2 gap-4" >
2026-01-30 21:44:22 +00:00
< ProgressDonut data = { budgetComplianceData } title = "Cumplimiento Presupuestario" centerValue = { ` ${ stats . total > 0 ? Math . round ( ( withinBudget / stats . total ) * 100 ) : 0 } % ` } centerLabel = "Éxito" / >
2026-02-06 19:06:25 +00:00
< ProgressDonut data = { durationData } title = "Duración de Proyectos" centerValue = { closedProjects . length } centerLabel = "Proyectos" / >
2026-01-30 19:56:34 +00:00
< / div >
2026-01-30 21:44:22 +00:00
{ /* Eficiencia por proyecto */ }
2026-01-30 19:56:34 +00:00
< div className = "grid grid-cols-1 lg:grid-cols-2 gap-4" >
2026-01-30 21:44:22 +00:00
< 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 >
) ;
}
2026-01-30 19:56:34 +00:00
2026-01-30 21:44:22 +00:00
// ==================== 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" >
2026-01-31 12:56:51 +00:00
< 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 >
2026-01-30 19:56:34 +00:00
< / div >
2026-01-30 21:44:22 +00:00
) ;
}
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 ;
2026-01-30 19:56:34 +00:00
2026-01-30 21:44:22 +00:00
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" >
2026-02-06 19:06:25 +00:00
< 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" / > } / >
2026-01-30 21:44:22 +00:00
< / 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" / >
2026-02-06 19:06:25 +00:00
< BudgetBarChart projects = { completedProjects . sort ( ( a , b ) = > b . budget - a . budget ) } title = "Presupuesto vs Gastado" maxItems = { 6 } / >
2026-01-30 21:44:22 +00:00
< / div >
2026-01-30 19:56:34 +00:00
< / div >
) ;
}
2026-01-30 21:44:22 +00:00
// ==================== SKELETON ====================
2026-01-30 19:56:34 +00:00
function StatisticsSkeleton() {
return (
< div className = "space-y-6" >
2026-01-30 21:44:22 +00:00
< div className = "grid grid-cols-2 md:grid-cols-4 gap-4" >
2026-01-30 19:56:34 +00:00
{ Array . from ( { length : 4 } ) . map ( ( _ , i ) = > (
2026-01-31 12:56:51 +00:00
< Card key = { i } className = "border" >
2026-01-30 21:44:22 +00:00
< 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 >
2026-01-30 19:56:34 +00:00
< / Card >
) ) }
< / div >
2026-01-30 21:44:22 +00:00
< div className = "grid grid-cols-1 lg:grid-cols-3 gap-4" >
{ Array . from ( { length : 3 } ) . map ( ( _ , i ) = > (
2026-01-31 12:56:51 +00:00
< Card key = { i } className = "border" >
2026-01-30 21:44:22 +00:00
< CardHeader className = "pb-2" > < Skeleton className = "h-4 w-32" / > < / CardHeader >
< CardContent > < Skeleton className = "h-64 w-full" / > < / CardContent >
2026-01-30 19:56:34 +00:00
< / Card >
) ) }
< / div >
< / div >
) ;
}
2026-01-30 21:44:22 +00:00
// ==================== COMPONENTE PRINCIPAL ====================
2026-01-30 19:56:34 +00:00
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 (
2026-01-31 12:56:51 +00:00
< div className = "min-h-screen bg-background flex items-center justify-center" >
2026-01-30 19:56:34 +00:00
< div className = "text-center" >
2026-01-31 12:56:51 +00:00
< 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 >
2026-01-30 21:44:22 +00:00
< button onClick = { ( ) = > window . location . reload ( ) } className = "mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700" >
2026-01-30 19:56:34 +00:00
Reintentar
< / button >
< / div >
< / div >
) ;
}
2026-01-30 21:44:22 +00:00
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 ;
2026-01-30 19:56:34 +00:00
return (
2026-01-31 12:56:51 +00:00
< div className = "min-h-screen bg-background" >
< header className = "bg-card border-b sticky top-0 z-10" >
2026-01-31 13:50:07 +00:00
< div className = "px-6 py-6" >
2026-01-30 21:44:22 +00:00
< 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 >
2026-01-31 12:56:51 +00:00
< 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 >
2026-01-30 19:56:34 +00:00
< / div >
< / div >
< / div >
< / header >
2026-01-31 13:50:07 +00:00
< main className = "px-6 py-8" >
2026-01-30 19:56:34 +00:00
{ loading ? (
< StatisticsSkeleton / >
) : (
< Tabs defaultValue = "todos" className = "space-y-6" >
2026-01-30 21:44:22 +00:00
< TabsList className = "grid w-full grid-cols-4 lg:w-auto lg:inline-grid" >
2026-01-30 19:56:34 +00:00
< TabsTrigger value = "todos" className = "gap-2" >
< LayoutGrid className = "w-4 h-4" / >
< span className = "hidden sm:inline" > Todos < / span >
2026-01-30 21:44:22 +00:00
< Badge variant = "secondary" className = "ml-1" > { projects . length } < / Badge >
2026-01-30 19:56:34 +00:00
< / TabsTrigger >
< TabsTrigger value = "activos" className = "gap-2" >
< TrendingUp className = "w-4 h-4" / >
< span className = "hidden sm:inline" > Activos < / span >
2026-01-30 21:44:22 +00:00
< 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 >
2026-01-30 19:56:34 +00:00
< / TabsTrigger >
< TabsTrigger value = "completados" className = "gap-2" >
< CheckCircle2 className = "w-4 h-4" / >
< span className = "hidden sm:inline" > Completados < / span >
2026-01-30 21:44:22 +00:00
< Badge variant = "secondary" className = "ml-1 bg-green-100 text-green-700" > { completedCount } < / Badge >
2026-01-30 19:56:34 +00:00
< / TabsTrigger >
< / TabsList >
2026-01-30 21:44:22 +00:00
< 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 >
2026-01-30 19:56:34 +00:00
< / Tabs >
) }
< / main >
< / div >
) ;
}