diff --git a/app/estadisticas/page.tsx b/app/estadisticas/page.tsx new file mode 100644 index 0000000..1c71af9 --- /dev/null +++ b/app/estadisticas/page.tsx @@ -0,0 +1,5 @@ +import StatisticsPage from "@/components/statistics/statistics-page"; + +export default function Estadisticas() { + return ; +} diff --git a/components/statistics/charts.tsx b/components/statistics/charts.tsx new file mode 100644 index 0000000..11f7ecd --- /dev/null +++ b/components/statistics/charts.tsx @@ -0,0 +1,591 @@ +"use client"; + +import { + PieChart, + Pie, + Cell, + ResponsiveContainer, + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + Legend, + RadialBarChart, + RadialBar, + AreaChart, + Area, + CartesianGrid, + LineChart, + Line, +} from "recharts"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +// Colores del tema +const COLORS = { + primary: "#3b82f6", // blue-500 + secondary: "#8b5cf6", // purple-500 + success: "#22c55e", // green-500 + warning: "#f59e0b", // amber-500 + danger: "#ef4444", // red-500 + info: "#06b6d4", // cyan-500 + gray: "#6b7280", // gray-500 + orange: "#f97316", // orange-500 + pink: "#ec4899", // pink-500 + indigo: "#6366f1", // indigo-500 +}; + +const STATUS_COLORS = { + borrador: "#9ca3af", // gray-400 + abierto: "#3b82f6", // blue-500 + cerrado: "#22c55e", // green-500 +}; + +const PROGRESS_COLORS = ["#ef4444", "#f97316", "#f59e0b", "#22c55e"]; + +// Tooltip personalizado +interface CustomTooltipProps { + active?: boolean; + payload?: Array<{ name: string; value: number; color?: string }>; + label?: string; + formatter?: (value: number) => string; +} + +function CustomTooltip({ active, payload, label, formatter }: CustomTooltipProps) { + if (active && payload && payload.length) { + return ( +
+ {label &&

{label}

} + {payload.map((entry, index) => ( +

+ {entry.name}: {formatter ? formatter(entry.value) : entry.value} +

+ ))} +
+ ); + } + return null; +} + +// ==================== PIE CHART ==================== +interface PieChartData { + name: string; + value: number; + color?: string; +} + +interface StatusPieChartProps { + data: PieChartData[]; + title: string; + subtitle?: string; +} + +export function StatusPieChart({ data, title, subtitle }: StatusPieChartProps) { + const total = data.reduce((acc, item) => acc + item.value, 0); + const colors = [STATUS_COLORS.borrador, STATUS_COLORS.abierto, STATUS_COLORS.cerrado]; + + return ( + + + {title} + {subtitle &&

{subtitle}

} +
+ +
+ + + `${name} ${((percent ?? 0) * 100).toFixed(0)}%`} + labelLine={false} + > + {data.map((entry, index) => ( + + ))} + + } /> + + +
+
+ {data.map((entry, index) => ( +
+
+ + {entry.name}: {entry.value} + +
+ ))} +
+ + + ); +} + +// ==================== DONUT CHART ==================== +interface DonutChartProps { + data: PieChartData[]; + title: string; + centerLabel?: string; + centerValue?: string | number; +} + +export function DonutChart({ data, title, centerLabel, centerValue }: DonutChartProps) { + return ( + + + {title} + + +
+ + + + {data.map((entry, index) => ( + + ))} + + } /> + + + {centerLabel && ( +
+ {centerValue} + {centerLabel} +
+ )} +
+
+ {data.map((entry, index) => ( +
+
+ {entry.name} +
+ ))} +
+ + + ); +} + +// ==================== RADIAL BAR CHART ==================== +interface RadialBarData { + name: string; + value: number; + fill: string; +} + +interface RadialProgressChartProps { + data: RadialBarData[]; + title: string; + subtitle?: string; +} + +export function RadialProgressChart({ data, title, subtitle }: RadialProgressChartProps) { + return ( + + + {title} + {subtitle &&

