"use client"; import Link from "next/link"; import { useEffect, useMemo, useRef, useState } from "react"; import { AlertTriangle, CalendarClock, CalendarDays, CheckCircle2, ChevronLeft, ChevronRight, } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { Task, TASK_STATUS_CONFIG } from "@/types/task"; type Zoom = "fit" | "week" | "day"; interface ProjectTaskGanttProps { tasks: Task[]; } interface TaskTimelineItem { task: Task; start: Date; end: Date; } interface Tick { key: string; label: string; date: Date; } const LEFT_COL_WIDTH = 340; const ROW_HEIGHT = 56; function startOfDay(date: Date): Date { return new Date(date.getFullYear(), date.getMonth(), date.getDate()); } function addDays(date: Date, days: number): Date { const next = new Date(date); next.setDate(next.getDate() + days); return next; } function diffDays(start: Date, end: Date): number { const msPerDay = 1000 * 60 * 60 * 24; return Math.floor((startOfDay(end).getTime() - startOfDay(start).getTime()) / msPerDay); } function parseTaskRange(task: Task): { start: Date; end: Date } | null { const startRaw = task.startDate || task.plannedStartDate || task.endDate || task.plannedEndDate; const endRaw = task.endDate || task.plannedEndDate || task.startDate || task.plannedStartDate; if (!startRaw || !endRaw) return null; const a = startOfDay(new Date(startRaw)); const b = startOfDay(new Date(endRaw)); if (isNaN(a.getTime()) || isNaN(b.getTime())) return null; return a <= b ? { start: a, end: b } : { start: b, end: a }; } function formatDate(date: Date): string { return date.toLocaleDateString("es-ES", { day: "2-digit", month: "short", year: "numeric" }); } function monday(date: Date): Date { const d = startOfDay(date); const day = d.getDay(); const offset = day === 0 ? -6 : 1 - day; return addDays(d, offset); } function buildWeekTicks(start: Date, end: Date): Tick[] { const ticks: Tick[] = []; let cursor = monday(start); while (cursor <= end) { ticks.push({ key: `w-${cursor.getFullYear()}-${cursor.getMonth()}-${cursor.getDate()}`, label: cursor.toLocaleDateString("es-ES", { day: "2-digit", month: "short" }).toUpperCase(), date: cursor, }); cursor = addDays(cursor, 7); } return ticks; } function buildDayTicks(start: Date, end: Date): Tick[] { const ticks: Tick[] = []; let cursor = startOfDay(start); while (cursor <= end) { ticks.push({ key: `d-${cursor.getFullYear()}-${cursor.getMonth()}-${cursor.getDate()}`, label: cursor.toLocaleDateString("es-ES", { day: "2-digit" }), date: cursor, }); cursor = addDays(cursor, 1); } return ticks; } export function ProjectTaskGantt({ tasks }: ProjectTaskGanttProps) { const [zoom, setZoom] = useState("fit"); const [windowStartOffset, setWindowStartOffset] = useState(0); const viewportRef = useRef(null); const [viewportWidth, setViewportWidth] = useState(0); useEffect(() => { if (!viewportRef.current) return; const observer = new ResizeObserver((entries) => { const entry = entries[0]; if (!entry) return; setViewportWidth(Math.floor(entry.contentRect.width)); }); observer.observe(viewportRef.current); return () => observer.disconnect(); }, []); const timelineItems = useMemo(() => { return tasks .map((task) => { const range = parseTaskRange(task); if (!range) return null; return { task, start: range.start, end: range.end }; }) .filter((item): item is TaskTimelineItem => item !== null) .sort((a, b) => a.start.getTime() - b.start.getTime()); }, [tasks]); if (tasks.length === 0) { return (

No hay tareas para mostrar en el Gantt

Crea tareas con fechas para planificar el proyecto.

); } if (timelineItems.length === 0) { return (

Faltan fechas en las tareas

Asigna fecha de inicio y fin para usar el planificador.

); } const projectStart = new Date(Math.min(...timelineItems.map((item) => item.start.getTime()))); const projectEnd = new Date(Math.max(...timelineItems.map((item) => item.end.getTime()))); const fullStart = addDays(projectStart, -2); const fullEnd = addDays(projectEnd, 2); const fullDurationDays = Math.max(1, diffDays(fullStart, fullEnd) + 1); const completed = tasks.filter((task) => task.status === "2").length; const overdue = timelineItems.filter((item) => item.end < startOfDay(new Date()) && item.task.status !== "2").length; // Nueva filosofia: // - fit: todo el rango en pantalla (sin scroll horizontal) // - week/day: ventana movil con navegacion const windowDays = zoom === "fit" ? fullDurationDays : zoom === "week" ? 56 : 21; const clampedOffset = Math.max(0, Math.min(windowStartOffset, Math.max(0, fullDurationDays - windowDays))); const visibleStart = addDays(fullStart, clampedOffset); const visibleEnd = addDays(visibleStart, windowDays - 1); const visibleDurationDays = Math.max(1, diffDays(visibleStart, visibleEnd) + 1); const canGoBack = zoom !== "fit" && clampedOffset > 0; const canGoForward = zoom !== "fit" && clampedOffset + windowDays < fullDurationDays; const ticks = zoom === "day" ? buildDayTicks(visibleStart, visibleEnd) : buildWeekTicks(visibleStart, visibleEnd); const fitTimelineWidth = Math.max( 360, (viewportWidth > 0 ? viewportWidth : LEFT_COL_WIDTH + 640) - LEFT_COL_WIDTH ); const maxTimelineWidth = Math.max(320, fitTimelineWidth); const baseTickWidth = zoom === "fit" ? 72 : zoom === "week" ? 84 : 36; const tickWidth = Math.min(baseTickWidth, maxTimelineWidth / Math.max(1, ticks.length)); const timelineWidth = Math.max(320, ticks.length * tickWidth); const today = startOfDay(new Date()); const showToday = today >= visibleStart && today <= visibleEnd; const todayPercent = showToday ? (diffDays(visibleStart, today) / visibleDurationDays) * 100 : null; const goBack = () => { const step = zoom === "day" ? 7 : 28; setWindowStartOffset((current) => Math.max(0, current - step)); }; const goForward = () => { const step = zoom === "day" ? 7 : 28; setWindowStartOffset((current) => Math.min(Math.max(0, fullDurationDays - windowDays), current + step)); }; const setZoomAndReset = (next: Zoom) => { setZoom(next); setWindowStartOffset(0); }; return (
{tasks.length} tareas {completed} completadas {formatDate(visibleStart)} - {formatDate(visibleEnd)} {overdue > 0 && ( {overdue} retrasadas )}
{zoom !== "fit" && (
)}
Tarea
{ticks.map((tick) => (
{tick.label}
))}
{timelineItems.map((item) => { const statusConfig = TASK_STATUS_CONFIG[item.task.status]; const startsInWindow = item.end >= visibleStart && item.start <= visibleEnd; const startOffset = Math.max(0, diffDays(visibleStart, item.start)); const endOffset = Math.min(visibleDurationDays, diffDays(visibleStart, item.end) + 1); const leftPercent = (startOffset / visibleDurationDays) * 100; const widthPercent = Math.max(0.7, ((endOffset - startOffset) / visibleDurationDays) * 100); const taskDurationDays = Math.max(1, diffDays(item.start, item.end) + 1); return (

{item.task.title}

{statusConfig.label} {item.task.progress}% {item.task.status === "2" && }
{ticks.map((tick) => (
))}
{todayPercent !== null && (
)} {startsInWindow && (() => { const barWidthPx = Math.max((widthPercent / 100) * timelineWidth, zoom === "day" ? 28 : 40); const showFullLabel = barWidthPx >= 72; const showCompactLabel = !showFullLabel && barWidthPx >= 52; return (
{showFullLabel && ( {`${item.task.progress}%`} )} {showCompactLabel && ( {`${item.task.progress}`} )}

{item.task.title}

Progreso: {item.task.progress}%

Estado: {statusConfig.label}

Inicio: {formatDate(item.start)}

Fin: {formatDate(item.end)}

Duración: {taskDurationDays} día{taskDurationDays === 1 ? "" : "s"}

); })()}
); })}
); }