From c9f48fcf596d988b52a9838d4f7bc76d70fd191e Mon Sep 17 00:00:00 2001 From: marcos Date: Fri, 6 Feb 2026 20:06:25 +0100 Subject: [PATCH] Gannt dentro de las tareas --- app/configuracion/page.tsx | 276 ++++++++ app/layout.tsx | 2 + app/perfil/page.tsx | 311 +++++++++ components/app-sidebar.tsx | 52 +- components/dashboard/project-grid.tsx | 4 + components/gantt/gantt-page.tsx | 498 +++++++------- components/project-detail/index.ts | 1 + .../project-detail/project-detail-view.tsx | 34 +- components/project-detail/project-header.tsx | 4 + components/project-detail/task-gantt.tsx | 647 ++++++++++++++++++ components/project-detail/tasks-section.tsx | 8 +- components/projects/projects-table.tsx | 4 + components/statistics/statistics-page.tsx | 478 ++++--------- components/task-detail/task-header.tsx | 5 +- components/task-form/task-form-sheet.tsx | 86 ++- lib/tasksService.ts | 107 ++- package-lock.json | 11 + package.json | 1 + types/task.ts | 4 + 19 files changed, 1879 insertions(+), 654 deletions(-) create mode 100644 app/configuracion/page.tsx create mode 100644 app/perfil/page.tsx create mode 100644 components/project-detail/task-gantt.tsx diff --git a/app/configuracion/page.tsx b/app/configuracion/page.tsx new file mode 100644 index 0000000..e4811ef --- /dev/null +++ b/app/configuracion/page.tsx @@ -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(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 ( +
+ {/* Header */} +
+

Ajustes

+

Configuracion de la aplicacion y conexion con Dolibarr

+
+ + {/* Theme */} + + + + + Apariencia + + Personaliza el aspecto visual de la aplicacion + + +
+ +
+ {mounted && themeOptions.map((option) => { + const isSelected = theme === option.value; + const Icon = option.icon; + return ( + + ); + })} + {!mounted && ( + <> + {[1, 2, 3].map((i) => ( + + ))} + + )} +
+
+
+
+ + {/* Dolibarr Connection */} + + + + + Conexion con Dolibarr + + Estado de la conexion con el servidor Dolibarr + + + {/* Connection status */} +
+
+ {isTestingConnection ? ( + + ) : connectionStatus?.connected ? ( + + ) : ( + + )} +
+

+ {isTestingConnection + ? "Verificando conexion..." + : connectionStatus?.connected + ? "Conectado" + : "Desconectado"} +

+

+ {connectionStatus?.url || "Verificando..."} +

+
+
+ +
+ + {/* Connection details */} + {connectionStatus && ( + <> + +
+ + {connectionStatus.connected && connectionStatus.version && ( + + )} + {connectionStatus.error && ( +
+ +

{connectionStatus.error}

+
+ )} + +
+ + )} +
+
+ + {/* App Info */} + + + + + Informacion de la Aplicacion + + + + + + + + + + + + + + + + {/* Locale / Language */} + + + + + Idioma y Region + + Configuracion regional de la aplicacion + + + + + + + + + +
+ ); +} + +function DetailRow({ + label, + value, + badge, + badgeVariant, +}: { + label: string; + value: string; + badge?: boolean; + badgeVariant?: "success" | "error"; +}) { + return ( +
+ {label} + {badge ? ( + + {value} + + ) : ( + {value} + )} +
+ ); +} diff --git a/app/layout.tsx b/app/layout.tsx index 74369bf..60c4fe1 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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({ + diff --git a/app/perfil/page.tsx b/app/perfil/page.tsx new file mode 100644 index 0000000..ecfb86d --- /dev/null +++ b/app/perfil/page.tsx @@ -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(null); + const [projects, setProjects] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(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 ; + } + + if (error || !user) { + return ( +
+
+ +

Error al cargar el perfil

+

{error}

+
+
+ ); + } + + 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 ( +
+ {/* Header */} +
+

Mi Perfil

+

Información de tu cuenta de Dolibarr

+
+ + {/* Profile card */} + + +
+ {/* Avatar */} + + + {initials} + + + + {/* Info */} +
+
+

{fullName}

+

@{user.login}

+
+
+ + {isActive ? "Activo" : "Inactivo"} + + {isAdmin && ( + + + Administrador + + )} + {isEmployee && ( + + + Empleado + + )} +
+
+
+
+
+ + {/* Stats */} +
+ + +

{activeProjects}

+

Proyectos activos

+
+
+ + +

{completedProjects}

+

Proyectos completados

+
+
+ + +

+ {totalBudget.toLocaleString("es-ES")} € +

+

Presupuesto total gestionado

+
+
+
+ + {/* Details */} +
+ {/* Contact info */} + + + Información de Contacto + Datos de contacto registrados en Dolibarr + + + + + + + + + + + + + {/* Account info */} + + + Información de la Cuenta + Detalles de tu cuenta en el sistema + + + + + + + + + + + +
+ + {/* Address */} + {(user.address || user.town || user.zip) && ( + + + Dirección + + +
+ +
+ {user.address &&

{user.address}

} + {(user.zip || user.town) && ( +

+ {[user.zip, user.town].filter(Boolean).join(", ")} +

+ )} +
+
+
+
+ )} +
+ ); +} + +// Reusable info row component +function InfoRow({ icon: Icon, label, value }: { icon: React.ComponentType<{ className?: string }>; label: string; value: string }) { + return ( +
+ +
+

{label}

+

{value}

+
+
+ ); +} + +// Skeleton +function ProfileSkeleton() { + return ( +
+
+ + +
+ + +
+ +
+ + +
+ + +
+
+
+
+
+
+ {[1, 2, 3].map((i) => ( + + + + + + + ))} +
+
+ {[1, 2].map((i) => ( + + + + + + + {[1, 2, 3, 4].map((j) => ( +
+ +
+ ))} +
+
+ ))} +
+
+ ); +} diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index fd6788a..18c2470 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -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 */}
-

