update del proyecto en general

This commit is contained in:
Levi Planelles 2026-01-28 11:44:49 +01:00
parent 8003aa2a55
commit ea708254e3
19 changed files with 997 additions and 102 deletions

View File

@ -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 }
);
}
}

View File

@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { SidebarProvider, SidebarTrigger, SidebarInset } from "@/components/ui/sidebar" import { SidebarProvider, SidebarTrigger, SidebarInset } from "@/components/ui/sidebar"
import { AppSidebar } from "@/components/app-sidebar" import { AppSidebar } from "@/components/app-sidebar"
import { ThemeProvider } from "@/components/theme-provider"
const geistSans = Geist({ const geistSans = Geist({
@ -26,18 +27,25 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="es" suppressHydrationWarning>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}> <body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
<SidebarProvider> <ThemeProvider
<AppSidebar /> attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<SidebarProvider>
<AppSidebar />
<SidebarInset> <SidebarInset>
<main> <main>
<SidebarTrigger /> <SidebarTrigger />
{children} {children}
</main> </main>
</SidebarInset> </SidebarInset>
</SidebarProvider> </SidebarProvider>
</ThemeProvider>
</body> </body>
</html> </html>
); );

View File

@ -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 <ProjectDetail project={project} />;
}

View File

@ -29,8 +29,7 @@ import {
Settings, Settings,
ChevronUp, ChevronUp,
LogOut, LogOut,
User, User
CreditCard
} from "lucide-react"; } from "lucide-react";
// Datos del menú // Datos del menú

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import { Search, LayoutGrid, List } from 'lucide-react'; import { Search, LayoutGrid, List } from 'lucide-react';
import { ThemeToggle } from '@/components/theme-toggle';
interface DashboardHeaderProps { interface DashboardHeaderProps {
searchTerm: string; searchTerm: string;
@ -24,6 +25,7 @@ export default function DashboardHeader({
<p className="text-sm text-gray-500 mt-1">Gestiona y visualiza todos tus proyectos</p> <p className="text-sm text-gray-500 mt-1">Gestiona y visualiza todos tus proyectos</p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<ThemeToggle />
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" /> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<input <input

View File

@ -0,0 +1,73 @@
"use client";
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
/**
* Componente skeleton para ProjectCard
* Muestra un placeholder mientras cargan los datos
*/
export default function ProjectCardSkeleton() {
return (
<Card className="border-gray-200">
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3 w-full">
{/* Avatar skeleton */}
<Skeleton className="w-12 h-12 rounded-lg" />
<div className="flex-1">
{/* Title skeleton */}
<Skeleton className="h-5 w-3/4 mb-2" />
{/* Ref skeleton */}
<Skeleton className="h-4 w-1/2" />
</div>
</div>
</div>
{/* Badge skeleton */}
<Skeleton className="h-6 w-20 mt-3" />
</CardHeader>
<CardContent>
{/* Description skeleton */}
<div className="mb-4">
<Skeleton className="h-3 w-16 mb-2" />
<Skeleton className="h-4 w-full mb-1" />
<Skeleton className="h-4 w-5/6" />
</div>
{/* Progress bar skeleton */}
<div className="mb-4">
<div className="flex items-center justify-between mb-2">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-8" />
</div>
<Skeleton className="w-full h-2 rounded-full" />
</div>
{/* Budget info skeleton */}
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<Skeleton className="h-3 w-20 mb-2" />
<Skeleton className="h-5 w-16" />
</div>
<div>
<Skeleton className="h-3 w-20 mb-2" />
<Skeleton className="h-5 w-16" />
</div>
</div>
{/* Footer skeleton */}
<div className="pt-4 border-t border-gray-100 flex items-center justify-between">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-24" />
</div>
{/* Client skeleton */}
<div className="mt-3 pt-3 border-t border-gray-100">
<Skeleton className="h-3 w-32" />
</div>
</CardContent>
</Card>
);
}

View File

@ -1,5 +1,6 @@
"use client"; "use client";
import Link from 'next/link';
import { Calendar, DollarSign, TrendingUp, FileText } from 'lucide-react'; import { Calendar, DollarSign, TrendingUp, FileText } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@ -58,95 +59,97 @@ export default function ProjectCard({ project }: ProjectCardProps) {
const initials = getInitials(project.name); const initials = getInitials(project.name);
return ( return (
<Card className="border-gray-200 hover:shadow-lg transition-all duration-300 hover:-translate-y-1"> <Link href={`/proyectos/${project.id}`}>
<CardHeader> <Card className="border-gray-200 hover:shadow-lg transition-all duration-300 hover:-translate-y-1 cursor-pointer">
<div className="flex items-start justify-between"> <CardHeader>
<div className="flex items-center gap-3"> <div className="flex items-start justify-between">
<Avatar className="w-12 h-12"> <div className="flex items-center gap-3">
<AvatarFallback className="bg-gradient-to-br from-blue-500 to-purple-600 text-white font-semibold"> <Avatar className="w-12 h-12">
{initials} <AvatarFallback className="bg-gradient-to-br from-blue-500 to-purple-600 text-white font-semibold">
</AvatarFallback> {initials}
</Avatar> </AvatarFallback>
<div> </Avatar>
<CardTitle className="text-lg text-gray-900">{project.name}</CardTitle> <div>
<CardDescription className="text-sm">{project.ref}</CardDescription> <CardTitle className="text-lg text-gray-900">{project.name}</CardTitle>
<CardDescription className="text-sm">{project.ref}</CardDescription>
</div>
</div> </div>
</div> </div>
</div> <StatusBadge status={project.status} />
<StatusBadge status={project.status} /> </CardHeader>
</CardHeader> <CardContent>
<CardContent> {/* Description */}
{/* Description */} {project.description && (
{project.description && ( <div className="mb-4">
<div className="flex items-center gap-1 text-gray-500 mb-1">
<FileText className="w-3 h-3" />
<span className="text-xs">Descripción</span>
</div>
<p className="text-sm text-gray-700 line-clamp-2">{project.description}</p>
</div>
)}
{/* Progress Bar */}
<div className="mb-4"> <div className="mb-4">
<div className="flex items-center gap-1 text-gray-500 mb-1"> <div className="flex items-center justify-between mb-2">
<FileText className="w-3 h-3" /> <span className="text-xs font-medium text-gray-600">Progreso</span>
<span className="text-xs">Descripción</span> <span className="text-xs font-semibold text-gray-900">{project.progress}%</span>
</div> </div>
<p className="text-sm text-gray-700 line-clamp-2">{project.description}</p> <div className="w-full bg-gray-200 rounded-full h-2">
</div> <div
)} className="bg-gradient-to-r from-blue-500 to-purple-600 h-2 rounded-full transition-all duration-500"
style={{ width: `${project.progress}%` }}
{/* Progress Bar */} />
<div className="mb-4">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-gray-600">Progreso</span>
<span className="text-xs font-semibold text-gray-900">{project.progress}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-gradient-to-r from-blue-500 to-purple-600 h-2 rounded-full transition-all duration-500"
style={{ width: `${project.progress}%` }}
/>
</div>
</div>
{/* Budget Info */}
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<div className="flex items-center gap-1 text-gray-500 mb-1">
<DollarSign className="w-3 h-3" />
<span className="text-xs">Presupuesto</span>
</div> </div>
<p className="text-sm font-semibold text-gray-900">
€{project.budget.toLocaleString('es-ES', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}
</p>
</div> </div>
<div>
<div className="flex items-center gap-1 text-gray-500 mb-1"> {/* Budget Info */}
<TrendingUp className="w-3 h-3" /> <div className="grid grid-cols-2 gap-4 mb-4">
<span className="text-xs">Estimado</span> <div>
<div className="flex items-center gap-1 text-gray-500 mb-1">
<DollarSign className="w-3 h-3" />
<span className="text-xs">Presupuesto</span>
</div>
<p className="text-sm font-semibold text-gray-900">
€{project.budget.toLocaleString('es-ES', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}
</p>
</div>
<div>
<div className="flex items-center gap-1 text-gray-500 mb-1">
<TrendingUp className="w-3 h-3" />
<span className="text-xs">Estimado</span>
</div>
<p className="text-sm font-semibold text-gray-900">
€{project.spent.toLocaleString('es-ES', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}
</p>
</div> </div>
<p className="text-sm font-semibold text-gray-900">
€{project.spent.toLocaleString('es-ES', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}
</p>
</div> </div>
</div>
{/* Footer Info */} {/* Footer Info */}
<div className="pt-4 border-t border-gray-100 flex items-center justify-between"> <div className="pt-4 border-t border-gray-100 flex items-center justify-between">
<div className="flex items-center gap-1 text-gray-500"> <div className="flex items-center gap-1 text-gray-500">
<Calendar className="w-4 h-4" /> <Calendar className="w-4 h-4" />
<span className="text-xs"> <span className="text-xs">
{new Date(project.startDate).toLocaleDateString('es-ES', { day: '2-digit', month: 'short', year: 'numeric' })} {new Date(project.startDate).toLocaleDateString('es-ES', { day: '2-digit', month: 'short', year: 'numeric' })}
</span> </span>
</div>
<div className="flex items-center gap-1 text-gray-500">
<Calendar className="w-4 h-4" />
<span className="text-xs">
{new Date(project.endDate).toLocaleDateString('es-ES', { day: '2-digit', month: 'short', year: 'numeric' })}
</span>
</div>
</div> </div>
<div className="flex items-center gap-1 text-gray-500">
<Calendar className="w-4 h-4" />
<span className="text-xs">
{new Date(project.endDate).toLocaleDateString('es-ES', { day: '2-digit', month: 'short', year: 'numeric' })}
</span>
</div>
</div>
{/* Cliente */} {/* Cliente */}
{project.client !== 'Sin cliente' && ( {project.client !== 'Sin cliente' && (
<div className="mt-3 pt-3 border-t border-gray-100"> <div className="mt-3 pt-3 border-t border-gray-100">
<span className="text-xs text-gray-500">Cliente: </span> <span className="text-xs text-gray-500">Cliente: </span>
<span className="text-xs font-medium text-gray-700">{project.client}</span> <span className="text-xs font-medium text-gray-700">{project.client}</span>
</div> </div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
</Link>
); );
} }

View File

@ -4,8 +4,10 @@ import { useState, useEffect } from 'react';
import DashboardHeader from './dashboard-header'; import DashboardHeader from './dashboard-header';
import StatsOverview from './stats-overview'; import StatsOverview from './stats-overview';
import ProjectGrid from './project-grid'; import ProjectGrid from './project-grid';
import ProjectGridSkeleton from './project-grid-skeleton';
import { getProjects } from '@/lib/projectsService'; // Importar desde el service import { getProjects } from '@/lib/projectsService'; // Importar desde el service
import { Project } from '@/types/project'; import { Project } from '@/types/project';
import { Skeleton } from '@/components/ui/skeleton';
export default function ProjectDashboard() { export default function ProjectDashboard() {
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
@ -39,11 +41,32 @@ export default function ProjectDashboard() {
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 flex items-center justify-center"> <div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">
<div className="text-center"> <DashboardHeader
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent"></div> searchTerm=""
<p className="mt-4 text-gray-600">Cargando proyectos...</p> onSearchChange={() => {}}
</div> viewMode={viewMode}
onViewModeChange={setViewMode}
/>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Stats skeleton */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="bg-white border border-gray-200 rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-4 rounded" />
</div>
<Skeleton className="h-8 w-16 mb-2" />
<Skeleton className="h-3 w-32" />
</div>
))}
</div>
{/* Projects skeleton */}
<ProjectGridSkeleton viewMode={viewMode} count={6} />
</main>
</div> </div>
); );
} }

View File

@ -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 (
<Badge variant="outline" className="border bg-gray-100 text-gray-800 border-gray-200 text-base px-4 py-1">
Borrador
</Badge>
);
}
if (status === '1') {
return (
<Badge variant="outline" className="border bg-blue-100 text-blue-800 border-blue-200 text-base px-4 py-1">
Abierto
</Badge>
);
}
if (status === '2') {
return (
<Badge variant="outline" className="border bg-red-100 text-red-800 border-red-200 text-base px-4 py-1">
Cerrado
</Badge>
);
}
return (
<Badge variant="outline" className="border bg-gray-100 text-gray-800 border-gray-200 text-base px-4 py-1">
Desconocido
</Badge>
);
}
/**
* 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 (
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">
{/* Header */}
<div className="bg-white border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<Button
variant="ghost"
onClick={() => router.back()}
className="mb-4"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Volver
</Button>
<div className="flex items-start gap-6">
<Avatar className="w-20 h-20">
<AvatarFallback className="bg-gradient-to-br from-blue-500 to-purple-600 text-white font-semibold text-2xl">
{initials}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">{project.name}</h1>
<p className="text-gray-500 mb-3">{project.ref}</p>
<StatusBadge status={project.status} />
</div>
</div>
</div>
</div>
</div>
</div>
{/* Content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
{/* Descripción */}
{project.description && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="w-5 h-5" />
Descripción
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-700 whitespace-pre-wrap">{project.description}</p>
</CardContent>
</Card>
)}
{/* Progreso */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="w-5 h-5" />
Progreso del Proyecto
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-600">Avance Total</span>
<span className="text-2xl font-bold text-gray-900">{project.progress}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-4">
<div
className="bg-gradient-to-r from-blue-500 to-purple-600 h-4 rounded-full transition-all duration-500"
style={{ width: `${project.progress}%` }}
/>
</div>
<div className="grid grid-cols-2 gap-4 pt-4">
<div className="text-center p-4 bg-gray-50 rounded-lg">
<p className="text-sm text-gray-600 mb-1">Estado</p>
<p className="text-lg font-semibold text-gray-900">
{project.status === '0' ? 'Borrador' : project.status === '1' ? 'En Progreso' : 'Finalizado'}
</p>
</div>
<div className="text-center p-4 bg-gray-50 rounded-lg">
<p className="text-sm text-gray-600 mb-1">Completado</p>
<p className="text-lg font-semibold text-gray-900">
{project.progress === 100 ? 'Sí' : 'No'}
</p>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Presupuesto */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<DollarSign className="w-5 h-5" />
Información Financiera
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<p className="text-sm text-gray-600">Presupuesto Total</p>
<p className="text-3xl font-bold text-gray-900">
€{project.budget.toLocaleString('es-ES', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
</div>
<div className="space-y-2">
<p className="text-sm text-gray-600">Estimado Gastado</p>
<p className="text-3xl font-bold text-blue-600">
€{project.spent.toLocaleString('es-ES', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
</div>
</div>
<Separator className="my-4" />
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<span className="text-sm font-medium text-gray-600">Restante</span>
<span className="text-xl font-bold text-green-600">
€{(project.budget - project.spent).toLocaleString('es-ES', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
</div>
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Información General */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Información General</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Cliente */}
{project.client !== 'Sin cliente' && (
<div className="flex items-start gap-3">
<Building2 className="w-5 h-5 text-gray-400 mt-0.5" />
<div>
<p className="text-sm text-gray-600">Cliente</p>
<p className="font-medium text-gray-900">{project.client}</p>
</div>
</div>
)}
<Separator />
{/* Fecha de Inicio */}
<div className="flex items-start gap-3">
<Calendar className="w-5 h-5 text-gray-400 mt-0.5" />
<div>
<p className="text-sm text-gray-600">Fecha de Inicio</p>
<p className="font-medium text-gray-900">
{new Date(project.startDate).toLocaleDateString('es-ES', {
day: 'numeric',
month: 'long',
year: 'numeric'
})}
</p>
</div>
</div>
<Separator />
{/* Fecha de Fin */}
<div className="flex items-start gap-3">
<Calendar className="w-5 h-5 text-gray-400 mt-0.5" />
<div>
<p className="text-sm text-gray-600">Fecha de Fin</p>
<p className="font-medium text-gray-900">
{new Date(project.endDate).toLocaleDateString('es-ES', {
day: 'numeric',
month: 'long',
year: 'numeric'
})}
</p>
</div>
</div>
<Separator />
{/* Duración */}
<div className="flex items-start gap-3">
<Calendar className="w-5 h-5 text-gray-400 mt-0.5" />
<div>
<p className="text-sm text-gray-600">Duración</p>
<p className="font-medium text-gray-900">
{Math.ceil((new Date(project.endDate).getTime() - new Date(project.startDate).getTime()) / (1000 * 60 * 60 * 24))} días
</p>
</div>
</div>
</CardContent>
</Card>
{/* Metadatos */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Metadatos</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div>
<p className="text-gray-600">ID del Proyecto</p>
<p className="font-mono text-gray-900">{project.id}</p>
</div>
<Separator />
<div>
<p className="text-gray-600">Referencia</p>
<p className="font-mono text-gray-900">{project.ref}</p>
</div>
<Separator />
<div>
<p className="text-gray-600">Creado</p>
<p className="text-gray-900">
{new Date(project.createdAt).toLocaleDateString('es-ES', {
day: 'numeric',
month: 'short',
year: 'numeric'
})}
</p>
</div>
<Separator />
<div>
<p className="text-gray-600">Última Actualización</p>
<p className="text-gray-900">
{new Date(project.updatedAt).toLocaleDateString('es-ES', {
day: 'numeric',
month: 'short',
year: 'numeric'
})}
</p>
</div>
</CardContent>
</Card>
</div>
</div>
</main>
</div>
);
}

View File

@ -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 (
<div className="flex flex-col gap-3">
{Array.from({ length: count }).map((_, index) => (
<ProjectListItemSkeleton key={index} />
))}
</div>
);
}
// Vista de grid (por defecto)
return (
<div className="grid gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: count }).map((_, index) => (
<ProjectCardSkeleton key={index} />
))}
</div>
);
}

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import ProjectCard from './project-card'; import ProjectCard from './project-card';
import ProjectListItem from './project-list-item';
import { Project } from '@/types/project'; import { Project } from '@/types/project';
interface ProjectGridProps { interface ProjectGridProps {
@ -17,8 +18,20 @@ export default function ProjectGrid({ projects, viewMode }: ProjectGridProps) {
); );
} }
// Vista de lista
if (viewMode === 'list') {
return (
<div className="flex flex-col gap-3">
{projects.map((project) => (
<ProjectListItem key={project.id} project={project} />
))}
</div>
);
}
// Vista de grid (por defecto)
return ( return (
<div className={`grid gap-6 ${viewMode === 'grid' ? 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3' : 'grid-cols-1'}`}> <div className="grid gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => ( {projects.map((project) => (
<ProjectCard key={project.id} project={project} /> <ProjectCard key={project.id} project={project} />
))} ))}

