From 8994c240eba834c4f9c35bb8f816bc2cd2e31125 Mon Sep 17 00:00:00 2001 From: marcos Date: Fri, 30 Jan 2026 22:44:22 +0100 Subject: [PATCH] feat: Introduce Gantt chart page with project timeline visualization and add Radix UI select component. --- app/gantt/page.tsx | 5 + components/app-sidebar.tsx | 8 +- components/gantt/gantt-page.tsx | 513 ++++++++ components/statistics/statistics-page.tsx | 1464 ++++++++++++--------- components/ui/select.tsx | 160 +++ package-lock.json | 121 ++ package.json | 1 + 7 files changed, 1651 insertions(+), 621 deletions(-) create mode 100644 app/gantt/page.tsx create mode 100644 components/gantt/gantt-page.tsx create mode 100644 components/ui/select.tsx diff --git a/app/gantt/page.tsx b/app/gantt/page.tsx new file mode 100644 index 0000000..a3b7483 --- /dev/null +++ b/app/gantt/page.tsx @@ -0,0 +1,5 @@ +import GanttPage from "@/components/gantt/gantt-page"; + +export default function Gantt() { + return ; +} diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index 8590c4a..fd6788a 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -29,7 +29,8 @@ import { Settings, ChevronUp, LogOut, - User + User, + GanttChart } from "lucide-react"; // Datos del menú @@ -49,6 +50,11 @@ const items = [ url: "/estadisticas", icon: BarChart3, }, + { + title: "Gantt", + url: "/gantt", + icon: GanttChart, + }, ]; export function AppSidebar() { diff --git a/components/gantt/gantt-page.tsx b/components/gantt/gantt-page.tsx new file mode 100644 index 0000000..dd5b038 --- /dev/null +++ b/components/gantt/gantt-page.tsx @@ -0,0 +1,513 @@ +"use client"; + +import { useState, useEffect, useMemo } from "react"; +import { + GanttChart as GanttIcon, + AlertCircle, + ChevronLeft, + ChevronRight, + Calendar, + ZoomIn, + ZoomOut, + Filter, +} from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { getProjects } from "@/lib/projectsService"; +import { Project, ProjectStatus } from "@/types/project"; + +// Paleta de colores para proyectos +const PROJECT_COLORS = [ + { bg: "bg-blue-500", light: "bg-blue-100", text: "text-blue-700", hex: "#3b82f6" }, + { bg: "bg-violet-500", light: "bg-violet-100", text: "text-violet-700", hex: "#8b5cf6" }, + { bg: "bg-cyan-500", light: "bg-cyan-100", text: "text-cyan-700", hex: "#06b6d4" }, + { bg: "bg-emerald-500", light: "bg-emerald-100", text: "text-emerald-700", hex: "#10b981" }, + { bg: "bg-amber-500", light: "bg-amber-100", text: "text-amber-700", hex: "#f59e0b" }, + { bg: "bg-rose-500", light: "bg-rose-100", text: "text-rose-700", hex: "#f43f5e" }, + { bg: "bg-pink-500", light: "bg-pink-100", text: "text-pink-700", hex: "#ec4899" }, + { bg: "bg-indigo-500", light: "bg-indigo-100", text: "text-indigo-700", hex: "#6366f1" }, + { bg: "bg-teal-500", light: "bg-teal-100", text: "text-teal-700", hex: "#14b8a6" }, + { bg: "bg-orange-500", light: "bg-orange-100", text: "text-orange-700", hex: "#f97316" }, +]; + +const STATUS_LABELS: Record = { + "0": "Borrador", + "1": "Abierto", + "2": "Cerrado", +}; + +type ViewMode = "month" | "quarter" | "year"; + +function getProjectColor(index: number) { + return PROJECT_COLORS[index % PROJECT_COLORS.length]; +} + +// Formatear fecha +function formatDate(dateStr: string): string { + const date = new Date(dateStr); + return date.toLocaleDateString("es-ES", { day: "2-digit", month: "short", year: "numeric" }); +} + +// Obtener días entre dos fechas +function getDaysBetween(start: Date, end: Date): number { + return Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)); +} + +// Generar array de meses entre dos fechas +function getMonthsBetween(start: Date, end: Date): { month: number; year: number; label: string }[] { + const months: { month: number; year: number; label: string }[] = []; + const current = new Date(start.getFullYear(), start.getMonth(), 1); + const endMonth = new Date(end.getFullYear(), end.getMonth(), 1); + + while (current <= endMonth) { + months.push({ + month: current.getMonth(), + year: current.getFullYear(), + label: current.toLocaleDateString("es-ES", { month: "short", year: "numeric" }), + }); + current.setMonth(current.getMonth() + 1); + } + + return months; +} + +// Obtener días en un mes +function getDaysInMonth(month: number, year: number): number { + return new Date(year, month + 1, 0).getDate(); +} + +export default function GanttPage() { + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [viewMode, setViewMode] = useState("month"); + const [statusFilter, setStatusFilter] = useState("all"); + const [viewOffset, setViewOffset] = useState(0); + + 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(); + }, []); + + // Filtrar proyectos + const filteredProjects = useMemo(() => { + if (statusFilter === "all") return projects; + return projects.filter(p => p.status === statusFilter); + }, [projects, statusFilter]); + + // Calcular rango de fechas del timeline + const { timelineStart, timelineEnd, months } = useMemo(() => { + if (filteredProjects.length === 0) { + const now = new Date(); + return { + timelineStart: new Date(now.getFullYear(), now.getMonth(), 1), + timelineEnd: new Date(now.getFullYear(), now.getMonth() + 6, 0), + months: [], + }; + } + + const allDates = filteredProjects.flatMap(p => [new Date(p.startDate), new Date(p.endDate)]); + const minDate = new Date(Math.min(...allDates.map(d => d.getTime()))); + const maxDate = new Date(Math.max(...allDates.map(d => d.getTime()))); + + // Añadir margen de un mes antes y después + const start = new Date(minDate.getFullYear(), minDate.getMonth() - 1, 1); + const end = new Date(maxDate.getFullYear(), maxDate.getMonth() + 2, 0); + + return { + timelineStart: start, + timelineEnd: end, + months: getMonthsBetween(start, end), + }; + }, [filteredProjects]); + + // Calcular meses visibles según el modo de vista + const visibleMonths = useMemo(() => { + const monthsToShow = viewMode === "month" ? 3 : viewMode === "quarter" ? 6 : 12; + const startIdx = Math.max(0, Math.min(viewOffset, months.length - monthsToShow)); + return months.slice(startIdx, startIdx + monthsToShow); + }, [months, viewMode, viewOffset]); + + // Calcular el ancho total en días para los meses visibles + const { totalDays, visibleStart, visibleEnd } = useMemo(() => { + if (visibleMonths.length === 0) { + return { totalDays: 30, visibleStart: new Date(), visibleEnd: new Date() }; + } + + const first = visibleMonths[0]; + const last = visibleMonths[visibleMonths.length - 1]; + const start = new Date(first.year, first.month, 1); + const end = new Date(last.year, last.month + 1, 0); + + return { + totalDays: getDaysBetween(start, end), + visibleStart: start, + visibleEnd: end, + }; + }, [visibleMonths]); + + // Navegación + const canGoBack = viewOffset > 0; + const canGoForward = viewOffset + (viewMode === "month" ? 3 : viewMode === "quarter" ? 6 : 12) < months.length; + + const goBack = () => { + const step = viewMode === "month" ? 1 : viewMode === "quarter" ? 3 : 6; + setViewOffset(Math.max(0, viewOffset - step)); + }; + + const goForward = () => { + const step = viewMode === "month" ? 1 : viewMode === "quarter" ? 3 : 6; + const maxOffset = Math.max(0, months.length - (viewMode === "month" ? 3 : viewMode === "quarter" ? 6 : 12)); + setViewOffset(Math.min(maxOffset, viewOffset + step)); + }; + + const goToToday = () => { + const now = new Date(); + const todayIndex = months.findIndex(m => m.month === now.getMonth() && m.year === now.getFullYear()); + if (todayIndex >= 0) { + setViewOffset(Math.max(0, todayIndex - 1)); + } + }; + + if (error) { + return ( +
+
+ +