Dolibarr

-

Gestión de Proyectos

+

Dolibarr

+

Gestión de Proyectos

@@ -99,16 +102,21 @@ export function AppSidebar() { Menú Principal - {items.map((item) => ( - - - - - {item.title} - - - - ))} + {items.map((item) => { + const isActive = item.url === "/" + ? pathname === "/" + : pathname.startsWith(item.url); + return ( + + + + + {item.title} + + + + ); + })} @@ -132,7 +140,7 @@ export function AppSidebar() {
{user.name} - {user.email} + {user.email}
@@ -153,20 +161,22 @@ export function AppSidebar() {
{user.name} - {user.email} + {user.email}
- - - + + + Perfil - + - - - Ajustes + + + + Ajustes + diff --git a/components/dashboard/project-grid.tsx b/components/dashboard/project-grid.tsx index 1f7d416..f6fecd3 100644 --- a/components/dashboard/project-grid.tsx +++ b/components/dashboard/project-grid.tsx @@ -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); } diff --git a/components/gantt/gantt-page.tsx b/components/gantt/gantt-page.tsx index ce3b323..0e16281 100644 --- a/components/gantt/gantt-page.tsx +++ b/components/gantt/gantt-page.tsx @@ -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 = { @@ -51,6 +51,8 @@ const STATUS_LABELS: Record = { "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,23 +60,20 @@ 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); const endMonth = new Date(end.getFullYear(), end.getMonth(), 1); - + while (current <= endMonth) { months.push({ month: current.getMonth(), @@ -83,24 +82,36 @@ function getMonthsBetween(start: Date, end: Date): { month: number; year: number }); current.setMonth(current.getMonth() + 1); } - + 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([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [viewMode, setViewMode] = useState("month"); const [statusFilter, setStatusFilter] = useState("all"); - const [timeRange, setTimeRange] = useState("future"); + const [timeRange, setTimeRange] = useState("all"); const [viewOffset, setViewOffset] = useState(0); + // Refs para sincronizar scroll vertical entre nombres y timeline + const namesRef = useRef(null); + const timelineRef = useRef(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); - } else if (timeRange === "past") { - // Proyectos que ya terminaron - filtered = filtered.filter(p => new Date(p.endDate) < today); - } - // "all" no filtra - - return filtered; - }, [projects, statusFilter, timeRange]); - // 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(() => { + if (timeRange === "future") { + return statusFiltered.filter(p => new Date(p.endDate) >= today); + } else if (timeRange === "past") { + return statusFiltered.filter(p => new Date(p.endDate) < today); + } + return statusFiltered; // "all" + }, [statusFiltered, 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()))); + 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; - } - - 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); - } - }, [months, viewMode, viewOffset, timeRange]); + if (timeRange === "all") return allMonths; - // Calcular el ancho total en días para los meses visibles + const monthsToShow = viewMode === "month" ? 3 : viewMode === "quarter" ? 6 : 12; + + if (timeRange === "past") { + const maxOff = Math.max(0, allMonths.length - monthsToShow); + const startIdx = Math.max(0, maxOff - viewOffset); + return allMonths.slice(startIdx, startIdx + monthsToShow); + } + const startIdx = Math.max(0, Math.min(viewOffset, allMonths.length - monthsToShow)); + return allMonths.slice(startIdx, startIdx + monthsToShow); + }, [allMonths, viewMode, viewOffset, timeRange]); + + // 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 (
- {/* Header - siempre visible */} + {/* Header */}
@@ -293,7 +255,7 @@ export default function GanttPage() {

Diagrama de Gantt

- Timeline de {filteredProjects.length} proyectos + Timeline de {filteredProjects.length} proyecto{filteredProjects.length !== 1 ? "s" : ""}

@@ -301,7 +263,7 @@ export default function GanttPage() { {/* Controles */}
{/* Filtro de estado */} - { setStatusFilter(v); setViewOffset(0); }}> @@ -338,42 +300,45 @@ export default function GanttPage() { onClick={() => { setTimeRange("all"); setViewOffset(0); }} > - Todo + Todos
- {/* Selector de vista */} - + {/* Selector de vista y navegación - solo en modos paginados */} + {timeRange !== "all" && ( + <> + - {/* Navegación */} -
- - - -
+
+ + + +
+ + )}
- {/* Content - área scrollable */} -
+ {/* Content */} +
{loading ? ( ) : filteredProjects.length === 0 ? ( @@ -385,54 +350,62 @@ export default function GanttPage() { ) : ( - - -
- {/* Columna de nombres de proyectos - fija */} -
- {/* Header */} -
- Proyecto + + +
+ {/* Columna de nombres - fija, scroll vertical */} +
+ {/* Header nombres */} +
+ Proyecto
- {/* Lista de proyectos */} - {filteredProjects.map((project, index) => { - const color = getProjectColor(index); - return ( -
-
-
-
-

- {project.name} -

-

{project.progress}%

+ {/* Lista nombres - scroll vertical */} +
syncScroll("names")} + > + {filteredProjects.map((project, index) => { + const color = getProjectColor(index); + return ( +
router.push(`/proyectos/${project.id}`)} + > +
+
+
+

+ {project.name} +

+

{STATUS_LABELS[project.status]} · {project.progress}%

+
-
- ); - })} + ); + })} +
- {/* Timeline - scroll horizontal en modo "todo" */} -
syncScroll("timeline")} > -
- {/* Header con meses */} -
+
+ {/* Header meses */} +
{visibleMonths.map((month) => { const isCurrentMonth = new Date().getMonth() === month.month && new Date().getFullYear() === month.year; - return (
{month.label} @@ -448,90 +421,79 @@ export default function GanttPage() { const color = getProjectColor(index); 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); - - // Verificar si el proyecto está visible en el rango actual + const widthPercent = Math.max(0.5, ((endOffset - startOffset) / totalDays) * 100); + const isVisible = projectEnd >= visibleStart && projectStart <= visibleEnd; const isOverdue = projectEnd < new Date() && project.progress < 100 && project.status === "1"; - + return ( -
+
{/* Grid de meses */}
{visibleMonths.map((month) => { const isCurrentMonth = new Date().getMonth() === month.month && new Date().getFullYear() === month.year; - return (
); })}
- + {/* Barra del proyecto */} {isVisible && (
router.push(`/proyectos/${project.id}`)} > - {/* Fondo de la barra */} -
- - {/* Progreso */} -
- - {/* Contenido de la barra */} -
- - {project.progress >= 30 ? `${project.progress}%` : ""} - +
+
+
+ + {project.progress >= 30 ? `${project.progress}%` : ""} + +
-
- - -
-

{project.name}

-
-

Estado: {STATUS_LABELS[project.status]}

-

Progreso: {project.progress}%

-

Inicio: {formatDate(project.startDate)}

-

Fin: {formatDate(project.endDate)}

-

Cliente: {project.client}

- {isOverdue && ( -

Proyecto retrasado

- )} + + +
+

{project.name}

+
+

Estado: {STATUS_LABELS[project.status]}

+

Progreso: {project.progress}%

+

Inicio: {formatDate(project.startDate)}

+

Fin: {formatDate(project.endDate)}

+

Cliente: {project.client}

+ {isOverdue && ( +

Proyecto retrasado

+ )} +
-
- - - )} -
- ); - })} +
+ + )} +
+ ); + })}
@@ -546,36 +508,36 @@ export default function GanttPage() { function GanttSkeleton() { return ( - - -
-
-
+ + +
+
+
- {Array.from({ length: 5 }).map((_, i) => ( -
-
- + {Array.from({ length: 8 }).map((_, i) => ( +
+
+
- - + +
))}
-
- {Array.from({ length: 3 }).map((_, i) => ( +
+ {Array.from({ length: 6 }).map((_, i) => (
- +
))}
- {Array.from({ length: 5 }).map((_, i) => ( -
- + {Array.from({ length: 8 }).map((_, i) => ( +
+
))}
diff --git a/components/project-detail/index.ts b/components/project-detail/index.ts index c64eeb6..96c0854 100644 --- a/components/project-detail/index.ts +++ b/components/project-detail/index.ts @@ -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"; diff --git a/components/project-detail/project-detail-view.tsx b/components/project-detail/project-detail-view.tsx index 395a9b1..513aff2 100644 --- a/components/project-detail/project-detail-view.tsx +++ b/components/project-detail/project-detail-view.tsx @@ -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 )} - - - Detalles + + + Gantt @@ -222,9 +223,28 @@ export function ProjectDetailView({ project: initialProject }: ProjectDetailView )} - {/* Tab: Detalles */} - - + {/* Tab: Gantt */} + + {isLoadingTasks ? ( +
+
+ + + +
+ +
+ ) : tasksError ? ( + + ) : ( + + )}
diff --git a/components/project-detail/project-header.tsx b/components/project-detail/project-header.tsx index a08bc06..c1889c6 100644 --- a/components/project-detail/project-header.tsx +++ b/components/project-detail/project-header.tsx @@ -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); } diff --git a/components/project-detail/task-gantt.tsx b/components/project-detail/task-gantt.tsx new file mode 100644 index 0000000..411ee5e --- /dev/null +++ b/components/project-detail/task-gantt.tsx @@ -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("future"); + const [isCreateSheetOpen, setIsCreateSheetOpen] = useState(false); + const [interactionMode, setInteractionMode] = useState("normal"); + const [linkSource, setLinkSource] = useState(null); + const timelineRef = useRef(null); + const timelineScrollRef = useRef(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(); + 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(); + 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 ( +
+
+ +
+

Sin tareas para el Gantt

+

+ Crea tareas con fechas de inicio y fin para verlas en el diagrama. +

+ + onTaskCreated?.(task)} + /> +
+ ); + } + + return ( +
+ {/* Controles */} +
+
+ {/* Toggle Futuro / Pasado */} +
+ + +
+ + + {months.length} meses · {ganttTasks.length} tareas + +
+ +
+ {/* Modo enlazar dependencias */} + {interactionMode === "linking" ? ( +
+ + + {linkSource === null + ? "Click en la tarea predecesora" + : "Ahora click en la dependiente"} + + +
+ ) : ( + + )} + + +
+
+ + {/* Leyenda dependencias */} + {dependencyArrows.length > 0 && ( +
+ + + + + + + + + {dependencyArrows.length} dependencia{dependencyArrows.length !== 1 ? "s" : ""} +
+ )} + + {/* Gantt Chart */} +
+
+ {/* Columna de nombres - fija */} +
+
+ Tarea +
+ {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 ( +
interactionMode === "linking" && handleBarClick(task.id)} + > +
+
+
+

+ {task.title} +

+
+ {task.progress}% + {task.status === "2" && } + {isOverdue && } + {task.dependencies && task.dependencies.length > 0 && } + {!hasDate && Sin fechas} +
+
+ + {statusConfig.label} + +
+
+ ); + })} +
+ + {/* Timeline con scroll horizontal */} +
+
+ {/* Header con meses */} +
+ {months.map((month) => { + const isCurrentMonth = new Date().getMonth() === month.month && new Date().getFullYear() === month.year; + return ( +
+ + {month.label} + +
+ ); + })} +
+ + {/* Barras + SVG de flechas */} +
+ {/* SVG flechas */} + + + + + + + {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 ( + + ); + })} + + + {/* Filas */} + + {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 ( +
+ {/* Grid de meses */} +
+ {months.map((month) => { + const isCurrentMonth = new Date().getMonth() === month.month && new Date().getFullYear() === month.year; + return ( +
+ ); + })} +
+ + {/* 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 ( +
+ ); + } + return null; + })()} + + {/* Barra de la tarea */} + {isVisible && barPos && ( + + +
interactionMode === "linking" && handleBarClick(task.id)} + > +
+
+
+ + {barPos.widthPx > 40 ? `${task.progress}%` : ""} + +
+
+ + +
+

{task.title}

+
+

Estado: {TASK_STATUS_CONFIG[task.status].label}

+

Prioridad: {priorityConfig.label}

+

Progreso: {task.progress}%

+

Inicio: {formatDate(task.startDate || task.plannedStartDate!)}

+

Fin: {formatDate(task.endDate || task.plannedEndDate!)}

+ {task.plannedHours > 0 && ( +

Horas: {task.workedHours}h / {task.plannedHours}h

+ )} + {hasDeps && ( +
+

+ + Depende de: +

+ {task.dependencies.map((depId) => { + const depTask = ganttTasks.find(t => t.id === depId); + return

{depTask?.title || `Tarea #${depId}`}

; + })} +
+ )} + {isOverdue &&

Tarea retrasada

} +
+
+
+ + )} + + {/* Indicador para tareas sin fecha */} + {!hasDate && ( +
+ Sin fechas asignadas +
+ )} +
+ ); + })} + +
+
+
+
+
+ + {/* Sheet de creación */} + onTaskCreated?.(task)} + /> +
+ ); +} diff --git a/components/project-detail/tasks-section.tsx b/components/project-detail/tasks-section.tsx index 7415e2c..c618e1d 100644 --- a/components/project-detail/tasks-section.tsx +++ b/components/project-detail/tasks-section.tsx @@ -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} /> diff --git a/components/projects/projects-table.tsx b/components/projects/projects-table.tsx index 0a3bd30..2d69aff 100644 --- a/components/projects/projects-table.tsx +++ b/components/projects/projects-table.tsx @@ -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); } diff --git a/components/statistics/statistics-page.tsx b/components/statistics/statistics-page.tsx index 425d458..a669559 100644 --- a/components/statistics/statistics-page.tsx +++ b/components/statistics/statistics-page.tsx @@ -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 ( - - - {title} - - -
- - - - - -
-
-

{value}%

- {subtitle &&

{subtitle}

} -
-
-
-
-
- ); -} - // 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 ( - - - {title} - - -
- - - - - - [formatter ? formatter(Number(value) || 0) : (value ?? 0), '']} - contentStyle={{ fontSize: '12px' }} - /> - - {data.map((entry, index) => ( - - ))} - - - -
-
-
- ); -} - // Multi Radial para comparar metricas interface MultiRadialProps { data: { name: string; value: number; fill: string }[]; @@ -485,10 +402,9 @@ function AllProjectsStats({ projects }: { projects: Project[] }) { const borradores = projects.filter(p => p.status === "0").length; 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 (
{/* KPIs principales */}
- } highlight /> - } /> - } /> - } /> + } + highlight + /> + } + /> + } + /> + 0 ? "Proyectos retrasados" : "Todo en orden"} + icon={ 0 ? "text-red-500" : "text-green-500"}`} />} + />
- {/* Alertas de fechas */} + {/* Graficos: Estado + Progreso */} +
+ + +
+ + {/* Presupuesto por proyecto */} + b.budget - a.budget)} + title="Presupuesto vs Gastado por Proyecto" + maxItems={8} + /> + + {/* Alertas: retrasados y proximos a vencer */} {(overdueProjects.length > 0 || upcomingDeadlines.length > 0) && (
{overdueProjects.length > 0 && ( - - -
-
-

- Proyectos Retrasados -

-

{overdueProjects.length}

-

Fecha límite superada

-
-
- {overdueProjects.slice(0, 2).map(p => ( -

{p.name}

- ))} -
+ + + + + Retrasados ({overdueProjects.length}) + + + +
+ {overdueProjects.slice(0, 4).map(p => { + const daysOverdue = Math.abs(getDaysRemaining(p.endDate)); + return ( +
+
+

{p.name}

+

{p.progress}%

+
+ + -{daysOverdue}d + +
+ ); + })}
)} {upcomingDeadlines.length > 0 && ( - - -
-
-

- Próximos a Vencer -

-

{upcomingDeadlines.length}

-

En los próximos 14 días

-
-
- {upcomingDeadlines.slice(0, 2).map(p => ( -

- {p.name} ({getDaysRemaining(p.endDate)}d) -

- ))} -
+ + + + + Proximos a vencer ({upcomingDeadlines.length}) + + + +
+ {upcomingDeadlines.slice(0, 4).map(p => { + const days = getDaysRemaining(p.endDate); + return ( +
+
+

{p.name}

+

{p.progress}%

+
+ + {days}d + +
+ ); + })}
)}
)} - - {/* Gráficos principales */} -
- - - 80 ? COLORS.danger : COLORS.primary} /> -
- - {/* Comparativas */} -
- formatCurrency(v)} /> - -
- - {/* Timeline de fechas - Próximos vencimientos y retrasados */} - {(upcomingDeadlines.length > 0 || overdueProjects.length > 0) && ( - - - - Timeline de Proyectos - - - -
- {/* Proyectos retrasados */} - {overdueProjects.length > 0 && ( -
-

- Retrasados ({overdueProjects.length}) -

-
- {overdueProjects.slice(0, 6).map((p, index) => { - const daysOverdue = Math.abs(getDaysRemaining(p.endDate)); - return ( -
-
-
-

{p.name}

-

{p.progress}% completado

-
- - -{daysOverdue}d - -
- ); - })} -
-
- )} - - {/* Próximos vencimientos */} - {upcomingDeadlines.length > 0 && ( -
-

- Próximos 14 días ({upcomingDeadlines.length}) -

-
- {upcomingDeadlines.slice(0, 6).map((p, index) => { - const days = getDaysRemaining(p.endDate); - return ( -
-
-
-

{p.name}

-

{p.progress}% completado

-
- - {days}d - -
- ); - })} -
-
- )} - - {/* Resumen de fechas */} -
-
- -
- Retrasados: {overdueProjects.length} - - -
- Vencen en 7 días: {upcomingDeadlines.filter(p => getDaysRemaining(p.endDate) <= 7).length} - - -
- En tiempo: {projects.filter(p => p.status === "1" && getDaysRemaining(p.endDate) > 14).length} - -
-
-
- - - )} - - {/* Presupuesto detallado */} - b.budget - a.budget)} title="Presupuesto vs Gastado por Proyecto" maxItems={6} />
); } @@ -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 (
{/* KPIs */} @@ -725,56 +568,13 @@ function ActiveProjectsStats({ projects }: { projects: Project[] }) { } highlight /> } /> } /> - } /> -
- - {/* Alertas */} -
- 0 ? 'border-red-200 dark:border-red-900 bg-red-50 dark:bg-red-950/30' : 'border bg-muted/30'}`}> - -
-
-

