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:
parent
9331e7e890
commit
ae66248df9
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
141
types/project.ts
141
types/project.ts
|
|
@ -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
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue