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

648 lines
28 KiB
TypeScript
Raw Permalink Normal View History

2026-02-06 19:06:25 +00:00
"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 &middot; {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>
);
}