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.
This commit is contained in:
Levi Planelles 2026-01-31 14:50:07 +01:00
parent 9331e7e890
commit ae66248df9
22 changed files with 2171 additions and 493 deletions

View File

@ -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 }
);
}
}

View File

@ -1,20 +1,23 @@
"use client"; "use client";
import { Search, LayoutGrid, List } from 'lucide-react'; import { Search, LayoutGrid, List, Plus } from 'lucide-react';
import { ThemeToggle } from '@/components/theme-toggle'; import { ThemeToggle } from '@/components/theme-toggle';
import { Button } from '@/components/ui/button';
interface DashboardHeaderProps { interface DashboardHeaderProps {
searchTerm: string; searchTerm: string;
onSearchChange: (value: string) => void; onSearchChange: (value: string) => void;
viewMode: 'grid' | 'list'; viewMode: 'grid' | 'list';
onViewModeChange: (mode: 'grid' | 'list') => void; onViewModeChange: (mode: 'grid' | 'list') => void;
onCreateProject?: () => void;
} }
export default function DashboardHeader({ export default function DashboardHeader({
searchTerm, searchTerm,
onSearchChange, onSearchChange,
viewMode, viewMode,
onViewModeChange onViewModeChange,
onCreateProject
}: DashboardHeaderProps) { }: DashboardHeaderProps) {
return ( return (
<header className="bg-card border-b"> <header className="bg-card border-b">
@ -50,6 +53,12 @@ export default function DashboardHeader({
<List className="w-4 h-4 text-muted-foreground" /> <List className="w-4 h-4 text-muted-foreground" />
</button> </button>
</div> </div>
{onCreateProject && (
<Button onClick={onCreateProject} size="sm">
<Plus className="w-4 h-4 mr-2" />
Nuevo proyecto
</Button>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,14 +1,24 @@
"use client"; "use client";
import Link from 'next/link'; 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Avatar, AvatarFallback } from '@/components/ui/avatar'; 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 { ProjectStatusBadge } from '@/components/ui/project-status-badge';
import { Project } from '@/types/project'; import { Project } from '@/types/project';
interface ProjectCardProps { interface ProjectCardProps {
project: Project; project: Project;
onEdit?: (project: Project) => void;
onDelete?: (project: Project) => void;
} }
// Función para generar iniciales // Función para generar iniciales
@ -21,28 +31,62 @@ function getInitials(name: string): string {
.slice(0, 2); .slice(0, 2);
} }
export default function ProjectCard({ project }: ProjectCardProps) { export default function ProjectCard({ project, onEdit, onDelete }: ProjectCardProps) {
const initials = getInitials(project.name); const initials = getInitials(project.name);
return ( return (
<Link href={`/proyectos/${project.id}`}> <Card className="hover:shadow-lg transition-all duration-300 hover:-translate-y-1 h-full group">
<Card className="hover:shadow-lg transition-all duration-300 hover:-translate-y-1 cursor-pointer h-full"> <CardHeader>
<CardHeader> <div className="flex items-start justify-between">
<div className="flex items-start justify-between"> <Link href={`/proyectos/${project.id}`} className="flex items-center gap-3 flex-1 cursor-pointer">
<div className="flex items-center gap-3"> <Avatar className="w-12 h-12">
<Avatar className="w-12 h-12"> <AvatarFallback className="bg-gradient-to-br from-blue-500 to-purple-600 text-white font-semibold">
<AvatarFallback className="bg-gradient-to-br from-blue-500 to-purple-600 text-white font-semibold"> {initials}
{initials} </AvatarFallback>
</AvatarFallback> </Avatar>
</Avatar> <div>
<div> <CardTitle className="text-lg hover:text-primary transition-colors">{project.name}</CardTitle>
<CardTitle className="text-lg">{project.name}</CardTitle> <CardDescription className="text-sm">{project.ref}</CardDescription>
<CardDescription className="text-sm">{project.ref}</CardDescription>
</div>
</div> </div>
</div> </Link>
<ProjectStatusBadge status={project.status} className="mt-3 w-fit" />
</CardHeader> {/* Menú de acciones */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/proyectos/${project.id}`}>
<Eye className="mr-2 h-4 w-4" />
Ver detalles
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEdit?.(project)}>
<Pencil className="mr-2 h-4 w-4" />
Editar
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => onDelete?.(project)}
>
<Trash2 className="mr-2 h-4 w-4" />
Eliminar
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<ProjectStatusBadge status={project.status} className="mt-3 w-fit" />
</CardHeader>
<Link href={`/proyectos/${project.id}`} className="cursor-pointer">
<CardContent> <CardContent>
{/* Description */} {/* Description */}
{project.description && ( {project.description && (
@ -115,7 +159,7 @@ export default function ProjectCard({ project }: ProjectCardProps) {
</div> </div>
)} )}
</CardContent> </CardContent>
</Card> </Link>
</Link> </Card>
); );
} }

View File

@ -5,7 +5,8 @@ import DashboardHeader from './dashboard-header';
import StatsOverview from './stats-overview'; import StatsOverview from './stats-overview';
import ProjectGrid from './project-grid'; import ProjectGrid from './project-grid';
import ProjectGridSkeleton from './project-grid-skeleton'; 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 { Project } from '@/types/project';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
@ -15,6 +16,7 @@ export default function ProjectDashboard() {
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isCreateSheetOpen, setIsCreateSheetOpen] = useState(false);
useEffect(() => { useEffect(() => {
async function loadProjects() { async function loadProjects() {
@ -33,6 +35,23 @@ export default function ProjectDashboard() {
loadProjects(); 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 => const filteredProjects = projects.filter(project =>
project.name.toLowerCase().includes(searchTerm.toLowerCase()) || project.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
project.client.toLowerCase().includes(searchTerm.toLowerCase()) || project.client.toLowerCase().includes(searchTerm.toLowerCase()) ||
@ -47,6 +66,7 @@ export default function ProjectDashboard() {
onSearchChange={() => {}} onSearchChange={() => {}}
viewMode={viewMode} viewMode={viewMode}
onViewModeChange={setViewMode} onViewModeChange={setViewMode}
onCreateProject={() => {}}
/> />
<main className="px-4 sm:px-6 lg:px-8 py-8"> <main className="px-4 sm:px-6 lg:px-8 py-8">
@ -94,12 +114,26 @@ export default function ProjectDashboard() {
onSearchChange={setSearchTerm} onSearchChange={setSearchTerm}
viewMode={viewMode} viewMode={viewMode}
onViewModeChange={setViewMode} onViewModeChange={setViewMode}
onCreateProject={() => setIsCreateSheetOpen(true)}
/> />
<main className="px-4 sm:px-6 lg:px-8 py-8"> <main className="px-4 sm:px-6 lg:px-8 py-8">
<StatsOverview projects={projects} /> <StatsOverview projects={projects} />
<ProjectGrid projects={filteredProjects} viewMode={viewMode} /> <ProjectGrid
projects={filteredProjects}
viewMode={viewMode}
onProjectUpdated={handleProjectUpdated}
onProjectDeleted={handleProjectDeleted}
/>
</main> </main>
{/* Sheet de creación de proyecto */}
<ProjectFormSheet
open={isCreateSheetOpen}
onOpenChange={setIsCreateSheetOpen}
mode="create"
onSuccess={handleProjectCreated}
/>
</div> </div>
); );
} }

View File

@ -1,15 +1,63 @@
"use client"; "use client";
import { useState } from 'react';
import ProjectCard from './project-card'; import ProjectCard from './project-card';
import ProjectListItem from './project-list-item'; 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'; import { Project } from '@/types/project';
interface ProjectGridProps { interface ProjectGridProps {
projects: Project[]; projects: Project[];
viewMode: 'grid' | 'list'; 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<Project | null>(null);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [projectToDelete, setProjectToDelete] = useState<Project | null>(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) { if (projects.length === 0) {
return ( return (
<div className="text-center py-12"> <div className="text-center py-12">
@ -21,20 +69,86 @@ export default function ProjectGrid({ projects, viewMode }: ProjectGridProps) {
// Vista de lista // Vista de lista
if (viewMode === 'list') { if (viewMode === 'list') {
return ( return (
<div className="flex flex-col gap-3"> <>
{projects.map((project) => ( <div className="flex flex-col gap-3">
<ProjectListItem key={project.id} project={project} /> {projects.map((project) => (
))} <ProjectListItem
</div> key={project.id}
project={project}
onEdit={handleEditProject}
onDelete={handleDeleteProject}
/>
))}
</div>
{/* Sheet de edición */}
<ProjectFormSheet
open={isEditSheetOpen}
onOpenChange={(open) => {
setIsEditSheetOpen(open);
if (!open) setProjectToEdit(null);
}}
mode="edit"
project={projectToEdit}
onSuccess={handleEditSuccess}
/>
{/* Diálogo de confirmación de eliminación */}
<DeleteConfirmationDialog
open={isDeleteDialogOpen}
onOpenChange={(open) => {
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) // Vista de grid (por defecto)
return ( return (
<div className="grid gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3"> <>
{projects.map((project) => ( <div className="grid gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
<ProjectCard key={project.id} project={project} /> {projects.map((project) => (
))} <ProjectCard
</div> key={project.id}
project={project}
onEdit={handleEditProject}
onDelete={handleDeleteProject}
/>
))}
</div>
{/* Sheet de edición */}
<ProjectFormSheet
open={isEditSheetOpen}
onOpenChange={(open) => {
setIsEditSheetOpen(open);
if (!open) setProjectToEdit(null);
}}
mode="edit"
project={projectToEdit}
onSuccess={handleEditSuccess}
/>
{/* Diálogo de confirmación de eliminación */}
<DeleteConfirmationDialog
open={isDeleteDialogOpen}
onOpenChange={(open) => {
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}
/>
</>
); );
} }

View File

@ -1,13 +1,23 @@
"use client"; "use client";
import Link from 'next/link'; 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 { 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 { ProjectStatusBadge } from '@/components/ui/project-status-badge';
import { Project } from '@/types/project'; import { Project } from '@/types/project';
interface ProjectListItemProps { interface ProjectListItemProps {
project: Project; project: Project;
onEdit?: (project: Project) => void;
onDelete?: (project: Project) => void;
} }
// Función para generar iniciales // Función para generar iniciales
@ -24,80 +34,113 @@ function getInitials(name: string): string {
* Componente de fila de proyecto para vista de lista * Componente de fila de proyecto para vista de lista
* Diseño horizontal y compacto * Diseño horizontal y compacto
*/ */
export default function ProjectListItem({ project }: ProjectListItemProps) { export default function ProjectListItem({ project, onEdit, onDelete }: ProjectListItemProps) {
const initials = getInitials(project.name); const initials = getInitials(project.name);
return ( return (
<Link href={`/proyectos/${project.id}`}> <div className="bg-card border rounded-lg p-4 hover:shadow-md transition-all duration-300 group">
<div className="bg-card border rounded-lg p-4 hover:shadow-md transition-all duration-300 cursor-pointer"> <div className="flex items-center gap-4">
<div className="flex items-center gap-4"> {/* Avatar */}
{/* Avatar */} <Link href={`/proyectos/${project.id}`}>
<Avatar className="w-12 h-12 flex-shrink-0"> <Avatar className="w-12 h-12 flex-shrink-0 cursor-pointer">
<AvatarFallback className="bg-gradient-to-br from-blue-500 to-purple-600 text-white font-semibold"> <AvatarFallback className="bg-gradient-to-br from-blue-500 to-purple-600 text-white font-semibold">
{initials} {initials}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
</Link>
{/* Info Principal */} {/* Info Principal */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex-1"> <Link href={`/proyectos/${project.id}`} className="flex-1">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<h3 className="text-base font-semibold truncate"> <h3 className="text-base font-semibold truncate hover:text-primary transition-colors">
{project.name} {project.name}
</h3> </h3>
<span className="text-xs text-muted-foreground flex-shrink-0">{project.ref}</span> <span className="text-xs text-muted-foreground flex-shrink-0">{project.ref}</span>
</div>
{project.client !== 'Sin cliente' && (
<p className="text-sm text-muted-foreground truncate">{project.client}</p>
)}
</div> </div>
{project.client !== 'Sin cliente' && (
<p className="text-sm text-muted-foreground truncate">{project.client}</p>
)}
</Link>
{/* Estado */} {/* Estado */}
<ProjectStatusBadge status={project.status} /> <ProjectStatusBadge status={project.status} />
</div>
</div> </div>
</div>
{/* Progreso */} {/* Progreso */}
<div className="hidden md:flex items-center gap-2 flex-shrink-0 w-32"> <div className="hidden md:flex items-center gap-2 flex-shrink-0 w-32">
<TrendingUp className="w-4 h-4 text-muted-foreground" /> <TrendingUp className="w-4 h-4 text-muted-foreground" />
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<span className="text-xs text-muted-foreground">Progreso</span> <span className="text-xs text-muted-foreground">Progreso</span>
<span className="text-xs font-semibold">{project.progress}%</span> <span className="text-xs font-semibold">{project.progress}%</span>
</div>
<div className="w-full bg-muted rounded-full h-1.5">
<div
className="bg-gradient-to-r from-blue-500 to-purple-600 h-1.5 rounded-full transition-all duration-500"
style={{ width: `${project.progress}%` }}
/>
</div>
</div> </div>
</div> <div className="w-full bg-muted rounded-full h-1.5">
<div
{/* Presupuesto */} className="bg-gradient-to-r from-blue-500 to-purple-600 h-1.5 rounded-full transition-all duration-500"
<div className="hidden lg:flex items-center gap-2 flex-shrink-0"> style={{ width: `${project.progress}%` }}
<DollarSign className="w-4 h-4 text-green-500" /> />
<div className="text-right">
<div className="text-sm font-semibold">
€{project.budget.toLocaleString('es-ES', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}
</div>
<div className="text-xs text-muted-foreground">Presupuesto</div>
</div>
</div>
{/* Fechas */}
<div className="hidden xl:flex items-center gap-2 flex-shrink-0">
<Calendar className="w-4 h-4 text-muted-foreground" />
<div className="text-right">
<div className="text-sm">
{new Date(project.startDate).toLocaleDateString('es-ES', { day: '2-digit', month: 'short' })}
</div>
<div className="text-xs text-muted-foreground">Inicio</div>
</div> </div>
</div> </div>
</div> </div>
{/* Presupuesto */}
<div className="hidden lg:flex items-center gap-2 flex-shrink-0">
<DollarSign className="w-4 h-4 text-green-500" />
<div className="text-right">
<div className="text-sm font-semibold">
€{project.budget.toLocaleString('es-ES', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}
</div>
<div className="text-xs text-muted-foreground">Presupuesto</div>
</div>
</div>
{/* Fechas */}
<div className="hidden xl:flex items-center gap-2 flex-shrink-0">
<Calendar className="w-4 h-4 text-muted-foreground" />
<div className="text-right">
<div className="text-sm">
{new Date(project.startDate).toLocaleDateString('es-ES', { day: '2-digit', month: 'short' })}
</div>
<div className="text-xs text-muted-foreground">Inicio</div>
</div>
</div>
{/* Menú de acciones */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/proyectos/${project.id}`}>
<Eye className="mr-2 h-4 w-4" />
Ver detalles
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEdit?.(project)}>
<Pencil className="mr-2 h-4 w-4" />
Editar
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => onDelete?.(project)}
>
<Trash2 className="mr-2 h-4 w-4" />
Eliminar
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</Link> </div>
); );
} }

View File

@ -80,7 +80,8 @@ function ErrorState({ onRetry }: { onRetry: () => void }) {
); );
} }
export function ProjectDetailView({ project }: ProjectDetailViewProps) { export function ProjectDetailView({ project: initialProject }: ProjectDetailViewProps) {
const [project, setProject] = useState<Project>(initialProject);
const [tasks, setTasks] = useState<Task[]>([]); const [tasks, setTasks] = useState<Task[]>([]);
const [isLoadingTasks, setIsLoadingTasks] = useState(true); const [isLoadingTasks, setIsLoadingTasks] = useState(true);
const [tasksError, setTasksError] = useState<string | null>(null); const [tasksError, setTasksError] = useState<string | null>(null);
@ -104,18 +105,38 @@ export function ProjectDetailView({ project }: ProjectDetailViewProps) {
fetchTasks(); fetchTasks();
}, [fetchTasks]); }, [fetchTasks]);
// Handler para cuando se actualiza el proyecto
const handleProjectUpdated = (updatedProject: Project) => {
setProject(updatedProject);
};
// Handler para cuando se crea una tarea // Handler para cuando se crea una tarea
const handleTaskCreated = (newTask: Task) => { const handleTaskCreated = (newTask: Task) => {
setTasks(prevTasks => [newTask, ...prevTasks]); 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 // Calcular estadísticas de tareas
const taskStats = calculateTaskStats(tasks); const taskStats = calculateTaskStats(tasks);
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
{/* Header del proyecto */} {/* Header del proyecto */}
<ProjectHeader project={project} /> <ProjectHeader
project={project}
onProjectUpdated={handleProjectUpdated}
/>
{/* Contenido principal */} {/* Contenido principal */}
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
@ -165,6 +186,8 @@ export function ProjectDetailView({ project }: ProjectDetailViewProps) {
tasks={tasks.slice(0, 5)} tasks={tasks.slice(0, 5)}
projectId={project.id} projectId={project.id}
onTaskCreated={handleTaskCreated} onTaskCreated={handleTaskCreated}
onTaskUpdated={handleTaskUpdated}
onTaskDeleted={handleTaskDeleted}
/> />
</CardContent> </CardContent>
</Card> </Card>
@ -193,6 +216,8 @@ export function ProjectDetailView({ project }: ProjectDetailViewProps) {
tasks={tasks} tasks={tasks}
projectId={project.id} projectId={project.id}
onTaskCreated={handleTaskCreated} onTaskCreated={handleTaskCreated}
onTaskUpdated={handleTaskUpdated}
onTaskDeleted={handleTaskDeleted}
/> />
)} )}
</TabsContent> </TabsContent>

