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

View File

@ -1,14 +1,24 @@
"use client";
import Link from 'next/link';
import { Calendar, DollarSign, TrendingUp, FileText } from 'lucide-react';
import { Calendar, DollarSign, TrendingUp, FileText, MoreHorizontal, Pencil, Trash2, Eye } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ProjectStatusBadge } from '@/components/ui/project-status-badge';
import { Project } from '@/types/project';
interface ProjectCardProps {
project: Project;
onEdit?: (project: Project) => void;
onDelete?: (project: Project) => void;
}
// Función para generar iniciales
@ -21,28 +31,62 @@ function getInitials(name: string): string {
.slice(0, 2);
}
export default function ProjectCard({ project }: ProjectCardProps) {
export default function ProjectCard({ project, onEdit, onDelete }: ProjectCardProps) {
const initials = getInitials(project.name);
return (
<Link href={`/proyectos/${project.id}`}>
<Card className="hover:shadow-lg transition-all duration-300 hover:-translate-y-1 cursor-pointer h-full">
<Card className="hover:shadow-lg transition-all duration-300 hover:-translate-y-1 h-full group">
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<Link href={`/proyectos/${project.id}`} className="flex items-center gap-3 flex-1 cursor-pointer">
<Avatar className="w-12 h-12">
<AvatarFallback className="bg-gradient-to-br from-blue-500 to-purple-600 text-white font-semibold">
{initials}
</AvatarFallback>
</Avatar>
<div>
<CardTitle className="text-lg">{project.name}</CardTitle>
<CardTitle className="text-lg hover:text-primary transition-colors">{project.name}</CardTitle>
<CardDescription className="text-sm">{project.ref}</CardDescription>
</div>
</div>
</Link>
{/* 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>
{/* Description */}
{project.description && (
@ -115,7 +159,7 @@ export default function ProjectCard({ project }: ProjectCardProps) {
</div>
)}
</CardContent>
</Card>
</Link>
</Card>
);
}

View File

@ -5,7 +5,8 @@ import DashboardHeader from './dashboard-header';
import StatsOverview from './stats-overview';
import ProjectGrid from './project-grid';
import ProjectGridSkeleton from './project-grid-skeleton';
import { getProjects } from '@/lib/projectsService'; // Importar desde el service
import { ProjectFormSheet } from '@/components/project-form/project-form-sheet';
import { getProjects } from '@/lib/projectsService';
import { Project } from '@/types/project';
import { Skeleton } from '@/components/ui/skeleton';
@ -15,6 +16,7 @@ export default function ProjectDashboard() {
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isCreateSheetOpen, setIsCreateSheetOpen] = useState(false);
useEffect(() => {
async function loadProjects() {
@ -33,6 +35,23 @@ export default function ProjectDashboard() {
loadProjects();
}, []);
// Handler para cuando se crea un proyecto
const handleProjectCreated = (newProject: Project) => {
setProjects(prevProjects => [newProject, ...prevProjects]);
};
// Handler para cuando se actualiza un proyecto
const handleProjectUpdated = (updatedProject: Project) => {
setProjects(prevProjects =>
prevProjects.map(p => p.id === updatedProject.id ? updatedProject : p)
);
};
// Handler para cuando se elimina un proyecto
const handleProjectDeleted = (projectId: number) => {
setProjects(prevProjects => prevProjects.filter(p => p.id !== projectId));
};
const filteredProjects = projects.filter(project =>
project.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
project.client.toLowerCase().includes(searchTerm.toLowerCase()) ||
@ -47,6 +66,7 @@ export default function ProjectDashboard() {
onSearchChange={() => {}}
viewMode={viewMode}
onViewModeChange={setViewMode}
onCreateProject={() => {}}
/>
<main className="px-4 sm:px-6 lg:px-8 py-8">
@ -94,12 +114,26 @@ export default function ProjectDashboard() {
onSearchChange={setSearchTerm}
viewMode={viewMode}
onViewModeChange={setViewMode}
onCreateProject={() => setIsCreateSheetOpen(true)}
/>
<main className="px-4 sm:px-6 lg:px-8 py-8">
<StatsOverview projects={projects} />
<ProjectGrid projects={filteredProjects} viewMode={viewMode} />
<ProjectGrid
projects={filteredProjects}
viewMode={viewMode}
onProjectUpdated={handleProjectUpdated}
onProjectDeleted={handleProjectDeleted}
/>
</main>
{/* Sheet de creación de proyecto */}
<ProjectFormSheet
open={isCreateSheetOpen}
onOpenChange={setIsCreateSheetOpen}
mode="create"
onSuccess={handleProjectCreated}
/>
</div>
);
}

View File

@ -1,15 +1,63 @@
"use client";
import { useState } from 'react';
import ProjectCard from './project-card';
import ProjectListItem from './project-list-item';
import { ProjectFormSheet } from '@/components/project-form/project-form-sheet';
import { DeleteConfirmationDialog } from '@/components/ui/delete-confirmation-dialog';
import { deleteProject } from '@/lib/projectsService';
import { Project } from '@/types/project';
interface ProjectGridProps {
projects: Project[];
viewMode: 'grid' | 'list';
onProjectUpdated?: (project: Project) => void;
onProjectDeleted?: (projectId: number) => void;
}
export default function ProjectGrid({ projects, viewMode }: ProjectGridProps) {
export default function ProjectGrid({
projects,
viewMode,
onProjectUpdated,
onProjectDeleted
}: ProjectGridProps) {
const [isEditSheetOpen, setIsEditSheetOpen] = useState(false);
const [projectToEdit, setProjectToEdit] = useState<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) {
return (
<div className="text-center py-12">
@ -21,20 +69,86 @@ export default function ProjectGrid({ projects, viewMode }: ProjectGridProps) {
// Vista de lista
if (viewMode === 'list') {
return (
<>
<div className="flex flex-col gap-3">
{projects.map((project) => (
<ProjectListItem key={project.id} project={project} />
<ProjectListItem
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)
return (
<>
<div className="grid gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => (
<ProjectCard key={project.id} project={project} />
<ProjectCard
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";
import Link from 'next/link';
import { Calendar, DollarSign, TrendingUp } from 'lucide-react';
import { Calendar, DollarSign, TrendingUp, MoreHorizontal, Pencil, Trash2, Eye } from 'lucide-react';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ProjectStatusBadge } from '@/components/ui/project-status-badge';
import { Project } from '@/types/project';
interface ProjectListItemProps {
project: Project;
onEdit?: (project: Project) => void;
onDelete?: (project: Project) => void;
}
// Función para generar iniciales
@ -24,26 +34,27 @@ function getInitials(name: string): string {
* Componente de fila de proyecto para vista de lista
* Diseño horizontal y compacto
*/
export default function ProjectListItem({ project }: ProjectListItemProps) {
export default function ProjectListItem({ project, onEdit, onDelete }: ProjectListItemProps) {
const initials = getInitials(project.name);
return (
<Link href={`/proyectos/${project.id}`}>
<div className="bg-card border rounded-lg p-4 hover:shadow-md transition-all duration-300 cursor-pointer">
<div className="bg-card border rounded-lg p-4 hover:shadow-md transition-all duration-300 group">
<div className="flex items-center gap-4">
{/* Avatar */}
<Avatar className="w-12 h-12 flex-shrink-0">
<Link href={`/proyectos/${project.id}`}>
<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">
{initials}
</AvatarFallback>
</Avatar>
</Link>
{/* Info Principal */}
<div className="flex-1 min-w-0">
<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">
<h3 className="text-base font-semibold truncate">
<h3 className="text-base font-semibold truncate hover:text-primary transition-colors">
{project.name}
</h3>
<span className="text-xs text-muted-foreground flex-shrink-0">{project.ref}</span>
@ -51,7 +62,7 @@ export default function ProjectListItem({ project }: ProjectListItemProps) {
{project.client !== 'Sin cliente' && (
<p className="text-sm text-muted-foreground truncate">{project.client}</p>
)}
</div>
</Link>
{/* Estado */}
<ProjectStatusBadge status={project.status} />
@ -96,8 +107,40 @@ export default function ProjectListItem({ project }: ProjectListItemProps) {
<div className="text-xs text-muted-foreground">Inicio</div>
</div>
</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>
);
}

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

View File

@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import { ArrowLeft, MoreHorizontal, Pencil, Trash2, Share2, Copy } from "lucide-react";
import { useRouter } from "next/navigation";
import Link from "next/link";
@ -14,10 +15,15 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ProjectStatusBadge } from "@/components/ui/project-status-badge";
import { ProjectFormSheet } from "@/components/project-form/project-form-sheet";
import { DeleteConfirmationDialog } from "@/components/ui/delete-confirmation-dialog";
import { deleteProject } from "@/lib/projectsService";
import { Project } from "@/types/project";
interface ProjectHeaderProps {
project: Project;
onProjectUpdated?: (project: Project) => void;
onProjectDeleted?: () => void;
}
// Función para generar iniciales
@ -30,16 +36,34 @@ function getInitials(name: string): string {
.slice(0, 2);
}
export function ProjectHeader({ project }: ProjectHeaderProps) {
export function ProjectHeader({ project, onProjectUpdated, onProjectDeleted }: ProjectHeaderProps) {
const router = useRouter();
const initials = getInitials(project.name);
const [isEditSheetOpen, setIsEditSheetOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const handleCopyRef = () => {
navigator.clipboard.writeText(project.ref);
};
const handleEditSuccess = (updatedProject: Project) => {
onProjectUpdated?.(updatedProject);
};
const handleConfirmDelete = async () => {
setIsDeleting(true);
try {
await deleteProject(project.id);
onProjectDeleted?.();
router.push('/proyectos');
} catch (error) {
console.error("Error deleting project:", error);
} finally {
setIsDeleting(false);
}
};
return (
<div className="border-b bg-card">
<div className="px-6 py-4">
@ -103,7 +127,7 @@ export function ProjectHeader({ project }: ProjectHeaderProps) {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<DropdownMenuItem onClick={() => setIsEditSheetOpen(true)}>
<Pencil className="h-4 w-4 mr-2" />
Editar proyecto
</DropdownMenuItem>
@ -112,7 +136,10 @@ export function ProjectHeader({ project }: ProjectHeaderProps) {
Compartir
</DropdownMenuItem>
<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" />
Eliminar
</DropdownMenuItem>
@ -121,6 +148,26 @@ export function ProjectHeader({ project }: ProjectHeaderProps) {
</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>
);
}

View File

@ -93,7 +93,15 @@ function formatHours(hours: number): string {
return `${hours}h`;
}
export const taskColumns: ColumnDef<Task>[] = [
// Interfaz para callbacks de acciones
export interface TaskColumnCallbacks {
onEdit?: (task: Task) => void;
onDelete?: (task: Task) => void;
}
// Función para crear columnas con callbacks opcionales
export function createTaskColumns(callbacks?: TaskColumnCallbacks): ColumnDef<Task>[] {
return [
// Checkbox
{
id: "select",
@ -243,7 +251,10 @@ export const taskColumns: ColumnDef<Task>[] = [
Ver detalles
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownMenuItem
onClick={() => callbacks?.onEdit?.(task)}
disabled={!callbacks?.onEdit}
>
<Pencil className="mr-2 h-4 w-4" />
Editar
</DropdownMenuItem>
@ -252,7 +263,11 @@ export const taskColumns: ColumnDef<Task>[] = [
Registrar tiempo
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:text-destructive">
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => callbacks?.onDelete?.(task)}
disabled={!callbacks?.onDelete}
>
<Trash2 className="mr-2 h-4 w-4" />
Eliminar
</DropdownMenuItem>
@ -262,3 +277,7 @@ export const taskColumns: ColumnDef<Task>[] = [
},
},
];
}
// Columnas por defecto sin callbacks (para compatibilidad)
export const taskColumns: ColumnDef<Task>[] = createTaskColumns();

View File

@ -25,16 +25,20 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { DeleteConfirmationDialog } from "@/components/ui/delete-confirmation-dialog";
import { DataTable } from "@/components/projects/data-table";
import { taskColumns } from "./task-columns";
import { createTaskColumns } from "./task-columns";
import { TaskFormSheet } from "@/components/task-form/task-form-sheet";
import { Task, TASK_STATUS_CONFIG, TASK_PRIORITY_CONFIG } from "@/types/task";
import { deleteTask } from "@/lib/tasksService";
interface TasksSectionProps {
tasks: Task[];
projectId: number;
isLoading?: boolean;
onTaskCreated?: (task: Task) => void;
onTaskUpdated?: (task: Task) => void;
onTaskDeleted?: (taskId: number) => void;
}
// Tarjeta de tarea para vista grid
@ -129,18 +133,65 @@ function EmptyTasks({ onCreateClick }: { onCreateClick: () => void }) {
);
}
export function TasksSection({ tasks, projectId, isLoading, onTaskCreated }: TasksSectionProps) {
export function TasksSection({ tasks, projectId, isLoading, onTaskCreated, onTaskUpdated, onTaskDeleted }: TasksSectionProps) {
const [searchValue, setSearchValue] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [priorityFilter, setPriorityFilter] = useState("all");
const [viewMode, setViewMode] = useState<"table" | "grid">("table");
const [isCreateSheetOpen, setIsCreateSheetOpen] = useState(false);
const [isEditSheetOpen, setIsEditSheetOpen] = useState(false);
const [taskToEdit, setTaskToEdit] = useState<Task | null>(null);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [taskToDelete, setTaskToDelete] = useState<Task | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
// Handler para cuando se crea una tarea exitosamente
const handleCreateSuccess = (task: Task) => {
onTaskCreated?.(task);
};
// Handler para cuando se edita una tarea exitosamente
const handleEditSuccess = (task: Task) => {
onTaskUpdated?.(task);
setTaskToEdit(null);
};
// Handler para abrir el sheet de edición
const handleEditTask = (task: Task) => {
setTaskToEdit(task);
setIsEditSheetOpen(true);
};
// Handler para abrir el diálogo de eliminación
const handleDeleteTask = (task: Task) => {
setTaskToDelete(task);
setIsDeleteDialogOpen(true);
};
// Handler para confirmar eliminación
const handleConfirmDelete = async () => {
if (!taskToDelete) return;
setIsDeleting(true);
try {
await deleteTask(taskToDelete.id);
onTaskDeleted?.(taskToDelete.id);
setIsDeleteDialogOpen(false);
setTaskToDelete(null);
} catch (error) {
console.error("Error deleting task:", error);
// TODO: Mostrar toast de error
} finally {
setIsDeleting(false);
}
};
// Crear columnas con callbacks
const columns = useMemo(() => createTaskColumns({
onEdit: handleEditTask,
onDelete: handleDeleteTask,
}), []);
// Filtrar tareas
const filteredTasks = useMemo(() => {
let result = [...tasks];
@ -310,7 +361,7 @@ export function TasksSection({ tasks, projectId, isLoading, onTaskCreated }: Tas
{/* Contenido */}
{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">
{filteredTasks.map((task) => (
@ -339,6 +390,32 @@ export function TasksSection({ tasks, projectId, isLoading, onTaskCreated }: Tas
projectId={projectId}
onSuccess={handleCreateSuccess}
/>
{/* Sheet de edición de tarea */}
<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>
);
}

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,
} from "@/components/ui/dropdown-menu";
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
function ProgressBar({ progress }: { progress: number }) {
@ -65,7 +65,15 @@ function formatDate(dateString: string): string {
}
}
export const projectColumns: ColumnDef<Project>[] = [
// Interfaz para callbacks de acciones
export interface ProjectColumnCallbacks {
onEdit?: (project: Project) => void;
onDelete?: (project: Project) => void;
}
// Función para crear columnas con callbacks opcionales
export function createProjectColumns(callbacks?: ProjectColumnCallbacks): ColumnDef<Project>[] {
return [
// Columna de selección
{
id: "select",
@ -245,12 +253,20 @@ export const projectColumns: ColumnDef<Project>[] = [
Ver detalles
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer">
<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">
<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>
@ -260,3 +276,7 @@ export const projectColumns: ColumnDef<Project>[] = [
},
},
];
}
// Columnas por defecto sin callbacks (para compatibilidad)
export const projectColumns: ColumnDef<Project>[] = createProjectColumns();

View File

@ -1,15 +1,17 @@
"use client";
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 { getProjects } from "@/lib/projectsService";
import { getProjects, deleteProject } from "@/lib/projectsService";
import { DataTable } from "./data-table";
import { DataTableToolbar } from "./data-table-toolbar";
import { DataTableSkeleton } from "./data-table-skeleton";
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";
export function ProjectsTable() {
@ -21,6 +23,14 @@ export function ProjectsTable() {
const [searchValue, setSearchValue] = useState("");
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
const fetchProjects = async () => {
setIsLoading(true);
@ -40,6 +50,48 @@ export function ProjectsTable() {
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
const filteredProjects = useMemo(() => {
let result = [...projects];
@ -97,17 +149,26 @@ export function ProjectsTable() {
// Estado vacío
if (projects.length === 0) {
return (
<>
<EmptyState
title="No hay proyectos"
description="Aún no tienes proyectos creados. Comienza creando tu primer proyecto."
actionLabel="Crear proyecto"
onAction={() => console.log("Create project")}
onAction={() => setIsCreateSheetOpen(true)}
/>
<ProjectFormSheet
open={isCreateSheetOpen}
onOpenChange={setIsCreateSheetOpen}
mode="create"
onSuccess={handleProjectCreated}
/>
</>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<DataTableToolbar
searchValue={searchValue}
onSearchChange={setSearchValue}
@ -116,12 +177,51 @@ export function ProjectsTable() {
selectedCount={selectedCount}
onClearFilters={handleClearFilters}
/>
<Button onClick={() => setIsCreateSheetOpen(true)} size="sm">
<Plus className="h-4 w-4 mr-2" />
Nuevo proyecto
</Button>
</div>
<DataTable
columns={projectColumns}
columns={columns}
data={filteredProjects}
searchKey="name"
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>
);
}

View File

@ -1059,7 +1059,7 @@ export default function StatisticsPage() {
return (
<div className="min-h-screen bg-background">
<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="p-2 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg">
<BarChart3 className="w-6 h-6 text-white" />
@ -1072,7 +1072,7 @@ export default function StatisticsPage() {
</div>
</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 ? (
<StatisticsSkeleton />
) : (

View File

@ -14,7 +14,9 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { DeleteConfirmationDialog } from "@/components/ui/delete-confirmation-dialog";
import { TaskFormSheet } from "@/components/task-form/task-form-sheet";
import { deleteTask } from "@/lib/tasksService";
import {
Task,
TaskStatus,
@ -28,6 +30,7 @@ interface TaskHeaderProps {
task: Task;
project: Project | null;
onTaskUpdated?: (task: Task) => void;
onTaskDeleted?: () => void;
}
// 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 [isEditSheetOpen, setIsEditSheetOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const handleCopyRef = () => {
navigator.clipboard.writeText(task.ref);
@ -64,6 +69,25 @@ export function TaskHeader({ task, project, onTaskUpdated }: TaskHeaderProps) {
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 (
<div className="border-b bg-card">
<div className="px-6 py-4">
@ -151,7 +175,7 @@ export function TaskHeader({ task, project, onTaskUpdated }: TaskHeaderProps) {
Registrar tiempo
</DropdownMenuItem>
<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" />
Eliminar
</DropdownMenuItem>
@ -170,6 +194,16 @@ export function TaskHeader({ task, project, onTaskUpdated }: TaskHeaderProps) {
task={task}
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>
);
}

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
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
export async function getDolibarrProjects(): Promise<DolibarrProject[]> {
@ -21,13 +27,13 @@ export async function getProjects(): Promise<Project[]> {
}
// 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 {
const projects = await getProjects();
return projects.find(p => p.id === id);
const dolibarrProject = await getDolibarrProjectById(id);
return mapDolibarrProject(dolibarrProject);
} catch (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> {
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",
"version": "0.1.0",
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
@ -17,6 +18,7 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-table": "^8.21.3",
@ -1289,6 +1291,90 @@
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"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": {
"version": "1.1.7",
"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": {
"version": "1.1.13",
"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"
},
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
@ -22,6 +23,7 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-table": "^8.21.3",

View File

@ -39,6 +39,7 @@ export interface Project {
description: string;
createdAt: string;
updatedAt: string;
socid?: number; // ID del tercero/cliente
}
// 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 || '',
createdAt: timestampToDate(dolibarr.date_c),
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
};
}