diff --git a/app/api/dolibarr/[...path]/route.ts b/app/api/dolibarr/[...path]/route.ts new file mode 100644 index 0000000..a9fb10a --- /dev/null +++ b/app/api/dolibarr/[...path]/route.ts @@ -0,0 +1,120 @@ +import { NextRequest, NextResponse } from 'next/server'; + +/** + * API Route proxy para Dolibarr + * Protege la API key manteniéndola en el servidor + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ path: string[] }> } +) { + try { + // Await params en Next.js 16 + const resolvedParams = await params; + + // Construir el endpoint desde los parámetros de ruta + const endpoint = resolvedParams.path.join('/'); + + // Obtener API key y URL del servidor (sin NEXT_PUBLIC_) + const apiUrl = process.env.DOLIBARR_API_URL || process.env.NEXT_PUBLIC_API_URL; + const apiKey = process.env.DOLIBARR_API_KEY || process.env.NEXT_PUBLIC_DOLIBARR_API_KEY; + + if (!apiUrl || !apiKey) { + console.error('Missing Dolibarr configuration'); + return NextResponse.json( + { error: 'Server configuration error' }, + { status: 500 } + ); + } + + // Construir URL completa + const url = `${apiUrl}/${endpoint}?DOLAPIKEY=${apiKey}`; + + // Realizar la petición a Dolibarr + const response = await fetch(url, { + method: 'GET', + headers: { + 'Accept': 'application/json', + }, + // Cache durante 60 segundos + next: { revalidate: 60 } + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('Dolibarr API error:', response.status, errorText); + + return NextResponse.json( + { error: 'Error al comunicarse con Dolibarr', details: errorText }, + { status: response.status } + ); + } + + const data = await response.json(); + + return NextResponse.json(data); + } catch (error) { + console.error('Error in Dolibarr API route:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + +/** + * Método POST para crear recursos en Dolibarr + */ +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ path: string[] }> } +) { + try { + // Await params en Next.js 16 + const resolvedParams = await params; + + const endpoint = resolvedParams.path.join('/'); + const body = await request.json(); + + const apiUrl = process.env.DOLIBARR_API_URL || process.env.NEXT_PUBLIC_API_URL; + const apiKey = process.env.DOLIBARR_API_KEY || process.env.NEXT_PUBLIC_DOLIBARR_API_KEY; + + if (!apiUrl || !apiKey) { + return NextResponse.json( + { error: 'Server configuration error' }, + { status: 500 } + ); + } + + const url = `${apiUrl}/${endpoint}?DOLAPIKEY=${apiKey}`; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('Dolibarr API error:', response.status, errorText); + + return NextResponse.json( + { error: 'Error al comunicarse con Dolibarr', details: errorText }, + { status: response.status } + ); + } + + const data = await response.json(); + + return NextResponse.json(data); + } catch (error) { + console.error('Error in Dolibarr API route:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/app/layout.tsx b/app/layout.tsx index 7ead734..74369bf 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { SidebarProvider, SidebarTrigger, SidebarInset } from "@/components/ui/sidebar" import { AppSidebar } from "@/components/app-sidebar" +import { ThemeProvider } from "@/components/theme-provider" const geistSans = Geist({ @@ -26,18 +27,25 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + - - + + + - -
- - {children} -
-
-
+ +
+ + {children} +
+
+
+ ); diff --git a/app/proyectos/[id]/page.tsx b/app/proyectos/[id]/page.tsx new file mode 100644 index 0000000..32cc14c --- /dev/null +++ b/app/proyectos/[id]/page.tsx @@ -0,0 +1,29 @@ +import { notFound } from 'next/navigation'; +import { getProjectById } from '@/lib/projectsService'; +import ProjectDetail from '@/components/dashboard/project-detail'; + +interface PageProps { + params: { + id: string; + }; +} + +/** + * Página de detalle de proyecto + * Ruta dinámica: /proyectos/[id] + */ +export default async function ProjectDetailPage({ params }: PageProps) { + const projectId = parseInt(params.id); + + if (isNaN(projectId)) { + notFound(); + } + + const project = await getProjectById(projectId); + + if (!project) { + notFound(); + } + + return ; +} diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index 06ba1de..8590c4a 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -29,8 +29,7 @@ import { Settings, ChevronUp, LogOut, - User, - CreditCard + User } from "lucide-react"; // Datos del menú diff --git a/components/dashboard/dashboard-header.tsx b/components/dashboard/dashboard-header.tsx index 9e77a1b..cc579a1 100644 --- a/components/dashboard/dashboard-header.tsx +++ b/components/dashboard/dashboard-header.tsx @@ -1,6 +1,7 @@ "use client"; import { Search, LayoutGrid, List } from 'lucide-react'; +import { ThemeToggle } from '@/components/theme-toggle'; interface DashboardHeaderProps { searchTerm: string; @@ -24,6 +25,7 @@ export default function DashboardHeader({

Gestiona y visualiza todos tus proyectos

+
+ +
+
+ {/* Avatar skeleton */} + + +
+ {/* Title skeleton */} + + {/* Ref skeleton */} + +
+
+
+ {/* Badge skeleton */} + +
+ + + {/* Description skeleton */} +
+ + + +
+ + {/* Progress bar skeleton */} +
+
+ + +
+ +
+ + {/* Budget info skeleton */} +
+
+ + +
+
+ + +
+
+ + {/* Footer skeleton */} +
+ + +
+ + {/* Client skeleton */} +
+ +
+
+ + ); +} diff --git a/components/dashboard/project-card.tsx b/components/dashboard/project-card.tsx index 571b6d7..e829e13 100644 --- a/components/dashboard/project-card.tsx +++ b/components/dashboard/project-card.tsx @@ -1,5 +1,6 @@ "use client"; +import Link from 'next/link'; import { Calendar, DollarSign, TrendingUp, FileText } from 'lucide-react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; @@ -58,95 +59,97 @@ export default function ProjectCard({ project }: ProjectCardProps) { const initials = getInitials(project.name); return ( - - -
-
- - - {initials} - - -
- {project.name} - {project.ref} + + + +
+
+ + + {initials} + + +
+ {project.name} + {project.ref} +
-
- - - - {/* Description */} - {project.description && ( + + + + {/* Description */} + {project.description && ( +
+
+ + Descripción +
+

{project.description}

+
+ )} + + {/* Progress Bar */}
-
- - Descripción +
+ Progreso + {project.progress}%
-

{project.description}

-
- )} - - {/* Progress Bar */} -
-
- Progreso - {project.progress}% -
-
-
-
-
- - {/* Budget Info */} -
-
-
- - Presupuesto +
+
-