{subtitle}

} +
+ +
+ + + + `${v}%`} />} /> + + + +
+
+
+ ); +} + +// ==================== SINGLE RADIAL GAUGE ==================== +interface RadialGaugeProps { + value: number; + maxValue?: number; + title: string; + subtitle?: string; + color?: string; +} + +export function RadialGauge({ value, maxValue = 100, title, subtitle, color = COLORS.primary }: RadialGaugeProps) { + const percentage = Math.min((value / maxValue) * 100, 100); + const data = [ + { name: title, value: percentage, fill: color }, + ]; + + return ( + + + {title} + {subtitle &&

{subtitle}

} +
+ +
+ + + + + +
+ {value}% +
+
+
+
+ ); +} + +// ==================== BAR CHART ==================== +interface BarChartData { + name: string; + value: number; + value2?: number; +} + +interface HorizontalBarChartProps { + data: BarChartData[]; + title: string; + subtitle?: string; + dataKey?: string; + color?: string; + formatter?: (value: number) => string; +} + +export function HorizontalBarChart({ + data, + title, + subtitle, + dataKey = "value", + color = COLORS.primary, + formatter, +}: HorizontalBarChartProps) { + return ( + + + {title} + {subtitle &&

{subtitle}

} +
+ +
+ + + + + + } /> + + + +
+
+
+ ); +} + +// ==================== VERTICAL BAR CHART ==================== +interface VerticalBarChartProps { + data: BarChartData[]; + title: string; + subtitle?: string; + color?: string; + secondaryColor?: string; + showSecondary?: boolean; + formatter?: (value: number) => string; +} + +export function VerticalBarChart({ + data, + title, + subtitle, + color = COLORS.primary, + secondaryColor = COLORS.secondary, + showSecondary = false, + formatter, +}: VerticalBarChartProps) { + return ( + + + {title} + {subtitle &&

{subtitle}

} +
+ +
+ + + + + + } /> + + {showSecondary && ( + + )} + {showSecondary && } + + +
+
+
+ ); +} + +// ==================== AREA CHART ==================== +interface AreaChartData { + name: string; + value: number; + value2?: number; +} + +interface AreaChartProps { + data: AreaChartData[]; + title: string; + subtitle?: string; + color?: string; + gradientId?: string; +} + +export function AreaChartComponent({ + data, + title, + subtitle, + color = COLORS.primary, + gradientId = "colorValue", +}: AreaChartProps) { + return ( + + + {title} + {subtitle &&

{subtitle}

} +
+ +
+ + + + + + + + + + + + } /> + + + +
+
+
+ ); +} + +// ==================== LINE CHART ==================== +interface LineChartData { + name: string; + presupuesto: number; + gastado: number; +} + +interface BudgetLineChartProps { + data: LineChartData[]; + title: string; + subtitle?: string; +} + +export function BudgetLineChart({ data, title, subtitle }: BudgetLineChartProps) { + return ( + + + {title} + {subtitle &&

{subtitle}

} +
+ +
+ + + + + `${(v / 1000).toFixed(0)}k`} /> + `€${v.toLocaleString("es-ES")}`} />} + /> + + + + + +
+
+
+ ); +} + +// ==================== STACKED BAR CHART ==================== +interface StackedBarData { + name: string; + completado: number; + restante: number; +} + +interface StackedProgressChartProps { + data: StackedBarData[]; + title: string; + subtitle?: string; +} + +export function StackedProgressChart({ data, title, subtitle }: StackedProgressChartProps) { + return ( + + + {title} + {subtitle &&

{subtitle}

} +
+ +
+ + + + `${v}%`} /> + + `${v}%`} />} /> + + + + + +
+
+
+ ); +} + +// ==================== MULTI RADIAL CHART ==================== +interface MultiRadialData { + name: string; + value: number; + fill: string; +} + +interface MultiRadialChartProps { + data: MultiRadialData[]; + title: string; + subtitle?: string; +} + +export function MultiRadialChart({ data, title, subtitle }: MultiRadialChartProps) { + return ( + + + {title} + {subtitle &&

