From ae66248df986c8f795fa0baf6d30374c6866951b Mon Sep 17 00:00:00 2001 From: Levi Planelles Date: Sat, 31 Jan 2026 14:50:07 +0100 Subject: [PATCH] feat: implement project CRUD functionality with create, edit, and delete dialogs - Added project creation and editing capabilities with ProjectFormSheet component. - Integrated delete confirmation dialog for project deletion. - Updated projects service to handle create, update, and delete operations. - Enhanced ProjectsTable component to manage project state and actions. - Introduced new types for project creation and updating in project.ts. - Improved UI with alert dialogs for confirmation actions. - Refactored statistics page layout for better responsiveness. - Added switch component for UI interactions. --- app/api/dolibarr/[...path]/route.ts | 138 ++++++ components/dashboard/dashboard-header.tsx | 13 +- components/dashboard/project-card.tsx | 86 +++- components/dashboard/project-dashboard.tsx | 38 +- components/dashboard/project-grid.tsx | 136 +++++- components/dashboard/project-list-item.tsx | 163 ++++--- .../project-detail/project-detail-view.tsx | 29 +- components/project-detail/project-header.tsx | 57 ++- components/project-detail/task-columns.tsx | 351 ++++++++------- components/project-detail/tasks-section.tsx | 83 +++- .../project-form/project-form-sheet.tsx | 299 +++++++++++++ components/projects/columns.tsx | 408 +++++++++--------- components/projects/projects-table.tsx | 136 +++++- components/statistics/statistics-page.tsx | 4 +- components/task-detail/task-header.tsx | 38 +- components/ui/alert-dialog.tsx | 141 ++++++ components/ui/delete-confirmation-dialog.tsx | 59 +++ components/ui/switch.tsx | 29 ++ lib/projectsService.ts | 142 +++++- package-lock.json | 171 ++++++++ package.json | 2 + types/project.ts | 141 ++++++ 22 files changed, 2171 insertions(+), 493 deletions(-) create mode 100644 components/project-form/project-form-sheet.tsx create mode 100644 components/ui/alert-dialog.tsx create mode 100644 components/ui/delete-confirmation-dialog.tsx create mode 100644 components/ui/switch.tsx diff --git a/app/api/dolibarr/[...path]/route.ts b/app/api/dolibarr/[...path]/route.ts index 23ac735..eb1fefb 100644 --- a/app/api/dolibarr/[...path]/route.ts +++ b/app/api/dolibarr/[...path]/route.ts @@ -134,3 +134,141 @@ export async function POST( ); } } + +/** + * Método PUT para actualizar recursos en Dolibarr + */ +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ path: string[] }> } +) { + try { + const resolvedParams = await params; + + const endpoint = resolvedParams.path.join('/'); + const body = await request.json(); + + const apiUrl = process.env.DOLIBARR_API_URL || process.env.NEXT_PUBLIC_API_URL; + const apiKey = process.env.DOLIBARR_API_KEY || process.env.NEXT_PUBLIC_DOLIBARR_API_KEY; + + if (!apiUrl || !apiKey) { + return NextResponse.json( + { error: 'Server configuration error' }, + { status: 500 } + ); + } + + const url = `${apiUrl}/${endpoint}?DOLAPIKEY=${apiKey}`; + + const response = await fetch(url, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify(body), + }); + + const responseText = await response.text(); + + if (!response.ok) { + console.error('Dolibarr API error:', response.status, responseText); + + let errorDetails = responseText; + try { + const errorJson = JSON.parse(responseText); + errorDetails = errorJson.error?.message || errorJson.error || responseText; + } catch { + // Mantener el texto original si no es JSON + } + + return NextResponse.json( + { error: errorDetails }, + { status: response.status } + ); + } + + let data; + try { + data = JSON.parse(responseText); + } catch { + data = responseText; + } + + return NextResponse.json(data); + } catch (error) { + console.error('Error in Dolibarr API route:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + +/** + * Método DELETE para eliminar recursos en Dolibarr + */ +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ path: string[] }> } +) { + try { + const resolvedParams = await params; + + const endpoint = resolvedParams.path.join('/'); + + const apiUrl = process.env.DOLIBARR_API_URL || process.env.NEXT_PUBLIC_API_URL; + const apiKey = process.env.DOLIBARR_API_KEY || process.env.NEXT_PUBLIC_DOLIBARR_API_KEY; + + if (!apiUrl || !apiKey) { + return NextResponse.json( + { error: 'Server configuration error' }, + { status: 500 } + ); + } + + const url = `${apiUrl}/${endpoint}?DOLAPIKEY=${apiKey}`; + + const response = await fetch(url, { + method: 'DELETE', + headers: { + 'Accept': 'application/json', + }, + }); + + if (!response.ok) { + const responseText = await response.text(); + console.error('Dolibarr API error:', response.status, responseText); + + let errorDetails = responseText; + try { + const errorJson = JSON.parse(responseText); + errorDetails = errorJson.error?.message || errorJson.error || responseText; + } catch { + // Mantener el texto original si no es JSON + } + + return NextResponse.json( + { error: errorDetails }, + { status: response.status } + ); + } + + // DELETE suele devolver vacío o el ID eliminado + const responseText = await response.text(); + let data; + try { + data = responseText ? JSON.parse(responseText) : { success: true }; + } catch { + data = { success: true }; + } + + return NextResponse.json(data); + } catch (error) { + console.error('Error in Dolibarr API route:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/components/dashboard/dashboard-header.tsx b/components/dashboard/dashboard-header.tsx index 6f21684..c8e7c27 100644 --- a/components/dashboard/dashboard-header.tsx +++ b/components/dashboard/dashboard-header.tsx @@ -1,20 +1,23 @@ "use client"; -import { Search, LayoutGrid, List } from 'lucide-react'; +import { Search, LayoutGrid, List, Plus } from 'lucide-react'; import { ThemeToggle } from '@/components/theme-toggle'; +import { Button } from '@/components/ui/button'; interface DashboardHeaderProps { searchTerm: string; onSearchChange: (value: string) => void; viewMode: 'grid' | 'list'; onViewModeChange: (mode: 'grid' | 'list') => void; + onCreateProject?: () => void; } export default function DashboardHeader({ searchTerm, onSearchChange, viewMode, - onViewModeChange + onViewModeChange, + onCreateProject }: DashboardHeaderProps) { return (
@@ -50,6 +53,12 @@ export default function DashboardHeader({ + {onCreateProject && ( + + )} diff --git a/components/dashboard/project-card.tsx b/components/dashboard/project-card.tsx index 934e4d0..774429a 100644 --- a/components/dashboard/project-card.tsx +++ b/components/dashboard/project-card.tsx @@ -1,14 +1,24 @@ "use client"; import Link from 'next/link'; -import { Calendar, DollarSign, TrendingUp, FileText } from 'lucide-react'; +import { Calendar, DollarSign, TrendingUp, FileText, MoreHorizontal, Pencil, Trash2, Eye } from 'lucide-react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Avatar, AvatarFallback } from '@/components/ui/avatar'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { ProjectStatusBadge } from '@/components/ui/project-status-badge'; import { Project } from '@/types/project'; interface ProjectCardProps { project: Project; + onEdit?: (project: Project) => void; + onDelete?: (project: Project) => void; } // Función para generar iniciales @@ -21,28 +31,62 @@ function getInitials(name: string): string { .slice(0, 2); } -export default function ProjectCard({ project }: ProjectCardProps) { +export default function ProjectCard({ project, onEdit, onDelete }: ProjectCardProps) { const initials = getInitials(project.name); return ( - - - -
-
- - - {initials} - - -
- {project.name} - {project.ref} -
+ + +
+ + + + {initials} + + +
+ {project.name} + {project.ref}
-
- -
+ + + {/* Menú de acciones */} + + + + + + + + + Ver detalles + + + onEdit?.(project)}> + + Editar + + + onDelete?.(project)} + > + + Eliminar + + + +
+ + + {/* Description */} {project.description && ( @@ -115,7 +159,7 @@ export default function ProjectCard({ project }: ProjectCardProps) {
)} -
- + + ); } \ No newline at end of file diff --git a/components/dashboard/project-dashboard.tsx b/components/dashboard/project-dashboard.tsx index 5f1a29f..bf1ba8a 100644 --- a/components/dashboard/project-dashboard.tsx +++ b/components/dashboard/project-dashboard.tsx @@ -5,7 +5,8 @@ import DashboardHeader from './dashboard-header'; import StatsOverview from './stats-overview'; import ProjectGrid from './project-grid'; import ProjectGridSkeleton from './project-grid-skeleton'; -import { getProjects } from '@/lib/projectsService'; // Importar desde el service +import { ProjectFormSheet } from '@/components/project-form/project-form-sheet'; +import { getProjects } from '@/lib/projectsService'; import { Project } from '@/types/project'; import { Skeleton } from '@/components/ui/skeleton'; @@ -15,6 +16,7 @@ export default function ProjectDashboard() { const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [isCreateSheetOpen, setIsCreateSheetOpen] = useState(false); useEffect(() => { async function loadProjects() { @@ -33,6 +35,23 @@ export default function ProjectDashboard() { loadProjects(); }, []); + // Handler para cuando se crea un proyecto + const handleProjectCreated = (newProject: Project) => { + setProjects(prevProjects => [newProject, ...prevProjects]); + }; + + // Handler para cuando se actualiza un proyecto + const handleProjectUpdated = (updatedProject: Project) => { + setProjects(prevProjects => + prevProjects.map(p => p.id === updatedProject.id ? updatedProject : p) + ); + }; + + // Handler para cuando se elimina un proyecto + const handleProjectDeleted = (projectId: number) => { + setProjects(prevProjects => prevProjects.filter(p => p.id !== projectId)); + }; + const filteredProjects = projects.filter(project => project.name.toLowerCase().includes(searchTerm.toLowerCase()) || project.client.toLowerCase().includes(searchTerm.toLowerCase()) || @@ -47,6 +66,7 @@ export default function ProjectDashboard() { onSearchChange={() => {}} viewMode={viewMode} onViewModeChange={setViewMode} + onCreateProject={() => {}} />
@@ -94,12 +114,26 @@ export default function ProjectDashboard() { onSearchChange={setSearchTerm} viewMode={viewMode} onViewModeChange={setViewMode} + onCreateProject={() => setIsCreateSheetOpen(true)} />
- +
+ + {/* Sheet de creación de proyecto */} + ); } \ No newline at end of file diff --git a/components/dashboard/project-grid.tsx b/components/dashboard/project-grid.tsx index dd7b2b8..1f7d416 100644 --- a/components/dashboard/project-grid.tsx +++ b/components/dashboard/project-grid.tsx @@ -1,15 +1,63 @@ "use client"; +import { useState } from 'react'; import ProjectCard from './project-card'; import ProjectListItem from './project-list-item'; +import { ProjectFormSheet } from '@/components/project-form/project-form-sheet'; +import { DeleteConfirmationDialog } from '@/components/ui/delete-confirmation-dialog'; +import { deleteProject } from '@/lib/projectsService'; import { Project } from '@/types/project'; interface ProjectGridProps { projects: Project[]; viewMode: 'grid' | 'list'; + onProjectUpdated?: (project: Project) => void; + onProjectDeleted?: (projectId: number) => void; } -export default function ProjectGrid({ projects, viewMode }: ProjectGridProps) { +export default function ProjectGrid({ + projects, + viewMode, + onProjectUpdated, + onProjectDeleted +}: ProjectGridProps) { + const [isEditSheetOpen, setIsEditSheetOpen] = useState(false); + const [projectToEdit, setProjectToEdit] = useState(null); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [projectToDelete, setProjectToDelete] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + + const handleEditProject = (project: Project) => { + setProjectToEdit(project); + setIsEditSheetOpen(true); + }; + + const handleDeleteProject = (project: Project) => { + setProjectToDelete(project); + setIsDeleteDialogOpen(true); + }; + + const handleEditSuccess = (updatedProject: Project) => { + onProjectUpdated?.(updatedProject); + setProjectToEdit(null); + }; + + const handleConfirmDelete = async () => { + if (!projectToDelete) return; + + setIsDeleting(true); + try { + await deleteProject(projectToDelete.id); + onProjectDeleted?.(projectToDelete.id); + setIsDeleteDialogOpen(false); + setProjectToDelete(null); + } catch (error) { + console.error("Error deleting project:", error); + } finally { + setIsDeleting(false); + } + }; + if (projects.length === 0) { return (
@@ -21,20 +69,86 @@ export default function ProjectGrid({ projects, viewMode }: ProjectGridProps) { // Vista de lista if (viewMode === 'list') { return ( -
- {projects.map((project) => ( - - ))} -
+ <> +
+ {projects.map((project) => ( + + ))} +
+ + {/* Sheet de edición */} + { + setIsEditSheetOpen(open); + if (!open) setProjectToEdit(null); + }} + mode="edit" + project={projectToEdit} + onSuccess={handleEditSuccess} + /> + + {/* Diálogo de confirmación de eliminación */} + { + setIsDeleteDialogOpen(open); + if (!open) setProjectToDelete(null); + }} + onConfirm={handleConfirmDelete} + title="¿Eliminar proyecto?" + itemName={projectToDelete?.name} + description={`Esta acción eliminará el proyecto "${projectToDelete?.name}" y todas sus tareas asociadas. Esta acción no se puede deshacer.`} + isDeleting={isDeleting} + /> + ); } // Vista de grid (por defecto) return ( -
- {projects.map((project) => ( - - ))} -
+ <> +
+ {projects.map((project) => ( + + ))} +
+ + {/* Sheet de edición */} + { + setIsEditSheetOpen(open); + if (!open) setProjectToEdit(null); + }} + mode="edit" + project={projectToEdit} + onSuccess={handleEditSuccess} + /> + + {/* Diálogo de confirmación de eliminación */} + { + setIsDeleteDialogOpen(open); + if (!open) setProjectToDelete(null); + }} + onConfirm={handleConfirmDelete} + title="¿Eliminar proyecto?" + itemName={projectToDelete?.name} + description={`Esta acción eliminará el proyecto "${projectToDelete?.name}" y todas sus tareas asociadas. Esta acción no se puede deshacer.`} + isDeleting={isDeleting} + /> + ); } \ No newline at end of file diff --git a/components/dashboard/project-list-item.tsx b/components/dashboard/project-list-item.tsx index 7622c23..f2a5023 100644 --- a/components/dashboard/project-list-item.tsx +++ b/components/dashboard/project-list-item.tsx @@ -1,13 +1,23 @@ "use client"; import Link from 'next/link'; -import { Calendar, DollarSign, TrendingUp } from 'lucide-react'; +import { Calendar, DollarSign, TrendingUp, MoreHorizontal, Pencil, Trash2, Eye } from 'lucide-react'; import { Avatar, AvatarFallback } from '@/components/ui/avatar'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { ProjectStatusBadge } from '@/components/ui/project-status-badge'; import { Project } from '@/types/project'; interface ProjectListItemProps { project: Project; + onEdit?: (project: Project) => void; + onDelete?: (project: Project) => void; } // Función para generar iniciales @@ -24,80 +34,113 @@ function getInitials(name: string): string { * Componente de fila de proyecto para vista de lista * Diseño horizontal y compacto */ -export default function ProjectListItem({ project }: ProjectListItemProps) { +export default function ProjectListItem({ project, onEdit, onDelete }: ProjectListItemProps) { const initials = getInitials(project.name); return ( - -
-
- {/* Avatar */} - +
+
+ {/* Avatar */} + + {initials} + - {/* Info Principal */} -
-
-
-
-

