423 lines
14 KiB
TypeScript
423 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useMemo } from "react";
|
|
import {
|
|
Search,
|
|
X,
|
|
Plus,
|
|
ListTodo,
|
|
CheckCircle2,
|
|
Clock,
|
|
AlertTriangle,
|
|
LayoutGrid,
|
|
List
|
|
} from "lucide-react";
|
|
import Link from "next/link";
|
|
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { DeleteConfirmationDialog } from "@/components/ui/delete-confirmation-dialog";
|
|
import { DataTable } from "@/components/projects/data-table";
|
|
import { createTaskColumns } from "./task-columns";
|
|
import { TaskFormSheet } from "@/components/task-form/task-form-sheet";
|
|
import { Task, TASK_STATUS_CONFIG, TASK_PRIORITY_CONFIG } from "@/types/task";
|
|
import { deleteTask } from "@/lib/tasksService";
|
|
|
|
interface TasksSectionProps {
|
|
tasks: Task[];
|
|
projectId: number;
|
|
isLoading?: boolean;
|
|
onTaskCreated?: (task: Task) => void;
|
|
onTaskUpdated?: (task: Task) => void;
|
|
onTaskDeleted?: (taskId: number) => void;
|
|
}
|
|
|
|
|
|
// Tarjeta de tarea para vista grid
|
|
function TaskCard({ task }: { task: Task }) {
|
|
const statusConfig = TASK_STATUS_CONFIG[task.status];
|
|
const priorityConfig = TASK_PRIORITY_CONFIG[task.priority];
|
|
|
|
const isOverdue = (() => {
|
|
const endDate = task.endDate || task.plannedEndDate;
|
|
return endDate && new Date(endDate) < new Date() && task.status !== '2';
|
|
})();
|
|
|
|
return (
|
|
<Link href={`/tareas/${task.id}`}>
|
|
<Card className={`hover:shadow-md hover:border-blue-300 dark:hover:border-blue-700 transition-all cursor-pointer ${isOverdue ? 'border-red-200 dark:border-red-900' : ''}`}>
|
|
<CardContent className="p-4">
|
|
<div className="space-y-3">
|
|
{/* Header */}
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-medium truncate hover:text-blue-600 transition-colors">{task.title}</p>
|
|
{task.ref && (
|
|
<p className="text-xs text-muted-foreground font-mono">{task.ref}</p>
|
|
)}
|
|
</div>
|
|
{task.priority !== '0' && (
|
|
<Badge variant="outline" className={priorityConfig.bgClass}>
|
|
{task.priority === '3' && <AlertTriangle className="h-3 w-3 mr-1" />}
|
|
{priorityConfig.label}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
|
|
{/* Progreso */}
|
|
<div className="space-y-1">
|
|
<div className="flex items-center justify-between text-xs">
|
|
<span className="text-muted-foreground">Progreso</span>
|
|
<span className="font-medium">{task.progress}%</span>
|
|
</div>
|
|
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
|
|
<div
|
|
className={`h-full rounded-full transition-all ${
|
|
task.progress >= 100 ? 'bg-green-500' :
|
|
task.progress >= 50 ? 'bg-blue-500' : 'bg-gray-400'
|
|
}`}
|
|
style={{ width: `${task.progress}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="flex items-center justify-between pt-2">
|
|
<Badge variant="outline" className={statusConfig.bgClass}>
|
|
{task.status === '2' && <CheckCircle2 className="h-3 w-3 mr-1" />}
|
|
{statusConfig.label}
|
|
</Badge>
|
|
|
|
{(task.endDate || task.plannedEndDate) && (
|
|
<span className={`text-xs flex items-center gap-1 ${isOverdue ? 'text-red-500' : 'text-muted-foreground'}`}>
|
|
<Clock className="h-3 w-3" />
|
|
{new Date(task.endDate || task.plannedEndDate!).toLocaleDateString('es-ES', {
|
|
day: '2-digit',
|
|
month: 'short',
|
|
})}
|
|
{isOverdue && <AlertTriangle className="h-3 w-3" />}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
// Estado vacío
|
|
function EmptyTasks({ onCreateClick }: { onCreateClick: () => void }) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
|
<div className="p-3 rounded-full bg-muted mb-4">
|
|
<ListTodo className="h-8 w-8 text-muted-foreground" />
|
|
</div>
|
|
<h3 className="text-lg font-semibold mb-1">Sin tareas</h3>
|
|
<p className="text-sm text-muted-foreground mb-4 max-w-sm">
|
|
Este proyecto aún no tiene tareas asignadas. Crea la primera tarea para comenzar.
|
|
</p>
|
|
<Button onClick={onCreateClick}>
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
Crear tarea
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function TasksSection({ tasks, projectId, isLoading, onTaskCreated, onTaskUpdated, onTaskDeleted }: TasksSectionProps) {
|
|
const [searchValue, setSearchValue] = useState("");
|
|
const [statusFilter, setStatusFilter] = useState("all");
|
|
const [priorityFilter, setPriorityFilter] = useState("all");
|
|
const [viewMode, setViewMode] = useState<"table" | "grid">("table");
|
|
const [isCreateSheetOpen, setIsCreateSheetOpen] = useState(false);
|
|
const [isEditSheetOpen, setIsEditSheetOpen] = useState(false);
|
|
const [taskToEdit, setTaskToEdit] = useState<Task | null>(null);
|
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
|
const [taskToDelete, setTaskToDelete] = useState<Task | null>(null);
|
|
const [isDeleting, setIsDeleting] = useState(false);
|
|
|
|
// Handler para cuando se crea una tarea exitosamente
|
|
const handleCreateSuccess = (task: Task) => {
|
|
onTaskCreated?.(task);
|
|
};
|
|
|
|
// Handler para cuando se edita una tarea exitosamente
|
|
const handleEditSuccess = (task: Task) => {
|
|
onTaskUpdated?.(task);
|
|
setTaskToEdit(null);
|
|
};
|
|
|
|
// Handler para abrir el sheet de edición
|
|
const handleEditTask = (task: Task) => {
|
|
setTaskToEdit(task);
|
|
setIsEditSheetOpen(true);
|
|
};
|
|
|
|
// Handler para abrir el diálogo de eliminación
|
|
const handleDeleteTask = (task: Task) => {
|
|
setTaskToDelete(task);
|
|
setIsDeleteDialogOpen(true);
|
|
};
|
|
|
|
// Handler para confirmar eliminación
|
|
const handleConfirmDelete = async () => {
|
|
if (!taskToDelete) return;
|
|
|
|
setIsDeleting(true);
|
|
try {
|
|
await deleteTask(taskToDelete.id);
|
|
onTaskDeleted?.(taskToDelete.id);
|
|
setIsDeleteDialogOpen(false);
|
|
setTaskToDelete(null);
|
|
} catch (error) {
|
|
console.error("Error deleting task:", error);
|
|
// TODO: Mostrar toast de error
|
|
} finally {
|
|
setIsDeleting(false);
|
|
}
|
|
};
|
|
|
|
// Crear columnas con callbacks
|
|
const columns = useMemo(() => createTaskColumns({
|
|
onEdit: handleEditTask,
|
|
onDelete: handleDeleteTask,
|
|
}), []);
|
|
|
|
// Filtrar tareas
|
|
const filteredTasks = useMemo(() => {
|
|
let result = [...tasks];
|
|
|
|
// Búsqueda
|
|
if (searchValue) {
|
|
const search = searchValue.toLowerCase();
|
|
result = result.filter(
|
|
(task) =>
|
|
task.title.toLowerCase().includes(search) ||
|
|
task.ref?.toLowerCase().includes(search) ||
|
|
task.description?.toLowerCase().includes(search)
|
|
);
|
|
}
|
|
|
|
// Filtro de estado
|
|
if (statusFilter !== "all") {
|
|
result = result.filter((task) => task.status === statusFilter);
|
|
}
|
|
|
|
// Filtro de prioridad
|
|
if (priorityFilter !== "all") {
|
|
result = result.filter((task) => task.priority === priorityFilter);
|
|
}
|
|
|
|
return result;
|
|
}, [tasks, searchValue, statusFilter, priorityFilter]);
|
|
|
|
// Estadísticas rápidas
|
|
const stats = useMemo(() => ({
|
|
total: tasks.length,
|
|
completed: tasks.filter(t => t.status === '2').length,
|
|
inProgress: tasks.filter(t => t.status === '1').length,
|
|
highPriority: tasks.filter(t => t.priority === '3').length,
|
|
}), [tasks]);
|
|
|
|
// Limpiar filtros
|
|
const handleClearFilters = () => {
|
|
setSearchValue("");
|
|
setStatusFilter("all");
|
|
setPriorityFilter("all");
|
|
};
|
|
|
|
const isFiltered = searchValue !== "" || statusFilter !== "all" || priorityFilter !== "all";
|
|
|
|
if (tasks.length === 0 && !isLoading) {
|
|
return (
|
|
<>
|
|
<EmptyTasks onCreateClick={() => setIsCreateSheetOpen(true)} />
|
|
<TaskFormSheet
|
|
open={isCreateSheetOpen}
|
|
onOpenChange={setIsCreateSheetOpen}
|
|
mode="create"
|
|
projectId={projectId}
|
|
onSuccess={handleCreateSuccess}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-w-0 space-y-4">
|
|
{/* Stats rápidos */}
|
|
<div className="flex flex-wrap items-center gap-4 text-sm">
|
|
<div className="flex items-center gap-2">
|
|
<ListTodo className="h-4 w-4 text-muted-foreground" />
|
|
<span className="text-muted-foreground">Total:</span>
|
|
<span className="font-medium">{stats.total}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
|
<span className="text-muted-foreground">Completadas:</span>
|
|
<span className="font-medium">{stats.completed}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Clock className="h-4 w-4 text-blue-500" />
|
|
<span className="text-muted-foreground">En progreso:</span>
|
|
<span className="font-medium">{stats.inProgress}</span>
|
|
</div>
|
|
{stats.highPriority > 0 && (
|
|
<div className="flex items-center gap-2">
|
|
<AlertTriangle className="h-4 w-4 text-red-500" />
|
|
<span className="text-muted-foreground">Alta prioridad:</span>
|
|
<span className="font-medium text-red-500">{stats.highPriority}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Toolbar */}
|
|
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
|
<div className="flex flex-1 flex-wrap items-center gap-2">
|
|
{/* Búsqueda */}
|
|
<div className="relative w-full sm:w-[240px]">
|
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Buscar tareas..."
|
|
value={searchValue}
|
|
onChange={(e) => setSearchValue(e.target.value)}
|
|
className="pl-8 h-9"
|
|
/>
|
|
</div>
|
|
|
|
{/* Filtro estado */}
|
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
|
<SelectTrigger className="w-full sm:w-[130px] h-9">
|
|
<SelectValue placeholder="Estado" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">Todos</SelectItem>
|
|
<SelectItem value="0">Borrador</SelectItem>
|
|
<SelectItem value="1">Validada</SelectItem>
|
|
<SelectItem value="2">Completada</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{/* Filtro prioridad */}
|
|
<Select value={priorityFilter} onValueChange={setPriorityFilter}>
|
|
<SelectTrigger className="w-full sm:w-[130px] h-9">
|
|
<SelectValue placeholder="Prioridad" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">Todas</SelectItem>
|
|
<SelectItem value="3">Alta</SelectItem>
|
|
<SelectItem value="2">Media</SelectItem>
|
|
<SelectItem value="1">Baja</SelectItem>
|
|
<SelectItem value="0">Sin prioridad</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{/* Limpiar */}
|
|
{isFiltered && (
|
|
<Button variant="ghost" size="sm" onClick={handleClearFilters} className="h-9 px-2">
|
|
Limpiar
|
|
<X className="ml-1 h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{/* Toggle vista */}
|
|
<div className="flex items-center border rounded-md">
|
|
<Button
|
|
variant={viewMode === "table" ? "secondary" : "ghost"}
|
|
size="sm"
|
|
className="h-8 px-2 rounded-r-none"
|
|
onClick={() => setViewMode("table")}
|
|
>
|
|
<List className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant={viewMode === "grid" ? "secondary" : "ghost"}
|
|
size="sm"
|
|
className="h-8 px-2 rounded-l-none"
|
|
onClick={() => setViewMode("grid")}
|
|
>
|
|
<LayoutGrid className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Nueva tarea */}
|
|
<Button size="sm" className="h-9" onClick={() => setIsCreateSheetOpen(true)}>
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
Nueva tarea
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Contenido */}
|
|
{viewMode === "table" ? (
|
|
<DataTable columns={columns} data={filteredTasks} />
|
|
) : (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{filteredTasks.map((task) => (
|
|
<TaskCard key={task.id} task={task} />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Mensaje sin resultados */}
|
|
{filteredTasks.length === 0 && tasks.length > 0 && (
|
|
<div className="text-center py-8">
|
|
<p className="text-muted-foreground">
|
|
No se encontraron tareas con los filtros aplicados.
|
|
</p>
|
|
<Button variant="link" onClick={handleClearFilters}>
|
|
Limpiar filtros
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Sheet de creación de tarea */}
|
|
<TaskFormSheet
|
|
open={isCreateSheetOpen}
|
|
onOpenChange={setIsCreateSheetOpen}
|
|
mode="create"
|
|
projectId={projectId}
|
|
onSuccess={handleCreateSuccess}
|
|
/>
|
|
|
|
{/* Sheet de edición de tarea */}
|
|
<TaskFormSheet
|
|
open={isEditSheetOpen}
|
|
onOpenChange={(open) => {
|
|
setIsEditSheetOpen(open);
|
|
if (!open) setTaskToEdit(null);
|
|
}}
|
|
mode="edit"
|
|
projectId={projectId}
|
|
task={taskToEdit}
|
|
onSuccess={handleEditSuccess}
|
|
/>
|
|
|
|
{/* Diálogo de confirmación de eliminación */}
|
|
<DeleteConfirmationDialog
|
|
open={isDeleteDialogOpen}
|
|
onOpenChange={(open) => {
|
|
setIsDeleteDialogOpen(open);
|
|
if (!open) setTaskToDelete(null);
|
|
}}
|
|
onConfirm={handleConfirmDelete}
|
|
title="¿Eliminar tarea?"
|
|
itemName={taskToDelete?.title}
|
|
isDeleting={isDeleting}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|