View File

@ -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 (
<div className="bg-white border border-gray-200 rounded-lg p-4">
<div className="flex items-center gap-4">
{/* Avatar skeleton */}
<Skeleton className="w-12 h-12 rounded-lg flex-shrink-0" />
{/* Info Principal skeleton */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<Skeleton className="h-5 w-48 mb-2" />
<Skeleton className="h-4 w-32" />
</div>
{/* Estado skeleton */}
<Skeleton className="h-6 w-20" />
</div>
</div>
{/* Progreso skeleton */}
<div className="hidden md:flex items-center gap-2 flex-shrink-0 w-32">
<Skeleton className="w-4 h-4 rounded" />
<div className="flex-1">
<Skeleton className="h-3 w-full mb-1" />
<Skeleton className="h-1.5 w-full rounded-full" />
</div>
</div>
{/* Presupuesto skeleton */}
<div className="hidden lg:flex items-center gap-2 flex-shrink-0">
<Skeleton className="w-4 h-4 rounded" />
<div className="text-right">
<Skeleton className="h-5 w-20 mb-1" />
<Skeleton className="h-3 w-16" />
</div>
</div>
{/* Fechas skeleton */}
<div className="hidden xl:flex items-center gap-2 flex-shrink-0">
<Skeleton className="w-4 h-4 rounded" />
<div className="text-right">
<Skeleton className="h-5 w-16 mb-1" />
<Skeleton className="h-3 w-12" />
</div>
</div>
</div>
</div>
);
}