- €{project.budget.toLocaleString('es-ES', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} -

-
-
- - Estimado + + {/* Budget Info */} +
+
+
+ + Presupuesto +
+

+ €{project.budget.toLocaleString('es-ES', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} +

+
+
+
+ + Estimado +
+

+ €{project.spent.toLocaleString('es-ES', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} +

-

- €{project.spent.toLocaleString('es-ES', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} -

-
- {/* Footer Info */} -
-
- - - {new Date(project.startDate).toLocaleDateString('es-ES', { day: '2-digit', month: 'short', year: 'numeric' })} - + {/* Footer Info */} +
+
+ + + {new Date(project.startDate).toLocaleDateString('es-ES', { day: '2-digit', month: 'short', year: 'numeric' })} + +
+
+ + + {new Date(project.endDate).toLocaleDateString('es-ES', { day: '2-digit', month: 'short', year: 'numeric' })} + +
-
- - - {new Date(project.endDate).toLocaleDateString('es-ES', { day: '2-digit', month: 'short', year: 'numeric' })} - -
-
- {/* Cliente */} - {project.client !== 'Sin cliente' && ( -
- Cliente: - {project.client} -
- )} - - + {/* Cliente */} + {project.client !== 'Sin cliente' && ( +
+ Cliente: + {project.client} +
+ )} + + + ); } \ No newline at end of file diff --git a/components/dashboard/project-dashboard.tsx b/components/dashboard/project-dashboard.tsx index 8332f84..39f8a02 100644 --- a/components/dashboard/project-dashboard.tsx +++ b/components/dashboard/project-dashboard.tsx @@ -4,8 +4,10 @@ import { useState, useEffect } from 'react'; import DashboardHeader from './dashboard-header'; import StatsOverview from './stats-overview'; import ProjectGrid from './project-grid'; +import ProjectGridSkeleton from './project-grid-skeleton'; import { getProjects } from '@/lib/projectsService'; // Importar desde el service import { Project } from '@/types/project'; +import { Skeleton } from '@/components/ui/skeleton'; export default function ProjectDashboard() { const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); @@ -39,11 +41,32 @@ export default function ProjectDashboard() { if (loading) { return ( -
-
-
-

Cargando proyectos...

-
+
+ {}} + viewMode={viewMode} + onViewModeChange={setViewMode} + /> + +
+ {/* Stats skeleton */} +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+
+ + +
+ + +
+ ))} +
+ + {/* Projects skeleton */} + +
); } diff --git a/components/dashboard/project-detail.tsx b/components/dashboard/project-detail.tsx new file mode 100644 index 0000000..491e9c0 --- /dev/null +++ b/components/dashboard/project-detail.tsx @@ -0,0 +1,305 @@ +"use client"; + +import { ArrowLeft, Calendar, DollarSign, TrendingUp, FileText, Building2 } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Avatar, AvatarFallback } from '@/components/ui/avatar'; +import { Button } from '@/components/ui/button'; +import { Separator } from '@/components/ui/separator'; +import { Project, ProjectStatus } from '@/types/project'; + +interface ProjectDetailProps { + project: Project; +} + +// Función para generar iniciales +function getInitials(name: string): string { + return name + .split(' ') + .map(word => word[0]) + .join('') + .toUpperCase() + .slice(0, 2); +} + +// Componente para el badge de estado +function StatusBadge({ status }: { status: ProjectStatus }) { + if (status === '0') { + return ( + + Borrador + + ); + } + + if (status === '1') { + return ( + + Abierto + + ); + } + + if (status === '2') { + return ( + + Cerrado + + ); + } + + return ( + + Desconocido + + ); +} + +/** + * Componente de detalle completo de un proyecto + * Muestra toda la información disponible del proyecto + */ +export default function ProjectDetail({ project }: ProjectDetailProps) { + const router = useRouter(); + const initials = getInitials(project.name); + + return ( +
+ {/* Header */} +
+
+ + +
+ + + {initials} + + + +
+
+
+

