90 ? "bg-red-500" : spentPercentage > 70 ? "bg-yellow-500" : "bg-blue-500"
- }`}
- style={{ width: `${Math.min(spentPercentage, 100)}%` }}
- />
+
+
+
+
+
+
+
+
+
+
{value}%
+ {subtitle &&
{subtitle}
}
+
@@ -171,24 +268,159 @@ function BudgetStats({ projects, title = "Resumen de Presupuesto" }: BudgetStats
);
}
-// Funciones helper para calcular estadísticas
+// 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 (
+
+
+ {title}
+
+
+
+
+
+
+ `${(v / 1000).toFixed(0)}k`} fontSize={11} />
+
+ [`€${Number(value ?? 0).toLocaleString("es-ES")}`, name === 'presupuesto' ? 'Presupuesto' : 'Gastado']}
+ labelFormatter={(label, payload) => payload?.[0]?.payload?.fullName || label}
+ contentStyle={{ fontSize: '12px' }}
+ />
+
+
+ {data.map((entry, index) => (
+ |
+ ))}
+
+
+ {data.map((entry, index) => (
+ |
+ ))}
+
+
+
+
+
+
+ );
+}
+
+// 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 (
+
+
+ {title}
+
+
+
+
+
+
+
+
+ [formatter ? formatter(Number(value) || 0) : (value ?? 0), '']}
+ contentStyle={{ fontSize: '12px' }}
+ />
+
+ {data.map((entry, index) => (
+ |
+ ))}
+
+
+
+
+
+
+ );
+}
+
+// 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 (
+
+
+ {title}
+
+
+
+
+
+
+ [`${value ?? 0}${valueLabel}`, '']}
+ contentStyle={{ fontSize: '12px' }}
+ />
+
+
+
+
+
+
+ );
+}
+
+// ==================== 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,
- minBudget: 0,
- maxBudget: 0,
- uniqueClients: 0,
- oldestProject: null as string | null,
- newestProject: null as string | null,
- avgDuration: 0,
+ total: 0, avgProgress: 0, totalBudget: 0, totalSpent: 0,
+ avgBudget: 0, budgetConsumed: 0, uniqueClients: 0, avgDuration: 0,
};
}
@@ -197,18 +429,8 @@ function calculateStats(projects: Project[]) {
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();
@@ -217,554 +439,572 @@ function calculateStats(projects: Project[]) {
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,
- };
+ return { total, avgProgress, totalBudget, totalSpent, avgBudget, budgetConsumed, uniqueClients, avgDuration };
}
-// Panel de estadísticas para "Todos los proyectos"
-interface AllProjectsStatsProps {
- projects: Project[];
+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")}`;
}
-function AllProjectsStats({ projects }: AllProjectsStatsProps) {
+// 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 activeProjects = projects.filter((p) => p.status !== "2" && p.progress < 100);
- const completedProjects = projects.filter((p) => p.status === "2" || p.progress >= 100);
+ 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 (
- {/* Cards principales */}
-
-
}
- highlight
- />
-
0 ? Math.round((activeProjects.length / stats.total) * 100) : 0}% del total`}
- icon={}
- />
- 0 ? Math.round((completedProjects.length / stats.total) * 100) : 0}% del total`}
- icon={}
- />
- }
- />
+ {/* KPIs principales */}
+
+ } highlight />
+ } />
+ } />
+ } />
- {/* Segunda fila: Presupuesto */}
-
-
}
- />
-
}
- />
-
}
- />
-
}
- />
+ {/* Alertas de fechas */}
+ {(overdueProjects.length > 0 || upcomingDeadlines.length > 0) && (
+
+ {overdueProjects.length > 0 && (
+
+
+
+
+
+ Proyectos Retrasados
+
+
{overdueProjects.length}
+
Fecha límite superada
+
+
+ {overdueProjects.slice(0, 2).map(p => (
+
{p.name}
+ ))}
+
+
+
+
+ )}
+ {upcomingDeadlines.length > 0 && (
+
+
+
+
+
+ Próximos a Vencer
+
+
{upcomingDeadlines.length}
+
En los próximos 14 días
+
+
+ {upcomingDeadlines.slice(0, 2).map(p => (
+
+ {p.name} ({getDaysRemaining(p.endDate)}d)
+
+ ))}
+
+
+
+
+ )}
+
+ )}
+
+ {/* Gráficos principales */}
+
+
+
+
80 ? COLORS.danger : COLORS.primary} />
- {/* Tercera fila: Fechas y distribucion */}
-
- }
- />
- }
- />
- }
- />
-
-
- {/* Cuarta fila: Graficos/Distribuciones */}
+ {/* Comparativas */}
-
-
+ formatCurrency(v)} />
+
- {/* Info adicional: Rango de presupuestos */}
-
+ {/* Timeline de fechas - Próximos vencimientos y retrasados */}
+ {(upcomingDeadlines.length > 0 || overdueProjects.length > 0) && (
-
+
-
- Rango de Presupuestos
+ Timeline de Proyectos
-
-
-
Minimo
-
{stats.minBudget.toLocaleString("es-ES")}
-
-
-
-
Maximo
-
{stats.maxBudget.toLocaleString("es-ES")}
+
+ {/* Proyectos retrasados */}
+ {overdueProjects.length > 0 && (
+
+
+ Retrasados ({overdueProjects.length})
+
+
+ {overdueProjects.slice(0, 6).map((p, index) => {
+ const daysOverdue = Math.abs(getDaysRemaining(p.endDate));
+ return (
+
+
+
+
{p.name}
+
{p.progress}% completado
+
+
+ -{daysOverdue}d
+
+
+ );
+ })}
+
+
+ )}
+
+ {/* Próximos vencimientos */}
+ {upcomingDeadlines.length > 0 && (
+
+
+ Próximos 14 días ({upcomingDeadlines.length})
+
+
+ {upcomingDeadlines.slice(0, 6).map((p, index) => {
+ const days = getDaysRemaining(p.endDate);
+ return (
+
+
+
+
{p.name}
+
{p.progress}% completado
+
+
+ {days}d
+
+
+ );
+ })}
+
+
+ )}
+
+ {/* Resumen de fechas */}
+
+
+
+
+ Retrasados: {overdueProjects.length}
+
+
+
+ Vencen en 7 días: {upcomingDeadlines.filter(p => getDaysRemaining(p.endDate) <= 7).length}
+
+
+
+ En tiempo: {projects.filter(p => p.status === "1" && getDaysRemaining(p.endDate) > 14).length}
+
+
+ )}
-
-
-
-
- Proyectos por Progreso
-
-
-
-
-
-
- {projects.filter((p) => p.progress < 25).length}
-
-
0-25%
-
-
-
- {projects.filter((p) => p.progress >= 25 && p.progress < 50).length}
-
-
25-50%
-
-
-
- {projects.filter((p) => p.progress >= 50 && p.progress < 75).length}
-
-
50-75%
-
-
-
- {projects.filter((p) => p.progress >= 75).length}
-
-
75-100%
-
-
-
-
-
+ {/* Presupuesto detallado */}
+
b.budget - a.budget)} title="Presupuesto vs Gastado por Proyecto" maxItems={6} />
);
}
-// 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);
+// ==================== 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 (
-
+
No hay proyectos activos
-
Todos los proyectos estan completados o cerrados
+
Todos los proyectos están cerrados o en borrador
);
}
- // 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;
- });
+ 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;
- // Proyectos proximos a terminar
- const nearCompletion = activeProjects.filter((p) => p.progress >= 75);
+ 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 (
- {/* Cards principales */}
-
-
}
- highlight
- />
-
}
- />
-
}
- />
-
}
- />
+ {/* KPIs */}
+
+ } highlight />
+ } />
+ } />
+ } />
{/* Alertas */}
-
-
-
-
-
- Requieren Atencion
-
-
-
- {needsAttention.length}
-
- Proyectos con bajo progreso o alto consumo de presupuesto
-
-
-
-
-
-
-
-
- Proximos a Completar
-
-
-
- {nearCompletion.length}
-
- Proyectos con 75% o mas de progreso
-
-
-
-
-
- {/* Estadísticas adicionales */}
-
}
- />
-
}
- />
-
}
- />
+
0 ? 'bg-red-50' : 'bg-gray-50 border-gray-200'}`}>
+
+
+
+
0 ? 'text-red-700' : 'text-gray-500'}`}>
+ Retrasados
+
+
0 ? 'text-red-600' : 'text-gray-400'}`}>{overdueProjects.length}
+
0 ? 'text-red-600' : 'text-gray-400'}`}>Fecha superada
+
+
+
+
+
0 ? 'bg-orange-50' : 'bg-gray-50 border-gray-200'}`}>
+
+
+
+
0 ? 'text-orange-700' : 'text-gray-500'}`}>
+ Requieren Atención
+
+
0 ? 'text-orange-600' : 'text-gray-400'}`}>{needsAttention}
+
0 ? 'text-orange-600' : 'text-gray-400'}`}>Menos del 25%
+
+
+
+
+
+
+
+
+
+ Próximos a Completar
+
+
{nearCompletion}
+
75% o más
+
+
+
+
- {/* Distribucion y Budget */}
+ {/* Gráficos */}
- {/* Proyectos por progreso */}
-
-
-
-
- Distribucion por Progreso
-
-
-
-
-
-
- {activeProjects.filter((p) => p.progress < 25).length}
-
-
Inicio (0-25%)
+ {/* Próximos vencimientos */}
+ {upcomingDeadlines.length > 0 && (
+
+
+
+ Próximos Vencimientos (14 días)
+
+
+
+
+ {upcomingDeadlines.slice(0, 6).map((p, index) => {
+ const days = getDaysRemaining(p.endDate);
+ return (
+
+
+
+
{p.name}
+
{p.progress}% completado
+
+
+ {days}d
+
+
+ );
+ })}
-
-
- {activeProjects.filter((p) => p.progress >= 25 && p.progress < 50).length}
-
-
En curso (25-50%)
-
-
-
- {activeProjects.filter((p) => p.progress >= 50 && p.progress < 75).length}
-
-
Avanzado (50-75%)
-
-
-
- {activeProjects.filter((p) => p.progress >= 75).length}
-
-
Casi listo (75-99%)
-
-
-
-
+
+
+ )}
+
+ {/* Presupuesto */}
+
b.budget - a.budget)} title="Presupuesto vs Gastado por Proyecto" maxItems={6} />
);
}
-// Panel de estadísticas para "Proyectos Completados"
-interface CompletedProjectsStatsProps {
- projects: Project[];
+// ==================== 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 (
+
+
+
No hay proyectos cerrados
+
Aún no se ha cerrado ningún proyecto
+
+ );
+ }
+
+ 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 (
+
+ {/* KPIs */}
+
+ } highlight />
+ } />
+ 0 ? Math.round((withinBudget / stats.total) * 100) : 0}%`} subtitle={`${withinBudget} dentro de presupuesto`} icon={} />
+ } />
+
+
+ {/* Cumplimiento visual */}
+
+
+
+
+
+
+
+
+
Dentro del Presupuesto
+
{withinBudget}
+
{stats.total > 0 ? Math.round((withinBudget / stats.total) * 100) : 0}% de los cerrados
+
+
+
+
+
0 ? 'bg-gradient-to-br from-red-50 to-orange-50' : 'bg-gray-50 border-gray-200'}`}>
+
+
+
0 ? 'bg-red-100' : 'bg-gray-100'}`}>
+
0 ? 'text-red-600' : 'text-gray-400'}`} />
+
+
+
0 ? 'text-red-700' : 'text-gray-500'}`}>Excedieron Presupuesto
+
0 ? 'text-red-600' : 'text-gray-400'}`}>{overBudget}
+
0 ? 'text-red-600' : 'text-gray-400'}`}>{stats.total > 0 ? Math.round((overBudget / stats.total) * 100) : 0}% de los cerrados
+
+
+
+
+
+
+ {/* Gráficos */}
+
+
0 ? Math.round((withinBudget / stats.total) * 100) : 0}%`} centerLabel="Éxito" />
+
+
+
+
+ {/* Eficiencia por proyecto */}
+
+
+ b.budget - a.budget)} title="Presupuesto vs Gastado" maxItems={5} />
+
+
+ );
}
-function CompletedProjectsStats({ projects }: CompletedProjectsStatsProps) {
- // Filtrar proyectos completados (cerrados o 100% progreso)
- const completedProjects = projects.filter((p) => p.status === "2" || p.progress >= 100);
+// ==================== 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 (
-
+
No hay proyectos completados
-
Aun no se ha completado ningun proyecto
+
Aún no se ha completado ningún proyecto al 100%
);
}
- // Proyectos que terminaron dentro del presupuesto
- const withinBudget = completedProjects.filter((p) => p.spent <= p.budget);
- const overBudget = completedProjects.filter((p) => p.spent > p.budget);
+ 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;
- // 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;
+ 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 (
- {/* Cards principales */}
-
- }
- highlight
- />
- }
- />
- }
- />
- }
- />
-
-
- {/* Cumplimiento de presupuesto */}
-
-
-
-
-
- Dentro del Presupuesto
-
-
-
- {withinBudget.length}
-
- {stats.total > 0 ? Math.round((withinBudget.length / stats.total) * 100) : 0}% de los proyectos completados
-
-
-
-
-
-
-
-
- Excedieron Presupuesto
-
-
-
- {overBudget.length}
-
- {stats.total > 0 ? Math.round((overBudget.length / stats.total) * 100) : 0}% de los proyectos completados
-
-
-
-
-
- {/* Estadísticas adicionales */}
-
- }
- />
- }
- />
- }
- />
-
-
- {/* Distribucion y Budget */}
-
-
-
-
-
-
-
- Rango de Presupuestos Completados
-
-
-
-
-
-
Minimo
-
{stats.minBudget.toLocaleString("es-ES")}
-
-
-
-
Maximo
-
{stats.maxBudget.toLocaleString("es-ES")}
-
-
-
-
-
-
- {/* Timeline */}
-
-
-
-
- Rango de Fechas
-
-
-
-
+ {/* Banner de éxito */}
+
+
+
-
Primer Proyecto
-
{stats.oldestProject || "N/A"}
-
-
-
+
{stats.total}
+
Proyectos al 100%
+
-
Ultimo Proyecto
-
{stats.newestProject || "N/A"}
+
{successRate}%
+
Tasa de éxito
+
+
+
+
{formatCurrency(savings)}
+
Ahorro total
+
+ {/* KPIs */}
+
+ } />
+ } />
+ } />
+ } />
+
+
+ {/* Gráficos */}
+
+
+
formatCurrency(v)} />
+
+
+ {/* Detalle de presupuesto */}
+
b.budget - a.budget)} title="Detalle: Presupuesto vs Gastado" maxItems={6} />
);
}
-// Componente de skeleton para carga
+// ==================== SKELETON ====================
function StatisticsSkeleton() {
return (
-
+
{Array.from({ length: 4 }).map((_, i) => (
-
-
-
-
-
-
-
-
+
+
))}
-
- {Array.from({ length: 4 }).map((_, i) => (
+
+ {Array.from({ length: 3 }).map((_, i) => (
-
-
-
-
-
-
-
-
+
+
))}
@@ -772,7 +1012,7 @@ function StatisticsSkeleton() {
);
}
-// Componente principal de la página
+// ==================== COMPONENTE PRINCIPAL ====================
export default function StatisticsPage() {
const [projects, setProjects] = useState
([]);
const [loading, setLoading] = useState(true);
@@ -801,10 +1041,7 @@ export default function StatisticsPage() {
{error}
-
@@ -812,71 +1049,58 @@ export default function StatisticsPage() {
);
}
- const activeCount = projects.filter((p) => p.status !== "2" && p.progress < 100).length;
- const completedCount = projects.filter((p) => p.status === "2" || p.progress >= 100).length;
+ 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 (
- {/* Header */}
-
-
-
-
-
-
-
-
Estadisticas
-
- Analisis detallado de {projects.length} proyectos
-
-
+
+
+
+
+
+
+
Estadisticas
+
Analisis de {projects.length} proyectos en Dolibarr
- {/* Main content */}
{loading ? (
) : (
-
+
Todos
-
- {projects.length}
-
+ {projects.length}
Activos
-
- {activeCount}
-
+ {activeCount}
+
+
+
+ Cerrados
+ {closedCount}
Completados
-
- {completedCount}
-
+ {completedCount}
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
)}
diff --git a/components/ui/select.tsx b/components/ui/select.tsx
new file mode 100644
index 0000000..a45647c
--- /dev/null
+++ b/components/ui/select.tsx
@@ -0,0 +1,160 @@
+"use client"
+
+import * as React from "react"
+import * as SelectPrimitive from "@radix-ui/react-select"
+import { Check, ChevronDown, ChevronUp } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Select = SelectPrimitive.Root
+
+const SelectGroup = SelectPrimitive.Group
+
+const SelectValue = SelectPrimitive.Value
+
+const SelectTrigger = React.forwardRef<
+ React.ElementRef
,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+ span]:line-clamp-1",
+ className
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+
+))
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
+
+const SelectScrollUpButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
+
+const SelectScrollDownButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+SelectScrollDownButton.displayName =
+ SelectPrimitive.ScrollDownButton.displayName
+
+const SelectContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, position = "popper", ...props }, ref) => (
+
+
+
+
+ {children}
+
+
+
+
+))
+SelectContent.displayName = SelectPrimitive.Content.displayName
+
+const SelectLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SelectLabel.displayName = SelectPrimitive.Label.displayName
+
+const SelectItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+
+ {children}
+
+))
+SelectItem.displayName = SelectPrimitive.Item.displayName
+
+const SelectSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SelectSeparator.displayName = SelectPrimitive.Separator.displayName
+
+export {
+ Select,
+ SelectGroup,
+ SelectValue,
+ SelectTrigger,
+ SelectContent,
+ SelectLabel,
+ SelectItem,
+ SelectSeparator,
+ SelectScrollUpButton,
+ SelectScrollDownButton,
+}
diff --git a/package-lock.json b/package-lock.json
index 28724ae..1f9d506 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,6 +11,7 @@
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
+ "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
@@ -1272,6 +1273,12 @@
"node": ">=12.4.0"
}
},
+ "node_modules/@radix-ui/number": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
+ "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
+ "license": "MIT"
+ },
"node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
@@ -2223,6 +2230,105 @@
}
}
},
+ "node_modules/@radix-ui/react-select": {
+ "version": "2.2.6",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
+ "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/number": "1.1.1",
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-focus-guards": "1.1.3",
+ "@radix-ui/react-focus-scope": "1.1.7",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-popper": "1.2.8",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1",
+ "@radix-ui/react-use-previous": "1.1.1",
+ "@radix-ui/react-visually-hidden": "1.2.3",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.6.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-context": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
+ "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-primitive": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
+ "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+ "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-separator": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz",
@@ -2543,6 +2649,21 @@
}
}
},
+ "node_modules/@radix-ui/react-use-previous": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
+ "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-use-rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
diff --git a/package.json b/package.json
index abfeb4d..39dbb88 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,7 @@
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
+ "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",