View File

@ -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 (
<Badge variant="outline" className="border bg-gray-100 text-gray-800 border-gray-200">
Borrador
</Badge>
);
}
if (status === '1') {
return (
<Badge variant="outline" className="border bg-blue-100 text-blue-800 border-blue-200">
Abierto
</Badge>
);
}
if (status === '2') {
return (
<Badge variant="outline" className="border bg-red-100 text-red-800 border-red-200">
Cerrado
</Badge>
);
}
// Fallback
return (
<Badge variant="outline" className="border bg-gray-100 text-gray-800 border-gray-200">
Desconocido
</Badge>
);
}
/**
* 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 (
<Link href={`/proyectos/${project.id}`}>
<div className="bg-white border border-gray-200 rounded-lg p-4 hover:shadow-md transition-all duration-300 cursor-pointer">
<div className="flex items-center gap-4">
{/* Avatar */}
<Avatar className="w-12 h-12 flex-shrink-0">
<AvatarFallback className="bg-gradient-to-br from-blue-500 to-purple-600 text-white font-semibold">
{initials}
</AvatarFallback>
</Avatar>
{/* Info Principal */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-base font-semibold text-gray-900 truncate">
{project.name}
</h3>
<span className="text-xs text-gray-500 flex-shrink-0">{project.ref}</span>
</div>
{project.client !== 'Sin cliente' && (
<p className="text-sm text-gray-600 truncate">{project.client}</p>
)}
</div>
{/* Estado */}
<StatusBadge status={project.status} />
</div>
</div>
{/* Progreso */}
<div className="hidden md:flex items-center gap-2 flex-shrink-0 w-32">
<TrendingUp className="w-4 h-4 text-gray-400" />
<div className="flex-1">
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-gray-600">Progreso</span>
<span className="text-xs font-semibold text-gray-900">{project.progress}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-1.5">
<div
className="bg-gradient-to-r from-blue-500 to-purple-600 h-1.5 rounded-full transition-all duration-500"
style={{ width: `${project.progress}%` }}
/>
</div>
</div>
</div>
{/* Presupuesto */}
<div className="hidden lg:flex items-center gap-2 flex-shrink-0">
<DollarSign className="w-4 h-4 text-green-500" />
<div className="text-right">
<div className="text-sm font-semibold text-gray-900">
€{project.budget.toLocaleString('es-ES', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}
</div>
<div className="text-xs text-gray-500">Presupuesto</div>
</div>
</div>
{/* Fechas */}
<div className="hidden xl:flex items-center gap-2 flex-shrink-0">
<Calendar className="w-4 h-4 text-gray-400" />
<div className="text-right">
<div className="text-sm text-gray-900">
{new Date(project.startDate).toLocaleDateString('es-ES', { day: '2-digit', month: 'short' })}
</div>
<div className="text-xs text-gray-500">Inicio</div>
</div>
</div>
</div>
</div>
</Link>
);
}