{project.name}

+

{project.ref}

+ +
+
+
+
+
+
+ + {/* Content */} +
+
+ {/* Main Content */} +
+ {/* Descripción */} + {project.description && ( + + + + + Descripción + + + +

{project.description}

+
+
+ )} + + {/* Progreso */} + + + + + Progreso del Proyecto + + + +
+
+ Avance Total + {project.progress}% +
+
+
+
+
+
+

Estado

+

+ {project.status === '0' ? 'Borrador' : project.status === '1' ? 'En Progreso' : 'Finalizado'} +

+
+
+

Completado

+

+ {project.progress === 100 ? 'Sí' : 'No'} +

+
+
+
+ + + + {/* Presupuesto */} + + + + + Información Financiera + + + +
+
+

Presupuesto Total

+

+ €{project.budget.toLocaleString('es-ES', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +

+
+
+

Estimado Gastado

+

+ €{project.spent.toLocaleString('es-ES', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +

+
+
+ +
+ Restante + + €{(project.budget - project.spent).toLocaleString('es-ES', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + +
+
+
+
+ + {/* Sidebar */} +
+ {/* Información General */} + + + Información General + + + {/* Cliente */} + {project.client !== 'Sin cliente' && ( +
+ +
+

Cliente

+

{project.client}

+
+
+ )} + + + + {/* Fecha de Inicio */} +
+ +
+

Fecha de Inicio

+

+ {new Date(project.startDate).toLocaleDateString('es-ES', { + day: 'numeric', + month: 'long', + year: 'numeric' + })} +

+
+
+ + + + {/* Fecha de Fin */} +
+ +
+

Fecha de Fin

+

+ {new Date(project.endDate).toLocaleDateString('es-ES', { + day: 'numeric', + month: 'long', + year: 'numeric' + })} +

+
+
+ + + + {/* Duración */} +
+ +
+

Duración

+

+ {Math.ceil((new Date(project.endDate).getTime() - new Date(project.startDate).getTime()) / (1000 * 60 * 60 * 24))} días +

+
+
+
+
+ + {/* Metadatos */} + + + Metadatos + + +
+

ID del Proyecto

+

{project.id}

+
+ +
+

Referencia

+

{project.ref}

+
+ +
+

Creado

+

+ {new Date(project.createdAt).toLocaleDateString('es-ES', { + day: 'numeric', + month: 'short', + year: 'numeric' + })} +

+
+ +
+

Última Actualización

+

+ {new Date(project.updatedAt).toLocaleDateString('es-ES', { + day: 'numeric', + month: 'short', + year: 'numeric' + })} +

+
+
+
+
+
+
+
+ ); +} diff --git a/components/dashboard/project-grid-skeleton.tsx b/components/dashboard/project-grid-skeleton.tsx new file mode 100644 index 0000000..17a161e --- /dev/null +++ b/components/dashboard/project-grid-skeleton.tsx @@ -0,0 +1,39 @@ +"use client"; + +import ProjectCardSkeleton from './project-card-skeleton'; +import ProjectListItemSkeleton from './project-list-item-skeleton'; + +interface ProjectGridSkeletonProps { + viewMode?: 'grid' | 'list'; + count?: number; +} + +/** + * Componente skeleton para ProjectGrid + * Muestra múltiples skeletons de tarjetas mientras cargan los datos + * Soporta vista grid y lista + */ +export default function ProjectGridSkeleton({ + viewMode = 'grid', + count = 6 +}: ProjectGridSkeletonProps) { + // Vista de lista + if (viewMode === 'list') { + return ( +
+ {Array.from({ length: count }).map((_, index) => ( + + ))} +
+ ); + } + + // Vista de grid (por defecto) + return ( +
+ {Array.from({ length: count }).map((_, index) => ( + + ))} +
+ ); +} diff --git a/components/dashboard/project-grid.tsx b/components/dashboard/project-grid.tsx index d4ff5c2..41a781f 100644 --- a/components/dashboard/project-grid.tsx +++ b/components/dashboard/project-grid.tsx @@ -1,6 +1,7 @@ "use client"; import ProjectCard from './project-card'; +import ProjectListItem from './project-list-item'; import { Project } from '@/types/project'; interface ProjectGridProps { @@ -17,8 +18,20 @@ export default function ProjectGrid({ projects, viewMode }: ProjectGridProps) { ); } + // Vista de lista + if (viewMode === 'list') { + return ( +
+ {projects.map((project) => ( + + ))} +
+ ); + } + + // Vista de grid (por defecto) return ( -
+
{projects.map((project) => ( ))} diff --git a/components/dashboard/project-list-item-skeleton.tsx b/components/dashboard/project-list-item-skeleton.tsx new file mode 100644 index 0000000..684db98 --- /dev/null +++ b/components/dashboard/project-list-item-skeleton.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { Skeleton } from '@/components/ui/skeleton'; + +/** + * Componente skeleton para ProjectListItem + * Muestra un placeholder en formato de lista + */ +export default function ProjectListItemSkeleton() { + return ( +
+
+ {/* Avatar skeleton */} + + + {/* Info Principal skeleton */} +
+
+
+ + +
+ + {/* Estado skeleton */} + +
+
+ + {/* Progreso skeleton */} +
+ +
+ + +
+
+ + {/* Presupuesto skeleton */} +
+ +
+ + +
+
+ + {/* Fechas skeleton */} +
+ +
+ + +
+
+
+
+ ); +} diff --git a/components/dashboard/project-list-item.tsx b/components/dashboard/project-list-item.tsx new file mode 100644 index 0000000..f419f2e --- /dev/null +++ b/components/dashboard/project-list-item.tsx @@ -0,0 +1,137 @@ +"use client"; + +import Link from 'next/link'; +import { Calendar, DollarSign, TrendingUp } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Avatar, AvatarFallback } from '@/components/ui/avatar'; +import { Project, ProjectStatus } from '@/types/project'; + +interface ProjectListItemProps { + project: Project; +} + +// Función para generar iniciales +function getInitials(name: string): string { + return name + .split(' ') + .map(word => word[0]) + .join('') + .toUpperCase() + .slice(0, 2); +} + +// Componente para el badge de estado con estilos directos +function StatusBadge({ status }: { status: ProjectStatus }) { + if (status === '0') { + return ( + + Borrador + + ); + } + + if (status === '1') { + return ( + + Abierto + + ); + } + + if (status === '2') { + return ( + + Cerrado + + ); + } + + // Fallback + return ( + + Desconocido + + ); +} + +/** + * Componente de fila de proyecto para vista de lista + * Diseño horizontal y compacto + */ +export default function ProjectListItem({ project }: ProjectListItemProps) { + const initials = getInitials(project.name); + + return ( + +
+
+ {/* Avatar */} + + + {initials} + + + + {/* Info Principal */} +
+
+
+
+

