update del proyecto en general
This commit is contained in:
parent
8003aa2a55
commit
ea708254e3
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,8 +27,14 @@ export default function RootLayout({
|
|||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<html lang="es" suppressHydrationWarning>
|
||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
|
||||
|
|
@ -38,6 +45,7 @@ export default function RootLayout({
|
|||
</main>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -29,8 +29,7 @@ import {
|
|||
Settings,
|
||||
ChevronUp,
|
||||
LogOut,
|
||||
User,
|
||||
CreditCard
|
||||
User
|
||||
} from "lucide-react";
|
||||
|
||||
// Datos del menú
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<p className="text-sm text-gray-500 mt-1">Gestiona y visualiza todos tus proyectos</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<ThemeToggle />
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,7 +59,8 @@ export default function ProjectCard({ project }: ProjectCardProps) {
|
|||
const initials = getInitials(project.name);
|
||||
|
||||
return (
|
||||
<Card className="border-gray-200 hover:shadow-lg transition-all duration-300 hover:-translate-y-1">
|
||||
<Link href={`/proyectos/${project.id}`}>
|
||||
<Card className="border-gray-200 hover:shadow-lg transition-all duration-300 hover:-translate-y-1 cursor-pointer">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
|
|
@ -148,5 +150,6 @@ export default function ProjectCard({ project }: ProjectCardProps) {
|
|||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent"></div>
|
||||
<p className="mt-4 text-gray-600">Cargando proyectos...</p>
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">
|
||||
<DashboardHeader
|
||||
searchTerm=""
|
||||
onSearchChange={() => {}}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className={`grid gap-6 ${viewMode === 'grid' ? 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3' : 'grid-cols-1'}`}>
|
||||
<div className="flex flex-col gap-3">
|
||||
{projects.map((project) => (
|
||||
<ProjectListItem key={project.id} project={project} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Vista de grid (por defecto)
|
||||
return (
|
||||
<div className="grid gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.map((project) => (
|
||||
<ProjectCard key={project.id} project={project} />
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue