trello_fake/components/project-detail/project-task-gantt.tsx

393 lines
16 KiB
TypeScript

"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
);
const maxTimelineWidth = Math.max(320, fitTimelineWidth);
const baseTickWidth = zoom === "fit" ? 72 : zoom === "week" ? 84 : 36;
const rawTimelineWidth = ticks.length * baseTickWidth;
const timelineWidth = Math.max(320, Math.min(maxTimelineWidth, rawTimelineWidth));
const tickWidth = timelineWidth / Math.max(1, ticks.length);
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">
<Button size="icon" variant="outline" onClick={goBack} disabled={!canGoBack} aria-label="Semana anterior">
<ChevronLeft className="w-4 h-4" />
</Button>
<Button size="icon" variant="outline" onClick={goForward} disabled={!canGoForward} aria-label="Semana siguiente">
<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]"
>
<div
style={{
minWidth: zoom === "fit" ? "100%" : `${LEFT_COL_WIDTH + timelineWidth}px`,
width: zoom === "fit" ? "100%" : "max-content",
}}
>
<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) => (
<div
key={tick.key}
className="border-r flex items-center justify-center"
style={{ width: `${tickWidth}px` }}
title={tick.label}
>
<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) => (
<div key={`grid-${item.task.id}-${tick.key}`} className="border-r" style={{ width: `${tickWidth}px` }} />
))}
</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>
);
}