View File

@ -13,7 +13,9 @@ export default function StatsOverview({ projects }: StatsOverviewProps) {
total: projects.length, total: projects.length,
activos: projects.filter(p => p.progress < 100 && p.status !== "2").length, activos: projects.filter(p => p.progress < 100 && p.status !== "2").length,
totalBudget: projects.reduce((acc, p) => acc + p.budget, 0), 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 ( return (

View File

@ -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<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View File

@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Cambiar tema</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Claro
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Oscuro
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
Sistema
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -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 = {}) { 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) { if (!res.ok) {
console.error("Error en la llamada Dolibarr:", res.status, await res.text()); const errorData = await res.json().catch(() => ({ error: 'Unknown error' }));
throw new Error("Dolibarr API error"); console.error("Error en la llamada Dolibarr:", res.status, errorData);
throw new Error(errorData.error || "Dolibarr API error");
} }
return res.json(); return res.json();

11
package-lock.json generated
View File

@ -18,6 +18,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.556.0", "lucide-react": "^0.556.0",
"next": "16.0.7", "next": "16.0.7",
"next-themes": "^0.4.6",
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"tailwind-merge": "^3.4.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": { "node_modules/next/node_modules/postcss": {
"version": "8.4.31", "version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",

View File

@ -19,6 +19,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.556.0", "lucide-react": "^0.556.0",
"next": "16.0.7", "next": "16.0.7",
"next-themes": "^0.4.6",
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",