-
-
- {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 !== 'Sin cliente' && (
+
+ )}
+
+
+
);
}
\ 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",