0 ? 'text-red-700 dark:text-red-400' : 'text-muted-foreground'}`}> - Retrasados -

-

0 ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground/50'}`}>{overdueProjects.length}

-

0 ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground/50'}`}>Fecha superada

-
-
-
-
- 0 ? 'border-orange-200 dark:border-orange-900 bg-orange-50 dark:bg-orange-950/30' : 'border bg-muted/30'}`}> - -
-
-

0 ? 'text-orange-700 dark:text-orange-400' : 'text-muted-foreground'}`}> - Requieren Atención -

-

0 ? 'text-orange-600 dark:text-orange-400' : 'text-muted-foreground/50'}`}>{needsAttention}

-

0 ? 'text-orange-600 dark:text-orange-400' : 'text-muted-foreground/50'}`}>Menos del 25%

-
-
-
-
- - -
-
-

- Próximos a Completar -

-

{nearCompletion}

-

75% o más

-
-
-
-
+ } />
{/* Gráficos */}
- + b.budget - a.budget)} title="Presupuesto vs Gastado" maxItems={6} />
{/* Próximos vencimientos */} @@ -806,9 +606,6 @@ function ActiveProjectsStats({ projects }: { projects: Project[] }) { )} - - {/* Presupuesto */} - b.budget - a.budget)} title="Presupuesto vs Gastado por Proyecto" maxItems={6} />
); } @@ -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[] }) {
{/* Gráficos */} -
+
0 ? Math.round((withinBudget / stats.total) * 100) : 0}%`} centerLabel="Éxito" /> - - +
{/* 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 (
- {/* Banner de éxito */} - - -
-
-

{stats.total}

-

Proyectos al 100%

-
-
-
-

{successRate}%

-

Tasa de éxito

-
-
-
-

{formatCurrency(savings)}

-

Ahorro total

-
-
- - - {/* KPIs */}
- } /> - } /> - } /> - } /> + } highlight /> + } /> + } /> + } />
{/* Gráficos */}
- formatCurrency(v)} /> + b.budget - a.budget)} title="Presupuesto vs Gastado" maxItems={6} />
- - {/* Detalle de presupuesto */} - b.budget - a.budget)} title="Detalle: Presupuesto vs Gastado" maxItems={6} />
); } diff --git a/components/task-detail/task-header.tsx b/components/task-detail/task-header.tsx index 08b5127..c717ac3 100644 --- a/components/task-detail/task-header.tsx +++ b/components/task-detail/task-header.tsx @@ -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); } diff --git a/components/task-form/task-form-sheet.tsx b/components/task-form/task-form-sheet.tsx index a7c7b01..7393ec1 100644 --- a/components/task-form/task-form-sheet.tsx +++ b/components/task-form/task-form-sheet.tsx @@ -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({ @@ -74,6 +78,11 @@ export function TaskFormSheet({ const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(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({
+ {/* Dependencias (tareas predecesoras) */} + {selectableTasks.length > 0 && ( +
+ +

+ Selecciona las tareas que deben completarse antes de esta. +

+ + {/* Lista de dependencias seleccionadas */} + {formData.dependencies.length > 0 && ( +
+ {formData.dependencies.map((depId) => { + const depTask = availableTasks.find(t => t.id === depId); + return ( + + + {depTask?.title || `Tarea #${depId}`} + + + + ); + })} +
+ )} +
+ )} + {/* Error message */} {error && (
diff --git a/lib/tasksService.ts b/lib/tasksService.ts index bc8bced..f92b727 100644 --- a/lib/tasksService.ts +++ b/lib/tasksService.ts @@ -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 { + 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): 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 { + const all = getAllDependencies(); + const result = new Map(); + 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 => 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 { 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 { export async function getTaskById(taskId: number): Promise { 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 { 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; diff --git a/package-lock.json b/package-lock.json index 9698279..b4d4cfd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 526f263..f82800e 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/types/task.ts b/types/task.ts index bb256e1..50a1fd7 100644 --- a/types/task.ts +++ b/types/task.ts @@ -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 || [], }; }