{subtitle}

} +
+ +
+ + + + `${v}%`} />} /> + + + +
+
+
+ ); +} + +export { COLORS, STATUS_COLORS, PROGRESS_COLORS }; diff --git a/components/statistics/statistics-page.tsx b/components/statistics/statistics-page.tsx new file mode 100644 index 0000000..f51ae17 --- /dev/null +++ b/components/statistics/statistics-page.tsx @@ -0,0 +1,885 @@ +"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 ( + + + {title} + {icon} + + +
{value}
+ {subtitle &&

{subtitle}

} + {trend && ( +
+ {trend.positive ? "+" : ""}{trend.value}% + {trend.label} +
+ )} +
+
+ ); +} + +// Componente para mostrar la distribución por estado +interface StatusDistributionProps { + projects: Project[]; +} + +function StatusDistribution({ projects }: StatusDistributionProps) { + const statusCounts: Record = { + "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 ( + + + + + Distribucion por Estado + + + + {(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 ( +
+
+ + {config.label} + + + {count} ({percentage}%) + +
+
+
+
+
+ ); + })} + + + ); +} + +// 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 ( + + + + + {title} + + + +
+
+

+ {totalBudget >= 1000 ? `${(totalBudget / 1000).toFixed(0)}k` : totalBudget.toLocaleString("es-ES")} +

+

Presupuesto Total

+
+
+

+ {totalSpent >= 1000 ? `${(totalSpent / 1000).toFixed(0)}k` : totalSpent.toLocaleString("es-ES")} +

+

Gastado

+
+
+

= 0 ? "text-green-600" : "text-red-600"}`}> + {remaining >= 1000 ? `${(remaining / 1000).toFixed(0)}k` : remaining.toLocaleString("es-ES")} +

+

Restante

+
+
+
+
+ Consumido + {spentPercentage}% +
+
+
90 ? "bg-red-500" : spentPercentage > 70 ? "bg-yellow-500" : "bg-blue-500" + }`} + style={{ width: `${Math.min(spentPercentage, 100)}%` }} + /> +
+
+ + + ); +} + +// 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 ( +
+ {/* 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={} + /> + } + /> +
+ + {/* Segunda fila: Presupuesto */} +
+ } + /> + } + /> + } + /> + } + /> +
+ + {/* Tercera fila: Fechas y distribucion */} +
+ } + /> + } + /> + } + /> +
+ + {/* Cuarta fila: Graficos/Distribuciones */} +
+ + +
+ + {/* Info adicional: Rango de presupuestos */} +
+ + + + + Rango de Presupuestos + + + +
+
+

Minimo

+

{stats.minBudget.toLocaleString("es-ES")}

+
+
+
+

Maximo

+

{stats.maxBudget.toLocaleString("es-ES")}

+
+
+ + + + + + + + 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%

+
+
+
+
+
+
+ ); +} + +// 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 ( +
+ +

No hay proyectos activos

+

Todos los proyectos estan completados o cerrados

+
+ ); + } + + // 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 ( +
+ {/* Cards principales */} +
+ } + 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 */} +
+ } + /> + } + /> + } + /> +
+ + {/* Distribucion y Budget */} +
+ + +
+ + {/* Proyectos por progreso */} + + + + + Distribucion por Progreso + + + +
+
+

+ {activeProjects.filter((p) => p.progress < 25).length} +

+

Inicio (0-25%)

+
+
+

+ {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%)

+
+
+
+
+
+ ); +} + +// 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 ( +
+ +

No hay proyectos completados

+

Aun no se ha completado ningun proyecto