View File

@ -1,5 +1,6 @@
"use client"; "use client";
import { useState } from "react";
import { ArrowLeft, MoreHorizontal, Pencil, Trash2, Share2, Copy } from "lucide-react"; import { ArrowLeft, MoreHorizontal, Pencil, Trash2, Share2, Copy } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
@ -14,10 +15,15 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { ProjectStatusBadge } from "@/components/ui/project-status-badge"; 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"; import { Project } from "@/types/project";
interface ProjectHeaderProps { interface ProjectHeaderProps {
project: Project; project: Project;
onProjectUpdated?: (project: Project) => void;
onProjectDeleted?: () => void;
} }
// Función para generar iniciales // Función para generar iniciales
@ -30,16 +36,34 @@ function getInitials(name: string): string {
.slice(0, 2); .slice(0, 2);
} }
export function ProjectHeader({ project, onProjectUpdated, onProjectDeleted }: ProjectHeaderProps) {
export function ProjectHeader({ project }: ProjectHeaderProps) {
const router = useRouter(); const router = useRouter();
const initials = getInitials(project.name); const initials = getInitials(project.name);
const [isEditSheetOpen, setIsEditSheetOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const handleCopyRef = () => { const handleCopyRef = () => {
navigator.clipboard.writeText(project.ref); 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 ( return (
<div className="border-b bg-card"> <div className="border-b bg-card">
<div className="px-6 py-4"> <div className="px-6 py-4">
@ -103,7 +127,7 @@ export function ProjectHeader({ project }: ProjectHeaderProps) {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem> <DropdownMenuItem onClick={() => setIsEditSheetOpen(true)}>
<Pencil className="h-4 w-4 mr-2" /> <Pencil className="h-4 w-4 mr-2" />
Editar proyecto Editar proyecto
</DropdownMenuItem> </DropdownMenuItem>
@ -112,7 +136,10 @@ export function ProjectHeader({ project }: ProjectHeaderProps) {
Compartir Compartir
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:text-destructive"> <DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setIsDeleteDialogOpen(true)}
>
<Trash2 className="h-4 w-4 mr-2" /> <Trash2 className="h-4 w-4 mr-2" />
Eliminar Eliminar
</DropdownMenuItem> </DropdownMenuItem>
@ -121,6 +148,26 @@ export function ProjectHeader({ project }: ProjectHeaderProps) {
</div> </div>
</div> </div>
</div> </div>
{/* Sheet de edición */}
<ProjectFormSheet
open={isEditSheetOpen}
onOpenChange={setIsEditSheetOpen}
mode="edit"
project={project}
onSuccess={handleEditSuccess}
/>
{/* Diálogo de confirmación de eliminación */}
<DeleteConfirmationDialog
open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
onConfirm={handleConfirmDelete}
title="¿Eliminar proyecto?"
itemName={project.name}
description={`Esta acción eliminará el proyecto "${project.name}" y todas sus tareas asociadas. Esta acción no se puede deshacer.`}
isDeleting={isDeleting}
/>
</div> </div>
); );
} }

View File

@ -93,172 +93,191 @@ function formatHours(hours: number): string {
return `${hours}h`; return `${hours}h`;
} }
export const taskColumns: ColumnDef<Task>[] = [ // Interfaz para callbacks de acciones
// Checkbox export interface TaskColumnCallbacks {
{ onEdit?: (task: Task) => void;
id: "select", onDelete?: (task: Task) => void;
header: ({ table }) => ( }
<Checkbox
checked={ // Función para crear columnas con callbacks opcionales
table.getIsAllPageRowsSelected() || export function createTaskColumns(callbacks?: TaskColumnCallbacks): ColumnDef<Task>[] {
(table.getIsSomePageRowsSelected() && "indeterminate") return [
} // Checkbox
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} {
aria-label="Seleccionar todo" id: "select",
/> header: ({ table }) => (
), <Checkbox
cell: ({ row }) => ( checked={
<Checkbox table.getIsAllPageRowsSelected() ||
checked={row.getIsSelected()} (table.getIsSomePageRowsSelected() && "indeterminate")
onCheckedChange={(value) => row.toggleSelected(!!value)} }
aria-label="Seleccionar fila" onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
/> aria-label="Seleccionar todo"
), />
enableSorting: false, ),
enableHiding: false, cell: ({ row }) => (
}, <Checkbox
// Título checked={row.getIsSelected()}
{ onCheckedChange={(value) => row.toggleSelected(!!value)}
accessorKey: "title", aria-label="Seleccionar fila"
header: ({ column }) => ( />
<Button ),
variant="ghost" enableSorting: false,
size="sm" enableHiding: false,
className="-ml-3 h-8" },
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} // Título
> {
Tarea accessorKey: "title",
<ArrowUpDown className="ml-2 h-4 w-4" /> header: ({ column }) => (
</Button> <Button
), variant="ghost"
cell: ({ row }) => ( size="sm"
<div className="max-w-[300px]"> className="-ml-3 h-8"
<Link onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
href={`/tareas/${row.original.id}`}
className="font-medium truncate hover:text-blue-600 hover:underline transition-colors block"
> >
{row.getValue("title")} Tarea
</Link> <ArrowUpDown className="ml-2 h-4 w-4" />
{row.original.ref && ( </Button>
<p className="text-xs text-muted-foreground font-mono">{row.original.ref}</p> ),
)} cell: ({ row }) => (
</div> <div className="max-w-[300px]">
), <Link
}, href={`/tareas/${row.original.id}`}
// Estado className="font-medium truncate hover:text-blue-600 hover:underline transition-colors block"
{ >
accessorKey: "status", {row.getValue("title")}
header: "Estado", </Link>
cell: ({ row }) => <TaskStatusBadge status={row.getValue("status")} />, {row.original.ref && (
filterFn: (row, id, value) => value.includes(row.getValue(id)), <p className="text-xs text-muted-foreground font-mono">{row.original.ref}</p>
}, )}
// Prioridad </div>
{ ),
accessorKey: "priority",
header: "Prioridad",
cell: ({ row }) => <TaskPriorityBadge priority={row.getValue("priority")} />,
filterFn: (row, id, value) => value.includes(row.getValue(id)),
},
// Progreso
{
accessorKey: "progress",
header: ({ column }) => (
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Progreso
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => <TaskProgress progress={row.getValue("progress")} />,
},
// Horas
{
id: "hours",
header: () => (
<div className="flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
Horas
</div>
),
cell: ({ row }) => (
<div className="text-sm">
<span className="font-medium">{formatHours(row.original.workedHours)}</span>
{row.original.plannedHours > 0 && (
<span className="text-muted-foreground"> / {formatHours(row.original.plannedHours)}</span>
)}
</div>
),
},
// Fecha límite
{
accessorKey: "endDate",
header: ({ column }) => (
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Fecha límite
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => {
const endDate = row.original.endDate || row.original.plannedEndDate;
const isOverdue = endDate && new Date(endDate) < new Date() && row.original.status !== '2';
return (
<span className={`text-sm ${isOverdue ? 'text-red-500 font-medium' : 'text-muted-foreground'}`}>
{formatDate(endDate)}
{isOverdue && <AlertTriangle className="h-3 w-3 ml-1 inline" />}
</span>
);
}, },
}, // Estado
// Acciones {
{ accessorKey: "status",
id: "actions", header: "Estado",
enableHiding: false, cell: ({ row }) => <TaskStatusBadge status={row.getValue("status")} />,
cell: ({ row }) => { filterFn: (row, id, value) => value.includes(row.getValue(id)),
const task = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Abrir menú</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Acciones</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={`/tareas/${task.id}`} className="cursor-pointer">
<Eye className="mr-2 h-4 w-4" />
Ver detalles
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<Pencil className="mr-2 h-4 w-4" />
Editar
</DropdownMenuItem>
<DropdownMenuItem>
<Clock className="mr-2 h-4 w-4" />
Registrar tiempo
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:text-destructive">
<Trash2 className="mr-2 h-4 w-4" />
Eliminar
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}, },
}, // Prioridad
]; {
accessorKey: "priority",
header: "Prioridad",
cell: ({ row }) => <TaskPriorityBadge priority={row.getValue("priority")} />,
filterFn: (row, id, value) => value.includes(row.getValue(id)),
},
// Progreso
{
accessorKey: "progress",
header: ({ column }) => (
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Progreso
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => <TaskProgress progress={row.getValue("progress")} />,
},
// Horas
{
id: "hours",
header: () => (
<div className="flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
Horas
</div>
),
cell: ({ row }) => (
<div className="text-sm">
<span className="font-medium">{formatHours(row.original.workedHours)}</span>
{row.original.plannedHours > 0 && (
<span className="text-muted-foreground"> / {formatHours(row.original.plannedHours)}</span>
)}
</div>
),
},
// Fecha límite
{
accessorKey: "endDate",
header: ({ column }) => (
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Fecha límite
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => {
const endDate = row.original.endDate || row.original.plannedEndDate;
const isOverdue = endDate && new Date(endDate) < new Date() && row.original.status !== '2';
return (
<span className={`text-sm ${isOverdue ? 'text-red-500 font-medium' : 'text-muted-foreground'}`}>
{formatDate(endDate)}
{isOverdue && <AlertTriangle className="h-3 w-3 ml-1 inline" />}
</span>
);
},
},
// Acciones
{
id: "actions",
enableHiding: false,
cell: ({ row }) => {
const task = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Abrir menú</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Acciones</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={`/tareas/${task.id}`} className="cursor-pointer">
<Eye className="mr-2 h-4 w-4" />
Ver detalles
</Link>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => callbacks?.onEdit?.(task)}
disabled={!callbacks?.onEdit}
>
<Pencil className="mr-2 h-4 w-4" />
Editar
</DropdownMenuItem>
<DropdownMenuItem>
<Clock className="mr-2 h-4 w-4" />
Registrar tiempo
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => callbacks?.onDelete?.(task)}
disabled={!callbacks?.onDelete}
>
<Trash2 className="mr-2 h-4 w-4" />
Eliminar
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
}
// Columnas por defecto sin callbacks (para compatibilidad)
export const taskColumns: ColumnDef<Task>[] = createTaskColumns();

View File

@ -25,16 +25,20 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { DeleteConfirmationDialog } from "@/components/ui/delete-confirmation-dialog";
import { DataTable } from "@/components/projects/data-table"; 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 { TaskFormSheet } from "@/components/task-form/task-form-sheet";
import { Task, TASK_STATUS_CONFIG, TASK_PRIORITY_CONFIG } from "@/types/task"; import { Task, TASK_STATUS_CONFIG, TASK_PRIORITY_CONFIG } from "@/types/task";
import { deleteTask } from "@/lib/tasksService";
interface TasksSectionProps { interface TasksSectionProps {
tasks: Task[]; tasks: Task[];
projectId: number; projectId: number;
isLoading?: boolean; isLoading?: boolean;
onTaskCreated?: (task: Task) => void; onTaskCreated?: (task: Task) => void;
onTaskUpdated?: (task: Task) => void;
onTaskDeleted?: (taskId: number) => void;
} }
// Tarjeta de tarea para vista grid // 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 [searchValue, setSearchValue] = useState("");
const [statusFilter, setStatusFilter] = useState("all"); const [statusFilter, setStatusFilter] = useState("all");
const [priorityFilter, setPriorityFilter] = useState("all"); const [priorityFilter, setPriorityFilter] = useState("all");
const [viewMode, setViewMode] = useState<"table" | "grid">("table"); const [viewMode, setViewMode] = useState<"table" | "grid">("table");
const [isCreateSheetOpen, setIsCreateSheetOpen] = useState(false); 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 // Handler para cuando se crea una tarea exitosamente
const handleCreateSuccess = (task: Task) => { const handleCreateSuccess = (task: Task) => {
onTaskCreated?.(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 // Filtrar tareas
const filteredTasks = useMemo(() => { const filteredTasks = useMemo(() => {
let result = [...tasks]; let result = [...tasks];
@ -310,7 +361,7 @@ export function TasksSection({ tasks, projectId, isLoading, onTaskCreated }: Tas
{/* Contenido */} {/* Contenido */}
{viewMode === "table" ? ( {viewMode === "table" ? (
<DataTable columns={taskColumns} data={filteredTasks} /> <DataTable columns={columns} data={filteredTasks} />
) : ( ) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredTasks.map((task) => ( {filteredTasks.map((task) => (
@ -339,6 +390,32 @@ export function TasksSection({ tasks, projectId, isLoading, onTaskCreated }: Tas
projectId={projectId} projectId={projectId}
onSuccess={handleCreateSuccess} 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> </div>
); );
} }

View File

@ -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<ProjectFormData>(defaultFormData);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(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<HTMLInputElement | HTMLTextAreaElement>
) => {
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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="sm:max-w-lg overflow-y-auto">
<SheetHeader>
<SheetTitle>
{mode === "create" ? "Nuevo Proyecto" : "Editar Proyecto"}
</SheetTitle>
<SheetDescription>
{mode === "create"
? "Crea un nuevo proyecto para gestionar tus tareas."
: "Modifica los detalles del proyecto."}
</SheetDescription>
</SheetHeader>
<form onSubmit={handleSubmit} className="mt-6 space-y-6">
{/* Nombre */}
<div className="space-y-2">
<Label htmlFor="name">
Nombre <span className="text-red-500">*</span>
</Label>
<Input
id="name"
name="name"
value={formData.name}
onChange={handleInputChange}
placeholder="Nombre del proyecto"
required
/>
</div>
{/* Descripción */}
<div className="space-y-2">
<Label htmlFor="description">Descripción</Label>
<Textarea
id="description"
name="description"
value={formData.description}
onChange={handleInputChange}
placeholder="Descripción del proyecto..."
rows={3}
/>
</div>
{/* Estado (solo en modo edición) */}
{mode === "edit" && (
<div className="space-y-2">
<Label htmlFor="status">Estado</Label>
<Select
value={formData.status}
onValueChange={(value) => handleSelectChange("status", value)}
>
<SelectTrigger>
<SelectValue placeholder="Seleccionar estado" />
</SelectTrigger>
<SelectContent>
{(Object.keys(STATUS_CONFIG) as ProjectStatus[]).map(
(status) => (
<SelectItem key={status} value={status}>
{STATUS_CONFIG[status].label}
</SelectItem>
)
)}
</SelectContent>
</Select>
</div>
)}
{/* Progreso */}
<div className="space-y-2">
<Label htmlFor="progress">Progreso (%)</Label>
<Input
id="progress"
name="progress"
type="number"
min="0"
max="100"
value={formData.progress}
onChange={handleInputChange}
placeholder="0"
/>
</div>
{/* Presupuesto */}
<div className="space-y-2">
<Label htmlFor="budget">Presupuesto (€)</Label>
<Input
id="budget"
name="budget"
type="number"
min="0"
step="0.01"
value={formData.budget}
onChange={handleInputChange}
placeholder="0.00"
/>
</div>
{/* Fechas - Grid de 2 columnas */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="startDate">Fecha inicio</Label>
<Input
id="startDate"
name="startDate"
type="date"
value={formData.startDate}
onChange={handleInputChange}
/>
</div>
<div className="space-y-2">
<Label htmlFor="endDate">Fecha fin</Label>
<Input
id="endDate"
name="endDate"
type="date"
value={formData.endDate}
onChange={handleInputChange}
/>
</div>
</div>
{/* Proyecto público */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="isPublic">Proyecto público</Label>
<p className="text-sm text-muted-foreground">
Los proyectos públicos son visibles para todos los usuarios
</p>
</div>
<Switch
id="isPublic"
checked={formData.isPublic}
onCheckedChange={(checked) => handleSwitchChange("isPublic", checked)}
/>
</div>
{/* Error message */}
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 dark:bg-red-950/50 dark:text-red-400 rounded-md">
{error}
</div>
)}
{/* Submit button */}
<SheetFooter className="pt-4">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancelar
</Button>
<Button type="submit" disabled={!isFormValid || isSubmitting}>
{isSubmitting && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{mode === "create" ? "Crear proyecto" : "Guardar cambios"}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}

View File

@ -15,7 +15,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { ProjectStatusBadge } from "@/components/ui/project-status-badge"; import { ProjectStatusBadge } from "@/components/ui/project-status-badge";
import { Project, ProjectStatus } from "@/types/project"; import { Project } from "@/types/project";
// Componente para la barra de progreso // Componente para la barra de progreso
function ProgressBar({ progress }: { progress: number }) { function ProgressBar({ progress }: { progress: number }) {
@ -65,198 +65,218 @@ function formatDate(dateString: string): string {
} }
} }
export const projectColumns: ColumnDef<Project>[] = [ // Interfaz para callbacks de acciones
// Columna de selección export interface ProjectColumnCallbacks {
{ onEdit?: (project: Project) => void;
id: "select", onDelete?: (project: Project) => void;
header: ({ table }) => ( }
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Seleccionar todo"
className="translate-y-[2px]"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Seleccionar fila"
className="translate-y-[2px]"
/>
),
enableSorting: false,
enableHiding: false,
},
// Referencia
{
accessorKey: "ref",
header: ({ column }) => (
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Referencia
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<span className="font-mono text-sm text-muted-foreground">
{row.getValue("ref")}
</span>
),
},
// Nombre del proyecto
{
accessorKey: "name",
header: ({ column }) => (
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Nombre
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<div className="max-w-[250px]">
<Link
href={`/proyectos/${row.original.id}`}
className="font-medium hover:underline hover:text-primary transition-colors"
>
{row.getValue("name")}
</Link>
</div>
),
},
// Cliente
{
accessorKey: "client",
header: "Cliente",
cell: ({ row }) => (
<span className="text-sm">
{row.getValue("client")}
</span>
),
},
// Estado
{
accessorKey: "status",
header: "Estado",
cell: ({ row }) => <ProjectStatusBadge status={row.getValue("status")} />,
filterFn: (row, id, value) => {
return value.includes(row.getValue(id));
},
},
// Progreso
{
accessorKey: "progress",
header: ({ column }) => (
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Progreso
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => <ProgressBar progress={row.getValue("progress")} />,
},
// Presupuesto
{
accessorKey: "budget",
header: ({ column }) => (
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Presupuesto
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<span className="font-medium tabular-nums">
{formatCurrency(row.getValue("budget"))}
</span>
),
},
// Fecha inicio
{
accessorKey: "startDate",
header: ({ column }) => (
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Inicio
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{formatDate(row.getValue("startDate"))}
</span>
),
},
// Fecha fin
{
accessorKey: "endDate",
header: "Fin",
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{formatDate(row.getValue("endDate"))}
</span>
),
},
// Acciones
{
id: "actions",
enableHiding: false,
cell: ({ row }) => {
const project = row.original;
return ( // Función para crear columnas con callbacks opcionales
<DropdownMenu> export function createProjectColumns(callbacks?: ProjectColumnCallbacks): ColumnDef<Project>[] {
<DropdownMenuTrigger asChild> return [
<Button variant="ghost" className="h-8 w-8 p-0"> // Columna de selección
<span className="sr-only">Abrir menú</span> {
<MoreHorizontal className="h-4 w-4" /> id: "select",
</Button> header: ({ table }) => (
</DropdownMenuTrigger> <Checkbox
<DropdownMenuContent align="end"> checked={
<DropdownMenuLabel>Acciones</DropdownMenuLabel> table.getIsAllPageRowsSelected() ||
<DropdownMenuSeparator /> (table.getIsSomePageRowsSelected() && "indeterminate")
<DropdownMenuItem asChild> }
<Link href={`/proyectos/${project.id}`} className="cursor-pointer"> onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
<Eye className="mr-2 h-4 w-4" /> aria-label="Seleccionar todo"
Ver detalles className="translate-y-[2px]"
</Link> />
</DropdownMenuItem> ),
<DropdownMenuItem className="cursor-pointer"> cell: ({ row }) => (
<Pencil className="mr-2 h-4 w-4" /> <Checkbox
Editar checked={row.getIsSelected()}
</DropdownMenuItem> onCheckedChange={(value) => row.toggleSelected(!!value)}
<DropdownMenuSeparator /> aria-label="Seleccionar fila"
<DropdownMenuItem className="cursor-pointer text-destructive focus:text-destructive"> className="translate-y-[2px]"
<Trash2 className="mr-2 h-4 w-4" /> />
Eliminar ),
</DropdownMenuItem> enableSorting: false,
</DropdownMenuContent> enableHiding: false,
</DropdownMenu>
);
}, },
}, // Referencia
]; {
accessorKey: "ref",
header: ({ column }) => (
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Referencia
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<span className="font-mono text-sm text-muted-foreground">
{row.getValue("ref")}
</span>
),
},
// Nombre del proyecto
{
accessorKey: "name",
header: ({ column }) => (
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Nombre
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<div className="max-w-[250px]">
<Link
href={`/proyectos/${row.original.id}`}
className="font-medium hover:underline hover:text-primary transition-colors"
>
{row.getValue("name")}
</Link>
</div>
),
},
// Cliente
{
accessorKey: "client",
header: "Cliente",
cell: ({ row }) => (
<span className="text-sm">
{row.getValue("client")}
</span>
),
},
// Estado
{
accessorKey: "status",
header: "Estado",
cell: ({ row }) => <ProjectStatusBadge status={row.getValue("status")} />,
filterFn: (row, id, value) => {
return value.includes(row.getValue(id));
},
},
// Progreso
{
accessorKey: "progress",
header: ({ column }) => (
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Progreso
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => <ProgressBar progress={row.getValue("progress")} />,
},
// Presupuesto
{
accessorKey: "budget",
header: ({ column }) => (
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Presupuesto
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<span className="font-medium tabular-nums">
{formatCurrency(row.getValue("budget"))}
</span>
),
},
// Fecha inicio
{
accessorKey: "startDate",
header: ({ column }) => (
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Inicio
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{formatDate(row.getValue("startDate"))}
</span>
),
},
// Fecha fin
{
accessorKey: "endDate",
header: "Fin",
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{formatDate(row.getValue("endDate"))}
</span>
),
},
// Acciones
{
id: "actions",
enableHiding: false,
cell: ({ row }) => {
const project = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Abrir menú</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Acciones</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={`/proyectos/${project.id}`} className="cursor-pointer">
<Eye className="mr-2 h-4 w-4" />
Ver detalles
</Link>
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onClick={() => callbacks?.onEdit?.(project)}
disabled={!callbacks?.onEdit}
>
<Pencil className="mr-2 h-4 w-4" />
Editar
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer text-destructive focus:text-destructive"
onClick={() => callbacks?.onDelete?.(project)}
disabled={!callbacks?.onDelete}
>
<Trash2 className="mr-2 h-4 w-4" />
Eliminar
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
}
// Columnas por defecto sin callbacks (para compatibilidad)
export const projectColumns: ColumnDef<Project>[] = createProjectColumns();

View File

@ -1,15 +1,17 @@
"use client"; "use client";
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo } from "react";
import { FolderKanban, RefreshCw } from "lucide-react"; import { FolderKanban, RefreshCw, Plus } from "lucide-react";
import { Project } from "@/types/project"; import { Project } from "@/types/project";
import { getProjects } from "@/lib/projectsService"; import { getProjects, deleteProject } from "@/lib/projectsService";
import { DataTable } from "./data-table"; import { DataTable } from "./data-table";
import { DataTableToolbar } from "./data-table-toolbar"; import { DataTableToolbar } from "./data-table-toolbar";
import { DataTableSkeleton } from "./data-table-skeleton"; import { DataTableSkeleton } from "./data-table-skeleton";
import { EmptyState } from "./empty-state"; import { EmptyState } from "./empty-state";
import { projectColumns } from "./columns"; import { createProjectColumns } from "./columns";
import { ProjectFormSheet } from "@/components/project-form/project-form-sheet";
import { DeleteConfirmationDialog } from "@/components/ui/delete-confirmation-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
export function ProjectsTable() { export function ProjectsTable() {
@ -21,6 +23,14 @@ export function ProjectsTable() {
const [searchValue, setSearchValue] = useState(""); const [searchValue, setSearchValue] = useState("");
const [statusFilter, setStatusFilter] = useState("all"); const [statusFilter, setStatusFilter] = useState("all");
// Estados para crear/editar/eliminar
const [isCreateSheetOpen, setIsCreateSheetOpen] = useState(false);
const [isEditSheetOpen, setIsEditSheetOpen] = useState(false);
const [projectToEdit, setProjectToEdit] = useState<Project | null>(null);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [projectToDelete, setProjectToDelete] = useState<Project | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
// Cargar proyectos // Cargar proyectos
const fetchProjects = async () => { const fetchProjects = async () => {
setIsLoading(true); setIsLoading(true);
@ -40,6 +50,48 @@ export function ProjectsTable() {
fetchProjects(); fetchProjects();
}, []); }, []);
// Handlers para CRUD
const handleProjectCreated = (newProject: Project) => {
setProjects(prev => [newProject, ...prev]);
};
const handleEditProject = (project: Project) => {
setProjectToEdit(project);
setIsEditSheetOpen(true);
};
const handleEditSuccess = (updatedProject: Project) => {
setProjects(prev => prev.map(p => p.id === updatedProject.id ? updatedProject : p));
setProjectToEdit(null);
};
const handleDeleteProject = (project: Project) => {
setProjectToDelete(project);
setIsDeleteDialogOpen(true);
};
const handleConfirmDelete = async () => {
if (!projectToDelete) return;
setIsDeleting(true);
try {
await deleteProject(projectToDelete.id);
setProjects(prev => prev.filter(p => p.id !== projectToDelete.id));
setIsDeleteDialogOpen(false);
setProjectToDelete(null);
} catch (error) {
console.error("Error deleting project:", error);
} finally {
setIsDeleting(false);
}
};
// Crear columnas con callbacks
const columns = useMemo(() => createProjectColumns({
onEdit: handleEditProject,
onDelete: handleDeleteProject,
}), []);
// Filtrar proyectos // Filtrar proyectos
const filteredProjects = useMemo(() => { const filteredProjects = useMemo(() => {
let result = [...projects]; let result = [...projects];
@ -97,31 +149,79 @@ export function ProjectsTable() {
// Estado vacío // Estado vacío
if (projects.length === 0) { if (projects.length === 0) {
return ( return (
<EmptyState <>
title="No hay proyectos" <EmptyState
description="Aún no tienes proyectos creados. Comienza creando tu primer proyecto." title="No hay proyectos"
actionLabel="Crear proyecto" description="Aún no tienes proyectos creados. Comienza creando tu primer proyecto."
onAction={() => console.log("Create project")} actionLabel="Crear proyecto"
/> onAction={() => setIsCreateSheetOpen(true)}
/>
<ProjectFormSheet
open={isCreateSheetOpen}
onOpenChange={setIsCreateSheetOpen}
mode="create"
onSuccess={handleProjectCreated}
/>
</>
); );
} }
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<DataTableToolbar <div className="flex items-center justify-between">
searchValue={searchValue} <DataTableToolbar
onSearchChange={setSearchValue} searchValue={searchValue}
statusFilter={statusFilter} onSearchChange={setSearchValue}
onStatusFilterChange={setStatusFilter} statusFilter={statusFilter}
selectedCount={selectedCount} onStatusFilterChange={setStatusFilter}
onClearFilters={handleClearFilters} selectedCount={selectedCount}
/> onClearFilters={handleClearFilters}
/>
<Button onClick={() => setIsCreateSheetOpen(true)} size="sm">
<Plus className="h-4 w-4 mr-2" />
Nuevo proyecto
</Button>
</div>
<DataTable <DataTable
columns={projectColumns} columns={columns}
data={filteredProjects} data={filteredProjects}
searchKey="name" searchKey="name"
searchValue={searchValue} searchValue={searchValue}
/> />
{/* Sheet de creación */}
<ProjectFormSheet
open={isCreateSheetOpen}
onOpenChange={setIsCreateSheetOpen}
mode="create"
onSuccess={handleProjectCreated}
/>
{/* Sheet de edición */}
<ProjectFormSheet
open={isEditSheetOpen}
onOpenChange={(open) => {
setIsEditSheetOpen(open);
if (!open) setProjectToEdit(null);
}}
mode="edit"
project={projectToEdit}
onSuccess={handleEditSuccess}
/>
{/* Diálogo de confirmación de eliminación */}
<DeleteConfirmationDialog
open={isDeleteDialogOpen}
onOpenChange={(open) => {
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}
/>
</div> </div>
); );
} }

View File

@ -1059,7 +1059,7 @@ export default function StatisticsPage() {
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
<header className="bg-card border-b sticky top-0 z-10"> <header className="bg-card border-b sticky top-0 z-10">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <div className="px-6 py-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-2 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg"> <div className="p-2 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg">
<BarChart3 className="w-6 h-6 text-white" /> <BarChart3 className="w-6 h-6 text-white" />
@ -1072,7 +1072,7 @@ export default function StatisticsPage() {
</div> </div>
</header> </header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <main className="px-6 py-8">
{loading ? ( {loading ? (
<StatisticsSkeleton /> <StatisticsSkeleton />
) : ( ) : (

View File

@ -14,7 +14,9 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { DeleteConfirmationDialog } from "@/components/ui/delete-confirmation-dialog";
import { TaskFormSheet } from "@/components/task-form/task-form-sheet"; import { TaskFormSheet } from "@/components/task-form/task-form-sheet";
import { deleteTask } from "@/lib/tasksService";
import { import {
Task, Task,
TaskStatus, TaskStatus,
@ -28,6 +30,7 @@ interface TaskHeaderProps {
task: Task; task: Task;
project: Project | null; project: Project | null;
onTaskUpdated?: (task: Task) => void; onTaskUpdated?: (task: Task) => void;
onTaskDeleted?: () => void;
} }
// Badge de estado // Badge de estado
@ -52,9 +55,11 @@ function TaskPriorityBadge({ priority }: { priority: TaskPriority }) {
); );
} }
export function TaskHeader({ task, project, onTaskUpdated }: TaskHeaderProps) { export function TaskHeader({ task, project, onTaskUpdated, onTaskDeleted }: TaskHeaderProps) {
const router = useRouter(); const router = useRouter();
const [isEditSheetOpen, setIsEditSheetOpen] = useState(false); const [isEditSheetOpen, setIsEditSheetOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const handleCopyRef = () => { const handleCopyRef = () => {
navigator.clipboard.writeText(task.ref); navigator.clipboard.writeText(task.ref);
@ -64,6 +69,25 @@ export function TaskHeader({ task, project, onTaskUpdated }: TaskHeaderProps) {
onTaskUpdated?.(updatedTask); onTaskUpdated?.(updatedTask);
}; };
const handleConfirmDelete = async () => {
setIsDeleting(true);
try {
await deleteTask(task.id);
onTaskDeleted?.();
// Navegar al proyecto o a la lista de proyectos
if (project) {
router.push(`/proyectos/${project.id}`);
} else {
router.push('/proyectos');
}
} catch (error) {
console.error("Error deleting task:", error);
// TODO: Mostrar toast de error
} finally {
setIsDeleting(false);
}
};
return ( return (
<div className="border-b bg-card"> <div className="border-b bg-card">
<div className="px-6 py-4"> <div className="px-6 py-4">
@ -151,7 +175,7 @@ export function TaskHeader({ task, project, onTaskUpdated }: TaskHeaderProps) {
Registrar tiempo Registrar tiempo
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:text-destructive"> <DropdownMenuItem className="text-destructive focus:text-destructive" onClick={() => setIsDeleteDialogOpen(true)}>
<Trash2 className="h-4 w-4 mr-2" /> <Trash2 className="h-4 w-4 mr-2" />
Eliminar Eliminar
</DropdownMenuItem> </DropdownMenuItem>
@ -170,6 +194,16 @@ export function TaskHeader({ task, project, onTaskUpdated }: TaskHeaderProps) {
task={task} task={task}
onSuccess={handleEditSuccess} onSuccess={handleEditSuccess}
/> />
{/* Diálogo de confirmación de eliminación */}
<DeleteConfirmationDialog
open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
onConfirm={handleConfirmDelete}
title="¿Eliminar tarea?"
itemName={task.title}
isDeleting={isDeleting}
/>
</div> </div>
); );
} }

View File

@ -0,0 +1,141 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@ -0,0 +1,59 @@
"use client";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
interface DeleteConfirmationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
title?: string;
description?: string;
itemName?: string;
isDeleting?: boolean;
}
export function DeleteConfirmationDialog({
open,
onOpenChange,
onConfirm,
title = "¿Estás seguro?",
description,
itemName,
isDeleting = false,
}: DeleteConfirmationDialogProps) {
const defaultDescription = itemName
? `Esta acción no se puede deshacer. Se eliminará permanentemente "${itemName}" y todos sus datos asociados.`
: "Esta acción no se puede deshacer. Se eliminarán permanentemente todos los datos asociados.";
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>
{description || defaultDescription}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? "Eliminando..." : "Eliminar"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

29
components/ui/switch.tsx Normal file
View File

@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@ -1,6 +1,12 @@
// lib/projectsService.ts // lib/projectsService.ts
import { dolibarrFetch } from "./dolibarrClient"; import { dolibarrFetch } from "./dolibarrClient";
import { DolibarrProject, Project, mapDolibarrProject } from "@/types/project"; import {
DolibarrProject,
Project,
mapDolibarrProject,
CreateProjectData,
UpdateProjectData
} from "@/types/project";
// Obtener proyectos crudos de Dolibarr // Obtener proyectos crudos de Dolibarr
export async function getDolibarrProjects(): Promise<DolibarrProject[]> { export async function getDolibarrProjects(): Promise<DolibarrProject[]> {
@ -21,13 +27,13 @@ export async function getProjects(): Promise<Project[]> {
} }
// Obtener un proyecto específico por ID // Obtener un proyecto específico por ID
export async function getProjectById(id: number): Promise<Project | undefined> { export async function getProjectById(id: number): Promise<Project | null> {
try { try {
const projects = await getProjects(); const dolibarrProject = await getDolibarrProjectById(id);
return projects.find(p => p.id === id); return mapDolibarrProject(dolibarrProject);
} catch (error) { } catch (error) {
console.error('Error fetching project by id:', error); console.error('Error fetching project by id:', error);
throw error; return null;
} }
} }
@ -35,3 +41,129 @@ export async function getProjectById(id: number): Promise<Project | undefined> {
export async function getDolibarrProjectById(id: number): Promise<DolibarrProject> { export async function getDolibarrProjectById(id: number): Promise<DolibarrProject> {
return dolibarrFetch(`projects/${id}`); return dolibarrFetch(`projects/${id}`);
} }
// ==================== FUNCIONES DE ESCRITURA (CREATE/UPDATE/DELETE) ====================
/**
* Crear un nuevo proyecto
* Endpoint: POST /projects
* @returns El proyecto creado con su ID
*/
export async function createProject(data: CreateProjectData): Promise<Project> {
try {
// Limpiar datos undefined/null para la API
const cleanData = Object.fromEntries(
Object.entries(data).filter(([, v]) => v !== undefined && v !== null && v !== '')
);
const response = await dolibarrFetch('projects', {
method: 'POST',
body: JSON.stringify(cleanData),
});
// Dolibarr devuelve el ID del proyecto creado
const projectId = typeof response === 'object' ? response.id || response : response;
// Obtener el proyecto completo
const createdProject = await getProjectById(Number(projectId));
if (!createdProject) {
throw new Error('No se pudo obtener el proyecto creado');
}
return createdProject;
} catch (error) {
console.error('Error creating project:', error);
throw error;
}
}
/**
* Actualizar un proyecto existente
* Endpoint: PUT /projects/{id}
* @returns El proyecto actualizado
*/
export async function updateProject(projectId: number, data: UpdateProjectData): Promise<Project> {
try {
// Limpiar datos undefined/null para la API
const cleanData = Object.fromEntries(
Object.entries(data).filter(([, v]) => v !== undefined && v !== null)
);
await dolibarrFetch(`projects/${projectId}`, {
method: 'PUT',
body: JSON.stringify(cleanData),
});
// Obtener el proyecto actualizado
const updatedProject = await getProjectById(projectId);
if (!updatedProject) {
throw new Error('No se pudo obtener el proyecto actualizado');
}
return updatedProject;
} catch (error) {
console.error('Error updating project:', projectId, error);
throw error;
}
}
/**
* Eliminar un proyecto
* Endpoint: DELETE /projects/{id}
*/
export async function deleteProject(projectId: number): Promise<void> {
try {
await dolibarrFetch(`projects/${projectId}`, {
method: 'DELETE',
});
} catch (error) {
console.error('Error deleting project:', projectId, error);
throw error;
}
}
/**
* Validar (cambiar estado a 1 - abierto) un proyecto
* Endpoint: POST /projects/{id}/validate
*/
export async function validateProject(projectId: number): Promise<Project> {
try {
await dolibarrFetch(`projects/${projectId}/validate`, {
method: 'POST',
body: JSON.stringify({ notrigger: 0 }),
});
const project = await getProjectById(projectId);
if (!project) {
throw new Error('No se pudo obtener el proyecto validado');
}
return project;
} catch (error) {
console.error('Error validating project:', projectId, error);
throw error;
}
}
/**
* Cerrar un proyecto (cambiar estado a 2)
* Endpoint: POST /projects/{id}/close
*/
export async function closeProject(projectId: number): Promise<Project> {
try {
await dolibarrFetch(`projects/${projectId}/close`, {
method: 'POST',
body: JSON.stringify({ notrigger: 0 }),
});
const project = await getProjectById(projectId);
if (!project) {
throw new Error('No se pudo obtener el proyecto cerrado');
}
return project;
} catch (error) {
console.error('Error closing project:', projectId, error);
throw error;
}
}

171
package-lock.json generated
View File

@ -8,6 +8,7 @@
"name": "trello_fake", "name": "trello_fake",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
@ -17,6 +18,7 @@
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
@ -1289,6 +1291,90 @@
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@radix-ui/react-alert-dialog": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz",
"integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dialog": "1.1.15",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-arrow": { "node_modules/@radix-ui/react-arrow": {
"version": "1.1.7", "version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
@ -2507,6 +2593,91 @@
} }
} }
}, },
"node_modules/@radix-ui/react-switch": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
"integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tabs": { "node_modules/@radix-ui/react-tabs": {
"version": "1.1.13", "version": "1.1.13",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",

View File

@ -13,6 +13,7 @@
"diagnose": "node scripts/diagnose.js" "diagnose": "node scripts/diagnose.js"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
@ -22,6 +23,7 @@
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",

View File

@ -39,6 +39,7 @@ export interface Project {
description: string; description: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
socid?: number; // ID del tercero/cliente
} }
// Configuración de estados - AHORA CON FUNCIÓN QUE RETORNA LAS CLASES COMPLETAS // Configuración de estados - AHORA CON FUNCIÓN QUE RETORNA LAS CLASES COMPLETAS
@ -96,5 +97,145 @@ export function mapDolibarrProject(dolibarr: DolibarrProject): Project {
description: dolibarr.description || '', description: dolibarr.description || '',
createdAt: timestampToDate(dolibarr.date_c), createdAt: timestampToDate(dolibarr.date_c),
updatedAt: timestampToDate(dolibarr.date_m), updatedAt: timestampToDate(dolibarr.date_m),
socid: dolibarr.socid ? (typeof dolibarr.socid === 'string' ? parseInt(dolibarr.socid) : dolibarr.socid) : undefined,
};
}
// ==================== TIPOS PARA API DE CREAR/ACTUALIZAR ====================
/**
* Datos para crear un nuevo proyecto
* Campos requeridos por Dolibarr: ref, title
*/
export interface CreateProjectData {
ref: string; // Referencia única (requerido)
title: string; // Nombre del proyecto (requerido)
description?: string;
date_start?: number; // timestamp en segundos
date_end?: number; // timestamp en segundos
opp_amount?: number; // presupuesto
opp_percent?: number; // progreso (0-100)
budget_amount?: number;
socid?: number; // ID del tercero/cliente
public?: number; // 0: privado, 1: público
usage_opportunity?: number;
usage_task?: number;
usage_bill_time?: number;
usage_organize_event?: number;
}
/**
* Datos para actualizar un proyecto existente
* Todos los campos son opcionales
*/
export interface UpdateProjectData {
ref?: string;
title?: string;
description?: string;
date_start?: number; // timestamp en segundos
date_end?: number; // timestamp en segundos
opp_amount?: number; // presupuesto
opp_percent?: number; // progreso (0-100)
budget_amount?: number;
socid?: number; // ID del tercero/cliente
status?: ProjectStatus;
public?: number;
}
/**
* Interface para el formulario de proyectos (UI)
* Usa formatos amigables
*/
export interface ProjectFormData {
name: string;
description: string;
status: ProjectStatus;
progress: number;
budget: number;
startDate: string; // YYYY-MM-DD
endDate: string; // YYYY-MM-DD
socid?: number; // ID del cliente
isPublic: boolean;
}
// Helper para convertir fecha string a timestamp (segundos)
export function dateStringToTimestamp(dateStr: string | null | undefined): number | undefined {
if (!dateStr || dateStr === '') return undefined;
const date = new Date(dateStr);
if (isNaN(date.getTime())) return undefined;
return Math.floor(date.getTime() / 1000);
}
// Helper para generar una referencia única para proyectos
function generateProjectRef(): string {
const timestamp = Date.now().toString(36).toUpperCase();
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
return `PJ-${timestamp}-${random}`;
}
/**
* Convierte datos del formulario UI al formato para crear proyecto en Dolibarr
*/
export function formDataToCreateProject(formData: ProjectFormData): CreateProjectData {
return {
ref: generateProjectRef(),
title: formData.name,
description: formData.description || undefined,
date_start: dateStringToTimestamp(formData.startDate),
date_end: dateStringToTimestamp(formData.endDate),
opp_amount: formData.budget > 0 ? formData.budget : undefined,
opp_percent: formData.progress,
budget_amount: formData.budget > 0 ? formData.budget : undefined,
socid: formData.socid || undefined,
public: formData.isPublic ? 1 : 0,
usage_task: 1, // Habilitar tareas por defecto
usage_opportunity: 1,
};
}
/**
* Convierte datos del formulario UI al formato para actualizar proyecto en Dolibarr
*/
export function formDataToUpdateProject(formData: Partial<ProjectFormData>): UpdateProjectData {
const updateData: UpdateProjectData = {};
if (formData.name !== undefined) updateData.title = formData.name;
if (formData.description !== undefined) updateData.description = formData.description;
if (formData.status !== undefined) updateData.status = formData.status;
if (formData.progress !== undefined) updateData.opp_percent = formData.progress;
if (formData.budget !== undefined) {
updateData.opp_amount = formData.budget;
updateData.budget_amount = formData.budget;
}
if (formData.startDate !== undefined) {
updateData.date_start = dateStringToTimestamp(formData.startDate);
}
if (formData.endDate !== undefined) {
updateData.date_end = dateStringToTimestamp(formData.endDate);
}
if (formData.socid !== undefined) {
updateData.socid = formData.socid;
}
if (formData.isPublic !== undefined) {
updateData.public = formData.isPublic ? 1 : 0;
}
return updateData;
}
/**
* Convierte un proyecto existente a datos del formulario
*/
export function projectToFormData(project: Project): ProjectFormData {
return {
name: project.name,
description: project.description || '',
status: project.status,
progress: project.progress,
budget: project.budget,
startDate: project.startDate || '',
endDate: project.endDate || '',
socid: project.socid,
isPublic: true, // Por defecto público
}; };
} }