{error}

+ +
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+
+
+ +
+
+

Diagrama de Gantt

+

+ Timeline de {filteredProjects.length} proyectos +

+
+
+ + {/* Controles */} +
+ {/* Filtro de estado */} + + + {/* Selector de vista */} + + + {/* Navegación */} +
+ + + +
+
+
+
+
+ + {/* Content */} +
+ {loading ? ( + + ) : filteredProjects.length === 0 ? ( + + + +

No hay proyectos

+

No se encontraron proyectos con los filtros seleccionados

+
+
+ ) : ( + + +
+ {/* Columna de nombres de proyectos */} +
+ {/* Header */} +
+ Proyecto +
+ {/* Lista de proyectos */} + {filteredProjects.map((project, index) => { + const color = getProjectColor(index); + return ( +
+
+
+
+

+ {project.name} +

+

{project.progress}%

+
+
+
+ ); + })} +
+ + {/* Timeline */} +
+ {/* Header con meses */} +
+ {visibleMonths.map((month, idx) => { + const daysInMonth = getDaysInMonth(month.month, month.year); + const widthPercent = (daysInMonth / totalDays) * 100; + const isCurrentMonth = new Date().getMonth() === month.month && new Date().getFullYear() === month.year; + + return ( +
+ + {month.label} + + {daysInMonth} días +
+ ); + })} +
+ + {/* Barras del Gantt */} + + {filteredProjects.map((project, index) => { + const color = getProjectColor(index); + const projectStart = new Date(project.startDate); + const projectEnd = new Date(project.endDate); + + // Calcular posición y ancho de la barra + const startOffset = Math.max(0, getDaysBetween(visibleStart, projectStart)); + const endOffset = Math.min(totalDays, getDaysBetween(visibleStart, projectEnd)); + + const leftPercent = (startOffset / totalDays) * 100; + const widthPercent = Math.max(1, ((endOffset - startOffset) / totalDays) * 100); + + // Verificar si el proyecto está visible en el rango actual + const isVisible = projectEnd >= visibleStart && projectStart <= visibleEnd; + const isOverdue = projectEnd < new Date() && project.progress < 100 && project.status === "1"; + + return ( +
+ {/* Grid de meses */} +
+ {visibleMonths.map((month, idx) => { + const daysInMonth = getDaysInMonth(month.month, month.year); + const monthWidthPercent = (daysInMonth / totalDays) * 100; + const isCurrentMonth = new Date().getMonth() === month.month && new Date().getFullYear() === month.year; + + return ( +
+ ); + })} +
+ + {/* Barra del proyecto */} + {isVisible && ( + + +
+ {/* Fondo de la barra */} +
+ + {/* Progreso */} +
+ + {/* Contenido de la barra */} +
+ + {project.progress >= 30 ? `${project.progress}%` : ""} + +
+
+ + +
+

{project.name}

+
+

Estado: {STATUS_LABELS[project.status]}

+

Progreso: {project.progress}%

+

Inicio: {formatDate(project.startDate)}

+

Fin: {formatDate(project.endDate)}

+

Cliente: {project.client}

+ {isOverdue && ( +

Proyecto retrasado

+ )} +
+
+
+ + )} +
+ ); + })} + +
+
+ + + )} + + {/* Leyenda */} + {!loading && filteredProjects.length > 0 && ( +
+
+
+
+
+ Progreso del proyecto +
+
+
+ Mes actual +
+
+
+ Proyecto retrasado +
+
+ )} +
+
+ ); +} + +function GanttSkeleton() { + return ( + + +
+
+
+ +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+ +
+ + +
+
+
+ ))} +
+
+
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ +
+ ))} +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ +
+ ))} +
+
+
+
+ ); +} diff --git a/components/statistics/statistics-page.tsx b/components/statistics/statistics-page.tsx index f51ae17..8234950 100644 --- a/components/statistics/statistics-page.tsx +++ b/components/statistics/statistics-page.tsx @@ -11,32 +11,77 @@ import { Users, Target, PiggyBank, - FileText, AlertCircle, BarChart3, + Lock, } from "lucide-react"; +import { + PieChart, + Pie, + Cell, + ResponsiveContainer, + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + Legend, + RadialBarChart, + RadialBar, + CartesianGrid, +} from "recharts"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Skeleton } from "@/components/ui/skeleton"; import { Badge } from "@/components/ui/badge"; import { getProjects } from "@/lib/projectsService"; -import { Project, ProjectStatus, STATUS_CONFIG } from "@/types/project"; +import { Project } from "@/types/project"; -// Componente para una card de estadística individual +// Colores consistentes +const COLORS = { + primary: "#3b82f6", + secondary: "#8b5cf6", + success: "#22c55e", + warning: "#f59e0b", + danger: "#ef4444", + gray: "#6b7280", + orange: "#f97316", + cyan: "#06b6d4", +}; + +const PROGRESS_COLORS = ["#ef4444", "#f97316", "#f59e0b", "#22c55e"]; + +// Paleta de colores para proyectos individuales (variada y distinguible) +const PROJECT_COLORS = [ + "#3b82f6", // blue + "#8b5cf6", // violet + "#06b6d4", // cyan + "#10b981", // emerald + "#f59e0b", // amber + "#ef4444", // red + "#ec4899", // pink + "#6366f1", // indigo + "#14b8a6", // teal + "#f97316", // orange + "#84cc16", // lime + "#a855f7", // purple +]; + +// Obtener color por índice (cíclico) +function getProjectColor(index: number): string { + return PROJECT_COLORS[index % PROJECT_COLORS.length]; +} + +// ==================== COMPONENTES DE CARDS ==================== interface StatCardProps { title: string; value: string | number; subtitle?: string; icon: React.ReactNode; - trend?: { - value: number; - label: string; - positive?: boolean; - }; highlight?: boolean; } -function StatCard({ title, value, subtitle, icon, trend, highlight }: StatCardProps) { +function StatCard({ title, value, subtitle, icon, highlight }: StatCardProps) { return ( @@ -46,124 +91,176 @@ function StatCard({ title, value, subtitle, icon, trend, highlight }: StatCardPr
{value}
{subtitle &&

{subtitle}

} - {trend && ( -
- {trend.positive ? "+" : ""}{trend.value}% - {trend.label} -
- )}
); } -// Componente para mostrar la distribución por estado -interface StatusDistributionProps { - projects: Project[]; +// ==================== COMPONENTES DE GRAFICOS ==================== + +// Pie Chart de distribucion por estado +interface StatusPieChartProps { + borradores: number; + abiertos: number; + cerrados: number; } -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, - }; +function StatusPieChart({ borradores, abiertos, cerrados }: StatusPieChartProps) { + const data = [ + { name: "Borrador", value: borradores, color: COLORS.gray }, + { name: "Abierto", value: abiertos, color: COLORS.primary }, + { name: "Cerrado", value: cerrados, color: COLORS.success }, + ].filter(d => d.value > 0); - const total = projects.length; + const total = borradores + abiertos + cerrados; return ( - - - - Distribucion por Estado - + + 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]; + +
+ + + + {data.map((entry, index) => ( + + ))} + + [`${value ?? 0} proyectos`, '']} + contentStyle={{ fontSize: '12px' }} + /> + + +
+
+

{total}

+

Total

+
+
+
+
+ {data.map((entry) => ( +
+
+ {entry.name}: {entry.value} +
+ ))} +
+ + + ); +} - return ( -
-
- - {config.label} - - - {count} ({percentage}%) - -
-
-
+// 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 ( + + + {title} + + +
+ + + + {data.map((entry, index) => ( + + ))} + + [`${value ?? 0} proyectos`, '']} + contentStyle={{ fontSize: '12px' }} + /> + + + {centerValue !== undefined && ( +
+
+

{centerValue}

+ {centerLabel &&

{centerLabel}

}
- ); - })} + )} +
+
+ {data.map((entry) => ( +
+
+ {entry.name} +
+ ))} +
); } -// Componente para estadísticas de presupuesto -interface BudgetStatsProps { - projects: Project[]; - title?: string; +// Radial Bar Chart para metricas +interface RadialMetricProps { + value: number; + title: string; + subtitle?: string; + color?: 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; +function RadialMetric({ value, title, subtitle, color = COLORS.primary }: RadialMetricProps) { + const data = [{ name: title, value: Math.min(value, 100), fill: color }]; return ( - - - - {title} - + + {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)}%` }} - /> + +
+ + + + + +
+
+

{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",