+
+ ); + } + + // 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 ( +
+ {/* 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 + + + +
+
+

Primer Proyecto

+

{stats.oldestProject || "N/A"}

+
+
+
+
+
+

Ultimo Proyecto

+

{stats.newestProject || "N/A"}

+
+
+ + +
+ ); +} + +// Componente de skeleton para carga +function StatisticsSkeleton() { + return ( +
+
+ {Array.from({ length: 4 }).map((_, i) => ( + + + + + + + + + + + ))} +
+
+ {Array.from({ length: 4 }).map((_, i) => ( + + + + + + + + + + + ))} +
+
+ ); +} + +// Componente principal de la página +export default function StatisticsPage() { + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
+ +

{error}

+ +
+
+ ); + } + + 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 ( +
+ {/* Header */} +
+
+
+
+
+ +
+
+

Estadisticas

+

+ Analisis detallado de {projects.length} proyectos +

+
+
+
+
+
+ + {/* Main content */} +
+ {loading ? ( + + ) : ( + + + + + Todos + + {projects.length} + + + + + Activos + + {activeCount} + + + + + Completados + + {completedCount} + + + + + + + + + + + + + + + + + )} +
+
+ ); +} diff --git a/components/ui/tabs.tsx b/components/ui/tabs.tsx new file mode 100644 index 0000000..26eb109 --- /dev/null +++ b/components/ui/tabs.tsx @@ -0,0 +1,55 @@ +"use client" + +import * as React from "react" +import * as TabsPrimitive from "@radix-ui/react-tabs" + +import { cn } from "@/lib/utils" + +const Tabs = TabsPrimitive.Root + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/package-lock.json b/package-lock.json index 2175811..28724ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -21,6 +22,7 @@ "next-themes": "^0.4.6", "react": "19.2.0", "react-dom": "19.2.0", + "recharts": "^3.7.0", "tailwind-merge": "^3.4.0", "tailwindcss-animate": "^1.0.7" }, @@ -2262,6 +2264,92 @@ } } }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "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-tabs/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-tabs/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-tabs/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-tooltip": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", @@ -2561,6 +2649,42 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.3", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz", + "integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -2568,6 +2692,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -2873,6 +3009,69 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2924,6 +3123,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.48.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz", @@ -4156,6 +4361,127 @@ "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -4235,6 +4561,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -4539,6 +4871,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz", + "integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -4986,6 +5328,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5481,6 +5829,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -5523,6 +5881,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -7206,9 +7573,31 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -7299,6 +7688,51 @@ "node": ">=8.10.0" } }, + "node_modules/recharts": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz", + "integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -7343,6 +7777,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -8057,6 +8497,12 @@ "node": ">=0.8" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -8454,6 +8900,28 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 5e8afe9..abfeb4d 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -25,6 +26,7 @@ "next-themes": "^0.4.6", "react": "19.2.0", "react-dom": "19.2.0", + "recharts": "^3.7.0", "tailwind-merge": "^3.4.0", "tailwindcss-animate": "^1.0.7" }, diff --git a/scripts/seed-projects-direct.js b/scripts/seed-projects-direct.js new file mode 100644 index 0000000..da8877f --- /dev/null +++ b/scripts/seed-projects-direct.js @@ -0,0 +1,385 @@ +#!/usr/bin/env node + +/** + * Script para crear proyectos de prueba en Dolibarr llamando directamente a la API + * NO requiere que Next.js esté corriendo + * + * Uso: node scripts/seed-projects-direct.js + */ + +// Configuración - lee de .env.local o usa valores por defecto +const fs = require('fs'); +const path = require('path'); + +// Leer .env.local +function loadEnv() { + const envPath = path.join(__dirname, '..', '.env.local'); + const env = {}; + + try { + const content = fs.readFileSync(envPath, 'utf8'); + content.split('\n').forEach(line => { + const [key, ...valueParts] = line.split('='); + if (key && valueParts.length > 0) { + env[key.trim()] = valueParts.join('=').trim(); + } + }); + } catch (e) { + console.error('Error leyendo .env.local:', e.message); + } + + return env; +} + +const env = loadEnv(); +const DOLIBARR_API_URL = env.NEXT_PUBLIC_API_URL || 'http://localhost:8200/api/index.php'; +const DOLIBARR_API_KEY = env.NEXT_PUBLIC_DOLIBARR_API_KEY || ''; + +if (!DOLIBARR_API_KEY) { + console.error('❌ ERROR: No se encontró NEXT_PUBLIC_DOLIBARR_API_KEY en .env.local'); + process.exit(1); +} + +// Nombres de clientes para asignar aleatoriamente +const clientNames = [ + "TechCorp Solutions", + "Innovatech S.L.", + "Digital Factory", + "CloudBase Systems", + "DataPrime Analytics", + "WebMasters Pro", + "AppDev Studio", + "CyberTech Security", + "SmartBiz Solutions", + "NextGen Software" +]; + +// Datos de proyectos de ejemplo con variedad de estados y presupuestos +const projectsData = [ + { + title: "Desarrollo de App Movil", + ref: "PROJ-2025-001", + description: "Aplicacion movil para gestion de inventario con funcionalidades de escaneo QR y sincronizacion en tiempo real.", + opp_amount: 45000, + opp_percent: 65, + date_start: Math.floor(new Date('2025-01-15').getTime() / 1000), + date_end: Math.floor(new Date('2025-06-30').getTime() / 1000), + }, + { + title: "Rediseno Web Corporativo", + ref: "PROJ-2025-002", + description: "Renovacion completa del sitio web corporativo con nuevo diseno responsive y optimizacion SEO.", + opp_amount: 28000, + opp_percent: 90, + date_start: Math.floor(new Date('2024-11-01').getTime() / 1000), + date_end: Math.floor(new Date('2025-02-28').getTime() / 1000), + }, + { + title: "Sistema de Facturacion Automatica", + ref: "PROJ-2025-003", + description: "Desarrollo de sistema automatizado para generacion y envio de facturas con integracion a contabilidad.", + opp_amount: 62000, + opp_percent: 35, + date_start: Math.floor(new Date('2025-02-01').getTime() / 1000), + date_end: Math.floor(new Date('2025-08-31').getTime() / 1000), + }, + { + title: "Migracion a Cloud AWS", + ref: "PROJ-2025-004", + description: "Migracion completa de infraestructura on-premise a AWS con configuracion de alta disponibilidad.", + opp_amount: 85000, + opp_percent: 20, + date_start: Math.floor(new Date('2025-03-01').getTime() / 1000), + date_end: Math.floor(new Date('2025-12-31').getTime() / 1000), + }, + { + title: "Dashboard Analitico BI", + ref: "PROJ-2025-005", + description: "Creacion de dashboard de Business Intelligence para analisis de ventas y KPIs en tiempo real.", + opp_amount: 38000, + opp_percent: 100, + date_start: Math.floor(new Date('2024-09-01').getTime() / 1000), + date_end: Math.floor(new Date('2024-12-15').getTime() / 1000), + status: 2, // Cerrado + }, + { + title: "API REST Microservicios", + ref: "PROJ-2025-006", + description: "Desarrollo de arquitectura de microservicios con API REST para integracion de sistemas legacy.", + opp_amount: 72000, + opp_percent: 50, + date_start: Math.floor(new Date('2025-01-20').getTime() / 1000), + date_end: Math.floor(new Date('2025-07-31').getTime() / 1000), + }, + { + title: "App de Gestion de Proyectos", + ref: "PROJ-2025-007", + description: "Plataforma web para gestion agil de proyectos con tableros Kanban, Gantt y reporting automatico.", + opp_amount: 55000, + opp_percent: 75, + date_start: Math.floor(new Date('2024-12-01').getTime() / 1000), + date_end: Math.floor(new Date('2025-05-31').getTime() / 1000), + }, + { + title: "E-commerce con Marketplace", + ref: "PROJ-2025-008", + description: "Plataforma de e-commerce completa con funcionalidad de marketplace multivendedor y pasarela de pagos.", + opp_amount: 120000, + opp_percent: 15, + date_start: Math.floor(new Date('2025-04-01').getTime() / 1000), + date_end: Math.floor(new Date('2026-03-31').getTime() / 1000), + }, + { + title: "Sistema CRM Personalizado", + ref: "PROJ-2025-009", + description: "CRM a medida con automatizacion de marketing, gestion de leads y pipeline de ventas.", + opp_amount: 48000, + opp_percent: 0, + date_start: Math.floor(new Date('2025-05-01').getTime() / 1000), + date_end: Math.floor(new Date('2025-11-30').getTime() / 1000), + }, + { + title: "Portal de Empleados", + ref: "PROJ-2025-010", + description: "Portal interno para empleados con gestion de vacaciones, nominas, formacion y comunicacion interna.", + opp_amount: 35000, + opp_percent: 100, + date_start: Math.floor(new Date('2024-10-01').getTime() / 1000), + date_end: Math.floor(new Date('2024-12-31').getTime() / 1000), + status: 2, // Cerrado + }, + { + title: "Sistema de Reservas Online", + ref: "PROJ-2025-011", + description: "Plataforma de reservas con calendario interactivo, confirmacion automatica y pasarela de pago.", + opp_amount: 42000, + opp_percent: 45, + date_start: Math.floor(new Date('2025-02-15').getTime() / 1000), + date_end: Math.floor(new Date('2025-07-15').getTime() / 1000), + }, + { + title: "App de Formacion Online", + ref: "PROJ-2025-012", + description: "Plataforma LMS para formacion online con seguimiento de progreso, certificados y evaluaciones.", + opp_amount: 68000, + opp_percent: 30, + date_start: Math.floor(new Date('2025-03-10').getTime() / 1000), + date_end: Math.floor(new Date('2025-10-31').getTime() / 1000), + }, + { + title: "Automatizacion de Procesos RPA", + ref: "PROJ-2025-013", + description: "Implementacion de robots de automatizacion para procesos administrativos repetitivos.", + opp_amount: 95000, + opp_percent: 10, + date_start: Math.floor(new Date('2025-06-01').getTime() / 1000), + date_end: Math.floor(new Date('2026-02-28').getTime() / 1000), + }, + { + title: "Plataforma IoT Industrial", + ref: "PROJ-2025-014", + description: "Sistema de monitoreo IoT para maquinaria industrial con alertas predictivas y mantenimiento.", + opp_amount: 150000, + opp_percent: 5, + date_start: Math.floor(new Date('2025-07-01').getTime() / 1000), + date_end: Math.floor(new Date('2026-06-30').getTime() / 1000), + }, + { + title: "Chatbot con IA", + ref: "PROJ-2025-015", + description: "Desarrollo de chatbot inteligente para atencion al cliente con procesamiento de lenguaje natural.", + opp_amount: 32000, + opp_percent: 80, + date_start: Math.floor(new Date('2024-11-15').getTime() / 1000), + date_end: Math.floor(new Date('2025-03-15').getTime() / 1000), + }, +]; + +/** + * Llamar a la API de Dolibarr directamente + */ +async function dolibarrFetch(endpoint, options = {}) { + const url = `${DOLIBARR_API_URL}/${endpoint}`; + + const response = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + 'DOLAPIKEY': DOLIBARR_API_KEY, + ...options.headers, + }, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`HTTP ${response.status}: ${text}`); + } + + return response.json(); +} + +/** + * Crear un proyecto + */ +async function createProject(projectData) { + return dolibarrFetch('projects', { + method: 'POST', + body: JSON.stringify(projectData), + }); +} + +/** + * Validar proyecto (cambiar estado a abierto) + */ +async function validateProject(projectId) { + try { + await dolibarrFetch(`projects/${projectId}/validate`, { + method: 'POST', + body: JSON.stringify({ notrigger: 0 }), + }); + return true; + } catch (e) { + return false; + } +} + +/** + * Cerrar proyecto + */ +async function closeProject(projectId) { + try { + await dolibarrFetch(`projects/${projectId}/close`, { + method: 'POST', + body: JSON.stringify({ notrigger: 0 }), + }); + return true; + } catch (e) { + return false; + } +} + +/** + * Script principal + */ +async function main() { + console.log('\n🚀 SEED DE PROYECTOS DE PRUEBA PARA DOLIBARR\n'); + console.log('═'.repeat(60)); + console.log(`📡 API Dolibarr: ${DOLIBARR_API_URL}`); + console.log(`🔑 API Key: ${DOLIBARR_API_KEY.substring(0, 10)}...`); + console.log(`📊 Proyectos a crear: ${projectsData.length}`); + console.log('═'.repeat(60)); + console.log(''); + + // Verificar conexión con Dolibarr + console.log('🔍 Verificando conexion con Dolibarr...'); + try { + await dolibarrFetch('status'); + console.log('✅ Conexion exitosa con Dolibarr\n'); + } catch (error) { + console.error('❌ ERROR: No se puede conectar a Dolibarr'); + console.error(` ${error.message}`); + console.error('\n Verifica que:'); + console.error(' 1. Docker está corriendo'); + console.error(' 2. La URL en .env.local es correcta'); + console.error(' 3. La API key es válida\n'); + process.exit(1); + } + + let successCount = 0; + let errorCount = 0; + const errors = []; + + for (let i = 0; i < projectsData.length; i++) { + const project = projectsData[i]; + const num = i + 1; + const percentage = project.opp_percent; + const shouldClose = project.status === 2; + + // Asignar cliente aleatorio + const clientName = clientNames[i % clientNames.length]; + + // Indicador visual del progreso + const progressBar = '▓'.repeat(Math.floor(percentage / 10)) + '░'.repeat(10 - Math.floor(percentage / 10)); + + console.log(`[${num}/${projectsData.length}] ${project.title}`); + console.log(` 📈 Progreso: [${progressBar}] ${percentage}%`); + console.log(` 💰 Presupuesto: €${project.opp_amount.toLocaleString()}`); + console.log(` 👤 Cliente: ${clientName}`); + + try { + // Crear proyecto + const projectPayload = { + ref: project.ref, + title: project.title, + description: project.description, + opp_amount: project.opp_amount.toString(), + opp_percent: project.opp_percent.toString(), + date_start: project.date_start, + date_end: project.date_end, + usage_opportunity: 1, + usage_task: 1, + public: 1, + }; + + const projectId = await createProject(projectPayload); + console.log(` ✅ Creado con ID: ${projectId}`); + + // Validar proyecto si tiene progreso > 0 + if (percentage > 0) { + const validated = await validateProject(projectId); + if (validated) { + console.log(` ✓ Validado (estado: abierto)`); + + // Cerrar si debe estar cerrado + if (shouldClose) { + const closed = await closeProject(projectId); + if (closed) { + console.log(` ✓ Cerrado (completado)`); + } + } + } else { + console.log(` ⚠ Creado pero no validado`); + } + } else { + console.log(` 📋 Dejado como borrador`); + } + + successCount++; + + // Pequeña pausa para no saturar la API + await new Promise(resolve => setTimeout(resolve, 300)); + } catch (error) { + console.log(` ❌ Error: ${error.message}`); + errors.push({ project: project.title, error: error.message }); + errorCount++; + } + + console.log(''); + } + + // Resumen final + console.log('═'.repeat(60)); + console.log('✨ PROCESO COMPLETADO\n'); + console.log(`✅ Proyectos creados exitosamente: ${successCount}`); + console.log(`❌ Proyectos con error: ${errorCount}`); + + if (errors.length > 0) { + console.log('\n📋 Errores encontrados:'); + errors.forEach(({ project, error }) => { + console.log(` • ${project}: ${error}`); + }); + } + + console.log('═'.repeat(60)); + + if (successCount > 0) { + console.log('\n💡 Ahora puedes iniciar tu app con "npm run dev"'); + console.log(' y ver los proyectos en http://localhost:3000\n'); + } +} + +// Ejecutar +main().catch(error => { + console.error('\n❌ ERROR FATAL:', error.message); + process.exit(1); +});