Gannt dentro de las tareas

This commit is contained in:
marcos 2026-02-06 20:06:25 +01:00
parent ae66248df9
commit c9f48fcf59
19 changed files with 1879 additions and 654 deletions

276
app/configuracion/page.tsx Normal file
View File

@ -0,0 +1,276 @@
"use client";
import { useEffect, useState } from "react";
import { useTheme } from "next-themes";
import {
Settings,
Palette,
Globe,
Server,
CheckCircle2,
XCircle,
RefreshCw,
Monitor,
Sun,
Moon,
Info
} from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { toast } from "sonner";
import { dolibarrFetch } from "@/lib/dolibarrClient";
interface ConnectionStatus {
connected: boolean;
url: string;
version?: string;
error?: string;
}
export default function ConfiguracionPage() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus | null>(null);
const [isTestingConnection, setIsTestingConnection] = useState(false);
useEffect(() => {
setMounted(true);
testConnection();
}, []);
const testConnection = async () => {
setIsTestingConnection(true);
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "No configurada";
try {
const status = await dolibarrFetch("status");
setConnectionStatus({
connected: true,
url: apiUrl,
version: status?.success?.dolibarr_version || status?.dolibarr_version || "Desconocida",
});
toast.success("Conexion verificada con Dolibarr");
} catch (err) {
console.error("Connection test failed:", err);
setConnectionStatus({
connected: false,
url: apiUrl,
error: err instanceof Error ? err.message : "Error desconocido",
});
toast.error("No se pudo conectar con Dolibarr");
} finally {
setIsTestingConnection(false);
}
};
const themeOptions = [
{ value: "light", label: "Claro", icon: Sun, description: "Tema claro" },
{ value: "dark", label: "Oscuro", icon: Moon, description: "Tema oscuro" },
{ value: "system", label: "Sistema", icon: Monitor, description: "Seguir preferencia del sistema" },
];
return (
<div className="p-6 space-y-6 max-w-3xl mx-auto">
{/* Header */}
<div>
<h1 className="text-2xl font-bold tracking-tight">Ajustes</h1>
<p className="text-muted-foreground">Configuracion de la aplicacion y conexion con Dolibarr</p>
</div>
{/* Theme */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Palette className="h-5 w-5" />
Apariencia
</CardTitle>
<CardDescription>Personaliza el aspecto visual de la aplicacion</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Tema</Label>
<div className="grid grid-cols-3 gap-3">
{mounted && themeOptions.map((option) => {
const isSelected = theme === option.value;
const Icon = option.icon;
return (
<button
key={option.value}
onClick={() => setTheme(option.value)}
className={`flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-all ${
isSelected
? "border-blue-500 bg-blue-50 dark:bg-blue-950"
: "border-muted hover:border-muted-foreground/25"
}`}
>
<Icon className={`h-6 w-6 ${isSelected ? "text-blue-500" : "text-muted-foreground"}`} />
<span className={`text-sm font-medium ${isSelected ? "text-blue-600 dark:text-blue-400" : ""}`}>
{option.label}
</span>
<span className="text-xs text-muted-foreground">{option.description}</span>
</button>
);
})}
{!mounted && (
<>
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-24 w-full rounded-lg" />
))}
</>
)}
</div>
</div>
</CardContent>
</Card>
{/* Dolibarr Connection */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Server className="h-5 w-5" />
Conexion con Dolibarr
</CardTitle>
<CardDescription>Estado de la conexion con el servidor Dolibarr</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Connection status */}
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50">
<div className="flex items-center gap-3">
{isTestingConnection ? (
<RefreshCw className="h-5 w-5 text-muted-foreground animate-spin" />
) : connectionStatus?.connected ? (
<CheckCircle2 className="h-5 w-5 text-green-500" />
) : (
<XCircle className="h-5 w-5 text-red-500" />
)}
<div>
<p className="text-sm font-medium">
{isTestingConnection
? "Verificando conexion..."
: connectionStatus?.connected
? "Conectado"
: "Desconectado"}
</p>
<p className="text-xs text-muted-foreground">
{connectionStatus?.url || "Verificando..."}
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={testConnection}
disabled={isTestingConnection}
>
<RefreshCw className={`h-4 w-4 mr-2 ${isTestingConnection ? "animate-spin" : ""}`} />
Probar
</Button>
</div>
{/* Connection details */}
{connectionStatus && (
<>
<Separator />
<div className="space-y-3">
<DetailRow label="URL de la API" value={connectionStatus.url} />
{connectionStatus.connected && connectionStatus.version && (
<DetailRow label="Version de Dolibarr" value={connectionStatus.version} />
)}
{connectionStatus.error && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-red-50 dark:bg-red-950/30 text-red-700 dark:text-red-400 text-sm">
<XCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
<p>{connectionStatus.error}</p>
</div>
)}
<DetailRow
label="Estado"
value={
connectionStatus.connected ? "Operativo" : "Sin conexion"
}
badge
badgeVariant={connectionStatus.connected ? "success" : "error"}
/>
</div>
</>
)}
</CardContent>
</Card>
{/* App Info */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Info className="h-5 w-5" />
Informacion de la Aplicacion
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<DetailRow label="Aplicacion" value="Dolibarr - Gestion de Proyectos" />
<Separator />
<DetailRow label="Framework" value="Next.js 16.0.7" />
<Separator />
<DetailRow label="React" value="19.2.0" />
<Separator />
<DetailRow label="UI" value="shadcn/ui + Tailwind CSS" />
<Separator />
<DetailRow label="Idioma" value="Espanol (es-ES)" />
</CardContent>
</Card>
{/* Locale / Language */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Globe className="h-5 w-5" />
Idioma y Region
</CardTitle>
<CardDescription>Configuracion regional de la aplicacion</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<DetailRow label="Idioma" value="Espanol" />
<Separator />
<DetailRow label="Formato de fecha" value="dd/mm/aaaa (es-ES)" />
<Separator />
<DetailRow label="Moneda" value="Euro (EUR)" />
</CardContent>
</Card>
</div>
);
}
function DetailRow({
label,
value,
badge,
badgeVariant,
}: {
label: string;
value: string;
badge?: boolean;
badgeVariant?: "success" | "error";
}) {
return (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">{label}</span>
{badge ? (
<Badge
variant="outline"
className={
badgeVariant === "success"
? "bg-green-100 text-green-700 border-green-200 dark:bg-green-950 dark:text-green-300 dark:border-green-800"
: "bg-red-100 text-red-700 border-red-200 dark:bg-red-950 dark:text-red-300 dark:border-red-800"
}
>
{value}
</Badge>
) : (
<span className="text-sm font-medium">{value}</span>
)}
</div>
);
}

View File

