648 lines
28 KiB
TypeScript
648 lines
28 KiB
TypeScript
"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<TimeRange>("future");
|
|
const [isCreateSheetOpen, setIsCreateSheetOpen] = useState(false);
|
|
const [interactionMode, setInteractionMode] = useState<InteractionMode>("normal");
|
|
const [linkSource, setLinkSource] = useState<number | null>(null);
|
|
const timelineRef = useRef<HTMLDivElement>(null);
|
|
const timelineScrollRef = useRef<HTMLDivElement>(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<number, number>();
|
|
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<number>();
|
|
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 (
|
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
|
<div className="p-3 rounded-full bg-muted mb-4">
|
|
<GanttIcon className="h-8 w-8 text-muted-foreground" />
|
|
</div>
|
|
<h3 className="text-lg font-semibold mb-1">Sin tareas para el Gantt</h3>
|
|
<p className="text-sm text-muted-foreground mb-4 max-w-sm">
|
|
Crea tareas con fechas de inicio y fin para verlas en el diagrama.
|
|
</p>
|
|
<Button onClick={() => setIsCreateSheetOpen(true)}>
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
Crear tarea
|
|
</Button>
|
|
<TaskFormSheet
|
|
open={isCreateSheetOpen}
|
|
onOpenChange={setIsCreateSheetOpen}
|
|
mode="create"
|
|
projectId={projectId}
|
|
availableTasks={tasks}
|
|
onSuccess={(task) => onTaskCreated?.(task)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Controles */}
|
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
|
<div className="flex items-center gap-3 flex-wrap">
|
|
{/* Toggle Futuro / Pasado */}
|
|
<div className="flex items-center gap-1 border rounded-lg p-1">
|
|
<Button
|
|
variant={timeRange === "future" ? "default" : "ghost"}
|
|
size="sm"
|
|
onClick={() => setTimeRange("future")}
|
|
>
|
|
<Clock className="w-4 h-4 mr-1" />
|
|
Futuro
|
|
</Button>
|
|
<Button
|
|
variant={timeRange === "past" ? "default" : "ghost"}
|
|
size="sm"
|
|
onClick={() => setTimeRange("past")}
|
|
>
|
|
<History className="w-4 h-4 mr-1" />
|
|
Pasado
|
|
</Button>
|
|
</div>
|
|
|
|
<span className="text-xs text-muted-foreground">
|
|
{months.length} meses · {ganttTasks.length} tareas
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
{/* Modo enlazar dependencias */}
|
|
{interactionMode === "linking" ? (
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="outline" className="bg-blue-50 dark:bg-blue-950/50 text-blue-700 dark:text-blue-400 border-blue-200 dark:border-blue-800 animate-pulse">
|
|
<Link2 className="h-3 w-3 mr-1" />
|
|
{linkSource === null
|
|
? "Click en la tarea predecesora"
|
|
: "Ahora click en la dependiente"}
|
|
</Badge>
|
|
<Button variant="ghost" size="sm" onClick={cancelLinking}>
|
|
Cancelar
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-9"
|
|
onClick={() => setInteractionMode("linking")}
|
|
>
|
|
<Link2 className="h-4 w-4 mr-2" />
|
|
Enlazar tareas
|
|
</Button>
|
|
)}
|
|
|
|
<Button size="sm" className="h-9" onClick={() => setIsCreateSheetOpen(true)}>
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
Nueva tarea
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Leyenda dependencias */}
|
|
{dependencyArrows.length > 0 && (
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<svg width="20" height="10">
|
|
<defs>
|
|
<marker id="legend-arrow" markerWidth="6" markerHeight="6" refX="5" refY="3" orient="auto">
|
|
<path d="M0,0 L6,3 L0,6 Z" fill="#6366f1" />
|
|
</marker>
|
|
</defs>
|
|
<line x1="0" y1="5" x2="14" y2="5" stroke="#6366f1" strokeWidth="1.5" markerEnd="url(#legend-arrow)" />
|
|
</svg>
|
|
<span>{dependencyArrows.length} dependencia{dependencyArrows.length !== 1 ? "s" : ""}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Gantt Chart */}
|
|
<div className="border rounded-lg overflow-hidden bg-card">
|
|
<div className="flex w-full overflow-hidden">
|
|
{/* Columna de nombres - fija */}
|
|
<div className="flex-shrink-0 border-r bg-muted/50" style={{ width: `${NAME_COL_WIDTH}px` }}>
|
|
<div className="border-b flex items-center px-4" style={{ height: `${HEADER_HEIGHT}px` }}>
|
|
<span className="font-semibold text-foreground text-sm">Tarea</span>
|
|
</div>
|
|
{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 (
|
|
<div
|
|
key={task.id}
|
|
className={`border-b flex items-center px-3 hover:bg-muted transition-colors ${
|
|
isLinkSource ? "bg-blue-50 dark:bg-blue-950/30" : ""
|
|
} ${interactionMode === "linking" ? "cursor-pointer" : ""}`}
|
|
style={{ height: `${ROW_HEIGHT}px` }}
|
|
onClick={() => interactionMode === "linking" && handleBarClick(task.id)}
|
|
>
|
|
<div className="flex items-center gap-2 min-w-0 w-full">
|
|
<div className={`w-2.5 h-2.5 rounded-full ${hasDate ? color.bg : "bg-gray-300 dark:bg-gray-600"} flex-shrink-0`} />
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-xs font-medium text-foreground truncate" title={task.title}>
|
|
{task.title}
|
|
</p>
|
|
<div className="flex items-center gap-1">
|
|
<span className="text-[10px] text-muted-foreground">{task.progress}%</span>
|
|
{task.status === "2" && <CheckCircle2 className="h-2.5 w-2.5 text-green-500" />}
|
|
{isOverdue && <AlertTriangle className="h-2.5 w-2.5 text-red-500" />}
|
|
{task.dependencies && task.dependencies.length > 0 && <Link2 className="h-2.5 w-2.5 text-indigo-500" />}
|
|
{!hasDate && <span className="text-[9px] text-amber-500">Sin fechas</span>}
|
|
</div>
|
|
</div>
|
|
<Badge variant="outline" className={`${statusConfig.bgClass} text-[9px] px-1 py-0 h-4 flex-shrink-0`}>
|
|
{statusConfig.label}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Timeline con scroll horizontal */}
|
|
<div
|
|
ref={timelineScrollRef}
|
|
className="overflow-x-auto overflow-y-hidden"
|
|
style={{ width: `calc(100% - ${NAME_COL_WIDTH}px)` }}
|
|
>
|
|
<div style={{ width: `${timelineWidth}px`, minWidth: "100%" }}>
|
|
{/* Header con meses */}
|
|
<div className="border-b flex" style={{ height: `${HEADER_HEIGHT}px` }}>
|
|
{months.map((month) => {
|
|
const isCurrentMonth = new Date().getMonth() === month.month && new Date().getFullYear() === month.year;
|
|
return (
|
|
<div
|
|
key={`${month.year}-${month.month}`}
|
|
className={`border-r flex flex-col justify-center px-2 ${
|
|
isCurrentMonth ? "bg-blue-50 dark:bg-blue-950/30" : "bg-card"
|
|
}`}
|
|
style={{ width: `${MONTH_WIDTH}px`, flexShrink: 0 }}
|
|
>
|
|
<span className={`text-xs font-medium ${isCurrentMonth ? "text-blue-700 dark:text-blue-400" : "text-muted-foreground"}`}>
|
|
{month.label}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Barras + SVG de flechas */}
|
|
<div className="relative" ref={timelineRef}>
|
|
{/* SVG flechas */}
|
|
<svg
|
|
className="absolute inset-0 pointer-events-none z-10"
|
|
width={timelineWidth}
|
|
height={ganttTasks.length * ROW_HEIGHT}
|
|
style={{ overflow: "visible" }}
|
|
>
|
|
<defs>
|
|
<marker id="dependency-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto" markerUnits="userSpaceOnUse">
|
|
<path d="M0,0 L8,4 L0,8 Z" fill="#6366f1" />
|
|
</marker>
|
|
</defs>
|
|
{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 (
|
|
<path
|
|
key={arrow.key}
|
|
d={pathD}
|
|
fill="none"
|
|
stroke="#6366f1"
|
|
strokeWidth="1.5"
|
|
strokeDasharray={dx <= 20 ? "4,3" : "none"}
|
|
markerEnd="url(#dependency-arrow)"
|
|
opacity="0.7"
|
|
/>
|
|
);
|
|
})}
|
|
</svg>
|
|
|
|
{/* Filas */}
|
|
<TooltipProvider>
|
|
{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 (
|
|
<div
|
|
key={task.id}
|
|
className={`border-b relative ${interactionMode === "linking" ? "cursor-pointer" : ""}`}
|
|
style={{ height: `${ROW_HEIGHT}px` }}
|
|
>
|
|
{/* Grid de meses */}
|
|
<div className="absolute inset-0 flex">
|
|
{months.map((month) => {
|
|
const isCurrentMonth = new Date().getMonth() === month.month && new Date().getFullYear() === month.year;
|
|
return (
|
|
<div
|
|
key={`grid-${month.year}-${month.month}`}
|
|
className={`border-r ${isCurrentMonth ? "bg-blue-50/30 dark:bg-blue-950/20" : ""}`}
|
|
style={{ width: `${MONTH_WIDTH}px`, flexShrink: 0 }}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* 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 (
|
|
<div
|
|
className="absolute top-0 bottom-0 w-px bg-red-400 z-[5]"
|
|
style={{ left: `${todayPx}px` }}
|
|
/>
|
|
);
|
|
}
|
|
return null;
|
|
})()}
|
|
|
|
{/* Barra de la tarea */}
|
|
{isVisible && barPos && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<div
|
|
className={`absolute top-2 rounded-md transition-all z-[2] ${
|
|
interactionMode === "linking"
|
|
? "cursor-pointer hover:ring-2 hover:ring-blue-400"
|
|
: "cursor-default hover:scale-[1.02] hover:shadow-md"
|
|
} ${isLinkSource ? "ring-2 ring-blue-500" : ""} ${isOverdue ? "ring-2 ring-red-400" : ""}`}
|
|
style={{
|
|
left: `${barPos.leftPx}px`,
|
|
width: `${barPos.widthPx}px`,
|
|
height: `${ROW_HEIGHT - 16}px`,
|
|
minWidth: "8px",
|
|
}}
|
|
onClick={() => interactionMode === "linking" && handleBarClick(task.id)}
|
|
>
|
|
<div className={`absolute inset-0 ${color.light} rounded-md`} />
|
|
<div
|
|
className={`absolute inset-y-0 left-0 ${color.bg} rounded-md transition-all`}
|
|
style={{ width: `${task.progress}%` }}
|
|
/>
|
|
<div className="relative h-full flex items-center px-2 z-10">
|
|
<span className="text-[10px] font-medium text-white truncate drop-shadow-sm">
|
|
{barPos.widthPx > 40 ? `${task.progress}%` : ""}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top" className="max-w-xs">
|
|
<div className="space-y-1.5">
|
|
<p className="font-semibold text-sm">{task.title}</p>
|
|
<div className="text-xs space-y-0.5">
|
|
<p><span className="text-muted-foreground">Estado:</span> {TASK_STATUS_CONFIG[task.status].label}</p>
|
|
<p><span className="text-muted-foreground">Prioridad:</span> {priorityConfig.label}</p>
|
|
<p><span className="text-muted-foreground">Progreso:</span> {task.progress}%</p>
|
|
<p><span className="text-muted-foreground">Inicio:</span> {formatDate(task.startDate || task.plannedStartDate!)}</p>
|
|
<p><span className="text-muted-foreground">Fin:</span> {formatDate(task.endDate || task.plannedEndDate!)}</p>
|
|
{task.plannedHours > 0 && (
|
|
<p><span className="text-muted-foreground">Horas:</span> {task.workedHours}h / {task.plannedHours}h</p>
|
|
)}
|
|
{hasDeps && (
|
|
<div className="pt-1 border-t">
|
|
<p className="text-muted-foreground flex items-center gap-1">
|
|
<Link2 className="h-3 w-3" />
|
|
Depende de:
|
|
</p>
|
|
{task.dependencies.map((depId) => {
|
|
const depTask = ganttTasks.find(t => t.id === depId);
|
|
return <p key={depId} className="pl-4 text-[11px]">{depTask?.title || `Tarea #${depId}`}</p>;
|
|
})}
|
|
</div>
|
|
)}
|
|
{isOverdue && <p className="text-red-500 font-medium pt-1">Tarea retrasada</p>}
|
|
</div>
|
|
</div>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
|
|
{/* Indicador para tareas sin fecha */}
|
|
{!hasDate && (
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
<span className="text-[10px] text-muted-foreground/60 italic">Sin fechas asignadas</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</TooltipProvider>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Sheet de creación */}
|
|
<TaskFormSheet
|
|
open={isCreateSheetOpen}
|
|
onOpenChange={setIsCreateSheetOpen}
|
|
mode="create"
|
|
projectId={projectId}
|
|
availableTasks={tasks}
|
|
onSuccess={(task) => onTaskCreated?.(task)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|