"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)} />
); }