@ -4,6 +4,7 @@ import "./globals.css";
import { SidebarProvider, SidebarTrigger, SidebarInset } from "@/components/ui/sidebar"
import { AppSidebar } from "@/components/app-sidebar"
import { ThemeProvider } from "@/components/theme-provider"
import { Toaster } from "sonner"
const geistSans = Geist({
@ -45,6 +46,7 @@ export default function RootLayout({
</main>
</SidebarInset>
</SidebarProvider>
<Toaster richColors position="bottom-right" />
</ThemeProvider>
</body>
</html>

311
app/perfil/page.tsx Normal file
View File

@ -0,0 +1,311 @@
"use client";
import { useEffect, useState } from "react";
import { User, Mail, Building, Calendar, Shield, Briefcase, Globe, Phone } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { getUser } from "@/lib/usersService";
import { getProjects } from "@/lib/projectsService";
import { Project } from "@/types/project";
interface DolibarrUser {
id: string;
login: string;
firstname: string;
lastname: string;
email: string;
admin: string;
statut: string;
employee: string;
job: string;
address: string;
zip: string;
town: string;
state_id: string;
office_phone: string;
user_mobile: string;
fk_member: string;
datelastlogin: number;
datepreviouslogin: number;
datec: string;
datem: string;
fk_soc: string;
entity: string;
}
export default function PerfilPage() {
const [user, setUser] = useState<DolibarrUser | null>(null);
const [projects, setProjects] = useState<Project[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function loadData() {
try {
const [userData, projectsData] = await Promise.all([
getUser(),
getProjects(),
]);
setUser(userData);
setProjects(projectsData);
} catch (err) {
console.error("Error loading profile data:", err);
setError("No se pudo cargar la información del perfil");
} finally {
setIsLoading(false);
}
}
loadData();
}, []);
if (isLoading) {
return <ProfileSkeleton />;
}
if (error || !user) {
return (
<div className="p-6">
<div className="flex flex-col items-center justify-center py-12 text-center">
<User className="h-12 w-12 text-muted-foreground mb-4" />
<h2 className="text-lg font-semibold mb-2">Error al cargar el perfil</h2>
<p className="text-sm text-muted-foreground">{error}</p>
</div>
</div>
);
}
const fullName = `${user.firstname} ${user.lastname}`;
const initials = `${user.firstname?.charAt(0) || ""}${user.lastname?.charAt(0) || ""}`;
const isAdmin = user.admin === "1";
const isActive = user.statut === "1";
const isEmployee = user.employee === "1";
// Stats from projects
const activeProjects = projects.filter((p) => p.status === "1").length;
const completedProjects = projects.filter((p) => p.status === "2").length;
const totalBudget = projects.reduce((acc, p) => acc + (p.budget || 0), 0);
const formatDate = (timestamp: number | string) => {
if (!timestamp) return "N/A";
const ts = typeof timestamp === "string" ? parseInt(timestamp) : timestamp;
const date = new Date(ts * 1000);
return date.toLocaleDateString("es-ES", {
day: "2-digit",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
return (
<div className="p-6 space-y-6 max-w-4xl mx-auto">
{/* Header */}
<div>
<h1 className="text-2xl font-bold tracking-tight">Mi Perfil</h1>
<p className="text-muted-foreground">Información de tu cuenta de Dolibarr</p>
</div>
{/* Profile card */}
<Card>
<CardContent className="p-6">
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-6">
{/* Avatar */}
<Avatar className="h-24 w-24 text-2xl">
<AvatarFallback className="bg-gradient-to-br from-blue-500 to-purple-600 text-white text-2xl">
{initials}
</AvatarFallback>
</Avatar>
{/* Info */}
<div className="flex-1 text-center sm:text-left space-y-3">
<div>
<h2 className="text-2xl font-bold">{fullName}</h2>
<p className="text-muted-foreground">@{user.login}</p>
</div>
<div className="flex flex-wrap items-center justify-center sm:justify-start gap-2">
<Badge variant={isActive ? "default" : "secondary"} className={isActive ? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200" : ""}>
{isActive ? "Activo" : "Inactivo"}
</Badge>
{isAdmin && (
<Badge variant="outline" className="bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-950 dark:text-amber-300 dark:border-amber-800">
<Shield className="h-3 w-3 mr-1" />
Administrador
</Badge>
)}
{isEmployee && (
<Badge variant="outline">
<Briefcase className="h-3 w-3 mr-1" />
Empleado
</Badge>
)}
</div>
</div>
</div>
</CardContent>
</Card>
{/* Stats */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Card>
<CardContent className="p-4 text-center">
<p className="text-3xl font-bold text-blue-600">{activeProjects}</p>
<p className="text-sm text-muted-foreground">Proyectos activos</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<p className="text-3xl font-bold text-green-600">{completedProjects}</p>
<p className="text-sm text-muted-foreground">Proyectos completados</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<p className="text-3xl font-bold bg-gradient-to-r from-blue-500 to-purple-600 bg-clip-text text-transparent">
{totalBudget.toLocaleString("es-ES")} &euro;
</p>
<p className="text-sm text-muted-foreground">Presupuesto total gestionado</p>
</CardContent>
</Card>
</div>
{/* Details */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Contact info */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Informaci&oacute;n de Contacto</CardTitle>
<CardDescription>Datos de contacto registrados en Dolibarr</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<InfoRow icon={Mail} label="Email" value={user.email || "No configurado"} />
<Separator />
<InfoRow icon={Phone} label="Tel&eacute;fono oficina" value={user.office_phone || "No configurado"} />
<Separator />
<InfoRow icon={Phone} label="M&oacute;vil" value={user.user_mobile || "No configurado"} />
<Separator />
<InfoRow icon={Briefcase} label="Puesto" value={user.job || "No configurado"} />
</CardContent>
</Card>
{/* Account info */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Informaci&oacute;n de la Cuenta</CardTitle>
<CardDescription>Detalles de tu cuenta en el sistema</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<InfoRow icon={User} label="ID de usuario" value={`#${user.id}`} />
<Separator />
<InfoRow icon={Globe} label="Entidad" value={user.entity || "1"} />
<Separator />
<InfoRow
icon={Calendar}
label="&Uacute;ltimo acceso"
value={user.datelastlogin ? formatDate(user.datelastlogin) : "N/A"}
/>
<Separator />
<InfoRow
icon={Calendar}
label="Acceso anterior"
value={user.datepreviouslogin ? formatDate(user.datepreviouslogin) : "N/A"}
/>
</CardContent>
</Card>
</div>
{/* Address */}
{(user.address || user.town || user.zip) && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Direcci&oacute;n</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-start gap-3">
<Building className="h-5 w-5 text-muted-foreground mt-0.5" />
<div className="text-sm">
{user.address && <p>{user.address}</p>}
{(user.zip || user.town) && (
<p className="text-muted-foreground">
{[user.zip, user.town].filter(Boolean).join(", ")}
</p>
)}
</div>
</div>
</CardContent>
</Card>
)}
</div>
);
}
// Reusable info row component
function InfoRow({ icon: Icon, label, value }: { icon: React.ComponentType<{ className?: string }>; label: string; value: string }) {
return (
<div className="flex items-center gap-3">
<Icon className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-sm font-medium truncate">{value}</p>
</div>
</div>
);
}
// Skeleton
function ProfileSkeleton() {
return (
<div className="p-6 space-y-6 max-w-4xl mx-auto">
<div>
<Skeleton className="h-8 w-48 mb-2" />
<Skeleton className="h-4 w-72" />
</div>
<Card>
<CardContent className="p-6">
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-6">
<Skeleton className="h-24 w-24 rounded-full" />
<div className="space-y-3 flex-1">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-32" />
<div className="flex gap-2">
<Skeleton className="h-6 w-16" />
<Skeleton className="h-6 w-28" />
</div>
</div>
</div>
</CardContent>
</Card>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardContent className="p-4 text-center space-y-2">
<Skeleton className="h-8 w-16 mx-auto" />
<Skeleton className="h-4 w-32 mx-auto" />
</CardContent>
</Card>
))}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{[1, 2].map((i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<CardContent className="space-y-4">
{[1, 2, 3, 4].map((j) => (
<div key={j}>
<Skeleton className="h-10 w-full" />
</div>
))}
</CardContent>
</Card>
))}
</div>
</div>
);
}

View File

@ -1,5 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { getUser } from "@/lib/usersService";
import {
@ -58,6 +60,7 @@ const items = [
];
export function AppSidebar() {
const pathname = usePathname();
const [user, setUser] = useState({
name: "Cargando...",
email: "",
@ -89,8 +92,8 @@ export function AppSidebar() {
{/* Header del Sidebar */}
<SidebarGroup>
<div className="px-4 py-6">
<h2 className="text-xl font-semibold text-gray-900">Dolibarr</h2>
<p className="text-sm text-gray-500">Gestión de Proyectos</p>
<h2 className="text-xl font-semibold text-foreground">Dolibarr</h2>
<p className="text-sm text-muted-foreground">Gestión de Proyectos</p>
</div>
</SidebarGroup>
@ -99,16 +102,21 @@ export function AppSidebar() {
<SidebarGroupLabel className="text-sm">Menú Principal</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild size="lg">
<a href={item.url} className="text-base">
<item.icon className="w-5 h-5" />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
{items.map((item) => {
const isActive = item.url === "/"
? pathname === "/"
: pathname.startsWith(item.url);
return (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild size="lg" isActive={isActive}>
<Link href={item.url} className="text-base">
<item.icon className="w-5 h-5" />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
@ -132,7 +140,7 @@ export function AppSidebar() {
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{user.name}</span>
<span className="truncate text-xs text-gray-500">{user.email}</span>
<span className="truncate text-xs text-muted-foreground">{user.email}</span>
</div>
<ChevronUp className="ml-auto size-4" />
</SidebarMenuButton>
@ -153,20 +161,22 @@ export function AppSidebar() {
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{user.name}</span>
<span className="truncate text-xs text-gray-500">{user.email}</span>
<span className="truncate text-xs text-muted-foreground">{user.email}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>
<User className="mr-2 h-4 w-4" />
<a href="/perfil">
<DropdownMenuItem asChild>
<Link href="/perfil" className="flex items-center">
<User className="mr-2 h-4 w-4" />
<span>Perfil</span>
</a>
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<Settings className="mr-2 h-4 w-4" />
<span>Ajustes</span>
<DropdownMenuItem asChild>
<Link href="/configuracion" className="flex items-center">
<Settings className="mr-2 h-4 w-4" />
<span>Ajustes</span>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-red-600">

View File

@ -1,6 +1,7 @@
"use client";
import { useState } from 'react';
import { toast } from 'sonner';
import ProjectCard from './project-card';
import ProjectListItem from './project-list-item';
import { ProjectFormSheet } from '@/components/project-form/project-form-sheet';
@ -53,6 +54,9 @@ export default function ProjectGrid({
setProjectToDelete(null);
} catch (error) {
console.error("Error deleting project:", error);
toast.error("Error al eliminar el proyecto", {
description: "No se pudo eliminar el proyecto. Inténtalo de nuevo.",
});
} finally {
setIsDeleting(false);
}

View File

@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
import { useRouter } from "next/navigation";
import {
GanttChart as GanttIcon,
AlertCircle,
@ -11,9 +12,8 @@ import {
Clock,
Filter,
} from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
@ -33,16 +33,16 @@ import { Project, ProjectStatus } from "@/types/project";
// Paleta de colores para proyectos
const PROJECT_COLORS = [
{ bg: "bg-blue-500", light: "bg-blue-100", text: "text-blue-700", hex: "#3b82f6" },
{ bg: "bg-violet-500", light: "bg-violet-100", text: "text-violet-700", hex: "#8b5cf6" },
{ bg: "bg-cyan-500", light: "bg-cyan-100", text: "text-cyan-700", hex: "#06b6d4" },
{ bg: "bg-emerald-500", light: "bg-emerald-100", text: "text-emerald-700", hex: "#10b981" },
{ bg: "bg-amber-500", light: "bg-amber-100", text: "text-amber-700", hex: "#f59e0b" },
{ bg: "bg-rose-500", light: "bg-rose-100", text: "text-rose-700", hex: "#f43f5e" },
{ bg: "bg-pink-500", light: "bg-pink-100", text: "text-pink-700", hex: "#ec4899" },
{ bg: "bg-indigo-500", light: "bg-indigo-100", text: "text-indigo-700", hex: "#6366f1" },
{ bg: "bg-teal-500", light: "bg-teal-100", text: "text-teal-700", hex: "#14b8a6" },
{ bg: "bg-orange-500", light: "bg-orange-100", text: "text-orange-700", hex: "#f97316" },
{ bg: "bg-blue-500", light: "bg-blue-100 dark:bg-blue-900/40", hex: "#3b82f6" },
{ bg: "bg-violet-500", light: "bg-violet-100 dark:bg-violet-900/40", hex: "#8b5cf6" },
{ bg: "bg-cyan-500", light: "bg-cyan-100 dark:bg-cyan-900/40", hex: "#06b6d4" },
{ bg: "bg-emerald-500", light: "bg-emerald-100 dark:bg-emerald-900/40", hex: "#10b981" },
{ bg: "bg-amber-500", light: "bg-amber-100 dark:bg-amber-900/40", hex: "#f59e0b" },
{ bg: "bg-rose-500", light: "bg-rose-100 dark:bg-rose-900/40", hex: "#f43f5e" },
{ bg: "bg-pink-500", light: "bg-pink-100 dark:bg-pink-900/40", hex: "#ec4899" },
{ bg: "bg-indigo-500", light: "bg-indigo-100 dark:bg-indigo-900/40", hex: "#6366f1" },
{ bg: "bg-teal-500", light: "bg-teal-100 dark:bg-teal-900/40", hex: "#14b8a6" },
{ bg: "bg-orange-500", light: "bg-orange-100 dark:bg-orange-900/40", hex: "#f97316" },
];
const STATUS_LABELS: Record<ProjectStatus, string> = {
@ -51,6 +51,8 @@ const STATUS_LABELS: Record<ProjectStatus, string> = {
"2": "Cerrado",
};
const MONTH_WIDTH_PX = 120; // Ancho fijo por mes en modo scroll
type ViewMode = "month" | "quarter" | "year";
type TimeRange = "future" | "past" | "all";
@ -58,18 +60,15 @@ function getProjectColor(index: number) {
return PROJECT_COLORS[index % PROJECT_COLORS.length];
}
// Formatear fecha
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleDateString("es-ES", { day: "2-digit", month: "short", year: "numeric" });
}
// Obtener días entre dos fechas
function getDaysBetween(start: Date, end: Date): number {
return Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
}
// Generar array de meses entre dos fechas
function getMonthsBetween(start: Date, end: Date): { month: number; year: number; label: string }[] {
const months: { month: number; year: number; label: string }[] = [];
const current = new Date(start.getFullYear(), start.getMonth(), 1);
@ -87,20 +86,32 @@ function getMonthsBetween(start: Date, end: Date): { month: number; year: number
return months;
}
// Obtener días en un mes
function getDaysInMonth(month: number, year: number): number {
return new Date(year, month + 1, 0).getDate();
}
export default function GanttPage() {
const router = useRouter();
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<ViewMode>("month");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [timeRange, setTimeRange] = useState<TimeRange>("future");
const [timeRange, setTimeRange] = useState<TimeRange>("all");
const [viewOffset, setViewOffset] = useState(0);
// Refs para sincronizar scroll vertical entre nombres y timeline
const namesRef = useRef<HTMLDivElement>(null);
const timelineRef = useRef<HTMLDivElement>(null);
const isSyncing = useRef(false);
const syncScroll = useCallback((source: "names" | "timeline") => {
if (isSyncing.current) return;
isSyncing.current = true;
const from = source === "names" ? namesRef.current : timelineRef.current;
const to = source === "names" ? timelineRef.current : namesRef.current;
if (from && to) {
to.scrollTop = from.scrollTop;
}
requestAnimationFrame(() => { isSyncing.current = false; });
}, []);
useEffect(() => {
async function loadProjects() {
try {
@ -118,150 +129,101 @@ export default function GanttPage() {
loadProjects();
}, []);
// Filtrar proyectos
// Filtrar proyectos por estado
const statusFiltered = useMemo(() => {
if (statusFilter === "all") return projects;
return projects.filter(p => p.status === statusFilter);
}, [projects, statusFilter]);
// Filtrar por rango temporal
const filteredProjects = useMemo(() => {
let filtered = projects;
// Filtrar por estado
if (statusFilter !== "all") {
filtered = filtered.filter(p => p.status === statusFilter);
}
// Filtrar por rango temporal
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
if (timeRange === "future") {
// Proyectos que terminan hoy o en el futuro
filtered = filtered.filter(p => new Date(p.endDate) >= today);
return statusFiltered.filter(p => new Date(p.endDate) >= today);
} else if (timeRange === "past") {
// Proyectos que ya terminaron
filtered = filtered.filter(p => new Date(p.endDate) < today);
return statusFiltered.filter(p => new Date(p.endDate) < today);
}
// "all" no filtra
return statusFiltered; // "all"
}, [statusFiltered, timeRange]);
return filtered;
}, [projects, statusFilter, timeRange]);
// Calcular meses del timeline completo
const allMonths = useMemo(() => {
if (filteredProjects.length === 0) {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth() - 3, 1);
const end = new Date(now.getFullYear(), now.getMonth() + 3, 0);
return getMonthsBetween(start, end);
}
const allStartDates = filteredProjects.map(p => new Date(p.startDate));
const allEndDates = filteredProjects.map(p => new Date(p.endDate));
const minStart = new Date(Math.min(...allStartDates.map(d => d.getTime())));
const maxEnd = new Date(Math.max(...allEndDates.map(d => d.getTime())));
// Calcular rango de fechas del timeline basado en los proyectos
// - Futuro: desde el mes actual hasta el fin del proyecto más tardío
// - Pasado: desde el inicio del proyecto más antiguo hasta el mes actual
// - Todo: desde el inicio del proyecto más antiguo hasta el fin del más tardío
const { timelineStart, timelineEnd, months } = useMemo(() => {
const now = new Date();
const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const currentMonthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
// Si no hay proyectos, mostrar 12 meses desde el actual
if (filteredProjects.length === 0) {
const start = new Date(now.getFullYear(), now.getMonth() - 6, 1);
const end = new Date(now.getFullYear(), now.getMonth() + 6, 0);
return {
timelineStart: start,
timelineEnd: end,
months: getMonthsBetween(start, end),
};
}
// Calcular fechas extremas de los proyectos
const allStartDates = filteredProjects.map(p => new Date(p.startDate));
const allEndDates = filteredProjects.map(p => new Date(p.endDate));
const minProjectStart = new Date(Math.min(...allStartDates.map(d => d.getTime())));
const maxProjectEnd = new Date(Math.max(...allEndDates.map(d => d.getTime())));
if (timeRange === "future") {
// Desde el mes actual hasta el fin del proyecto más tardío
const start = currentMonthStart;
const end = new Date(maxProjectEnd.getFullYear(), maxProjectEnd.getMonth() + 1, 0);
return {
timelineStart: start,
timelineEnd: end,
months: getMonthsBetween(start, end),
};
return getMonthsBetween(currentMonthStart, new Date(maxEnd.getFullYear(), maxEnd.getMonth() + 1, 0));
} else if (timeRange === "past") {
// Desde el inicio del proyecto más antiguo hasta el mes actual
const start = new Date(minProjectStart.getFullYear(), minProjectStart.getMonth(), 1);
const end = currentMonthEnd;
return {
timelineStart: start,
timelineEnd: end,
months: getMonthsBetween(start, end),
};
} else {
// "all" - Desde el inicio del proyecto más antiguo hasta el fin del más tardío
const start = new Date(minProjectStart.getFullYear(), minProjectStart.getMonth(), 1);
const end = new Date(maxProjectEnd.getFullYear(), maxProjectEnd.getMonth() + 1, 0);
return {
timelineStart: start,
timelineEnd: end,
months: getMonthsBetween(start, end),
};
return getMonthsBetween(new Date(minStart.getFullYear(), minStart.getMonth(), 1), currentMonthEnd);
}
// "all": desde el primer proyecto hasta el último
return getMonthsBetween(
new Date(minStart.getFullYear(), minStart.getMonth(), 1),
new Date(maxEnd.getFullYear(), maxEnd.getMonth() + 1, 0)
);
}, [filteredProjects, timeRange]);
// Calcular meses visibles según el modo de vista
// En modo "todo", mostrar TODOS los meses (scroll horizontal en el timeline)
// En modo "pasado", empezamos desde los meses más recientes
// Meses visibles: en modo "all" se muestran todos (scroll), en otros modos se pagina
const visibleMonths = useMemo(() => {
// En modo "todo", mostrar todos los meses
if (timeRange === "all") {
return months;
}
if (timeRange === "all") return allMonths;
const monthsToShow = viewMode === "month" ? 3 : viewMode === "quarter" ? 6 : 12;
if (timeRange === "past") {
// En pasado, viewOffset 0 = meses más recientes (final del array)
const maxOffset = Math.max(0, months.length - monthsToShow);
const startIdx = Math.max(0, maxOffset - viewOffset);
return months.slice(startIdx, startIdx + monthsToShow);
} else {
// En futuro, viewOffset 0 = primeros meses
const startIdx = Math.max(0, Math.min(viewOffset, months.length - monthsToShow));
return months.slice(startIdx, startIdx + monthsToShow);
const maxOff = Math.max(0, allMonths.length - monthsToShow);
const startIdx = Math.max(0, maxOff - viewOffset);
return allMonths.slice(startIdx, startIdx + monthsToShow);
}
}, [months, viewMode, viewOffset, timeRange]);
const startIdx = Math.max(0, Math.min(viewOffset, allMonths.length - monthsToShow));
return allMonths.slice(startIdx, startIdx + monthsToShow);
}, [allMonths, viewMode, viewOffset, timeRange]);
// Calcular el ancho total en días para los meses visibles
// Rango visible en días
const { totalDays, visibleStart, visibleEnd } = useMemo(() => {
if (visibleMonths.length === 0) {
return { totalDays: 30, visibleStart: new Date(), visibleEnd: new Date() };
}
const first = visibleMonths[0];
const last = visibleMonths[visibleMonths.length - 1];
const start = new Date(first.year, first.month, 1);
const end = new Date(last.year, last.month + 1, 0);
return {
totalDays: getDaysBetween(start, end),
visibleStart: start,
visibleEnd: end,
};
return { totalDays: getDaysBetween(start, end), visibleStart: start, visibleEnd: end };
}, [visibleMonths]);
// Navegación
// Navegación (solo para modos paginados, no "all")
const monthsToShow = viewMode === "month" ? 3 : viewMode === "quarter" ? 6 : 12;
const maxOffset = Math.max(0, months.length - monthsToShow);
const canGoBack = viewOffset > 0;
const canGoForward = viewOffset < maxOffset;
const maxOffset = Math.max(0, allMonths.length - monthsToShow);
const canGoBack = timeRange !== "all" && viewOffset > 0;
const canGoForward = timeRange !== "all" && viewOffset < maxOffset;
const goBack = () => {
const step = viewMode === "month" ? 1 : viewMode === "quarter" ? 3 : 6;
setViewOffset(Math.max(0, viewOffset - step));
};
const goForward = () => {
const step = viewMode === "month" ? 1 : viewMode === "quarter" ? 3 : 6;
setViewOffset(Math.min(maxOffset, viewOffset + step));
};
const goToToday = () => setViewOffset(0);
const goToToday = () => {
setViewOffset(0); // Volver al mes actual
};
// ¿El timeline usa ancho fijo (scroll horizontal) o flexible (fill)?
const useFixedWidth = timeRange === "all";
const timelineInnerWidth = useFixedWidth ? visibleMonths.length * MONTH_WIDTH_PX : undefined;
if (error) {
return (
@ -282,7 +244,7 @@ export default function GanttPage() {
return (
<div className="h-screen flex flex-col bg-background">
{/* Header - siempre visible */}
{/* Header */}
<header className="bg-card border-b flex-shrink-0 z-10">
<div className="px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between flex-wrap gap-4">
@ -293,7 +255,7 @@ export default function GanttPage() {
<div>
<h1 className="text-2xl font-bold text-foreground">Diagrama de Gantt</h1>
<p className="text-sm text-muted-foreground">
Timeline de {filteredProjects.length} proyectos
Timeline de {filteredProjects.length} proyecto{filteredProjects.length !== 1 ? "s" : ""}
</p>
</div>
</div>
@ -301,7 +263,7 @@ export default function GanttPage() {
{/* Controles */}
<div className="flex items-center gap-3 flex-wrap">
{/* Filtro de estado */}
<Select value={statusFilter} onValueChange={setStatusFilter}>
<Select value={statusFilter} onValueChange={(v) => { setStatusFilter(v); setViewOffset(0); }}>
<SelectTrigger className="w-40">
<Filter className="w-4 h-4 mr-2" />
<SelectValue placeholder="Estado" />
@ -338,42 +300,45 @@ export default function GanttPage() {
onClick={() => { setTimeRange("all"); setViewOffset(0); }}
>
<Calendar className="w-4 h-4 mr-1" />
Todo
Todos
</Button>
</div>
{/* Selector de vista */}
<Select value={viewMode} onValueChange={(v: ViewMode) => setViewMode(v)}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="month">3 meses</SelectItem>
<SelectItem value="quarter">6 meses</SelectItem>
<SelectItem value="year">12 meses</SelectItem>
</SelectContent>
</Select>
{/* Selector de vista y navegación - solo en modos paginados */}
{timeRange !== "all" && (
<>
<Select value={viewMode} onValueChange={(v: ViewMode) => { setViewMode(v); setViewOffset(0); }}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="month">3 meses</SelectItem>
<SelectItem value="quarter">6 meses</SelectItem>
<SelectItem value="year">12 meses</SelectItem>
</SelectContent>
</Select>
{/* Navegación */}
<div className="flex items-center gap-1">
<Button variant="outline" size="icon" onClick={goBack} disabled={!canGoBack}>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button variant="outline" size="sm" onClick={goToToday} disabled={viewOffset === 0}>
<Calendar className="w-4 h-4 mr-1" />
Inicio
</Button>
<Button variant="outline" size="icon" onClick={goForward} disabled={!canGoForward}>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
<div className="flex items-center gap-1">
<Button variant="outline" size="icon" onClick={goBack} disabled={!canGoBack}>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button variant="outline" size="sm" onClick={goToToday} disabled={viewOffset === 0}>
<Calendar className="w-4 h-4 mr-1" />
Inicio
</Button>
<Button variant="outline" size="icon" onClick={goForward} disabled={!canGoForward}>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</>
)}
</div>
</div>
</div>
</header>
{/* Content - área scrollable */}
<main className="flex-1 overflow-auto px-4 sm:px-6 lg:px-8 py-6">
{/* Content */}
<main className="flex-1 overflow-hidden px-4 sm:px-6 lg:px-8 py-6">
{loading ? (
<GanttSkeleton />
) : filteredProjects.length === 0 ? (
@ -385,54 +350,62 @@ export default function GanttPage() {
</CardContent>
</Card>
) : (
<Card className="border w-full">
<CardContent className="p-0 overflow-hidden">
<div className="flex w-full overflow-hidden">
{/* Columna de nombres de proyectos - fija */}
<div className="w-64 flex-shrink-0 border-r bg-muted/50">
{/* Header */}
<div className="h-16 border-b flex items-center px-4">
<span className="font-semibold text-foreground">Proyecto</span>
<Card className="border h-full flex flex-col overflow-hidden">
<CardContent className="p-0 flex-1 overflow-hidden">
<div className="flex h-full overflow-hidden">
{/* Columna de nombres - fija, scroll vertical */}
<div className="w-56 flex-shrink-0 border-r bg-muted/50 flex flex-col">
{/* Header nombres */}
<div className="h-12 border-b flex items-center px-4 flex-shrink-0">
<span className="font-semibold text-sm text-foreground">Proyecto</span>
</div>
{/* Lista de proyectos */}
{filteredProjects.map((project, index) => {
const color = getProjectColor(index);
return (
<div
key={project.id}
className="h-14 border-b flex items-center px-4 hover:bg-muted transition-colors"
>
<div className="flex items-center gap-3 min-w-0">
<div className={`w-3 h-3 rounded-full ${color.bg} flex-shrink-0`} />
<div className="min-w-0">
<p className="text-sm font-medium text-foreground truncate" title={project.name}>
{project.name}
</p>
<p className="text-xs text-muted-foreground">{project.progress}%</p>
{/* Lista nombres - scroll vertical */}
<div
ref={namesRef}
className="flex-1 overflow-y-auto overflow-x-hidden"
onScroll={() => syncScroll("names")}
>
{filteredProjects.map((project, index) => {
const color = getProjectColor(index);
return (
<div
key={project.id}
className="h-12 border-b flex items-center px-4 hover:bg-muted transition-colors cursor-pointer"
onClick={() => router.push(`/proyectos/${project.id}`)}
>
<div className="flex items-center gap-2.5 min-w-0">
<div className={`w-2.5 h-2.5 rounded-full ${color.bg} flex-shrink-0`} />
<div className="min-w-0">
<p className="text-sm font-medium text-foreground truncate" title={project.name}>
{project.name}
</p>
<p className="text-[11px] text-muted-foreground">{STATUS_LABELS[project.status]} · {project.progress}%</p>
</div>
</div>
</div>
</div>
);
})}
);
})}
</div>
</div>
{/* Timeline - scroll horizontal en modo "todo" */}
{/* Timeline - scroll horizontal (en modo "all") + scroll vertical sincronizado */}
<div
className={`${timeRange === "all" ? "overflow-x-auto overflow-y-hidden" : "overflow-hidden"}`}
style={{ width: "calc(100% - 256px)" }}
ref={timelineRef}
className="flex-1 min-w-0 overflow-auto"
onScroll={() => syncScroll("timeline")}
>
<div style={{ width: timeRange === "all" ? `${visibleMonths.length * 100}px` : "100%" }}>
{/* Header con meses */}
<div className="h-16 border-b flex">
<div style={{ width: timelineInnerWidth ? `${timelineInnerWidth}px` : "100%", minWidth: useFixedWidth ? undefined : "100%" }}>
{/* Header meses */}
<div className="h-12 border-b flex sticky top-0 z-10 bg-card">
{visibleMonths.map((month) => {
const isCurrentMonth = new Date().getMonth() === month.month && new Date().getFullYear() === month.year;
return (
<div
key={`${month.year}-${month.month}`}
className={`flex-1 border-r flex flex-col justify-center px-2 ${
className={`border-r flex items-center justify-center ${
isCurrentMonth ? "bg-blue-50 dark:bg-blue-950/30" : "bg-card"
}`}
style={{ width: useFixedWidth ? `${MONTH_WIDTH_PX}px` : undefined, flex: useFixedWidth ? "none" : 1 }}
>
<span className={`text-xs font-medium ${isCurrentMonth ? "text-blue-700 dark:text-blue-400" : "text-muted-foreground"}`}>
{month.label}
@ -449,33 +422,26 @@ export default function GanttPage() {
const projectStart = new Date(project.startDate);
const projectEnd = new Date(project.endDate);
// Calcular posición y ancho de la barra basado en días
const startOffset = Math.max(0, getDaysBetween(visibleStart, projectStart));
const endOffset = Math.min(totalDays, getDaysBetween(visibleStart, projectEnd));
const leftPercent = (startOffset / totalDays) * 100;
const widthPercent = Math.max(1, ((endOffset - startOffset) / totalDays) * 100);
const widthPercent = Math.max(0.5, ((endOffset - startOffset) / totalDays) * 100);
// Verificar si el proyecto está visible en el rango actual
const isVisible = projectEnd >= visibleStart && projectStart <= visibleEnd;
const isOverdue = projectEnd < new Date() && project.progress < 100 && project.status === "1";
return (
<div
key={project.id}
className="h-14 border-b relative"
>
<div key={project.id} className="h-12 border-b relative">
{/* Grid de meses */}
<div className="absolute inset-0 flex">
{visibleMonths.map((month) => {
const isCurrentMonth = new Date().getMonth() === month.month && new Date().getFullYear() === month.year;
return (
<div
key={`grid-${month.year}-${month.month}`}
className={`flex-1 border-r ${
isCurrentMonth ? "bg-blue-50/30 dark:bg-blue-950/20" : ""
}`}
className={`border-r ${isCurrentMonth ? "bg-blue-50/30 dark:bg-blue-950/20" : ""}`}
style={{ width: useFixedWidth ? `${MONTH_WIDTH_PX}px` : undefined, flex: useFixedWidth ? "none" : 1 }}
/>
);
})}
@ -486,52 +452,48 @@ export default function GanttPage() {
<Tooltip>
<TooltipTrigger asChild>
<div
className={`absolute top-2 h-10 rounded-md cursor-pointer transition-all hover:scale-[1.02] hover:shadow-md ${
className={`absolute top-1.5 h-9 rounded-md cursor-pointer transition-all hover:scale-[1.02] hover:shadow-md ${
isOverdue ? "ring-2 ring-red-400" : ""
}`}
style={{
left: `${leftPercent}%`,
width: `${widthPercent}%`,
minWidth: "20px",
minWidth: "16px",
}}
onClick={() => router.push(`/proyectos/${project.id}`)}
>
{/* Fondo de la barra */}
<div className={`absolute inset-0 ${color.light} rounded-md`} />
{/* Progreso */}
<div
className={`absolute inset-y-0 left-0 ${color.bg} rounded-md transition-all`}
style={{ width: `${project.progress}%` }}
/>
{/* Contenido de la barra */}
<div className="relative h-full flex items-center px-2 z-10">
<span className="text-xs font-medium text-white truncate drop-shadow-sm">
{project.progress >= 30 ? `${project.progress}%` : ""}
</span>
<div className={`absolute inset-0 ${color.light} rounded-md`} />
<div
className={`absolute inset-y-0 left-0 ${color.bg} rounded-md transition-all`}
style={{ width: `${project.progress}%` }}
/>
<div className="relative h-full flex items-center px-2 z-10">
<span className="text-xs font-medium text-white truncate drop-shadow-sm">
{project.progress >= 30 ? `${project.progress}%` : ""}
</span>
</div>
</div>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<div className="space-y-1">
<p className="font-semibold">{project.name}</p>
<div className="text-xs space-y-0.5">
<p><span className="text-gray-500">Estado:</span> {STATUS_LABELS[project.status]}</p>
<p><span className="text-gray-500">Progreso:</span> {project.progress}%</p>
<p><span className="text-gray-500">Inicio:</span> {formatDate(project.startDate)}</p>
<p><span className="text-gray-500">Fin:</span> {formatDate(project.endDate)}</p>
<p><span className="text-gray-500">Cliente:</span> {project.client}</p>
{isOverdue && (
<p className="text-red-500 font-medium">Proyecto retrasado</p>
)}
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<div className="space-y-1">
<p className="font-semibold">{project.name}</p>
<div className="text-xs space-y-0.5">
<p><span className="text-muted-foreground">Estado:</span> {STATUS_LABELS[project.status]}</p>
<p><span className="text-muted-foreground">Progreso:</span> {project.progress}%</p>
<p><span className="text-muted-foreground">Inicio:</span> {formatDate(project.startDate)}</p>
<p><span className="text-muted-foreground">Fin:</span> {formatDate(project.endDate)}</p>
<p><span className="text-muted-foreground">Cliente:</span> {project.client}</p>
{isOverdue && (
<p className="text-red-500 font-medium">Proyecto retrasado</p>
)}
</div>
</div>
</div>
</TooltipContent>
</Tooltip>
)}
</div>
);
})}
</TooltipContent>
</Tooltip>
)}
</div>
);
})}
</TooltipProvider>
</div>
</div>
@ -546,36 +508,36 @@ export default function GanttPage() {
function GanttSkeleton() {
return (
<Card className="border">
<CardContent className="p-0">
<div className="flex">
<div className="w-64 flex-shrink-0 border-r bg-muted/50">
<div className="h-16 border-b flex items-center px-4">
<Card className="border h-full">
<CardContent className="p-0 h-full">
<div className="flex h-full">
<div className="w-56 flex-shrink-0 border-r bg-muted/50">
<div className="h-12 border-b flex items-center px-4">
<Skeleton className="h-4 w-20" />
</div>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-14 border-b flex items-center px-4">
<div className="flex items-center gap-3">
<Skeleton className="w-3 h-3 rounded-full" />
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-12 border-b flex items-center px-4">
<div className="flex items-center gap-2.5">
<Skeleton className="w-2.5 h-2.5 rounded-full" />
<div>
<Skeleton className="h-4 w-32 mb-1" />
<Skeleton className="h-3 w-12" />
<Skeleton className="h-3.5 w-28 mb-1" />
<Skeleton className="h-2.5 w-16" />
</div>
</div>
</div>
))}
</div>
<div className="flex-1">
<div className="h-16 border-b flex">
{Array.from({ length: 3 }).map((_, i) => (
<div className="h-12 border-b flex">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex-1 border-r flex items-center justify-center">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-3.5 w-16" />
</div>
))}
</div>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-14 border-b px-4 flex items-center">
<Skeleton className="h-8 w-full max-w-md rounded-md" />
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-12 border-b px-4 flex items-center">
<Skeleton className="h-7 w-full max-w-md rounded-md" />
</div>
))}
</div>

View File

@ -2,5 +2,6 @@ export { ProjectHeader } from "./project-header";
export { ProjectStats } from "./project-stats";
export { ProjectInfo } from "./project-info";
export { TasksSection } from "./tasks-section";
export { TaskGantt } from "./task-gantt";
export { ProjectDetailView, ProjectDetailSkeleton } from "./project-detail-view";
export { taskColumns } from "./task-columns";

View File

@ -1,7 +1,7 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { FolderKanban, RefreshCw, LayoutDashboard, ListTodo, FileText } from "lucide-react";
import { FolderKanban, RefreshCw, LayoutDashboard, ListTodo, GanttChart } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
@ -12,6 +12,7 @@ import { ProjectHeader } from "./project-header";
import { ProjectStats } from "./project-stats";
import { ProjectInfo } from "./project-info";
import { TasksSection } from "./tasks-section";
import { TaskGantt } from "./task-gantt";
import { Project } from "@/types/project";
import { Task } from "@/types/task";
@ -159,9 +160,9 @@ export function ProjectDetailView({ project: initialProject }: ProjectDetailView
</span>
)}
</TabsTrigger>
<TabsTrigger value="details" className="gap-2">
<FileText className="h-4 w-4" />
Detalles
<TabsTrigger value="gantt" className="gap-2">
<GanttChart className="h-4 w-4" />
Gantt
</TabsTrigger>
</TabsList>
@ -222,9 +223,28 @@ export function ProjectDetailView({ project: initialProject }: ProjectDetailView
)}
</TabsContent>
{/* Tab: Detalles */}
<TabsContent value="details">
<ProjectInfo project={project} />
{/* Tab: Gantt */}
<TabsContent value="gantt">
{isLoadingTasks ? (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Skeleton className="h-9 w-[120px]" />
<Skeleton className="h-9 w-[100px]" />
<Skeleton className="h-9 w-[100px]" />
</div>
<Skeleton className="h-[400px] w-full rounded-lg" />
</div>
) : tasksError ? (
<ErrorState onRetry={fetchTasks} />
) : (
<TaskGantt
tasks={tasks}
projectId={project.id}
onTaskCreated={handleTaskCreated}
onTaskUpdated={handleTaskUpdated}
onTaskDeleted={handleTaskDeleted}
/>
)}
</TabsContent>
</Tabs>
</div>

View File

@ -4,6 +4,7 @@ import { useState } from "react";
import { ArrowLeft, MoreHorizontal, Pencil, Trash2, Share2, Copy } from "lucide-react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
@ -59,6 +60,9 @@ export function ProjectHeader({ project, onProjectUpdated, onProjectDeleted }: P
router.push('/proyectos');
} catch (error) {
console.error("Error deleting project:", error);
toast.error("Error al eliminar el proyecto", {
description: "No se pudo eliminar el proyecto. Inténtalo de nuevo.",
});
} finally {
setIsDeleting(false);
}

View File

@ -0,0 +1,647 @@
"use client";
import { useState, useMemo, useRef, useEffect, useCallback } from "react";
import {
GanttChart as GanttIcon,
Plus,
Link2,
AlertTriangle,
CheckCircle2,
Clock,
History,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Task, TASK_STATUS_CONFIG, TASK_PRIORITY_CONFIG } from "@/types/task";
import { setTaskDependencies } from "@/lib/tasksService";
import { TaskFormSheet } from "@/components/task-form/task-form-sheet";
// Paleta de colores para tareas
const TASK_COLORS = [
{ bg: "bg-blue-500", light: "bg-blue-100 dark:bg-blue-900/40", hex: "#3b82f6" },
{ bg: "bg-violet-500", light: "bg-violet-100 dark:bg-violet-900/40", hex: "#8b5cf6" },
{ bg: "bg-cyan-500", light: "bg-cyan-100 dark:bg-cyan-900/40", hex: "#06b6d4" },
{ bg: "bg-emerald-500", light: "bg-emerald-100 dark:bg-emerald-900/40", hex: "#10b981" },
{ bg: "bg-amber-500", light: "bg-amber-100 dark:bg-amber-900/40", hex: "#f59e0b" },
{ bg: "bg-rose-500", light: "bg-rose-100 dark:bg-rose-900/40", hex: "#f43f5e" },
{ bg: "bg-pink-500", light: "bg-pink-100 dark:bg-pink-900/40", hex: "#ec4899" },
{ bg: "bg-indigo-500", light: "bg-indigo-100 dark:bg-indigo-900/40", hex: "#6366f1" },
{ bg: "bg-teal-500", light: "bg-teal-100 dark:bg-teal-900/40", hex: "#14b8a6" },
{ bg: "bg-orange-500", light: "bg-orange-100 dark:bg-orange-900/40", hex: "#f97316" },
];
function getTaskColor(index: number) {
return TASK_COLORS[index % TASK_COLORS.length];
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleDateString("es-ES", { day: "2-digit", month: "short", year: "numeric" });
}
function getDaysBetween(start: Date, end: Date): number {
return Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
}
function getMonthsBetween(start: Date, end: Date): { month: number; year: number; label: string }[] {
const months: { month: number; year: number; label: string }[] = [];
const current = new Date(start.getFullYear(), start.getMonth(), 1);
const endMonth = new Date(end.getFullYear(), end.getMonth(), 1);
while (current <= endMonth) {
months.push({
month: current.getMonth(),
year: current.getFullYear(),
label: current.toLocaleDateString("es-ES", { month: "short", year: "numeric" }),
});
current.setMonth(current.getMonth() + 1);
}
return months;
}
type TimeRange = "future" | "past";
const ROW_HEIGHT = 48;
const HEADER_HEIGHT = 56;
const NAME_COL_WIDTH = 240;
const MONTH_WIDTH = 120; // px fijo por mes para scroll horizontal
interface TaskGanttProps {
tasks: Task[];
projectId: number;
onTaskCreated?: (task: Task) => void;
onTaskUpdated?: (task: Task) => void;
onTaskDeleted?: (taskId: number) => void;
}
type InteractionMode = "normal" | "linking";
export function TaskGantt({ tasks, projectId, onTaskCreated, onTaskUpdated }: TaskGanttProps) {
const [timeRange, setTimeRange] = useState<TimeRange>("future");
const [isCreateSheetOpen, setIsCreateSheetOpen] = useState(false);
const [interactionMode, setInteractionMode] = useState<InteractionMode>("normal");
const [linkSource, setLinkSource] = useState<number | null>(null);
const timelineRef = useRef<HTMLDivElement>(null);
const timelineScrollRef = useRef<HTMLDivElement>(null);
const [svgSize, setSvgSize] = useState({ width: 0, height: 0 });
// TODAS las tareas, ordenadas por fecha de inicio (las sin fecha al final)
const ganttTasks = useMemo(() => {
return [...tasks].sort((a, b) => {
const aStart = a.startDate || a.plannedStartDate;
const bStart = b.startDate || b.plannedStartDate;
if (!aStart && !bStart) return 0;
if (!aStart) return 1;
if (!bStart) return -1;
return new Date(aStart).getTime() - new Date(bStart).getTime();
});
}, [tasks]);
// Mapa de índices
const taskIndexMap = useMemo(() => {
const map = new Map<number, number>();
ganttTasks.forEach((t, i) => map.set(t.id, i));
return map;
}, [ganttTasks]);
// Calcular rango del timeline según timeRange
const { months, timelineStart, timelineEnd } = useMemo(() => {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
// Recoger las fechas de tareas que tienen fechas
const tasksWithDates = ganttTasks.filter(t => (t.startDate || t.plannedStartDate) && (t.endDate || t.plannedEndDate));
if (tasksWithDates.length === 0) {
// Sin tareas con fechas: mostrar 6 meses desde hoy
const start = currentMonthStart;
const end = new Date(now.getFullYear(), now.getMonth() + 6, 0);
return { months: getMonthsBetween(start, end), timelineStart: start, timelineEnd: end };
}
const allStartDates = tasksWithDates.map(t => new Date(t.startDate || t.plannedStartDate!));
const allEndDates = tasksWithDates.map(t => new Date(t.endDate || t.plannedEndDate!));
const minTaskStart = new Date(Math.min(...allStartDates.map(d => d.getTime())));
const maxTaskEnd = new Date(Math.max(...allEndDates.map(d => d.getTime())));
if (timeRange === "future") {
// Desde el mes actual hasta el fin de la última tarea (+1 mes margen)
const start = currentMonthStart;
const end = new Date(maxTaskEnd.getFullYear(), maxTaskEnd.getMonth() + 2, 0);
return { months: getMonthsBetween(start, end), timelineStart: start, timelineEnd: end };
} else {
// Pasado: desde el inicio de la primera tarea hasta el mes actual (+1 mes margen)
const start = new Date(minTaskStart.getFullYear(), minTaskStart.getMonth() - 1, 1);
const currentMonthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
return { months: getMonthsBetween(start, currentMonthEnd), timelineStart: start, timelineEnd: currentMonthEnd };
}
}, [ganttTasks, timeRange]);
// Total days del timeline completo
const totalDays = useMemo(() => {
if (months.length === 0) return 30;
const first = months[0];
const last = months[months.length - 1];
const start = new Date(first.year, first.month, 1);
const end = new Date(last.year, last.month + 1, 0);
return getDaysBetween(start, end);
}, [months]);
// Visión completa: start/end del timeline
const visibleStart = useMemo(() => {
if (months.length === 0) return new Date();
return new Date(months[0].year, months[0].month, 1);
}, [months]);
const visibleEnd = useMemo(() => {
if (months.length === 0) return new Date();
const last = months[months.length - 1];
return new Date(last.year, last.month + 1, 0);
}, [months]);
// Ancho total del timeline en px
const timelineWidth = months.length * MONTH_WIDTH;
// Calcular posición de barra (en px, no %)
const getBarPosition = useCallback(
(task: Task) => {
const taskStart = new Date(task.startDate || task.plannedStartDate!);
const taskEnd = new Date(task.endDate || task.plannedEndDate!);
const startOffset = Math.max(0, getDaysBetween(visibleStart, taskStart));
const endOffset = Math.min(totalDays, getDaysBetween(visibleStart, taskEnd));
const leftPx = (startOffset / totalDays) * timelineWidth;
const widthPx = Math.max(8, ((endOffset - startOffset) / totalDays) * timelineWidth);
return { leftPx, widthPx };
},
[visibleStart, totalDays, timelineWidth]
);
// Medir SVG
useEffect(() => {
setSvgSize({
width: timelineWidth,
height: Math.max(ganttTasks.length * ROW_HEIGHT, 100),
});
}, [ganttTasks.length, timelineWidth]);
// Flechas de dependencias
const dependencyArrows = useMemo(() => {
const arrows: { fromX: number; fromY: number; toX: number; toY: number; key: string }[] = [];
if (timelineWidth === 0) return arrows;
for (const task of ganttTasks) {
if (!task.dependencies || task.dependencies.length === 0) continue;
const hasDate = (task.startDate || task.plannedStartDate) && (task.endDate || task.plannedEndDate);
if (!hasDate) continue;
const toIndex = taskIndexMap.get(task.id);
if (toIndex === undefined) continue;
for (const depId of task.dependencies) {
const fromIndex = taskIndexMap.get(depId);
if (fromIndex === undefined) continue;
const depTask = ganttTasks[fromIndex];
const depHasDate = (depTask.startDate || depTask.plannedStartDate) && (depTask.endDate || depTask.plannedEndDate);
if (!depHasDate) continue;
const fromBar = getBarPosition(depTask);
const toBar = getBarPosition(task);
const fromX = fromBar.leftPx + fromBar.widthPx;
const fromY = fromIndex * ROW_HEIGHT + ROW_HEIGHT / 2;
const toX = toBar.leftPx;
const toY = toIndex * ROW_HEIGHT + ROW_HEIGHT / 2;
arrows.push({ fromX, fromY, toX, toY, key: `${depId}-${task.id}` });
}
}
return arrows;
}, [ganttTasks, taskIndexMap, getBarPosition, timelineWidth]);
// Click en modo linking
const handleBarClick = (taskId: number) => {
if (interactionMode !== "linking") return;
if (linkSource === null) {
setLinkSource(taskId);
} else {
if (taskId !== linkSource) {
const targetTask = ganttTasks.find(t => t.id === taskId);
if (targetTask) {
const wouldCreateCycle = checkCircularDependency(taskId, linkSource, ganttTasks);
if (!wouldCreateCycle) {
const newDeps = [...(targetTask.dependencies || [])];
if (!newDeps.includes(linkSource)) {
newDeps.push(linkSource);
setTaskDependencies(taskId, newDeps);
targetTask.dependencies = newDeps;
onTaskUpdated?.({ ...targetTask, dependencies: newDeps });
}
}
}
}
setLinkSource(null);
setInteractionMode("normal");
}
};
function checkCircularDependency(targetId: number, sourceId: number, allTasks: Task[]): boolean {
const visited = new Set<number>();
function hasDependency(taskId: number, searchId: number): boolean {
if (taskId === searchId) return true;
if (visited.has(taskId)) return false;
visited.add(taskId);
const task = allTasks.find(t => t.id === taskId);
if (!task || !task.dependencies) return false;
return task.dependencies.some(depId => hasDependency(depId, searchId));
}
return hasDependency(sourceId, targetId);
}
const cancelLinking = () => {
setInteractionMode("normal");
setLinkSource(null);
};
if (ganttTasks.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="p-3 rounded-full bg-muted mb-4">
<GanttIcon className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold mb-1">Sin tareas para el Gantt</h3>
<p className="text-sm text-muted-foreground mb-4 max-w-sm">
Crea tareas con fechas de inicio y fin para verlas en el diagrama.
</p>
<Button onClick={() => setIsCreateSheetOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Crear tarea
</Button>
<TaskFormSheet
open={isCreateSheetOpen}
onOpenChange={setIsCreateSheetOpen}
mode="create"
projectId={projectId}
availableTasks={tasks}
onSuccess={(task) => onTaskCreated?.(task)}
/>
</div>
);
}
return (
<div className="space-y-4">
{/* Controles */}
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3 flex-wrap">
{/* Toggle Futuro / Pasado */}
<div className="flex items-center gap-1 border rounded-lg p-1">
<Button
variant={timeRange === "future" ? "default" : "ghost"}
size="sm"
onClick={() => setTimeRange("future")}
>
<Clock className="w-4 h-4 mr-1" />
Futuro
</Button>
<Button
variant={timeRange === "past" ? "default" : "ghost"}
size="sm"
onClick={() => setTimeRange("past")}
>
<History className="w-4 h-4 mr-1" />
Pasado
</Button>
</div>
<span className="text-xs text-muted-foreground">
{months.length} meses &middot; {ganttTasks.length} tareas
</span>
</div>
<div className="flex items-center gap-2">
{/* Modo enlazar dependencias */}
{interactionMode === "linking" ? (
<div className="flex items-center gap-2">
<Badge variant="outline" className="bg-blue-50 dark:bg-blue-950/50 text-blue-700 dark:text-blue-400 border-blue-200 dark:border-blue-800 animate-pulse">
<Link2 className="h-3 w-3 mr-1" />
{linkSource === null
? "Click en la tarea predecesora"
: "Ahora click en la dependiente"}
</Badge>
<Button variant="ghost" size="sm" onClick={cancelLinking}>
Cancelar
</Button>
</div>
) : (
<Button
variant="outline"
size="sm"
className="h-9"
onClick={() => setInteractionMode("linking")}
>
<Link2 className="h-4 w-4 mr-2" />
Enlazar tareas
</Button>
)}
<Button size="sm" className="h-9" onClick={() => setIsCreateSheetOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Nueva tarea
</Button>
</div>
</div>
{/* Leyenda dependencias */}
{dependencyArrows.length > 0 && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<svg width="20" height="10">
<defs>
<marker id="legend-arrow" markerWidth="6" markerHeight="6" refX="5" refY="3" orient="auto">
<path d="M0,0 L6,3 L0,6 Z" fill="#6366f1" />
</marker>
</defs>
<line x1="0" y1="5" x2="14" y2="5" stroke="#6366f1" strokeWidth="1.5" markerEnd="url(#legend-arrow)" />
</svg>
<span>{dependencyArrows.length} dependencia{dependencyArrows.length !== 1 ? "s" : ""}</span>
</div>
)}
{/* Gantt Chart */}
<div className="border rounded-lg overflow-hidden bg-card">
<div className="flex w-full overflow-hidden">
{/* Columna de nombres - fija */}
<div className="flex-shrink-0 border-r bg-muted/50" style={{ width: `${NAME_COL_WIDTH}px` }}>
<div className="border-b flex items-center px-4" style={{ height: `${HEADER_HEIGHT}px` }}>
<span className="font-semibold text-foreground text-sm">Tarea</span>
</div>
{ganttTasks.map((task, index) => {
const color = getTaskColor(index);
const statusConfig = TASK_STATUS_CONFIG[task.status];
const hasDate = (task.startDate || task.plannedStartDate) && (task.endDate || task.plannedEndDate);
const isOverdue = (() => {
const endDate = task.endDate || task.plannedEndDate;
return endDate && new Date(endDate) < new Date() && task.status !== "2";
})();
const isLinkSource = interactionMode === "linking" && linkSource === task.id;
return (
<div
key={task.id}
className={`border-b flex items-center px-3 hover:bg-muted transition-colors ${
isLinkSource ? "bg-blue-50 dark:bg-blue-950/30" : ""
} ${interactionMode === "linking" ? "cursor-pointer" : ""}`}
style={{ height: `${ROW_HEIGHT}px` }}
onClick={() => interactionMode === "linking" && handleBarClick(task.id)}
>
<div className="flex items-center gap-2 min-w-0 w-full">
<div className={`w-2.5 h-2.5 rounded-full ${hasDate ? color.bg : "bg-gray-300 dark:bg-gray-600"} flex-shrink-0`} />
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-foreground truncate" title={task.title}>
{task.title}
</p>
<div className="flex items-center gap-1">
<span className="text-[10px] text-muted-foreground">{task.progress}%</span>
{task.status === "2" && <CheckCircle2 className="h-2.5 w-2.5 text-green-500" />}
{isOverdue && <AlertTriangle className="h-2.5 w-2.5 text-red-500" />}
{task.dependencies && task.dependencies.length > 0 && <Link2 className="h-2.5 w-2.5 text-indigo-500" />}
{!hasDate && <span className="text-[9px] text-amber-500">Sin fechas</span>}
</div>
</div>
<Badge variant="outline" className={`${statusConfig.bgClass} text-[9px] px-1 py-0 h-4 flex-shrink-0`}>
{statusConfig.label}
</Badge>
</div>
</div>
);
})}
</div>
{/* Timeline con scroll horizontal */}
<div
ref={timelineScrollRef}
className="overflow-x-auto overflow-y-hidden"
style={{ width: `calc(100% - ${NAME_COL_WIDTH}px)` }}
>
<div style={{ width: `${timelineWidth}px`, minWidth: "100%" }}>
{/* Header con meses */}
<div className="border-b flex" style={{ height: `${HEADER_HEIGHT}px` }}>
{months.map((month) => {
const isCurrentMonth = new Date().getMonth() === month.month && new Date().getFullYear() === month.year;
return (
<div
key={`${month.year}-${month.month}`}
className={`border-r flex flex-col justify-center px-2 ${
isCurrentMonth ? "bg-blue-50 dark:bg-blue-950/30" : "bg-card"
}`}
style={{ width: `${MONTH_WIDTH}px`, flexShrink: 0 }}
>
<span className={`text-xs font-medium ${isCurrentMonth ? "text-blue-700 dark:text-blue-400" : "text-muted-foreground"}`}>
{month.label}
</span>
</div>
);
})}
</div>
{/* Barras + SVG de flechas */}
<div className="relative" ref={timelineRef}>
{/* SVG flechas */}
<svg
className="absolute inset-0 pointer-events-none z-10"
width={timelineWidth}
height={ganttTasks.length * ROW_HEIGHT}
style={{ overflow: "visible" }}
>
<defs>
<marker id="dependency-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto" markerUnits="userSpaceOnUse">
<path d="M0,0 L8,4 L0,8 Z" fill="#6366f1" />
</marker>
</defs>
{dependencyArrows.map((arrow) => {
const dx = arrow.toX - arrow.fromX;
let pathD: string;
if (dx > 20) {
const midX = arrow.fromX + dx * 0.5;
pathD = `M${arrow.fromX},${arrow.fromY} C${midX},${arrow.fromY} ${midX},${arrow.toY} ${arrow.toX},${arrow.toY}`;
} else {
const offset = 15;
const belowY = Math.max(arrow.fromY, arrow.toY) + ROW_HEIGHT * 0.6;
pathD = `M${arrow.fromX},${arrow.fromY} L${arrow.fromX + offset},${arrow.fromY} L${arrow.fromX + offset},${belowY} L${arrow.toX - offset},${belowY} L${arrow.toX - offset},${arrow.toY} L${arrow.toX},${arrow.toY}`;
}
return (
<path
key={arrow.key}
d={pathD}
fill="none"
stroke="#6366f1"
strokeWidth="1.5"
strokeDasharray={dx <= 20 ? "4,3" : "none"}
markerEnd="url(#dependency-arrow)"
opacity="0.7"
/>
);
})}
</svg>
{/* Filas */}
<TooltipProvider>
{ganttTasks.map((task, index) => {
const color = getTaskColor(index);
const hasDate = (task.startDate || task.plannedStartDate) && (task.endDate || task.plannedEndDate);
const isOverdue = (() => {
const endDate = task.endDate || task.plannedEndDate;
return endDate && new Date(endDate) < new Date() && task.status !== "2";
})();
const isLinkSource = interactionMode === "linking" && linkSource === task.id;
const priorityConfig = TASK_PRIORITY_CONFIG[task.priority];
const hasDeps = task.dependencies && task.dependencies.length > 0;
// Posición de barra solo si tiene fechas
let barPos: { leftPx: number; widthPx: number } | null = null;
let isVisible = false;
if (hasDate) {
barPos = getBarPosition(task);
const taskStart = new Date(task.startDate || task.plannedStartDate!);
const taskEnd = new Date(task.endDate || task.plannedEndDate!);
isVisible = taskEnd >= visibleStart && taskStart <= visibleEnd;
}
return (
<div
key={task.id}
className={`border-b relative ${interactionMode === "linking" ? "cursor-pointer" : ""}`}
style={{ height: `${ROW_HEIGHT}px` }}
>
{/* Grid de meses */}
<div className="absolute inset-0 flex">
{months.map((month) => {
const isCurrentMonth = new Date().getMonth() === month.month && new Date().getFullYear() === month.year;
return (
<div
key={`grid-${month.year}-${month.month}`}
className={`border-r ${isCurrentMonth ? "bg-blue-50/30 dark:bg-blue-950/20" : ""}`}
style={{ width: `${MONTH_WIDTH}px`, flexShrink: 0 }}
/>
);
})}
</div>
{/* Línea del día actual */}
{(() => {
const now = new Date();
if (now >= visibleStart && now <= visibleEnd) {
const todayOffset = getDaysBetween(visibleStart, now);
const todayPx = (todayOffset / totalDays) * timelineWidth;
return (
<div
className="absolute top-0 bottom-0 w-px bg-red-400 z-[5]"
style={{ left: `${todayPx}px` }}
/>
);
}
return null;
})()}
{/* Barra de la tarea */}
{isVisible && barPos && (
<Tooltip>
<TooltipTrigger asChild>
<div
className={`absolute top-2 rounded-md transition-all z-[2] ${
interactionMode === "linking"
? "cursor-pointer hover:ring-2 hover:ring-blue-400"
: "cursor-default hover:scale-[1.02] hover:shadow-md"
} ${isLinkSource ? "ring-2 ring-blue-500" : ""} ${isOverdue ? "ring-2 ring-red-400" : ""}`}
style={{
left: `${barPos.leftPx}px`,
width: `${barPos.widthPx}px`,
height: `${ROW_HEIGHT - 16}px`,
minWidth: "8px",
}}
onClick={() => interactionMode === "linking" && handleBarClick(task.id)}
>
<div className={`absolute inset-0 ${color.light} rounded-md`} />
<div
className={`absolute inset-y-0 left-0 ${color.bg} rounded-md transition-all`}
style={{ width: `${task.progress}%` }}
/>
<div className="relative h-full flex items-center px-2 z-10">
<span className="text-[10px] font-medium text-white truncate drop-shadow-sm">
{barPos.widthPx > 40 ? `${task.progress}%` : ""}
</span>
</div>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<div className="space-y-1.5">
<p className="font-semibold text-sm">{task.title}</p>
<div className="text-xs space-y-0.5">
<p><span className="text-muted-foreground">Estado:</span> {TASK_STATUS_CONFIG[task.status].label}</p>
<p><span className="text-muted-foreground">Prioridad:</span> {priorityConfig.label}</p>
<p><span className="text-muted-foreground">Progreso:</span> {task.progress}%</p>
<p><span className="text-muted-foreground">Inicio:</span> {formatDate(task.startDate || task.plannedStartDate!)}</p>
<p><span className="text-muted-foreground">Fin:</span> {formatDate(task.endDate || task.plannedEndDate!)}</p>
{task.plannedHours > 0 && (
<p><span className="text-muted-foreground">Horas:</span> {task.workedHours}h / {task.plannedHours}h</p>
)}
{hasDeps && (
<div className="pt-1 border-t">
<p className="text-muted-foreground flex items-center gap-1">
<Link2 className="h-3 w-3" />
Depende de:
</p>
{task.dependencies.map((depId) => {
const depTask = ganttTasks.find(t => t.id === depId);
return <p key={depId} className="pl-4 text-[11px]">{depTask?.title || `Tarea #${depId}`}</p>;
})}
</div>
)}
{isOverdue && <p className="text-red-500 font-medium pt-1">Tarea retrasada</p>}
</div>
</div>
</TooltipContent>
</Tooltip>
)}
{/* Indicador para tareas sin fecha */}
{!hasDate && (
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-[10px] text-muted-foreground/60 italic">Sin fechas asignadas</span>
</div>
)}
</div>
);
})}
</TooltipProvider>
</div>
</div>
</div>
</div>
</div>
{/* Sheet de creación */}
<TaskFormSheet
open={isCreateSheetOpen}
onOpenChange={setIsCreateSheetOpen}
mode="create"
projectId={projectId}
availableTasks={tasks}
onSuccess={(task) => onTaskCreated?.(task)}
/>
</div>
);
}

View File

@ -13,6 +13,7 @@ import {
List
} from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@ -180,7 +181,9 @@ export function TasksSection({ tasks, projectId, isLoading, onTaskCreated, onTas
setTaskToDelete(null);
} catch (error) {
console.error("Error deleting task:", error);
// TODO: Mostrar toast de error
toast.error("Error al eliminar la tarea", {
description: "No se pudo eliminar la tarea. Inténtalo de nuevo.",
});
} finally {
setIsDeleting(false);
}
@ -246,6 +249,7 @@ export function TasksSection({ tasks, projectId, isLoading, onTaskCreated, onTas
onOpenChange={setIsCreateSheetOpen}
mode="create"
projectId={projectId}
availableTasks={tasks}
onSuccess={handleCreateSuccess}
/>
</>
@ -388,6 +392,7 @@ export function TasksSection({ tasks, projectId, isLoading, onTaskCreated, onTas
onOpenChange={setIsCreateSheetOpen}
mode="create"
projectId={projectId}
availableTasks={tasks}
onSuccess={handleCreateSuccess}
/>
@ -401,6 +406,7 @@ export function TasksSection({ tasks, projectId, isLoading, onTaskCreated, onTas
mode="edit"
projectId={projectId}
task={taskToEdit}
availableTasks={tasks}
onSuccess={handleEditSuccess}
/>

View File

@ -2,6 +2,7 @@
import { useState, useEffect, useMemo } from "react";
import { FolderKanban, RefreshCw, Plus } from "lucide-react";
import { toast } from "sonner";
import { Project } from "@/types/project";
import { getProjects, deleteProject } from "@/lib/projectsService";
@ -81,6 +82,9 @@ export function ProjectsTable() {
setProjectToDelete(null);
} catch (error) {
console.error("Error deleting project:", error);
toast.error("Error al eliminar el proyecto", {
description: "No se pudo eliminar el proyecto. Inténtalo de nuevo.",
});
} finally {
setIsDeleting(false);
}

View File

@ -222,52 +222,6 @@ function ProgressDonut({ data, title, centerValue, centerLabel }: ProgressDonutP
}
// Radial Bar Chart para metricas
interface RadialMetricProps {
value: number;
title: string;
subtitle?: string;
color?: string;
}
function RadialMetric({ value, title, subtitle, color = COLORS.primary }: RadialMetricProps) {
const data = [{ name: title, value: Math.min(value, 100), fill: color }];
return (
<Card className="border">
<CardHeader className="pb-0">
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
</CardHeader>
<CardContent>
<div className="h-40 relative">
<ResponsiveContainer width="100%" height="100%">
<RadialBarChart
cx="50%"
cy="50%"
innerRadius="60%"
outerRadius="90%"
data={data}
startAngle={180}
endAngle={0}
>
<RadialBar
background={{ fill: "hsl(var(--muted))" }}
dataKey="value"
cornerRadius={10}
/>
</RadialBarChart>
</ResponsiveContainer>
<div className="absolute inset-0 flex items-center justify-center pointer-events-none" style={{ marginTop: "-15px" }}>
<div className="text-center">
<p className="text-2xl font-bold text-foreground">{value}%</p>
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
</div>
</div>
</div>
</CardContent>
</Card>
);
}
// Bar Chart horizontal para presupuestos por proyecto
interface BudgetBarChartProps {
projects: Project[];
@ -325,43 +279,6 @@ function BudgetBarChart({ projects, title, maxItems = 6 }: BudgetBarChartProps)
);
}
// Bar Chart vertical para comparativas
interface ComparisonBarChartProps {
data: { name: string; value: number; color?: string }[];
title: string;
formatter?: (value: number) => string;
}
function ComparisonBarChart({ data, title, formatter }: ComparisonBarChartProps) {
return (
<Card className="border">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
</CardHeader>
<CardContent>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} margin={{ top: 10, right: 10, left: -10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="name" fontSize={11} />
<YAxis tickFormatter={formatter} fontSize={11} />
<Tooltip
formatter={(value) => [formatter ? formatter(Number(value) || 0) : (value ?? 0), '']}
contentStyle={{ fontSize: '12px' }}
/>
<Bar dataKey="value" radius={[4, 4, 0, 0]}>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color || COLORS.primary} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
);
}
// Multi Radial para comparar metricas
interface MultiRadialProps {
data: { name: string; value: number; fill: string }[];
@ -486,9 +403,8 @@ function AllProjectsStats({ projects }: { projects: Project[] }) {
const abiertos = projects.filter(p => p.status === "1").length;
const cerrados = projects.filter(p => p.status === "2").length;
// Nuevas métricas
const overdueProjects = getOverdueProjects(projects);
const upcomingDeadlines = getUpcomingDeadlines(projects, 14); // próximos 14 días
const upcomingDeadlines = getUpcomingDeadlines(projects, 14);
const progressData = [
{ name: "0-25%", value: projects.filter(p => p.progress < 25).length, color: PROGRESS_COLORS[0] },
@ -497,186 +413,123 @@ function AllProjectsStats({ projects }: { projects: Project[] }) {
{ name: "75-100%", value: projects.filter(p => p.progress >= 75).length, color: PROGRESS_COLORS[3] },
];
const budgetByStatus = [
{ name: "Borrador", value: projects.filter(p => p.status === "0").reduce((a, p) => a + p.budget, 0), color: COLORS.gray },
{ name: "Abierto", value: projects.filter(p => p.status === "1").reduce((a, p) => a + p.budget, 0), color: COLORS.primary },
{ name: "Cerrado", value: projects.filter(p => p.status === "2").reduce((a, p) => a + p.budget, 0), color: COLORS.success },
];
// Top proyectos por presupuesto con colores únicos
const topProjects = [...projects]
.sort((a, b) => b.budget - a.budget)
.slice(0, 5)
.map((p, index) => ({
name: p.name.length > 12 ? p.name.substring(0, 12) + "..." : p.name,
value: p.progress,
fill: getProjectColor(index),
}));
return (
<div className="space-y-6">
{/* KPIs principales */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard title="Total Proyectos" value={stats.total} subtitle={`${abiertos} activos, ${cerrados} cerrados`} icon={<LayoutGrid className="w-4 h-4 text-gray-400" />} highlight />
<StatCard title="Presupuesto Total" value={formatCurrency(stats.totalBudget)} subtitle={`Media: ${formatCurrency(stats.avgBudget)}`} icon={<DollarSign className="w-4 h-4 text-green-500" />} />
<StatCard title="Progreso Medio" value={`${stats.avgProgress}%`} subtitle={`${stats.budgetConsumed}% presupuesto usado`} icon={<TrendingUp className="w-4 h-4 text-blue-500" />} />
<StatCard title="Clientes" value={stats.uniqueClients} subtitle={`${stats.avgDuration} días duración media`} icon={<Users className="w-4 h-4 text-indigo-500" />} />
<StatCard
title="Total Proyectos"
value={stats.total}
subtitle={`${abiertos} activos, ${cerrados} cerrados`}
icon={<LayoutGrid className="w-4 h-4 text-gray-400" />}
highlight
/>
<StatCard
title="Presupuesto Total"
value={formatCurrency(stats.totalBudget)}
subtitle={`Gastado: ${formatCurrency(stats.totalSpent)}`}
icon={<DollarSign className="w-4 h-4 text-green-500" />}
/>
<StatCard
title="Progreso Medio"
value={`${stats.avgProgress}%`}
subtitle={`${stats.uniqueClients} clientes`}
icon={<TrendingUp className="w-4 h-4 text-blue-500" />}
/>
<StatCard
title="Requieren Atencion"
value={overdueProjects.length}
subtitle={overdueProjects.length > 0 ? "Proyectos retrasados" : "Todo en orden"}
icon={<AlertCircle className={`w-4 h-4 ${overdueProjects.length > 0 ? "text-red-500" : "text-green-500"}`} />}
/>
</div>
{/* Alertas de fechas */}
{/* Graficos: Estado + Progreso */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<StatusPieChart borradores={borradores} abiertos={abiertos} cerrados={cerrados} />
<ProgressDonut
data={progressData}
title="Distribucion por Progreso"
centerValue={`${stats.avgProgress}%`}
centerLabel="Promedio"
/>
</div>
{/* Presupuesto por proyecto */}
<BudgetBarChart
projects={[...projects].sort((a, b) => b.budget - a.budget)}
title="Presupuesto vs Gastado por Proyecto"
maxItems={8}
/>
{/* Alertas: retrasados y proximos a vencer */}
{(overdueProjects.length > 0 || upcomingDeadlines.length > 0) && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{overdueProjects.length > 0 && (
<Card className="border-red-200 bg-red-50 dark:bg-red-950/30 dark:border-red-900">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-red-700 dark:text-red-400 flex items-center gap-2">
<AlertCircle className="w-4 h-4" /> Proyectos Retrasados
</p>
<p className="text-3xl font-bold text-red-600 dark:text-red-400 mt-2">{overdueProjects.length}</p>
<p className="text-xs text-red-600 dark:text-red-400">Fecha límite superada</p>
</div>
<div className="text-right">
{overdueProjects.slice(0, 2).map(p => (
<p key={p.id} className="text-xs text-red-500 dark:text-red-400 truncate max-w-32">{p.name}</p>
))}
</div>
<Card className="border-red-200 dark:border-red-900">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-red-700 dark:text-red-400 flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
Retrasados ({overdueProjects.length})
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-2">
{overdueProjects.slice(0, 4).map(p => {
const daysOverdue = Math.abs(getDaysRemaining(p.endDate));
return (
<div key={p.id} className="flex items-center justify-between gap-2 p-2 rounded-md bg-red-50 dark:bg-red-950/20">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{p.name}</p>
<p className="text-xs text-muted-foreground">{p.progress}%</p>
</div>
<Badge variant="outline" className="border-red-300 dark:border-red-700 text-red-600 dark:text-red-400 text-xs shrink-0">
-{daysOverdue}d
</Badge>
</div>
);
})}
</div>
</CardContent>
</Card>
)}
{upcomingDeadlines.length > 0 && (
<Card className="border-amber-200 bg-amber-50 dark:bg-amber-950/30 dark:border-amber-900">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-amber-700 dark:text-amber-400 flex items-center gap-2">
<Clock className="w-4 h-4" /> Próximos a Vencer
</p>
<p className="text-3xl font-bold text-amber-600 dark:text-amber-400 mt-2">{upcomingDeadlines.length}</p>
<p className="text-xs text-amber-600 dark:text-amber-400">En los próximos 14 días</p>
</div>
<div className="text-right">
{upcomingDeadlines.slice(0, 2).map(p => (
<p key={p.id} className="text-xs text-amber-600 dark:text-amber-400 truncate max-w-32">
{p.name} ({getDaysRemaining(p.endDate)}d)
</p>
))}
</div>
<Card className="border-amber-200 dark:border-amber-900">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-amber-700 dark:text-amber-400 flex items-center gap-2">
<Clock className="w-4 h-4" />
Proximos a vencer ({upcomingDeadlines.length})
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-2">
{upcomingDeadlines.slice(0, 4).map(p => {
const days = getDaysRemaining(p.endDate);
return (
<div key={p.id} className="flex items-center justify-between gap-2 p-2 rounded-md bg-amber-50 dark:bg-amber-950/20">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{p.name}</p>
<p className="text-xs text-muted-foreground">{p.progress}%</p>
</div>
<Badge
variant="outline"
className={`text-xs shrink-0 ${
days <= 3
? "border-red-300 dark:border-red-700 text-red-600 dark:text-red-400"
: "border-amber-300 dark:border-amber-700 text-amber-600 dark:text-amber-400"
}`}
>
{days}d
</Badge>
</div>
);
})}
</div>
</CardContent>
</Card>
)}
</div>
)}
{/* Gráficos principales */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<StatusPieChart borradores={borradores} abiertos={abiertos} cerrados={cerrados} />
<ProgressDonut data={progressData} title="Distribución por Progreso" centerValue={`${stats.avgProgress}%`} centerLabel="Promedio" />
<RadialMetric value={stats.budgetConsumed} title="Consumo de Presupuesto" subtitle={formatCurrency(stats.totalSpent)} color={stats.budgetConsumed > 80 ? COLORS.danger : COLORS.primary} />
</div>
{/* Comparativas */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<ComparisonBarChart data={budgetByStatus} title="Presupuesto por Estado" formatter={(v) => formatCurrency(v)} />
<MultiRadialChart data={topProjects} title="Progreso de Top 5 Proyectos" valueLabel="%" />
</div>
{/* Timeline de fechas - Próximos vencimientos y retrasados */}
{(upcomingDeadlines.length > 0 || overdueProjects.length > 0) && (
<Card className="border">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Calendar className="w-4 h-4" /> Timeline de Proyectos
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Proyectos retrasados */}
{overdueProjects.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-red-600 dark:text-red-400 uppercase tracking-wide mb-2 flex items-center gap-1">
<AlertCircle className="w-3 h-3" /> Retrasados ({overdueProjects.length})
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
{overdueProjects.slice(0, 6).map((p, index) => {
const daysOverdue = Math.abs(getDaysRemaining(p.endDate));
return (
<div key={p.id} className="flex items-center gap-3 p-3 bg-red-50 dark:bg-red-950/30 border border-red-100 dark:border-red-900 rounded-lg">
<div className="w-2 h-2 rounded-full bg-red-500" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">{p.name}</p>
<p className="text-xs text-muted-foreground">{p.progress}% completado</p>
</div>
<Badge variant="outline" className="border-red-300 dark:border-red-700 text-red-600 dark:text-red-400 text-xs">
-{daysOverdue}d
</Badge>
</div>
);
})}
</div>
</div>
)}
{/* Próximos vencimientos */}
{upcomingDeadlines.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-amber-600 dark:text-amber-400 uppercase tracking-wide mb-2 flex items-center gap-1">
<Clock className="w-3 h-3" /> Próximos 14 días ({upcomingDeadlines.length})
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
{upcomingDeadlines.slice(0, 6).map((p, index) => {
const days = getDaysRemaining(p.endDate);
return (
<div key={p.id} className="flex items-center gap-3 p-3 bg-muted/50 border rounded-lg">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: getProjectColor(index) }} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">{p.name}</p>
<p className="text-xs text-muted-foreground">{p.progress}% completado</p>
</div>
<Badge
variant="outline"
className={`text-xs ${
days <= 3 ? 'border-red-300 dark:border-red-700 text-red-600 dark:text-red-400' :
days <= 7 ? 'border-amber-300 dark:border-amber-700 text-amber-600 dark:text-amber-400' :
'border text-muted-foreground'
}`}
>
{days}d
</Badge>
</div>
);
})}
</div>
</div>
)}
{/* Resumen de fechas */}
<div className="pt-3 border-t">
<div className="flex flex-wrap gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<div className="w-2 h-2 rounded-full bg-red-500" />
Retrasados: {overdueProjects.length}
</span>
<span className="flex items-center gap-1">
<div className="w-2 h-2 rounded-full bg-amber-500" />
Vencen en 7 días: {upcomingDeadlines.filter(p => getDaysRemaining(p.endDate) <= 7).length}
</span>
<span className="flex items-center gap-1">
<div className="w-2 h-2 rounded-full bg-green-500" />
En tiempo: {projects.filter(p => p.status === "1" && getDaysRemaining(p.endDate) > 14).length}
</span>
</div>
</div>
</div>
</CardContent>
</Card>
)}
{/* Presupuesto detallado */}
<BudgetBarChart projects={[...projects].sort((a, b) => b.budget - a.budget)} title="Presupuesto vs Gastado por Proyecto" maxItems={6} />
</div>
);
}
@ -708,16 +561,6 @@ function ActiveProjectsStats({ projects }: { projects: Project[] }) {
{ name: "Casi listo (75-99%)", value: activeProjects.filter(p => p.progress >= 75).length, color: PROGRESS_COLORS[3] },
];
// Top proyectos con colores únicos
const topByProgress = activeProjects
.sort((a, b) => b.progress - a.progress)
.slice(0, 5)
.map((p, index) => ({
name: p.name.length > 12 ? p.name.substring(0, 12) + "..." : p.name,
value: p.progress,
fill: getProjectColor(index),
}));
return (
<div className="space-y-6">
{/* KPIs */}
@ -725,56 +568,13 @@ function ActiveProjectsStats({ projects }: { projects: Project[] }) {
<StatCard title="Proyectos Activos" value={stats.total} subtitle="En progreso" icon={<TrendingUp className="w-4 h-4 text-blue-500" />} highlight />
<StatCard title="Presupuesto Activo" value={formatCurrency(stats.totalBudget)} subtitle={`${stats.budgetConsumed}% consumido`} icon={<DollarSign className="w-4 h-4 text-green-500" />} />
<StatCard title="Progreso Medio" value={`${stats.avgProgress}%`} subtitle={`${nearCompletion} casi listos`} icon={<Target className="w-4 h-4 text-purple-500" />} />
<StatCard title="Clientes Activos" value={stats.uniqueClients} subtitle="Con proyectos en curso" icon={<Users className="w-4 h-4 text-indigo-500" />} />
</div>
{/* Alertas */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className={`${overdueProjects.length > 0 ? 'border-red-200 dark:border-red-900 bg-red-50 dark:bg-red-950/30' : 'border bg-muted/30'}`}>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className={`text-sm font-medium flex items-center gap-2 ${overdueProjects.length > 0 ? 'text-red-700 dark:text-red-400' : 'text-muted-foreground'}`}>
<AlertCircle className="w-4 h-4" /> Retrasados
</p>
<p className={`text-3xl font-bold mt-2 ${overdueProjects.length > 0 ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground/50'}`}>{overdueProjects.length}</p>
<p className={`text-xs ${overdueProjects.length > 0 ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground/50'}`}>Fecha superada</p>
</div>
</div>
</CardContent>
</Card>
<Card className={`${needsAttention > 0 ? 'border-orange-200 dark:border-orange-900 bg-orange-50 dark:bg-orange-950/30' : 'border bg-muted/30'}`}>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className={`text-sm font-medium flex items-center gap-2 ${needsAttention > 0 ? 'text-orange-700 dark:text-orange-400' : 'text-muted-foreground'}`}>
<Clock className="w-4 h-4" /> Requieren Atención
</p>
<p className={`text-3xl font-bold mt-2 ${needsAttention > 0 ? 'text-orange-600 dark:text-orange-400' : 'text-muted-foreground/50'}`}>{needsAttention}</p>
<p className={`text-xs ${needsAttention > 0 ? 'text-orange-600 dark:text-orange-400' : 'text-muted-foreground/50'}`}>Menos del 25%</p>
</div>
</div>
</CardContent>
</Card>
<Card className="border-green-200 dark:border-green-900 bg-green-50 dark:bg-green-950/30">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-green-700 dark:text-green-400 flex items-center gap-2">
<CheckCircle2 className="w-4 h-4" /> Próximos a Completar
</p>
<p className="text-3xl font-bold text-green-600 dark:text-green-400 mt-2">{nearCompletion}</p>
<p className="text-xs text-green-600 dark:text-green-400">75% o más</p>
</div>
</div>
</CardContent>
</Card>
<StatCard title="Requieren Atención" value={overdueProjects.length + needsAttention} subtitle={`${overdueProjects.length} retrasados, ${needsAttention} < 25%`} icon={<AlertCircle className="w-4 h-4 text-red-500" />} />
</div>
{/* Gráficos */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<ProgressDonut data={progressData} title="Estado de Avance" centerValue={stats.total} centerLabel="Activos" />
<MultiRadialChart data={topByProgress} title="Top 5 Proyectos por Progreso" valueLabel="%" />
<BudgetBarChart projects={activeProjects.sort((a, b) => b.budget - a.budget)} title="Presupuesto vs Gastado" maxItems={6} />
</div>
{/* Próximos vencimientos */}
@ -806,9 +606,6 @@ function ActiveProjectsStats({ projects }: { projects: Project[] }) {
</CardContent>
</Card>
)}
{/* Presupuesto */}
<BudgetBarChart projects={activeProjects.sort((a, b) => b.budget - a.budget)} title="Presupuesto vs Gastado por Proyecto" maxItems={6} />
</div>
);
}
@ -838,9 +635,26 @@ function ClosedProjectsStats({ projects }: { projects: Project[] }) {
{ name: "Sobre presupuesto", value: overBudget, color: COLORS.danger },
].filter(d => d.value > 0);
const completionData = [
{ name: "100% completado", value: fullyCompleted, color: COLORS.success },
{ name: "Cerrado parcial", value: closedProjects.length - fullyCompleted, color: COLORS.warning },
// Distribución de duración de los proyectos cerrados
const durationRanges = closedProjects.reduce(
(acc, p) => {
const start = new Date(p.startDate);
const end = new Date(p.endDate);
const months = (end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24 * 30.44);
if (months < 3) acc.short++;
else if (months < 6) acc.medium++;
else if (months < 12) acc.long++;
else acc.veryLong++;
return acc;
},
{ short: 0, medium: 0, long: 0, veryLong: 0 }
);
const durationData = [
{ name: "< 3 meses", value: durationRanges.short, color: COLORS.success },
{ name: "3-6 meses", value: durationRanges.medium, color: COLORS.primary },
{ name: "6-12 meses", value: durationRanges.long, color: COLORS.warning },
{ name: "> 12 meses", value: durationRanges.veryLong, color: COLORS.danger },
].filter(d => d.value > 0);
// Eficiencia por proyecto con colores únicos
@ -898,10 +712,9 @@ function ClosedProjectsStats({ projects }: { projects: Project[] }) {
</div>
{/* Gráficos */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<ProgressDonut data={budgetComplianceData} title="Cumplimiento Presupuestario" centerValue={`${stats.total > 0 ? Math.round((withinBudget / stats.total) * 100) : 0}%`} centerLabel="Éxito" />
<ProgressDonut data={completionData} title="Estado de Completitud" centerValue={fullyCompleted} centerLabel="Completos" />
<RadialMetric value={Math.min(100, 100 - stats.budgetConsumed)} title="Eficiencia Global" subtitle="ahorro promedio" color={COLORS.success} />
<ProgressDonut data={durationData} title="Duración de Proyectos" centerValue={closedProjects.length} centerLabel="Proyectos" />
</div>
{/* Eficiencia por proyecto */}
@ -937,56 +750,21 @@ function CompletedProjectsStats({ projects }: { projects: Project[] }) {
{ name: "Cerrado", value: completedProjects.filter(p => p.status === "2").length, color: COLORS.success },
].filter(d => d.value > 0);
// Proyectos completados con colores únicos para el gráfico
const budgetData = completedProjects
.sort((a, b) => b.budget - a.budget)
.slice(0, 6)
.map((p, index) => ({
name: p.name.length > 15 ? p.name.substring(0, 15) + "..." : p.name,
value: p.budget,
color: getProjectColor(index),
}));
return (
<div className="space-y-6">
{/* Banner de éxito */}
<Card className="border-green-300 bg-gradient-to-r from-green-500 to-emerald-600 text-white">
<CardContent className="py-8">
<div className="flex items-center justify-center gap-8 md:gap-16 flex-wrap">
<div className="text-center">
<p className="text-5xl font-bold">{stats.total}</p>
<p className="text-sm text-green-100 mt-2">Proyectos al 100%</p>
</div>
<div className="h-16 w-px bg-green-400 hidden md:block" />
<div className="text-center">
<p className="text-5xl font-bold">{successRate}%</p>
<p className="text-sm text-green-100 mt-2">Tasa de éxito</p>
</div>
<div className="h-16 w-px bg-green-400 hidden md:block" />
<div className="text-center">
<p className="text-5xl font-bold">{formatCurrency(savings)}</p>
<p className="text-sm text-green-100 mt-2">Ahorro total</p>
</div>
</div>
</CardContent>
</Card>
{/* KPIs */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard title="Presupuesto Total" value={formatCurrency(stats.totalBudget)} subtitle="Gestionado" icon={<DollarSign className="w-4 h-4 text-green-500" />} />
<StatCard title="Total Gastado" value={formatCurrency(stats.totalSpent)} subtitle={`${stats.budgetConsumed}% usado`} icon={<PiggyBank className="w-4 h-4 text-blue-500" />} />
<StatCard title="Dentro Presupuesto" value={withinBudget} subtitle={`de ${stats.total} completados`} icon={<CheckCircle2 className="w-4 h-4 text-green-500" />} />
<StatCard title="Clientes Satisfechos" value={stats.uniqueClients} subtitle="Proyectos entregados" icon={<Users className="w-4 h-4 text-indigo-500" />} />
<StatCard title="Completados" value={stats.total} subtitle="Proyectos al 100%" icon={<CheckCircle2 className="w-4 h-4 text-green-500" />} highlight />
<StatCard title="Presupuesto Total" value={formatCurrency(stats.totalBudget)} subtitle={`Gastado: ${formatCurrency(stats.totalSpent)}`} icon={<DollarSign className="w-4 h-4 text-green-500" />} />
<StatCard title="Tasa de Éxito" value={`${successRate}%`} subtitle={`${withinBudget} dentro de presupuesto`} icon={<Target className="w-4 h-4 text-emerald-500" />} />
<StatCard title="Ahorro Total" value={formatCurrency(savings)} subtitle={`${100 - stats.budgetConsumed}% no usado`} icon={<PiggyBank className="w-4 h-4 text-blue-500" />} />
</div>
{/* Gráficos */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<ProgressDonut data={statusData} title="Estado Administrativo" centerValue={stats.total} centerLabel="Completados" />
<ComparisonBarChart data={budgetData} title="Presupuesto por Proyecto Completado" formatter={(v) => formatCurrency(v)} />
<BudgetBarChart projects={completedProjects.sort((a, b) => b.budget - a.budget)} title="Presupuesto vs Gastado" maxItems={6} />
</div>
{/* Detalle de presupuesto */}
<BudgetBarChart projects={completedProjects.sort((a, b) => b.budget - a.budget)} title="Detalle: Presupuesto vs Gastado" maxItems={6} />
</div>
);
}

View File

@ -4,6 +4,7 @@ import { useState } from "react";
import { ArrowLeft, MoreHorizontal, Pencil, Trash2, Clock, Copy, ExternalLink } from "lucide-react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@ -82,7 +83,9 @@ export function TaskHeader({ task, project, onTaskUpdated, onTaskDeleted }: Task
}
} catch (error) {
console.error("Error deleting task:", error);
// TODO: Mostrar toast de error
toast.error("Error al eliminar la tarea", {
description: "No se pudo eliminar la tarea. Inténtalo de nuevo.",
});
} finally {
setIsDeleting(false);
}

View File

@ -1,12 +1,13 @@
"use client";
import { useState, useEffect } from "react";
import { Loader2 } from "lucide-react";
import { Loader2, X, Link2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
@ -34,7 +35,7 @@ import {
formDataToCreateTask,
formDataToUpdateTask,
} from "@/types/task";
import { createTask, updateTask } from "@/lib/tasksService";
import { createTask, updateTask, setTaskDependencies } from "@/lib/tasksService";
interface TaskFormSheetProps {
open: boolean;
@ -42,6 +43,7 @@ interface TaskFormSheetProps {
mode: "create" | "edit";
projectId: number;
task?: Task | null;
availableTasks?: Task[]; // Tareas del mismo proyecto para seleccionar como dependencias
onSuccess?: (task: Task) => void;
}
@ -57,6 +59,7 @@ const defaultFormData: TaskFormData = {
startDate: "",
endDate: "",
budget: 0,
dependencies: [],
};
export function TaskFormSheet({
@ -65,6 +68,7 @@ export function TaskFormSheet({
mode,
projectId,
task,
availableTasks = [],
onSuccess,
}: TaskFormSheetProps) {
const [formData, setFormData] = useState<TaskFormData>({
@ -74,6 +78,11 @@ export function TaskFormSheet({
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Tareas seleccionables como dependencia (excluir la tarea actual en modo edición)
const selectableTasks = availableTasks.filter(
(t) => !(mode === "edit" && task && t.id === task.id)
);
// Initialize form data when task changes (for edit mode)
useEffect(() => {
if (mode === "edit" && task) {
@ -123,6 +132,10 @@ export function TaskFormSheet({
result = await updateTask(task.id, updateData);
}
// Guardar dependencias
setTaskDependencies(result.id, formData.dependencies);
result.dependencies = formData.dependencies;
onSuccess?.(result);
onOpenChange(false);
} catch (err) {
@ -296,6 +309,75 @@ export function TaskFormSheet({
</div>
</div>
{/* Dependencias (tareas predecesoras) */}
{selectableTasks.length > 0 && (
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Link2 className="h-4 w-4" />
Depende de (predecesoras)
</Label>
<p className="text-xs text-muted-foreground">
Selecciona las tareas que deben completarse antes de esta.
</p>
<Select
value=""
onValueChange={(value) => {
const taskId = parseInt(value);
if (!formData.dependencies.includes(taskId)) {
setFormData(prev => ({
...prev,
dependencies: [...prev.dependencies, taskId],
}));
}
}}
>
<SelectTrigger>
<SelectValue placeholder="Añadir tarea predecesora..." />
</SelectTrigger>
<SelectContent>
{selectableTasks
.filter(t => !formData.dependencies.includes(t.id))
.map((t) => (
<SelectItem key={t.id} value={String(t.id)}>
{t.title} ({t.ref})
</SelectItem>
))}
</SelectContent>
</Select>
{/* Lista de dependencias seleccionadas */}
{formData.dependencies.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{formData.dependencies.map((depId) => {
const depTask = availableTasks.find(t => t.id === depId);
return (
<Badge
key={depId}
variant="secondary"
className="flex items-center gap-1 pl-2 pr-1"
>
<span className="text-xs truncate max-w-[150px]">
{depTask?.title || `Tarea #${depId}`}
</span>
<button
type="button"
onClick={() => {
setFormData(prev => ({
...prev,
dependencies: prev.dependencies.filter(id => id !== depId),
}));
}}
className="ml-1 rounded-full p-0.5 hover:bg-muted-foreground/20 transition-colors"
>
<X className="h-3 w-3" />
</button>
</Badge>
);
})}
</div>
)}
</div>
)}
{/* Error message */}
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 dark:bg-red-950/50 dark:text-red-400 rounded-md">

View File

@ -8,6 +8,99 @@ import {
UpdateTaskData
} from "@/types/task";
// ==================== GESTIÓN DE DEPENDENCIAS ====================
// Las dependencias entre tareas se almacenan en localStorage ya que
// la API REST de Dolibarr no expone la tabla llx_projet_task_dependency.
// Formato: { [taskId]: number[] } donde el array contiene IDs de tareas predecesoras.
const DEPENDENCIES_STORAGE_KEY = "task_dependencies";
/**
* Obtener todas las dependencias almacenadas
*/
function getAllDependencies(): Record<string, number[]> {
if (typeof window === "undefined") return {};
try {
const stored = localStorage.getItem(DEPENDENCIES_STORAGE_KEY);
return stored ? JSON.parse(stored) : {};
} catch {
return {};
}
}
/**
* Guardar todas las dependencias
*/
function saveAllDependencies(deps: Record<string, number[]>): void {
if (typeof window === "undefined") return;
localStorage.setItem(DEPENDENCIES_STORAGE_KEY, JSON.stringify(deps));
}
/**
* Obtener dependencias (predecesoras) de una tarea específica
*/
export function getTaskDependencies(taskId: number): number[] {
const all = getAllDependencies();
return all[String(taskId)] || [];
}
/**
* Establecer dependencias (predecesoras) de una tarea
*/
export function setTaskDependencies(taskId: number, dependencies: number[]): void {
const all = getAllDependencies();
if (dependencies.length === 0) {
delete all[String(taskId)];
} else {
all[String(taskId)] = dependencies;
}
saveAllDependencies(all);
}
/**
* Eliminar todas las dependencias de una tarea (como predecesora y como dependiente)
*/
export function removeTaskDependencies(taskId: number): void {
const all = getAllDependencies();
// Eliminar la entrada de esta tarea
delete all[String(taskId)];
// Eliminar esta tarea de las dependencias de otras tareas
for (const key of Object.keys(all)) {
all[key] = all[key].filter(id => id !== taskId);
if (all[key].length === 0) delete all[key];
}
saveAllDependencies(all);
}
/**
* Obtener las dependencias de todas las tareas de un proyecto
* Devuelve un Map de taskId -> array de IDs de predecesoras
*/
export function getProjectTaskDependencies(taskIds: number[]): Map<number, number[]> {
const all = getAllDependencies();
const result = new Map<number, number[]>();
for (const taskId of taskIds) {
const deps = all[String(taskId)];
if (deps && deps.length > 0) {
// Solo incluir dependencias que pertenecen al mismo proyecto
result.set(taskId, deps.filter(depId => taskIds.includes(depId)));
}
}
return result;
}
/**
* Enriquecer tareas con sus dependencias
*/
function enrichTasksWithDependencies(tasks: Task[]): Task[] {
const taskIds = tasks.map(t => t.id);
const depsMap = getProjectTaskDependencies(taskIds);
return tasks.map(task => ({
...task,
dependencies: depsMap.get(task.id) || [],
}));
}
/**
* Obtener todas las tareas de un proyecto específico
*
@ -29,8 +122,9 @@ export async function getTasksByProjectId(projectId: number): Promise<Task[]> {
task => String(task.fk_project) === String(projectId)
);
// Mapear las tareas al formato de la UI
return projectTasks.map(mapDolibarrTask);
// Mapear las tareas al formato de la UI y enriquecer con dependencias
const mappedTasks = projectTasks.map(mapDolibarrTask);
return enrichTasksWithDependencies(mappedTasks);
} catch (error) {
console.error('Error fetching tasks for project:', projectId, error);
// Si el error es 404 (no hay tareas), devolver array vacío
@ -53,7 +147,8 @@ export async function getAllTasks(): Promise<Task[]> {
return [];
}
return dolibarrTasks.map(mapDolibarrTask);
const mappedTasks = dolibarrTasks.map(mapDolibarrTask);
return enrichTasksWithDependencies(mappedTasks);
} catch (error) {
console.error('Error fetching all tasks:', error);
throw error;
@ -67,7 +162,9 @@ export async function getAllTasks(): Promise<Task[]> {
export async function getTaskById(taskId: number): Promise<Task | null> {
try {
const dolibarrTask: DolibarrTask = await dolibarrFetch(`tasks/${taskId}`);
return mapDolibarrTask(dolibarrTask);
const task = mapDolibarrTask(dolibarrTask);
task.dependencies = getTaskDependencies(taskId);
return task;
} catch (error) {
console.error('Error fetching task:', taskId, error);
return null;
@ -197,6 +294,8 @@ export async function deleteTask(taskId: number): Promise<void> {
await dolibarrFetch(`tasks/${taskId}`, {
method: 'DELETE',
});
// Limpiar dependencias asociadas a esta tarea
removeTaskDependencies(taskId);
} catch (error) {
console.error('Error deleting task:', taskId, error);
throw error;

11
package-lock.json generated
View File

@ -30,6 +30,7 @@
"react": "19.2.0",
"react-dom": "19.2.0",
"recharts": "^3.7.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7"
},
@ -8595,6 +8596,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/sonner": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",

View File

@ -35,6 +35,7 @@
"react": "19.2.0",
"react-dom": "19.2.0",
"recharts": "^3.7.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7"
},

View File

@ -77,6 +77,7 @@ export interface Task {
updatedAt: string;
createdBy: number;
order: number;
dependencies: number[]; // IDs de tareas que deben completarse antes de esta
}
// Configuración de estados de tarea
@ -194,6 +195,7 @@ export function mapDolibarrTask(dolibarr: DolibarrTask): Task {
? parseInt(dolibarr.fk_user_creat)
: (dolibarr.fk_user_creat || 0),
order: toNumber(dolibarr.rang),
dependencies: [], // Se cargan por separado desde el servicio de dependencias
};
}
@ -264,6 +266,7 @@ export interface TaskFormData {
startDate: string; // YYYY-MM-DD
endDate: string; // YYYY-MM-DD
budget: number;
dependencies: number[]; // IDs de tareas predecesoras
}
// Helper para convertir fecha string a timestamp (segundos)
@ -355,5 +358,6 @@ export function taskToFormData(task: Task): TaskFormData {
startDate: task.startDate || task.plannedStartDate || '',
endDate: task.endDate || task.plannedEndDate || '',
budget: task.budget,
dependencies: task.dependencies || [],
};
}