Ir al contenido

Checklist de Subtareas

Gestiona listas de verificación dentro de tareas como subtareas completas.

  • Agregar items: Crear nuevas subtareas rápidamente
  • Marcar completadas: Checkbox para cambiar estado
  • Editar título: Click en el texto para editar in-place
  • Eliminar items: Botón de eliminar visible al hover
  • Reordenar: Arrastrar y soltar para cambiar orden (visual con GripVertical)
  • Progreso visual: Barra de progreso y porcentaje
  • Heredar proyecto: Las subtareas heredan automáticamente el proyecto de la tarea padre

El progreso se calcula automáticamente:

Progreso = (Items Completados / Total Items) × 100

Ejemplo: 3 de 5 items completados = 60% de progreso

  1. Abrir detalle de tarea
  2. Scroll hasta sección “Checklist”
  3. Escribir título del nuevo item
  4. Click en ”+” para agregar
  5. Marcar checkbox para completar/descompletar
  6. Click en texto del item para editar
  7. Hover y click en icono de basura para eliminar
Detalles Técnicos
  • Archivo: TaskChecklistManager.tsx
  • Ruta: src/features/projects/sub-modules/tasks/components/TaskChecklistManager.tsx

Las subtareas son tareas completas en la tabla tasks con parent_task_id:

interface TaskChecklistManagerProps {
parentTaskId: string;
subtasks?: Task[];
onUpdate?: () => void;
}
const createMutation = useMutation({
mutationFn: async (title: string) => {
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error("Usuario no autenticado");
// Obtener project_id del padre para heredar
const { data: parentTask } = await supabase
.from('tasks')
.select('project_id')
.eq('id', parentTaskId)
.single();
// Crear subtarea
const { data, error } = await supabase
.from('tasks')
.insert({
user_id: user.id,
parent_task_id: parentTaskId,
title,
status: 'pending',
priority: 'medium',
progress: 0,
project_id: parentTask?.project_id || null, // Heredar
})
.select()
.single();
if (error) throw error;
return data;
},
onSuccess: (data) => {
setNewItemTitle("");
setLocalSubtasks(prev => [...prev, data as Task]);
toast({ title: "Item añadido" });
}
});
const toggleMutation = useMutation({
mutationFn: async ({ id, currentStatus }) => {
const newStatus = currentStatus === 'completed' ? 'pending' : 'completed';
const { error } = await supabase
.from('tasks')
.update({
status: newStatus,
completed_at: newStatus === 'completed' ? new Date().toISOString() : null,
progress: newStatus === 'completed' ? 100 : 0,
})
.eq('id', id);
if (error) throw error;
return { id, newStatus };
},
onSuccess: (data) => {
// Actualizar estado local inmediatamente
setLocalSubtasks(prev => prev.map(subtask => {
if (subtask.id === data.id) {
return {
...subtask,
status: data.newStatus,
completed_at: data.newStatus === 'completed' ? new Date().toISOString() : null,
progress: data.newStatus === 'completed' ? 100 : 0
};
}
return subtask;
}));
}
});
const updateMutation = useMutation({
mutationFn: async ({ id, title }) => {
const { error } = await supabase
.from('tasks')
.update({ title })
.eq('id', id);
if (error) throw error;
return { id, title };
},
onSuccess: (data) => {
setEditingId(null);
setLocalSubtasks(prev => prev.map(subtask =>
subtask.id === data.id ? { ...subtask, title: data.title } : subtask
));
}
});
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
const { error } = await supabase
.from('tasks')
.delete()
.eq('id', id);
if (error) throw error;
return id;
},
onSuccess: (deletedId) => {
setLocalSubtasks(prev => prev.filter(subtask => subtask.id !== deletedId));
toast({ title: "Item eliminado" });
}
});
const completedCount = localSubtasks.filter(s => s.status === 'completed').length;
const totalCount = localSubtasks.length;
const progressPercentage = totalCount > 0
? Math.round((completedCount / totalCount) * 100)
: 0;
<div className="flex items-center gap-2 group p-2 rounded-lg hover:bg-accent/50">
{/* Grip para arrastrar (visual) */}
<div className="opacity-0 group-hover:opacity-100">
<GripVertical className="h-4 w-4 text-muted-foreground" />
</div>
{/* Checkbox */}
<Checkbox
checked={subtask.status === 'completed'}
onCheckedChange={() => handleToggle(subtask)}
/>
{/* Título editable */}
{editingId === subtask.id ? (
<Input
value={editingTitle}
onChange={(e) => setEditingTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveEdit();
if (e.key === 'Escape') handleCancelEdit();
}}
autoFocus
/>
) : (
<span
className={cn(
"flex-1 cursor-pointer",
subtask.status === 'completed' && "line-through text-muted-foreground"
)}
onClick={() => handleStartEdit(subtask)}
>
{subtask.title}
</span>
)}
{/* Botón eliminar */}
<Button
size="sm"
variant="ghost"
className="opacity-0 group-hover:opacity-100"
onClick={() => deleteMutation.mutate(subtask.id)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
<div className="w-full bg-secondary rounded-full h-2">
<div
className={cn(
"h-2 rounded-full transition-all duration-300",
progressPercentage === 100 ? "bg-primary" : "bg-primary/70"
)}
style={{ width: `${progressPercentage}%` }}
/>
</div>

Se utiliza estado local para UI responsive inmediata:

const [localSubtasks, setLocalSubtasks] = useState<Task[]>(subtasks);
// Sync con props
useEffect(() => {
setLocalSubtasks(subtasks);
}, [subtasks]);