- {project.name} -

- {project.ref} -
- {project.client !== 'Sin cliente' && ( -

{project.client}

- )} + {/* Info Principal */} +
+
+ +
+

+ {project.name} +

+ {project.ref}
- - {/* Estado */} - -
+ {project.client !== 'Sin cliente' && ( +

{project.client}

+ )} + + + {/* Estado */} +
+
- {/* Progreso */} -
- -
-
- Progreso - {project.progress}% -
-
-
-
+ {/* Progreso */} +
+ +
+
+ Progreso + {project.progress}%
-
- - {/* Presupuesto */} -
- -
-
- €{project.budget.toLocaleString('es-ES', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} -
-
Presupuesto
-
-
- - {/* Fechas */} -
- -
-
- {new Date(project.startDate).toLocaleDateString('es-ES', { day: '2-digit', month: 'short' })} -
-
Inicio
+
+
+ + {/* Presupuesto */} +
+ +
+
+ €{project.budget.toLocaleString('es-ES', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} +
+
Presupuesto
+
+
+ + {/* Fechas */} +
+ +
+
+ {new Date(project.startDate).toLocaleDateString('es-ES', { day: '2-digit', month: 'short' })} +
+
Inicio
+
+
+ + {/* Menú de acciones */} + + + + + + + + + Ver detalles + + + onEdit?.(project)}> + + Editar + + + onDelete?.(project)} + > + + Eliminar + + +
- +
); } diff --git a/components/project-detail/project-detail-view.tsx b/components/project-detail/project-detail-view.tsx index 572d746..395a9b1 100644 --- a/components/project-detail/project-detail-view.tsx +++ b/components/project-detail/project-detail-view.tsx @@ -80,7 +80,8 @@ function ErrorState({ onRetry }: { onRetry: () => void }) { ); } -export function ProjectDetailView({ project }: ProjectDetailViewProps) { +export function ProjectDetailView({ project: initialProject }: ProjectDetailViewProps) { + const [project, setProject] = useState(initialProject); const [tasks, setTasks] = useState([]); const [isLoadingTasks, setIsLoadingTasks] = useState(true); const [tasksError, setTasksError] = useState(null); @@ -104,18 +105,38 @@ export function ProjectDetailView({ project }: ProjectDetailViewProps) { fetchTasks(); }, [fetchTasks]); + // Handler para cuando se actualiza el proyecto + const handleProjectUpdated = (updatedProject: Project) => { + setProject(updatedProject); + }; + // Handler para cuando se crea una tarea const handleTaskCreated = (newTask: Task) => { setTasks(prevTasks => [newTask, ...prevTasks]); }; + // Handler para cuando se actualiza una tarea + const handleTaskUpdated = (updatedTask: Task) => { + setTasks(prevTasks => + prevTasks.map(task => task.id === updatedTask.id ? updatedTask : task) + ); + }; + + // Handler para cuando se elimina una tarea + const handleTaskDeleted = (taskId: number) => { + setTasks(prevTasks => prevTasks.filter(task => task.id !== taskId)); + }; + // Calcular estadísticas de tareas const taskStats = calculateTaskStats(tasks); return (
{/* Header del proyecto */} - + {/* Contenido principal */}
@@ -165,6 +186,8 @@ export function ProjectDetailView({ project }: ProjectDetailViewProps) { tasks={tasks.slice(0, 5)} projectId={project.id} onTaskCreated={handleTaskCreated} + onTaskUpdated={handleTaskUpdated} + onTaskDeleted={handleTaskDeleted} /> @@ -193,6 +216,8 @@ export function ProjectDetailView({ project }: ProjectDetailViewProps) { tasks={tasks} projectId={project.id} onTaskCreated={handleTaskCreated} + onTaskUpdated={handleTaskUpdated} + onTaskDeleted={handleTaskDeleted} /> )} diff --git a/components/project-detail/project-header.tsx b/components/project-detail/project-header.tsx index f4f9433..a08bc06 100644 --- a/components/project-detail/project-header.tsx +++ b/components/project-detail/project-header.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState } from "react"; import { ArrowLeft, MoreHorizontal, Pencil, Trash2, Share2, Copy } from "lucide-react"; import { useRouter } from "next/navigation"; import Link from "next/link"; @@ -14,10 +15,15 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { ProjectStatusBadge } from "@/components/ui/project-status-badge"; +import { ProjectFormSheet } from "@/components/project-form/project-form-sheet"; +import { DeleteConfirmationDialog } from "@/components/ui/delete-confirmation-dialog"; +import { deleteProject } from "@/lib/projectsService"; import { Project } from "@/types/project"; interface ProjectHeaderProps { project: Project; + onProjectUpdated?: (project: Project) => void; + onProjectDeleted?: () => void; } // Función para generar iniciales @@ -30,16 +36,34 @@ function getInitials(name: string): string { .slice(0, 2); } - - -export function ProjectHeader({ project }: ProjectHeaderProps) { +export function ProjectHeader({ project, onProjectUpdated, onProjectDeleted }: ProjectHeaderProps) { const router = useRouter(); const initials = getInitials(project.name); + const [isEditSheetOpen, setIsEditSheetOpen] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); const handleCopyRef = () => { navigator.clipboard.writeText(project.ref); }; + const handleEditSuccess = (updatedProject: Project) => { + onProjectUpdated?.(updatedProject); + }; + + const handleConfirmDelete = async () => { + setIsDeleting(true); + try { + await deleteProject(project.id); + onProjectDeleted?.(); + router.push('/proyectos'); + } catch (error) { + console.error("Error deleting project:", error); + } finally { + setIsDeleting(false); + } + }; + return (
@@ -103,7 +127,7 @@ export function ProjectHeader({ project }: ProjectHeaderProps) { - + setIsEditSheetOpen(true)}> Editar proyecto @@ -112,7 +136,10 @@ export function ProjectHeader({ project }: ProjectHeaderProps) { Compartir - + setIsDeleteDialogOpen(true)} + > Eliminar @@ -121,6 +148,26 @@ export function ProjectHeader({ project }: ProjectHeaderProps) {
+ + {/* Sheet de edición */} + + + {/* Diálogo de confirmación de eliminación */} +
); } diff --git a/components/project-detail/task-columns.tsx b/components/project-detail/task-columns.tsx index 5f22e05..040d5c9 100644 --- a/components/project-detail/task-columns.tsx +++ b/components/project-detail/task-columns.tsx @@ -93,172 +93,191 @@ function formatHours(hours: number): string { return `${hours}h`; } -export const taskColumns: ColumnDef[] = [ - // Checkbox - { - id: "select", - header: ({ table }) => ( - table.toggleAllPageRowsSelected(!!value)} - aria-label="Seleccionar todo" - /> - ), - cell: ({ row }) => ( - row.toggleSelected(!!value)} - aria-label="Seleccionar fila" - /> - ), - enableSorting: false, - enableHiding: false, - }, - // Título - { - accessorKey: "title", - header: ({ column }) => ( - - ), - cell: ({ row }) => ( -
- void; + onDelete?: (task: Task) => void; +} + +// Función para crear columnas con callbacks opcionales +export function createTaskColumns(callbacks?: TaskColumnCallbacks): ColumnDef[] { + return [ + // Checkbox + { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Seleccionar todo" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Seleccionar fila" + /> + ), + enableSorting: false, + enableHiding: false, + }, + // Título + { + accessorKey: "title", + header: ({ column }) => ( +
- ), - }, - // Estado - { - accessorKey: "status", - header: "Estado", - cell: ({ row }) => , - filterFn: (row, id, value) => value.includes(row.getValue(id)), - }, - // Prioridad - { - accessorKey: "priority", - header: "Prioridad", - cell: ({ row }) => , - filterFn: (row, id, value) => value.includes(row.getValue(id)), - }, - // Progreso - { - accessorKey: "progress", - header: ({ column }) => ( - - ), - cell: ({ row }) => , - }, - // Horas - { - id: "hours", - header: () => ( -
- - Horas -
- ), - cell: ({ row }) => ( -
- {formatHours(row.original.workedHours)} - {row.original.plannedHours > 0 && ( - / {formatHours(row.original.plannedHours)} - )} -
- ), - }, - // Fecha límite - { - accessorKey: "endDate", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const endDate = row.original.endDate || row.original.plannedEndDate; - const isOverdue = endDate && new Date(endDate) < new Date() && row.original.status !== '2'; - - return ( - - {formatDate(endDate)} - {isOverdue && } - - ); + Tarea + + + ), + cell: ({ row }) => ( +
+ + {row.getValue("title")} + + {row.original.ref && ( +

{row.original.ref}

+ )} +
+ ), }, - }, - // Acciones - { - id: "actions", - enableHiding: false, - cell: ({ row }) => { - const task = row.original; - - return ( - - - - - - Acciones - - - - - Ver detalles - - - - - Editar - - - - Registrar tiempo - - - - - Eliminar - - - - ); + // Estado + { + accessorKey: "status", + header: "Estado", + cell: ({ row }) => , + filterFn: (row, id, value) => value.includes(row.getValue(id)), }, - }, -]; + // Prioridad + { + accessorKey: "priority", + header: "Prioridad", + cell: ({ row }) => , + filterFn: (row, id, value) => value.includes(row.getValue(id)), + }, + // Progreso + { + accessorKey: "progress", + header: ({ column }) => ( + + ), + cell: ({ row }) => , + }, + // Horas + { + id: "hours", + header: () => ( +
+ + Horas +
+ ), + cell: ({ row }) => ( +
+ {formatHours(row.original.workedHours)} + {row.original.plannedHours > 0 && ( + / {formatHours(row.original.plannedHours)} + )} +
+ ), + }, + // Fecha límite + { + accessorKey: "endDate", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const endDate = row.original.endDate || row.original.plannedEndDate; + const isOverdue = endDate && new Date(endDate) < new Date() && row.original.status !== '2'; + + return ( + + {formatDate(endDate)} + {isOverdue && } + + ); + }, + }, + // Acciones + { + id: "actions", + enableHiding: false, + cell: ({ row }) => { + const task = row.original; + + return ( + + + + + + Acciones + + + + + Ver detalles + + + callbacks?.onEdit?.(task)} + disabled={!callbacks?.onEdit} + > + + Editar + + + + Registrar tiempo + + + callbacks?.onDelete?.(task)} + disabled={!callbacks?.onDelete} + > + + Eliminar + + + + ); + }, + }, + ]; +} + +// Columnas por defecto sin callbacks (para compatibilidad) +export const taskColumns: ColumnDef[] = createTaskColumns(); diff --git a/components/project-detail/tasks-section.tsx b/components/project-detail/tasks-section.tsx index 47b53ca..7415e2c 100644 --- a/components/project-detail/tasks-section.tsx +++ b/components/project-detail/tasks-section.tsx @@ -25,16 +25,20 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { DeleteConfirmationDialog } from "@/components/ui/delete-confirmation-dialog"; import { DataTable } from "@/components/projects/data-table"; -import { taskColumns } from "./task-columns"; +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 @@ -129,18 +133,65 @@ function EmptyTasks({ onCreateClick }: { onCreateClick: () => void }) { ); } -export function TasksSection({ tasks, projectId, isLoading, onTaskCreated }: TasksSectionProps) { +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(null); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [taskToDelete, setTaskToDelete] = useState(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]; @@ -310,7 +361,7 @@ export function TasksSection({ tasks, projectId, isLoading, onTaskCreated }: Tas {/* Contenido */} {viewMode === "table" ? ( - + ) : (
{filteredTasks.map((task) => ( @@ -339,6 +390,32 @@ export function TasksSection({ tasks, projectId, isLoading, onTaskCreated }: Tas projectId={projectId} onSuccess={handleCreateSuccess} /> + + {/* Sheet de edición de tarea */} + { + setIsEditSheetOpen(open); + if (!open) setTaskToEdit(null); + }} + mode="edit" + projectId={projectId} + task={taskToEdit} + onSuccess={handleEditSuccess} + /> + + {/* Diálogo de confirmación de eliminación */} + { + setIsDeleteDialogOpen(open); + if (!open) setTaskToDelete(null); + }} + onConfirm={handleConfirmDelete} + title="¿Eliminar tarea?" + itemName={taskToDelete?.title} + isDeleting={isDeleting} + />
); } diff --git a/components/project-form/project-form-sheet.tsx b/components/project-form/project-form-sheet.tsx new file mode 100644 index 0000000..60ff2fd --- /dev/null +++ b/components/project-form/project-form-sheet.tsx @@ -0,0 +1,299 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Loader2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { Switch } from "@/components/ui/switch"; + +import { + Project, + ProjectStatus, + ProjectFormData, + STATUS_CONFIG, + projectToFormData, + formDataToCreateProject, + formDataToUpdateProject, +} from "@/types/project"; +import { createProject, updateProject } from "@/lib/projectsService"; + +interface ProjectFormSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; + mode: "create" | "edit"; + project?: Project | null; + onSuccess?: (project: Project) => void; +} + +const defaultFormData: ProjectFormData = { + name: "", + description: "", + status: "0", + progress: 0, + budget: 0, + startDate: "", + endDate: "", + isPublic: true, +}; + +export function ProjectFormSheet({ + open, + onOpenChange, + mode, + project, + onSuccess, +}: ProjectFormSheetProps) { + const [formData, setFormData] = useState(defaultFormData); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + // Initialize form data when project changes (for edit mode) + useEffect(() => { + if (mode === "edit" && project) { + setFormData(projectToFormData(project)); + } else { + setFormData(defaultFormData); + } + setError(null); + }, [mode, project, open]); + + const handleInputChange = ( + e: React.ChangeEvent + ) => { + const { name, value, type } = e.target; + setFormData((prev) => ({ + ...prev, + [name]: type === "number" ? parseFloat(value) || 0 : value, + })); + }; + + const handleSelectChange = (name: string, value: string) => { + setFormData((prev) => ({ + ...prev, + [name]: value, + })); + }; + + const handleSwitchChange = (name: string, checked: boolean) => { + setFormData((prev) => ({ + ...prev, + [name]: checked, + })); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsSubmitting(true); + setError(null); + + try { + let result: Project; + + if (mode === "create") { + const createData = formDataToCreateProject(formData); + result = await createProject(createData); + } else { + if (!project) { + throw new Error("No hay proyecto para editar"); + } + const updateData = formDataToUpdateProject(formData); + result = await updateProject(project.id, updateData); + } + + onSuccess?.(result); + onOpenChange(false); + } catch (err) { + console.error("Error submitting project:", err); + setError( + err instanceof Error ? err.message : "Error al guardar el proyecto" + ); + } finally { + setIsSubmitting(false); + } + }; + + const isFormValid = formData.name.trim().length > 0; + + return ( + + + + + {mode === "create" ? "Nuevo Proyecto" : "Editar Proyecto"} + + + {mode === "create" + ? "Crea un nuevo proyecto para gestionar tus tareas." + : "Modifica los detalles del proyecto."} + + + +
+ {/* Nombre */} +
+ + +
+ + {/* Descripción */} +
+ +