+ {project.name} +

+ {project.ref} +
+ {project.client !== 'Sin cliente' && ( +

{project.client}

+ )} +
+ + {/* Estado */} + +
+
+ + {/* Progreso */} +
+ +
+
+ Progreso + {project.progress}% +
+
+
+
+
+
+ + {/* Presupuesto */} +
+ +
+
+ €{project.budget.toLocaleString('es-ES', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} +
+
Presupuesto
+
+
+ + {/* Fechas */} +
+ +
+
+ {new Date(project.startDate).toLocaleDateString('es-ES', { day: '2-digit', month: 'short' })} +
+
Inicio
+
+
+
+
+ + ); +} diff --git a/components/dashboard/stats-overview.tsx b/components/dashboard/stats-overview.tsx index 8352f74..cd6bfdb 100644 --- a/components/dashboard/stats-overview.tsx +++ b/components/dashboard/stats-overview.tsx @@ -13,7 +13,9 @@ export default function StatsOverview({ projects }: StatsOverviewProps) { total: projects.length, activos: projects.filter(p => p.progress < 100 && p.status !== "2").length, totalBudget: projects.reduce((acc, p) => acc + p.budget, 0), - avgProgress: Math.round(projects.reduce((acc, p) => acc + p.progress, 0) / projects.length) + avgProgress: projects.length > 0 + ? Math.round(projects.reduce((acc, p) => acc + p.progress, 0) / projects.length) + : 0 }; return ( diff --git a/components/theme-provider.tsx b/components/theme-provider.tsx new file mode 100644 index 0000000..2a4dd67 --- /dev/null +++ b/components/theme-provider.tsx @@ -0,0 +1,15 @@ +"use client"; + +import * as React from "react"; +import { ThemeProvider as NextThemesProvider } from "next-themes"; + +/** + * Theme Provider wrapper para next-themes + * Permite cambiar entre modo claro y oscuro + */ +export function ThemeProvider({ + children, + ...props +}: React.ComponentProps) { + return {children}; +} diff --git a/components/theme-toggle.tsx b/components/theme-toggle.tsx new file mode 100644 index 0000000..d470b45 --- /dev/null +++ b/components/theme-toggle.tsx @@ -0,0 +1,44 @@ +"use client"; + +import * as React from "react"; +import { Moon, Sun } from "lucide-react"; +import { useTheme } from "next-themes"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +/** + * Componente de toggle para cambiar entre modo claro, oscuro y sistema + * Usa next-themes para persistir la preferencia + */ +export function ThemeToggle() { + const { setTheme } = useTheme(); + + return ( + + + + + + setTheme("light")}> + Claro + + setTheme("dark")}> + Oscuro + + setTheme("system")}> + Sistema + + + + ); +} diff --git a/lib/dolibarrClient.ts b/lib/dolibarrClient.ts index 204d57f..92ea63e 100644 --- a/lib/dolibarrClient.ts +++ b/lib/dolibarrClient.ts @@ -1,11 +1,24 @@ +/** + * Cliente para la API de Dolibarr + * Ahora usa la API Route de Next.js (/api/dolibarr) en lugar de llamar directamente + * Esto mantiene la API key segura en el servidor + */ export async function dolibarrFetch(endpoint: string, options: RequestInit = {}) { - const url = `${process.env.NEXT_PUBLIC_API_URL}/${endpoint}?DOLAPIKEY=${process.env.NEXT_PUBLIC_DOLIBARR_API_KEY}`; + // Usar la API Route en lugar de llamar directamente a Dolibarr + const url = `/api/dolibarr/${endpoint}`; - const res = await fetch(url); + const res = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }); if (!res.ok) { - console.error("Error en la llamada Dolibarr:", res.status, await res.text()); - throw new Error("Dolibarr API error"); + const errorData = await res.json().catch(() => ({ error: 'Unknown error' })); + console.error("Error en la llamada Dolibarr:", res.status, errorData); + throw new Error(errorData.error || "Dolibarr API error"); } return res.json(); diff --git a/package-lock.json b/package-lock.json index be8e27b..2175811 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "clsx": "^2.1.1", "lucide-react": "^0.556.0", "next": "16.0.7", + "next-themes": "^0.4.6", "react": "19.2.0", "react-dom": "19.2.0", "tailwind-merge": "^3.4.0", @@ -6624,6 +6625,16 @@ } } }, + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", diff --git a/package.json b/package.json index 7757a52..b7eb709 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "clsx": "^2.1.1", "lucide-react": "^0.556.0", "next": "16.0.7", + "next-themes": "^0.4.6", "react": "19.2.0", "react-dom": "19.2.0", "tailwind-merge": "^3.4.0",