2026-04-27 17:09:17 +00:00
|
|
|
"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<Zoom>("fit");
|
|
|
|
|
const [windowStartOffset, setWindowStartOffset] = useState(0);
|
|
|
|
|
const viewportRef = useRef<HTMLDivElement | null>(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<TaskTimelineItem[]>(() => {
|
|
|
|
|
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 (
|
|
|
|
|
<Card>
|
|
|
|
|
<CardContent className="py-12 text-center">
|
|
|
|
|
<CalendarDays className="w-10 h-10 text-muted-foreground/60 mx-auto mb-3" />
|
|
|
|
|
<p className="font-medium text-foreground">No hay tareas para mostrar en el Gantt</p>
|
|
|
|
|
<p className="text-sm text-muted-foreground mt-1">Crea tareas con fechas para planificar el proyecto.</p>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (timelineItems.length === 0) {
|
|
|
|
|
return (
|
|
|
|
|
<Card>
|
|
|
|
|
<CardContent className="py-12 text-center">
|
|
|
|
|
<CalendarClock className="w-10 h-10 text-muted-foreground/60 mx-auto mb-3" />
|
|
|
|
|
<p className="font-medium text-foreground">Faltan fechas en las tareas</p>
|
|
|
|
|
<p className="text-sm text-muted-foreground mt-1">Asigna fecha de inicio y fin para usar el planificador.</p>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-30 16:58:30 +00:00
|
|
|
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);
|
2026-04-27 17:09:17 +00:00
|
|
|
|
|
|
|
|
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 (
|
|
|
|
|
<div className="space-y-4 w-full min-w-0 overflow-x-hidden">
|
|
|
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
|
|
|
<Badge variant="outline">{tasks.length} tareas</Badge>
|
|
|
|
|
<Badge variant="outline">{completed} completadas</Badge>
|
|
|
|
|
<Badge variant="outline">{formatDate(visibleStart)} - {formatDate(visibleEnd)}</Badge>
|
|
|
|
|
{overdue > 0 && (
|
|
|
|
|
<Badge className="bg-red-100 text-red-700 hover:bg-red-100 dark:bg-red-900/35 dark:text-red-300">
|
|
|
|
|
<AlertTriangle className="w-3.5 h-3.5 mr-1" />
|
|
|
|
|
{overdue} retrasadas
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<div className="flex items-center gap-1 border rounded-md p-1">
|
|
|
|
|
<Button size="sm" variant={zoom === "fit" ? "default" : "ghost"} onClick={() => setZoomAndReset("fit")}>Ajustar</Button>
|
|
|
|
|
<Button size="sm" variant={zoom === "week" ? "default" : "ghost"} onClick={() => setZoomAndReset("week")}>Semanas</Button>
|
|
|
|
|
<Button size="sm" variant={zoom === "day" ? "default" : "ghost"} onClick={() => setZoomAndReset("day")}>Días</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{zoom !== "fit" && (
|
|
|
|
|
<div className="flex items-center gap-1">
|
2026-04-30 16:58:30 +00:00
|
|
|
<Button size="icon" variant="outline" onClick={goBack} disabled={!canGoBack} aria-label="Semana anterior">
|
2026-04-27 17:09:17 +00:00
|
|
|
<ChevronLeft className="w-4 h-4" />
|
|
|
|
|
</Button>
|
2026-04-30 16:58:30 +00:00
|
|
|
<Button size="icon" variant="outline" onClick={goForward} disabled={!canGoForward} aria-label="Semana siguiente">
|
2026-04-27 17:09:17 +00:00
|
|
|
<ChevronRight className="w-4 h-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Card className="border shadow-sm overflow-hidden w-full max-w-full">
|
|
|
|
|
<CardContent className="p-0 w-full max-w-full overflow-hidden">
|
|
|
|
|
<TooltipProvider>
|
|
|
|
|
<div
|
|
|
|
|
ref={viewportRef}
|
|
|
|
|
className="w-full max-w-full max-h-[620px] overflow-x-auto overflow-y-auto overscroll-x-contain [scrollbar-gutter:stable_both-edges]"
|
|
|
|
|
>
|
2026-04-30 16:58:30 +00:00
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
minWidth: zoom === "fit" ? "100%" : `${LEFT_COL_WIDTH + timelineWidth}px`,
|
|
|
|
|
width: zoom === "fit" ? "100%" : "max-content",
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-04-27 17:09:17 +00:00
|
|
|
<div
|
|
|
|
|
className="border-b sticky top-0 z-20 bg-card/95 backdrop-blur"
|
|
|
|
|
style={{ display: "grid", gridTemplateColumns: `${LEFT_COL_WIDTH}px ${timelineWidth}px` }}
|
|
|
|
|
>
|
|
|
|
|
<div className="h-12 border-r px-4 flex items-center text-xs font-semibold text-muted-foreground sticky left-0 z-30 bg-card/95">
|
|
|
|
|
Tarea
|
|
|
|
|
</div>
|
|
|
|
|
<div className="h-12 flex">
|
|
|
|
|
{ticks.map((tick) => (
|
2026-04-30 16:58:30 +00:00
|
|
|
<div
|
|
|
|
|
key={tick.key}
|
|
|
|
|
className="border-r flex items-center justify-center"
|
|
|
|
|
style={{ width: `${tickWidth}px` }}
|
|
|
|
|
title={tick.label}
|
|
|
|
|
>
|
2026-04-27 17:09:17 +00:00
|
|
|
<span className="text-[11px] font-medium text-muted-foreground uppercase">{tick.label}</span>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{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 (
|
|
|
|
|
<div
|
|
|
|
|
key={item.task.id}
|
|
|
|
|
className="border-b"
|
|
|
|
|
style={{ display: "grid", gridTemplateColumns: `${LEFT_COL_WIDTH}px ${timelineWidth}px` }}
|
|
|
|
|
>
|
|
|
|
|
<Link
|
|
|
|
|
href={`/tareas/${item.task.id}`}
|
|
|
|
|
className="h-[56px] border-r px-4 py-2 hover:bg-muted/50 transition-colors sticky left-0 z-10 bg-background"
|
|
|
|
|
>
|
|
|
|
|
<p className="text-sm font-semibold text-foreground truncate">{item.task.title}</p>
|
|
|
|
|
<div className="mt-1.5 flex items-center gap-2">
|
|
|
|
|
<Badge variant="outline" className={`text-[10px] px-1.5 py-0 h-5 ${statusConfig.bgClass}`}>
|
|
|
|
|
{statusConfig.label}
|
|
|
|
|
</Badge>
|
|
|
|
|
<span className="text-[11px] text-muted-foreground">{item.task.progress}%</span>
|
|
|
|
|
{item.task.status === "2" && <CheckCircle2 className="w-3.5 h-3.5 text-green-500" />}
|
|
|
|
|
</div>
|
|
|
|
|
</Link>
|
|
|
|
|
|
|
|
|
|
<div className="relative h-[56px]">
|
|
|
|
|
<div className="absolute inset-0 flex pointer-events-none">
|
|
|
|
|
{ticks.map((tick) => (
|
2026-04-30 16:58:30 +00:00
|
|
|
<div key={`grid-${item.task.id}-${tick.key}`} className="border-r" style={{ width: `${tickWidth}px` }} />
|
2026-04-27 17:09:17 +00:00
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{todayPercent !== null && (
|
|
|
|
|
<div className="absolute top-0 bottom-0 w-px bg-red-400/70 z-[1]" style={{ left: `${todayPercent}%` }} />
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{startsInWindow && (() => {
|
|
|
|
|
const barWidthPx = Math.max((widthPercent / 100) * timelineWidth, zoom === "day" ? 28 : 40);
|
|
|
|
|
const showFullLabel = barWidthPx >= 72;
|
|
|
|
|
const showCompactLabel = !showFullLabel && barWidthPx >= 52;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Tooltip>
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
<Link
|
|
|
|
|
href={`/tareas/${item.task.id}`}
|
|
|
|
|
className="absolute top-1/2 -translate-y-1/2 h-9 rounded-md transition-all hover:scale-[1.01] hover:shadow-sm"
|
|
|
|
|
style={{
|
|
|
|
|
left: `${leftPercent}%`,
|
|
|
|
|
width: `${widthPercent}%`,
|
|
|
|
|
minWidth: zoom === "day" ? "28px" : "40px",
|
|
|
|
|
}}
|
|
|
|
|
aria-label={`${item.task.title} - ${item.task.progress}%`}
|
|
|
|
|
>
|
|
|
|
|
<div className="absolute inset-0 rounded-md bg-blue-100 dark:bg-blue-900/40" />
|
|
|
|
|
<div
|
|
|
|
|
className="absolute inset-y-0 left-0 rounded-md bg-gradient-to-r from-blue-500 to-violet-600"
|
|
|
|
|
style={{ width: `${Math.max(0, Math.min(100, item.task.progress))}%` }}
|
|
|
|
|
/>
|
|
|
|
|
<div className="relative h-full px-2.5 flex items-center z-10">
|
|
|
|
|
{showFullLabel && (
|
|
|
|
|
<span className="text-[11px] font-semibold text-white drop-shadow truncate">
|
|
|
|
|
{`${item.task.progress}%`}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
{showCompactLabel && (
|
|
|
|
|
<span className="text-[11px] font-semibold text-white drop-shadow truncate">
|
|
|
|
|
{`${item.task.progress}`}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</Link>
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
<TooltipContent side="top" className="max-w-xs">
|
|
|
|
|
<div className="space-y-1 text-xs">
|
|
|
|
|
<p className="font-semibold text-sm">{item.task.title}</p>
|
|
|
|
|
<p><span className="text-muted-foreground">Progreso:</span> {item.task.progress}%</p>
|
|
|
|
|
<p><span className="text-muted-foreground">Estado:</span> {statusConfig.label}</p>
|
|
|
|
|
<p><span className="text-muted-foreground">Inicio:</span> {formatDate(item.start)}</p>
|
|
|
|
|
<p><span className="text-muted-foreground">Fin:</span> {formatDate(item.end)}</p>
|
|
|
|
|
<p><span className="text-muted-foreground">Duración:</span> {taskDurationDays} día{taskDurationDays === 1 ? "" : "s"}</p>
|
|
|
|
|
</div>
|
|
|
|
|
</TooltipContent>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
);
|
|
|
|
|
})()}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</TooltipProvider>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|