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";
|
"use client";
|
||||||
|
|
||||||
import { Search, LayoutGrid, List } from 'lucide-react';
|
import { Search, LayoutGrid, List, Plus } from 'lucide-react';
|
||||||
import { ThemeToggle } from '@/components/theme-toggle';
|
import { ThemeToggle } from '@/components/theme-toggle';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
interface DashboardHeaderProps {
|
interface DashboardHeaderProps {
|
||||||
searchTerm: string;
|
searchTerm: string;
|
||||||
onSearchChange: (value: string) => void;
|
onSearchChange: (value: string) => void;
|
||||||
viewMode: 'grid' | 'list';
|
viewMode: 'grid' | 'list';
|
||||||
onViewModeChange: (mode: 'grid' | 'list') => void;
|
onViewModeChange: (mode: 'grid' | 'list') => void;
|
||||||
|
onCreateProject?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DashboardHeader({
|
export default function DashboardHeader({
|
||||||
searchTerm,
|
searchTerm,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
viewMode,
|
viewMode,
|
||||||
onViewModeChange
|
onViewModeChange,
|
||||||
|
onCreateProject
|
||||||
}: DashboardHeaderProps) {
|
}: DashboardHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<header className="bg-card border-b">
|
<header className="bg-card border-b">
|
||||||
|
|
@ -50,6 +53,12 @@ export default function DashboardHeader({
|
||||||
<List className="w-4 h-4 text-muted-foreground" />
|
<List className="w-4 h-4 text-muted-foreground" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{onCreateProject && (
|
||||||
|
<Button onClick={onCreateProject} size="sm">
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Nuevo proyecto
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,24 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Calendar, DollarSign, TrendingUp, FileText } from 'lucide-react';
|
import { Calendar, DollarSign, TrendingUp, FileText, MoreHorizontal, Pencil, Trash2, Eye } from 'lucide-react';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { ProjectStatusBadge } from '@/components/ui/project-status-badge';
|
import { ProjectStatusBadge } from '@/components/ui/project-status-badge';
|
||||||
import { Project } from '@/types/project';
|
import { Project } from '@/types/project';
|
||||||
|
|
||||||
interface ProjectCardProps {
|
interface ProjectCardProps {
|
||||||
project: Project;
|
project: Project;
|
||||||
|
onEdit?: (project: Project) => void;
|
||||||
|
onDelete?: (project: Project) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Función para generar iniciales
|
// Función para generar iniciales
|
||||||
|
|
@ -21,28 +31,62 @@ function getInitials(name: string): string {
|
||||||
.slice(0, 2);
|
.slice(0, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProjectCard({ project }: ProjectCardProps) {
|
export default function ProjectCard({ project, onEdit, onDelete }: ProjectCardProps) {
|
||||||
const initials = getInitials(project.name);
|
const initials = getInitials(project.name);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`/proyectos/${project.id}`}>
|
<Card className="hover:shadow-lg transition-all duration-300 hover:-translate-y-1 h-full group">
|
||||||
<Card className="hover:shadow-lg transition-all duration-300 hover:-translate-y-1 cursor-pointer h-full">
|
<CardHeader>
|
||||||
<CardHeader>
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex items-start justify-between">
|
<Link href={`/proyectos/${project.id}`} className="flex items-center gap-3 flex-1 cursor-pointer">
|
||||||
<div className="flex items-center gap-3">
|
<Avatar className="w-12 h-12">
|
||||||
<Avatar className="w-12 h-12">
|
<AvatarFallback className="bg-gradient-to-br from-blue-500 to-purple-600 text-white font-semibold">
|
||||||
<AvatarFallback className="bg-gradient-to-br from-blue-500 to-purple-600 text-white font-semibold">
|
{initials}
|
||||||
{initials}
|
</AvatarFallback>
|
||||||
</AvatarFallback>
|
</Avatar>
|
||||||
</Avatar>
|
<div>
|
||||||
<div>
|
<CardTitle className="text-lg hover:text-primary transition-colors">{project.name}</CardTitle>
|
||||||
<CardTitle className="text-lg">{project.name}</CardTitle>
|
<CardDescription className="text-sm">{project.ref}</CardDescription>
|
||||||
<CardDescription className="text-sm">{project.ref}</CardDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Link>
|
||||||
<ProjectStatusBadge status={project.status} className="mt-3 w-fit" />
|
|
||||||
</CardHeader>
|
{/* Menú de acciones */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href={`/proyectos/${project.id}`}>
|
||||||
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
|
Ver detalles
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => onEdit?.(project)}>
|
||||||
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
|
Editar
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
onClick={() => onDelete?.(project)}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Eliminar
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
<ProjectStatusBadge status={project.status} className="mt-3 w-fit" />
|
||||||
|
</CardHeader>
|
||||||
|
<Link href={`/proyectos/${project.id}`} className="cursor-pointer">
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
{project.description && (
|
{project.description && (
|
||||||
|
|
@ -115,7 +159,7 @@ export default function ProjectCard({ project }: ProjectCardProps) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Link>
|
||||||
</Link>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -5,7 +5,8 @@ import DashboardHeader from './dashboard-header';
|
||||||
import StatsOverview from './stats-overview';
|
import StatsOverview from './stats-overview';
|
||||||
import ProjectGrid from './project-grid';
|
import ProjectGrid from './project-grid';
|
||||||
import ProjectGridSkeleton from './project-grid-skeleton';
|
import ProjectGridSkeleton from './project-grid-skeleton';
|
||||||
import { getProjects } from '@/lib/projectsService'; // Importar desde el service
|
import { ProjectFormSheet } from '@/components/project-form/project-form-sheet';
|
||||||
|
import { getProjects } from '@/lib/projectsService';
|
||||||
import { Project } from '@/types/project';
|
import { Project } from '@/types/project';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
|
|
@ -15,6 +16,7 @@ export default function ProjectDashboard() {
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isCreateSheetOpen, setIsCreateSheetOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadProjects() {
|
async function loadProjects() {
|
||||||
|
|
@ -33,6 +35,23 @@ export default function ProjectDashboard() {
|
||||||
loadProjects();
|
loadProjects();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Handler para cuando se crea un proyecto
|
||||||
|
const handleProjectCreated = (newProject: Project) => {
|
||||||
|
setProjects(prevProjects => [newProject, ...prevProjects]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handler para cuando se actualiza un proyecto
|
||||||
|
const handleProjectUpdated = (updatedProject: Project) => {
|
||||||
|
setProjects(prevProjects =>
|
||||||
|
prevProjects.map(p => p.id === updatedProject.id ? updatedProject : p)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handler para cuando se elimina un proyecto
|
||||||
|
const handleProjectDeleted = (projectId: number) => {
|
||||||
|
setProjects(prevProjects => prevProjects.filter(p => p.id !== projectId));
|
||||||
|
};
|
||||||
|
|
||||||
const filteredProjects = projects.filter(project =>
|
const filteredProjects = projects.filter(project =>
|
||||||
project.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
project.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
project.client.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
project.client.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
|
@ -47,6 +66,7 @@ export default function ProjectDashboard() {
|
||||||
onSearchChange={() => {}}
|
onSearchChange={() => {}}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
onViewModeChange={setViewMode}
|
onViewModeChange={setViewMode}
|
||||||
|
onCreateProject={() => {}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main className="px-4 sm:px-6 lg:px-8 py-8">
|
<main className="px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
|
@ -94,12 +114,26 @@ export default function ProjectDashboard() {
|
||||||
onSearchChange={setSearchTerm}
|
onSearchChange={setSearchTerm}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
onViewModeChange={setViewMode}
|
onViewModeChange={setViewMode}
|
||||||
|
onCreateProject={() => setIsCreateSheetOpen(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main className="px-4 sm:px-6 lg:px-8 py-8">
|
<main className="px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<StatsOverview projects={projects} />
|
<StatsOverview projects={projects} />
|
||||||
<ProjectGrid projects={filteredProjects} viewMode={viewMode} />
|
<ProjectGrid
|
||||||
|
projects={filteredProjects}
|
||||||
|
viewMode={viewMode}
|
||||||
|
onProjectUpdated={handleProjectUpdated}
|
||||||
|
onProjectDeleted={handleProjectDeleted}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* Sheet de creación de proyecto */}
|
||||||
|
<ProjectFormSheet
|
||||||
|
open={isCreateSheetOpen}
|
||||||
|
onOpenChange={setIsCreateSheetOpen}
|
||||||
|
mode="create"
|
||||||
|
onSuccess={handleProjectCreated}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,15 +1,63 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
import ProjectCard from './project-card';
|
import ProjectCard from './project-card';
|
||||||
import ProjectListItem from './project-list-item';
|
import ProjectListItem from './project-list-item';
|
||||||
|
import { ProjectFormSheet } from '@/components/project-form/project-form-sheet';
|
||||||
|
import { DeleteConfirmationDialog } from '@/components/ui/delete-confirmation-dialog';
|
||||||
|
import { deleteProject } from '@/lib/projectsService';
|
||||||
import { Project } from '@/types/project';
|
import { Project } from '@/types/project';
|
||||||
|
|
||||||
interface ProjectGridProps {
|
interface ProjectGridProps {
|
||||||
projects: Project[];
|
projects: Project[];
|
||||||
viewMode: 'grid' | 'list';
|
viewMode: 'grid' | 'list';
|
||||||
|
onProjectUpdated?: (project: Project) => void;
|
||||||
|
onProjectDeleted?: (projectId: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProjectGrid({ projects, viewMode }: ProjectGridProps) {
|
export default function ProjectGrid({
|
||||||
|
projects,
|
||||||
|
viewMode,
|
||||||
|
onProjectUpdated,
|
||||||
|
onProjectDeleted
|
||||||
|
}: ProjectGridProps) {
|
||||||
|
const [isEditSheetOpen, setIsEditSheetOpen] = useState(false);
|
||||||
|
const [projectToEdit, setProjectToEdit] = useState<Project | null>(null);
|
||||||
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
const [projectToDelete, setProjectToDelete] = useState<Project | null>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
|
const handleEditProject = (project: Project) => {
|
||||||
|
setProjectToEdit(project);
|
||||||
|
setIsEditSheetOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteProject = (project: Project) => {
|
||||||
|
setProjectToDelete(project);
|
||||||
|
setIsDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditSuccess = (updatedProject: Project) => {
|
||||||
|
onProjectUpdated?.(updatedProject);
|
||||||
|
setProjectToEdit(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmDelete = async () => {
|
||||||
|
if (!projectToDelete) return;
|
||||||
|
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteProject(projectToDelete.id);
|
||||||
|
onProjectDeleted?.(projectToDelete.id);
|
||||||
|
setIsDeleteDialogOpen(false);
|
||||||
|
setProjectToDelete(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting project:", error);
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (projects.length === 0) {
|
if (projects.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
|
|
@ -21,20 +69,86 @@ export default function ProjectGrid({ projects, viewMode }: ProjectGridProps) {
|
||||||
// Vista de lista
|
// Vista de lista
|
||||||
if (viewMode === 'list') {
|
if (viewMode === 'list') {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<>
|
||||||
{projects.map((project) => (
|
<div className="flex flex-col gap-3">
|
||||||
<ProjectListItem key={project.id} project={project} />
|
{projects.map((project) => (
|
||||||
))}
|
<ProjectListItem
|
||||||
</div>
|
key={project.id}
|
||||||
|
project={project}
|
||||||
|
onEdit={handleEditProject}
|
||||||
|
onDelete={handleDeleteProject}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sheet de edición */}
|
||||||
|
<ProjectFormSheet
|
||||||
|
open={isEditSheetOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setIsEditSheetOpen(open);
|
||||||
|
if (!open) setProjectToEdit(null);
|
||||||
|
}}
|
||||||
|
mode="edit"
|
||||||
|
project={projectToEdit}
|
||||||
|
onSuccess={handleEditSuccess}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Diálogo de confirmación de eliminación */}
|
||||||
|
<DeleteConfirmationDialog
|
||||||
|
open={isDeleteDialogOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setIsDeleteDialogOpen(open);
|
||||||
|
if (!open) setProjectToDelete(null);
|
||||||
|
}}
|
||||||
|
onConfirm={handleConfirmDelete}
|
||||||
|
title="¿Eliminar proyecto?"
|
||||||
|
itemName={projectToDelete?.name}
|
||||||
|
description={`Esta acción eliminará el proyecto "${projectToDelete?.name}" y todas sus tareas asociadas. Esta acción no se puede deshacer.`}
|
||||||
|
isDeleting={isDeleting}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vista de grid (por defecto)
|
// Vista de grid (por defecto)
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
<>
|
||||||
{projects.map((project) => (
|
<div className="grid gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<ProjectCard key={project.id} project={project} />
|
{projects.map((project) => (
|
||||||
))}
|
<ProjectCard
|
||||||
</div>
|
key={project.id}
|
||||||
|
project={project}
|
||||||
|
onEdit={handleEditProject}
|
||||||
|
onDelete={handleDeleteProject}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sheet de edición */}
|
||||||
|
<ProjectFormSheet
|
||||||
|
open={isEditSheetOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setIsEditSheetOpen(open);
|
||||||
|
if (!open) setProjectToEdit(null);
|
||||||
|
}}
|
||||||
|
mode="edit"
|
||||||
|
project={projectToEdit}
|
||||||
|
onSuccess={handleEditSuccess}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Diálogo de confirmación de eliminación */}
|
||||||
|
<DeleteConfirmationDialog
|
||||||
|
open={isDeleteDialogOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setIsDeleteDialogOpen(open);
|
||||||
|
if (!open) setProjectToDelete(null);
|
||||||
|
}}
|
||||||
|
onConfirm={handleConfirmDelete}
|
||||||
|
title="¿Eliminar proyecto?"
|
||||||
|
itemName={projectToDelete?.name}
|
||||||
|
description={`Esta acción eliminará el proyecto "${projectToDelete?.name}" y todas sus tareas asociadas. Esta acción no se puede deshacer.`}
|
||||||
|
isDeleting={isDeleting}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,13 +1,23 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Calendar, DollarSign, TrendingUp } from 'lucide-react';
|
import { Calendar, DollarSign, TrendingUp, MoreHorizontal, Pencil, Trash2, Eye } from 'lucide-react';
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { ProjectStatusBadge } from '@/components/ui/project-status-badge';
|
import { ProjectStatusBadge } from '@/components/ui/project-status-badge';
|
||||||
import { Project } from '@/types/project';
|
import { Project } from '@/types/project';
|
||||||
|
|
||||||
interface ProjectListItemProps {
|
interface ProjectListItemProps {
|
||||||
project: Project;
|
project: Project;
|
||||||
|
onEdit?: (project: Project) => void;
|
||||||
|
onDelete?: (project: Project) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Función para generar iniciales
|
// Función para generar iniciales
|
||||||
|
|
@ -24,80 +34,113 @@ function getInitials(name: string): string {
|
||||||
* Componente de fila de proyecto para vista de lista
|
* Componente de fila de proyecto para vista de lista
|
||||||
* Diseño horizontal y compacto
|
* Diseño horizontal y compacto
|
||||||
*/
|
*/
|
||||||
export default function ProjectListItem({ project }: ProjectListItemProps) {
|
export default function ProjectListItem({ project, onEdit, onDelete }: ProjectListItemProps) {
|
||||||
const initials = getInitials(project.name);
|
const initials = getInitials(project.name);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`/proyectos/${project.id}`}>
|
<div className="bg-card border rounded-lg p-4 hover:shadow-md transition-all duration-300 group">
|
||||||
<div className="bg-card border rounded-lg p-4 hover:shadow-md transition-all duration-300 cursor-pointer">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex items-center gap-4">
|
{/* Avatar */}
|
||||||
{/* Avatar */}
|
<Link href={`/proyectos/${project.id}`}>
|
||||||
<Avatar className="w-12 h-12 flex-shrink-0">
|
<Avatar className="w-12 h-12 flex-shrink-0 cursor-pointer">
|
||||||
<AvatarFallback className="bg-gradient-to-br from-blue-500 to-purple-600 text-white font-semibold">
|
<AvatarFallback className="bg-gradient-to-br from-blue-500 to-purple-600 text-white font-semibold">
|
||||||
{initials}
|
{initials}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
</Link>
|
||||||
|
|
||||||
{/* Info Principal */}
|
{/* Info Principal */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="flex-1">
|
<Link href={`/proyectos/${project.id}`} className="flex-1">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<h3 className="text-base font-semibold truncate">
|
<h3 className="text-base font-semibold truncate hover:text-primary transition-colors">
|
||||||
{project.name}
|
{project.name}
|
||||||
</h3>
|
</h3>
|
||||||
<span className="text-xs text-muted-foreground flex-shrink-0">{project.ref}</span>
|
<span className="text-xs text-muted-foreground flex-shrink-0">{project.ref}</span>
|
||||||
</div>
|
|
||||||
{project.client !== 'Sin cliente' && (
|
|
||||||
<p className="text-sm text-muted-foreground truncate">{project.client}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
{project.client !== 'Sin cliente' && (
|
||||||
{/* Estado */}
|
<p className="text-sm text-muted-foreground truncate">{project.client}</p>
|
||||||
<ProjectStatusBadge status={project.status} />
|
)}
|
||||||
</div>
|
</Link>
|
||||||
|
|
||||||
|
{/* Estado */}
|
||||||
|
<ProjectStatusBadge status={project.status} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Progreso */}
|
{/* Progreso */}
|
||||||
<div className="hidden md:flex items-center gap-2 flex-shrink-0 w-32">
|
<div className="hidden md:flex items-center gap-2 flex-shrink-0 w-32">
|
||||||
<TrendingUp className="w-4 h-4 text-muted-foreground" />
|
<TrendingUp className="w-4 h-4 text-muted-foreground" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<span className="text-xs text-muted-foreground">Progreso</span>
|
<span className="text-xs text-muted-foreground">Progreso</span>
|
||||||
<span className="text-xs font-semibold">{project.progress}%</span>
|
<span className="text-xs font-semibold">{project.progress}%</span>
|
||||||
</div>
|
|
||||||
<div className="w-full bg-muted rounded-full h-1.5">
|
|
||||||
<div
|
|
||||||
className="bg-gradient-to-r from-blue-500 to-purple-600 h-1.5 rounded-full transition-all duration-500"
|
|
||||||
style={{ width: `${project.progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="w-full bg-muted rounded-full h-1.5">
|
||||||
|
<div
|
||||||
{/* Presupuesto */}
|
className="bg-gradient-to-r from-blue-500 to-purple-600 h-1.5 rounded-full transition-all duration-500"
|
||||||
<div className="hidden lg:flex items-center gap-2 flex-shrink-0">
|
style={{ width: `${project.progress}%` }}
|
||||||
<DollarSign className="w-4 h-4 text-green-500" />
|
/>
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-sm font-semibold">
|
|
||||||
€{project.budget.toLocaleString('es-ES', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">Presupuesto</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Fechas */}
|
|
||||||
<div className="hidden xl:flex items-center gap-2 flex-shrink-0">
|
|
||||||
<Calendar className="w-4 h-4 text-muted-foreground" />
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-sm">
|
|
||||||
{new Date(project.startDate).toLocaleDateString('es-ES', { day: '2-digit', month: 'short' })}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">Inicio</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Presupuesto */}
|
||||||
|
<div className="hidden lg:flex items-center gap-2 flex-shrink-0">
|
||||||
|
<DollarSign className="w-4 h-4 text-green-500" />
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-sm font-semibold">
|
||||||
|
€{project.budget.toLocaleString('es-ES', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Presupuesto</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fechas */}
|
||||||
|
<div className="hidden xl:flex items-center gap-2 flex-shrink-0">
|
||||||
|
<Calendar className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-sm">
|
||||||
|
{new Date(project.startDate).toLocaleDateString('es-ES', { day: '2-digit', month: 'short' })}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Inicio</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Menú de acciones */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href={`/proyectos/${project.id}`}>
|
||||||
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
|
Ver detalles
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => onEdit?.(project)}>
|
||||||
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
|
Editar
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
onClick={() => onDelete?.(project)}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Eliminar
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,8 @@ function ErrorState({ onRetry }: { onRetry: () => void }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProjectDetailView({ project }: ProjectDetailViewProps) {
|
export function ProjectDetailView({ project: initialProject }: ProjectDetailViewProps) {
|
||||||
|
const [project, setProject] = useState<Project>(initialProject);
|
||||||
const [tasks, setTasks] = useState<Task[]>([]);
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
const [isLoadingTasks, setIsLoadingTasks] = useState(true);
|
const [isLoadingTasks, setIsLoadingTasks] = useState(true);
|
||||||
const [tasksError, setTasksError] = useState<string | null>(null);
|
const [tasksError, setTasksError] = useState<string | null>(null);
|
||||||
|
|
@ -104,18 +105,38 @@ export function ProjectDetailView({ project }: ProjectDetailViewProps) {
|
||||||
fetchTasks();
|
fetchTasks();
|
||||||
}, [fetchTasks]);
|
}, [fetchTasks]);
|
||||||
|
|
||||||
|
// Handler para cuando se actualiza el proyecto
|
||||||
|
const handleProjectUpdated = (updatedProject: Project) => {
|
||||||
|
setProject(updatedProject);
|
||||||
|
};
|
||||||
|
|
||||||
// Handler para cuando se crea una tarea
|
// Handler para cuando se crea una tarea
|
||||||
const handleTaskCreated = (newTask: Task) => {
|
const handleTaskCreated = (newTask: Task) => {
|
||||||
setTasks(prevTasks => [newTask, ...prevTasks]);
|
setTasks(prevTasks => [newTask, ...prevTasks]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handler para cuando se actualiza una tarea
|
||||||
|
const handleTaskUpdated = (updatedTask: Task) => {
|
||||||
|
setTasks(prevTasks =>
|
||||||
|
prevTasks.map(task => task.id === updatedTask.id ? updatedTask : task)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handler para cuando se elimina una tarea
|
||||||
|
const handleTaskDeleted = (taskId: number) => {
|
||||||
|
setTasks(prevTasks => prevTasks.filter(task => task.id !== taskId));
|
||||||
|
};
|
||||||
|
|
||||||
// Calcular estadísticas de tareas
|
// Calcular estadísticas de tareas
|
||||||
const taskStats = calculateTaskStats(tasks);
|
const taskStats = calculateTaskStats(tasks);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
{/* Header del proyecto */}
|
{/* Header del proyecto */}
|
||||||
<ProjectHeader project={project} />
|
<ProjectHeader
|
||||||
|
project={project}
|
||||||
|
onProjectUpdated={handleProjectUpdated}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Contenido principal */}
|
{/* Contenido principal */}
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
|
|
@ -165,6 +186,8 @@ export function ProjectDetailView({ project }: ProjectDetailViewProps) {
|
||||||
tasks={tasks.slice(0, 5)}
|
tasks={tasks.slice(0, 5)}
|
||||||
projectId={project.id}
|
projectId={project.id}
|
||||||
onTaskCreated={handleTaskCreated}
|
onTaskCreated={handleTaskCreated}
|
||||||
|
onTaskUpdated={handleTaskUpdated}
|
||||||
|
onTaskDeleted={handleTaskDeleted}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -193,6 +216,8 @@ export function ProjectDetailView({ project }: ProjectDetailViewProps) {
|
||||||
tasks={tasks}
|
tasks={tasks}
|
||||||
projectId={project.id}
|
projectId={project.id}
|
||||||
onTaskCreated={handleTaskCreated}
|
onTaskCreated={handleTaskCreated}
|
||||||
|
onTaskUpdated={handleTaskUpdated}
|
||||||
|
onTaskDeleted={handleTaskDeleted}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { ArrowLeft, MoreHorizontal, Pencil, Trash2, Share2, Copy } from "lucide-react";
|
import { ArrowLeft, MoreHorizontal, Pencil, Trash2, Share2, Copy } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
@ -14,10 +15,15 @@ import {
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { ProjectStatusBadge } from "@/components/ui/project-status-badge";
|
import { ProjectStatusBadge } from "@/components/ui/project-status-badge";
|
||||||
|
import { ProjectFormSheet } from "@/components/project-form/project-form-sheet";
|
||||||
|
import { DeleteConfirmationDialog } from "@/components/ui/delete-confirmation-dialog";
|
||||||
|
import { deleteProject } from "@/lib/projectsService";
|
||||||
import { Project } from "@/types/project";
|
import { Project } from "@/types/project";
|
||||||
|
|
||||||
interface ProjectHeaderProps {
|
interface ProjectHeaderProps {
|
||||||
project: Project;
|
project: Project;
|
||||||
|
onProjectUpdated?: (project: Project) => void;
|
||||||
|
onProjectDeleted?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Función para generar iniciales
|
// Función para generar iniciales
|
||||||
|
|
@ -30,16 +36,34 @@ function getInitials(name: string): string {
|
||||||
.slice(0, 2);
|
.slice(0, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ProjectHeader({ project, onProjectUpdated, onProjectDeleted }: ProjectHeaderProps) {
|
||||||
|
|
||||||
export function ProjectHeader({ project }: ProjectHeaderProps) {
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const initials = getInitials(project.name);
|
const initials = getInitials(project.name);
|
||||||
|
const [isEditSheetOpen, setIsEditSheetOpen] = useState(false);
|
||||||
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
const handleCopyRef = () => {
|
const handleCopyRef = () => {
|
||||||
navigator.clipboard.writeText(project.ref);
|
navigator.clipboard.writeText(project.ref);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEditSuccess = (updatedProject: Project) => {
|
||||||
|
onProjectUpdated?.(updatedProject);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmDelete = async () => {
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteProject(project.id);
|
||||||
|
onProjectDeleted?.();
|
||||||
|
router.push('/proyectos');
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting project:", error);
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-b bg-card">
|
<div className="border-b bg-card">
|
||||||
<div className="px-6 py-4">
|
<div className="px-6 py-4">
|
||||||
|
|
@ -103,7 +127,7 @@ export function ProjectHeader({ project }: ProjectHeaderProps) {
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem onClick={() => setIsEditSheetOpen(true)}>
|
||||||
<Pencil className="h-4 w-4 mr-2" />
|
<Pencil className="h-4 w-4 mr-2" />
|
||||||
Editar proyecto
|
Editar proyecto
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
@ -112,7 +136,10 @@ export function ProjectHeader({ project }: ProjectHeaderProps) {
|
||||||
Compartir
|
Compartir
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem className="text-destructive focus:text-destructive">
|
<DropdownMenuItem
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
onClick={() => setIsDeleteDialogOpen(true)}
|
||||||
|
>
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
Eliminar
|
Eliminar
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
@ -121,6 +148,26 @@ export function ProjectHeader({ project }: ProjectHeaderProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Sheet de edición */}
|
||||||
|
<ProjectFormSheet
|
||||||
|
open={isEditSheetOpen}
|
||||||
|
onOpenChange={setIsEditSheetOpen}
|
||||||
|
mode="edit"
|
||||||
|
project={project}
|
||||||
|
onSuccess={handleEditSuccess}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Diálogo de confirmación de eliminación */}
|
||||||
|
<DeleteConfirmationDialog
|
||||||
|
open={isDeleteDialogOpen}
|
||||||
|
onOpenChange={setIsDeleteDialogOpen}
|
||||||
|
onConfirm={handleConfirmDelete}
|
||||||
|
title="¿Eliminar proyecto?"
|
||||||
|
itemName={project.name}
|
||||||
|
description={`Esta acción eliminará el proyecto "${project.name}" y todas sus tareas asociadas. Esta acción no se puede deshacer.`}
|
||||||
|
isDeleting={isDeleting}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -93,172 +93,191 @@ function formatHours(hours: number): string {
|
||||||
return `${hours}h`;
|
return `${hours}h`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const taskColumns: ColumnDef<Task>[] = [
|
// Interfaz para callbacks de acciones
|
||||||
// Checkbox
|
export interface TaskColumnCallbacks {
|
||||||
{
|
onEdit?: (task: Task) => void;
|
||||||
id: "select",
|
onDelete?: (task: Task) => void;
|
||||||
header: ({ table }) => (
|
}
|
||||||
<Checkbox
|
|
||||||
checked={
|
// Función para crear columnas con callbacks opcionales
|
||||||
table.getIsAllPageRowsSelected() ||
|
export function createTaskColumns(callbacks?: TaskColumnCallbacks): ColumnDef<Task>[] {
|
||||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
return [
|
||||||
}
|
// Checkbox
|
||||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
{
|
||||||
aria-label="Seleccionar todo"
|
id: "select",
|
||||||
/>
|
header: ({ table }) => (
|
||||||
),
|
<Checkbox
|
||||||
cell: ({ row }) => (
|
checked={
|
||||||
<Checkbox
|
table.getIsAllPageRowsSelected() ||
|
||||||
checked={row.getIsSelected()}
|
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
}
|
||||||
aria-label="Seleccionar fila"
|
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||||
/>
|
aria-label="Seleccionar todo"
|
||||||
),
|
/>
|
||||||
enableSorting: false,
|
),
|
||||||
enableHiding: false,
|
cell: ({ row }) => (
|
||||||
},
|
<Checkbox
|
||||||
// Título
|
checked={row.getIsSelected()}
|
||||||
{
|
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||||
accessorKey: "title",
|
aria-label="Seleccionar fila"
|
||||||
header: ({ column }) => (
|
/>
|
||||||
<Button
|
),
|
||||||
variant="ghost"
|
enableSorting: false,
|
||||||
size="sm"
|
enableHiding: false,
|
||||||
className="-ml-3 h-8"
|
},
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
// Título
|
||||||
>
|
{
|
||||||
Tarea
|
accessorKey: "title",
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
header: ({ column }) => (
|
||||||
</Button>
|
<Button
|
||||||
),
|
variant="ghost"
|
||||||
cell: ({ row }) => (
|
size="sm"
|
||||||
<div className="max-w-[300px]">
|
className="-ml-3 h-8"
|
||||||
<Link
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
href={`/tareas/${row.original.id}`}
|
|
||||||
className="font-medium truncate hover:text-blue-600 hover:underline transition-colors block"
|
|
||||||
>
|
>
|
||||||
{row.getValue("title")}
|
Tarea
|
||||||
</Link>
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
{row.original.ref && (
|
</Button>
|
||||||
<p className="text-xs text-muted-foreground font-mono">{row.original.ref}</p>
|
),
|
||||||
)}
|
cell: ({ row }) => (
|
||||||
</div>
|
<div className="max-w-[300px]">
|
||||||
),
|
<Link
|
||||||
},
|
href={`/tareas/${row.original.id}`}
|
||||||
// Estado
|
className="font-medium truncate hover:text-blue-600 hover:underline transition-colors block"
|
||||||
{
|
>
|
||||||
accessorKey: "status",
|
{row.getValue("title")}
|
||||||
header: "Estado",
|
</Link>
|
||||||
cell: ({ row }) => <TaskStatusBadge status={row.getValue("status")} />,
|
{row.original.ref && (
|
||||||
filterFn: (row, id, value) => value.includes(row.getValue(id)),
|
<p className="text-xs text-muted-foreground font-mono">{row.original.ref}</p>
|
||||||
},
|
)}
|
||||||
// Prioridad
|
</div>
|
||||||
{
|
),
|
||||||
accessorKey: "priority",
|
|
||||||
header: "Prioridad",
|
|
||||||
cell: ({ row }) => <TaskPriorityBadge priority={row.getValue("priority")} />,
|
|
||||||
filterFn: (row, id, value) => value.includes(row.getValue(id)),
|
|
||||||
},
|
|
||||||
// Progreso
|
|
||||||
{
|
|
||||||
accessorKey: "progress",
|
|
||||||
header: ({ column }) => (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="-ml-3 h-8"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
>
|
|
||||||
Progreso
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => <TaskProgress progress={row.getValue("progress")} />,
|
|
||||||
},
|
|
||||||
// Horas
|
|
||||||
{
|
|
||||||
id: "hours",
|
|
||||||
header: () => (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Clock className="h-3.5 w-3.5" />
|
|
||||||
Horas
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="text-sm">
|
|
||||||
<span className="font-medium">{formatHours(row.original.workedHours)}</span>
|
|
||||||
{row.original.plannedHours > 0 && (
|
|
||||||
<span className="text-muted-foreground"> / {formatHours(row.original.plannedHours)}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
// Fecha límite
|
|
||||||
{
|
|
||||||
accessorKey: "endDate",
|
|
||||||
header: ({ column }) => (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="-ml-3 h-8"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
>
|
|
||||||
Fecha límite
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const endDate = row.original.endDate || row.original.plannedEndDate;
|
|
||||||
const isOverdue = endDate && new Date(endDate) < new Date() && row.original.status !== '2';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className={`text-sm ${isOverdue ? 'text-red-500 font-medium' : 'text-muted-foreground'}`}>
|
|
||||||
{formatDate(endDate)}
|
|
||||||
{isOverdue && <AlertTriangle className="h-3 w-3 ml-1 inline" />}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
},
|
// Estado
|
||||||
// Acciones
|
{
|
||||||
{
|
accessorKey: "status",
|
||||||
id: "actions",
|
header: "Estado",
|
||||||
enableHiding: false,
|
cell: ({ row }) => <TaskStatusBadge status={row.getValue("status")} />,
|
||||||
cell: ({ row }) => {
|
filterFn: (row, id, value) => value.includes(row.getValue(id)),
|
||||||
const task = row.original;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
|
||||||
<span className="sr-only">Abrir menú</span>
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuLabel>Acciones</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link href={`/tareas/${task.id}`} className="cursor-pointer">
|
|
||||||
<Eye className="mr-2 h-4 w-4" />
|
|
||||||
Ver detalles
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<Pencil className="mr-2 h-4 w-4" />
|
|
||||||
Editar
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<Clock className="mr-2 h-4 w-4" />
|
|
||||||
Registrar tiempo
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem className="text-destructive focus:text-destructive">
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
|
||||||
Eliminar
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
},
|
// Prioridad
|
||||||
];
|
{
|
||||||
|
accessorKey: "priority",
|
||||||
|
header: "Prioridad",
|
||||||
|
cell: ({ row }) => <TaskPriorityBadge priority={row.getValue("priority")} />,
|
||||||
|
filterFn: (row, id, value) => value.includes(row.getValue(id)),
|
||||||
|
},
|
||||||
|
// Progreso
|
||||||
|
{
|
||||||
|
accessorKey: "progress",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
Progreso
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => <TaskProgress progress={row.getValue("progress")} />,
|
||||||
|
},
|
||||||
|
// Horas
|
||||||
|
{
|
||||||
|
id: "hours",
|
||||||
|
header: () => (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Clock className="h-3.5 w-3.5" />
|
||||||
|
Horas
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="font-medium">{formatHours(row.original.workedHours)}</span>
|
||||||
|
{row.original.plannedHours > 0 && (
|
||||||
|
<span className="text-muted-foreground"> / {formatHours(row.original.plannedHours)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
// Fecha límite
|
||||||
|
{
|
||||||
|
accessorKey: "endDate",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
Fecha límite
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const endDate = row.original.endDate || row.original.plannedEndDate;
|
||||||
|
const isOverdue = endDate && new Date(endDate) < new Date() && row.original.status !== '2';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`text-sm ${isOverdue ? 'text-red-500 font-medium' : 'text-muted-foreground'}`}>
|
||||||
|
{formatDate(endDate)}
|
||||||
|
{isOverdue && <AlertTriangle className="h-3 w-3 ml-1 inline" />}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Acciones
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
enableHiding: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const task = row.original;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
|
<span className="sr-only">Abrir menú</span>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>Acciones</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href={`/tareas/${task.id}`} className="cursor-pointer">
|
||||||
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
|
Ver detalles
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => callbacks?.onEdit?.(task)}
|
||||||
|
disabled={!callbacks?.onEdit}
|
||||||
|
>
|
||||||
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
|
Editar
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Clock className="mr-2 h-4 w-4" />
|
||||||
|
Registrar tiempo
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
onClick={() => callbacks?.onDelete?.(task)}
|
||||||
|
disabled={!callbacks?.onDelete}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Eliminar
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Columnas por defecto sin callbacks (para compatibilidad)
|
||||||
|
export const taskColumns: ColumnDef<Task>[] = createTaskColumns();
|
||||||
|
|
|
||||||
|
|
@ -25,16 +25,20 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { DeleteConfirmationDialog } from "@/components/ui/delete-confirmation-dialog";
|
||||||
import { DataTable } from "@/components/projects/data-table";
|
import { DataTable } from "@/components/projects/data-table";
|
||||||
import { taskColumns } from "./task-columns";
|
import { createTaskColumns } from "./task-columns";
|
||||||
import { TaskFormSheet } from "@/components/task-form/task-form-sheet";
|
import { TaskFormSheet } from "@/components/task-form/task-form-sheet";
|
||||||
import { Task, TASK_STATUS_CONFIG, TASK_PRIORITY_CONFIG } from "@/types/task";
|
import { Task, TASK_STATUS_CONFIG, TASK_PRIORITY_CONFIG } from "@/types/task";
|
||||||
|
import { deleteTask } from "@/lib/tasksService";
|
||||||
|
|
||||||
interface TasksSectionProps {
|
interface TasksSectionProps {
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
projectId: number;
|
projectId: number;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
onTaskCreated?: (task: Task) => void;
|
onTaskCreated?: (task: Task) => void;
|
||||||
|
onTaskUpdated?: (task: Task) => void;
|
||||||
|
onTaskDeleted?: (taskId: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tarjeta de tarea para vista grid
|
// Tarjeta de tarea para vista grid
|
||||||
|
|
@ -129,18 +133,65 @@ function EmptyTasks({ onCreateClick }: { onCreateClick: () => void }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TasksSection({ tasks, projectId, isLoading, onTaskCreated }: TasksSectionProps) {
|
export function TasksSection({ tasks, projectId, isLoading, onTaskCreated, onTaskUpdated, onTaskDeleted }: TasksSectionProps) {
|
||||||
const [searchValue, setSearchValue] = useState("");
|
const [searchValue, setSearchValue] = useState("");
|
||||||
const [statusFilter, setStatusFilter] = useState("all");
|
const [statusFilter, setStatusFilter] = useState("all");
|
||||||
const [priorityFilter, setPriorityFilter] = useState("all");
|
const [priorityFilter, setPriorityFilter] = useState("all");
|
||||||
const [viewMode, setViewMode] = useState<"table" | "grid">("table");
|
const [viewMode, setViewMode] = useState<"table" | "grid">("table");
|
||||||
const [isCreateSheetOpen, setIsCreateSheetOpen] = useState(false);
|
const [isCreateSheetOpen, setIsCreateSheetOpen] = useState(false);
|
||||||
|
const [isEditSheetOpen, setIsEditSheetOpen] = useState(false);
|
||||||
|
const [taskToEdit, setTaskToEdit] = useState<Task | null>(null);
|
||||||
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
const [taskToDelete, setTaskToDelete] = useState<Task | null>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
// Handler para cuando se crea una tarea exitosamente
|
// Handler para cuando se crea una tarea exitosamente
|
||||||
const handleCreateSuccess = (task: Task) => {
|
const handleCreateSuccess = (task: Task) => {
|
||||||
onTaskCreated?.(task);
|
onTaskCreated?.(task);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handler para cuando se edita una tarea exitosamente
|
||||||
|
const handleEditSuccess = (task: Task) => {
|
||||||
|
onTaskUpdated?.(task);
|
||||||
|
setTaskToEdit(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handler para abrir el sheet de edición
|
||||||
|
const handleEditTask = (task: Task) => {
|
||||||
|
setTaskToEdit(task);
|
||||||
|
setIsEditSheetOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handler para abrir el diálogo de eliminación
|
||||||
|
const handleDeleteTask = (task: Task) => {
|
||||||
|
setTaskToDelete(task);
|
||||||
|
setIsDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handler para confirmar eliminación
|
||||||
|
const handleConfirmDelete = async () => {
|
||||||
|
if (!taskToDelete) return;
|
||||||
|
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteTask(taskToDelete.id);
|
||||||
|
onTaskDeleted?.(taskToDelete.id);
|
||||||
|
setIsDeleteDialogOpen(false);
|
||||||
|
setTaskToDelete(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting task:", error);
|
||||||
|
// TODO: Mostrar toast de error
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Crear columnas con callbacks
|
||||||
|
const columns = useMemo(() => createTaskColumns({
|
||||||
|
onEdit: handleEditTask,
|
||||||
|
onDelete: handleDeleteTask,
|
||||||
|
}), []);
|
||||||
|
|
||||||
// Filtrar tareas
|
// Filtrar tareas
|
||||||
const filteredTasks = useMemo(() => {
|
const filteredTasks = useMemo(() => {
|
||||||
let result = [...tasks];
|
let result = [...tasks];
|
||||||
|
|
@ -310,7 +361,7 @@ export function TasksSection({ tasks, projectId, isLoading, onTaskCreated }: Tas
|
||||||
|
|
||||||
{/* Contenido */}
|
{/* Contenido */}
|
||||||
{viewMode === "table" ? (
|
{viewMode === "table" ? (
|
||||||
<DataTable columns={taskColumns} data={filteredTasks} />
|
<DataTable columns={columns} data={filteredTasks} />
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{filteredTasks.map((task) => (
|
{filteredTasks.map((task) => (
|
||||||
|
|
@ -339,6 +390,32 @@ export function TasksSection({ tasks, projectId, isLoading, onTaskCreated }: Tas
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
onSuccess={handleCreateSuccess}
|
onSuccess={handleCreateSuccess}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Sheet de edición de tarea */}
|
||||||
|
<TaskFormSheet
|
||||||
|
open={isEditSheetOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setIsEditSheetOpen(open);
|
||||||
|
if (!open) setTaskToEdit(null);
|
||||||
|
}}
|
||||||
|
mode="edit"
|
||||||
|
projectId={projectId}
|
||||||
|
task={taskToEdit}
|
||||||
|
onSuccess={handleEditSuccess}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Diálogo de confirmación de eliminación */}
|
||||||
|
<DeleteConfirmationDialog
|
||||||
|
open={isDeleteDialogOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setIsDeleteDialogOpen(open);
|
||||||
|
if (!open) setTaskToDelete(null);
|
||||||
|
}}
|
||||||
|
onConfirm={handleConfirmDelete}
|
||||||
|
title="¿Eliminar tarea?"
|
||||||
|
itemName={taskToDelete?.title}
|
||||||
|
isDeleting={isDeleting}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { ProjectStatusBadge } from "@/components/ui/project-status-badge";
|
import { ProjectStatusBadge } from "@/components/ui/project-status-badge";
|
||||||
import { Project, ProjectStatus } from "@/types/project";
|
import { Project } from "@/types/project";
|
||||||
|
|
||||||
// Componente para la barra de progreso
|
// Componente para la barra de progreso
|
||||||
function ProgressBar({ progress }: { progress: number }) {
|
function ProgressBar({ progress }: { progress: number }) {
|
||||||
|
|
@ -65,198 +65,218 @@ function formatDate(dateString: string): string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const projectColumns: ColumnDef<Project>[] = [
|
// Interfaz para callbacks de acciones
|
||||||
// Columna de selección
|
export interface ProjectColumnCallbacks {
|
||||||
{
|
onEdit?: (project: Project) => void;
|
||||||
id: "select",
|
onDelete?: (project: Project) => void;
|
||||||
header: ({ table }) => (
|
}
|
||||||
<Checkbox
|
|
||||||
checked={
|
|
||||||
table.getIsAllPageRowsSelected() ||
|
|
||||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
|
||||||
}
|
|
||||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
|
||||||
aria-label="Seleccionar todo"
|
|
||||||
className="translate-y-[2px]"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Checkbox
|
|
||||||
checked={row.getIsSelected()}
|
|
||||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
|
||||||
aria-label="Seleccionar fila"
|
|
||||||
className="translate-y-[2px]"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
enableSorting: false,
|
|
||||||
enableHiding: false,
|
|
||||||
},
|
|
||||||
// Referencia
|
|
||||||
{
|
|
||||||
accessorKey: "ref",
|
|
||||||
header: ({ column }) => (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="-ml-3 h-8"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
>
|
|
||||||
Referencia
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="font-mono text-sm text-muted-foreground">
|
|
||||||
{row.getValue("ref")}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
// Nombre del proyecto
|
|
||||||
{
|
|
||||||
accessorKey: "name",
|
|
||||||
header: ({ column }) => (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="-ml-3 h-8"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
>
|
|
||||||
Nombre
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="max-w-[250px]">
|
|
||||||
<Link
|
|
||||||
href={`/proyectos/${row.original.id}`}
|
|
||||||
className="font-medium hover:underline hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
{row.getValue("name")}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
// Cliente
|
|
||||||
{
|
|
||||||
accessorKey: "client",
|
|
||||||
header: "Cliente",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="text-sm">
|
|
||||||
{row.getValue("client")}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
// Estado
|
|
||||||
{
|
|
||||||
accessorKey: "status",
|
|
||||||
header: "Estado",
|
|
||||||
cell: ({ row }) => <ProjectStatusBadge status={row.getValue("status")} />,
|
|
||||||
filterFn: (row, id, value) => {
|
|
||||||
return value.includes(row.getValue(id));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// Progreso
|
|
||||||
{
|
|
||||||
accessorKey: "progress",
|
|
||||||
header: ({ column }) => (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="-ml-3 h-8"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
>
|
|
||||||
Progreso
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => <ProgressBar progress={row.getValue("progress")} />,
|
|
||||||
},
|
|
||||||
// Presupuesto
|
|
||||||
{
|
|
||||||
accessorKey: "budget",
|
|
||||||
header: ({ column }) => (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="-ml-3 h-8"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
>
|
|
||||||
Presupuesto
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="font-medium tabular-nums">
|
|
||||||
{formatCurrency(row.getValue("budget"))}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
// Fecha inicio
|
|
||||||
{
|
|
||||||
accessorKey: "startDate",
|
|
||||||
header: ({ column }) => (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="-ml-3 h-8"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
>
|
|
||||||
Inicio
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{formatDate(row.getValue("startDate"))}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
// Fecha fin
|
|
||||||
{
|
|
||||||
accessorKey: "endDate",
|
|
||||||
header: "Fin",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{formatDate(row.getValue("endDate"))}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
// Acciones
|
|
||||||
{
|
|
||||||
id: "actions",
|
|
||||||
enableHiding: false,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const project = row.original;
|
|
||||||
|
|
||||||
return (
|
// Función para crear columnas con callbacks opcionales
|
||||||
<DropdownMenu>
|
export function createProjectColumns(callbacks?: ProjectColumnCallbacks): ColumnDef<Project>[] {
|
||||||
<DropdownMenuTrigger asChild>
|
return [
|
||||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
// Columna de selección
|
||||||
<span className="sr-only">Abrir menú</span>
|
{
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
id: "select",
|
||||||
</Button>
|
header: ({ table }) => (
|
||||||
</DropdownMenuTrigger>
|
<Checkbox
|
||||||
<DropdownMenuContent align="end">
|
checked={
|
||||||
<DropdownMenuLabel>Acciones</DropdownMenuLabel>
|
table.getIsAllPageRowsSelected() ||
|
||||||
<DropdownMenuSeparator />
|
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||||
<DropdownMenuItem asChild>
|
}
|
||||||
<Link href={`/proyectos/${project.id}`} className="cursor-pointer">
|
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||||
<Eye className="mr-2 h-4 w-4" />
|
aria-label="Seleccionar todo"
|
||||||
Ver detalles
|
className="translate-y-[2px]"
|
||||||
</Link>
|
/>
|
||||||
</DropdownMenuItem>
|
),
|
||||||
<DropdownMenuItem className="cursor-pointer">
|
cell: ({ row }) => (
|
||||||
<Pencil className="mr-2 h-4 w-4" />
|
<Checkbox
|
||||||
Editar
|
checked={row.getIsSelected()}
|
||||||
</DropdownMenuItem>
|
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||||
<DropdownMenuSeparator />
|
aria-label="Seleccionar fila"
|
||||||
<DropdownMenuItem className="cursor-pointer text-destructive focus:text-destructive">
|
className="translate-y-[2px]"
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
/>
|
||||||
Eliminar
|
),
|
||||||
</DropdownMenuItem>
|
enableSorting: false,
|
||||||
</DropdownMenuContent>
|
enableHiding: false,
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
},
|
// Referencia
|
||||||
];
|
{
|
||||||
|
accessorKey: "ref",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
Referencia
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-sm text-muted-foreground">
|
||||||
|
{row.getValue("ref")}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
// Nombre del proyecto
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
Nombre
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="max-w-[250px]">
|
||||||
|
<Link
|
||||||
|
href={`/proyectos/${row.original.id}`}
|
||||||
|
className="font-medium hover:underline hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
{row.getValue("name")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
// Cliente
|
||||||
|
{
|
||||||
|
accessorKey: "client",
|
||||||
|
header: "Cliente",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-sm">
|
||||||
|
{row.getValue("client")}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
// Estado
|
||||||
|
{
|
||||||
|
accessorKey: "status",
|
||||||
|
header: "Estado",
|
||||||
|
cell: ({ row }) => <ProjectStatusBadge status={row.getValue("status")} />,
|
||||||
|
filterFn: (row, id, value) => {
|
||||||
|
return value.includes(row.getValue(id));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Progreso
|
||||||
|
{
|
||||||
|
accessorKey: "progress",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
Progreso
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => <ProgressBar progress={row.getValue("progress")} />,
|
||||||
|
},
|
||||||
|
// Presupuesto
|
||||||
|
{
|
||||||
|
accessorKey: "budget",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
Presupuesto
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-medium tabular-nums">
|
||||||
|
{formatCurrency(row.getValue("budget"))}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
// Fecha inicio
|
||||||
|
{
|
||||||
|
accessorKey: "startDate",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
Inicio
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{formatDate(row.getValue("startDate"))}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
// Fecha fin
|
||||||
|
{
|
||||||
|
accessorKey: "endDate",
|
||||||
|
header: "Fin",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{formatDate(row.getValue("endDate"))}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
// Acciones
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
enableHiding: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const project = row.original;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
|
<span className="sr-only">Abrir menú</span>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>Acciones</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href={`/proyectos/${project.id}`} className="cursor-pointer">
|
||||||
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
|
Ver detalles
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => callbacks?.onEdit?.(project)}
|
||||||
|
disabled={!callbacks?.onEdit}
|
||||||
|
>
|
||||||
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
|
Editar
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer text-destructive focus:text-destructive"
|
||||||
|
onClick={() => callbacks?.onDelete?.(project)}
|
||||||
|
disabled={!callbacks?.onDelete}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Eliminar
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Columnas por defecto sin callbacks (para compatibilidad)
|
||||||
|
export const projectColumns: ColumnDef<Project>[] = createProjectColumns();
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,17 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import { FolderKanban, RefreshCw } from "lucide-react";
|
import { FolderKanban, RefreshCw, Plus } from "lucide-react";
|
||||||
|
|
||||||
import { Project } from "@/types/project";
|
import { Project } from "@/types/project";
|
||||||
import { getProjects } from "@/lib/projectsService";
|
import { getProjects, deleteProject } from "@/lib/projectsService";
|
||||||
import { DataTable } from "./data-table";
|
import { DataTable } from "./data-table";
|
||||||
import { DataTableToolbar } from "./data-table-toolbar";
|
import { DataTableToolbar } from "./data-table-toolbar";
|
||||||
import { DataTableSkeleton } from "./data-table-skeleton";
|
import { DataTableSkeleton } from "./data-table-skeleton";
|
||||||
import { EmptyState } from "./empty-state";
|
import { EmptyState } from "./empty-state";
|
||||||
import { projectColumns } from "./columns";
|
import { createProjectColumns } from "./columns";
|
||||||
|
import { ProjectFormSheet } from "@/components/project-form/project-form-sheet";
|
||||||
|
import { DeleteConfirmationDialog } from "@/components/ui/delete-confirmation-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
export function ProjectsTable() {
|
export function ProjectsTable() {
|
||||||
|
|
@ -21,6 +23,14 @@ export function ProjectsTable() {
|
||||||
const [searchValue, setSearchValue] = useState("");
|
const [searchValue, setSearchValue] = useState("");
|
||||||
const [statusFilter, setStatusFilter] = useState("all");
|
const [statusFilter, setStatusFilter] = useState("all");
|
||||||
|
|
||||||
|
// Estados para crear/editar/eliminar
|
||||||
|
const [isCreateSheetOpen, setIsCreateSheetOpen] = useState(false);
|
||||||
|
const [isEditSheetOpen, setIsEditSheetOpen] = useState(false);
|
||||||
|
const [projectToEdit, setProjectToEdit] = useState<Project | null>(null);
|
||||||
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
const [projectToDelete, setProjectToDelete] = useState<Project | null>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
// Cargar proyectos
|
// Cargar proyectos
|
||||||
const fetchProjects = async () => {
|
const fetchProjects = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
@ -40,6 +50,48 @@ export function ProjectsTable() {
|
||||||
fetchProjects();
|
fetchProjects();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Handlers para CRUD
|
||||||
|
const handleProjectCreated = (newProject: Project) => {
|
||||||
|
setProjects(prev => [newProject, ...prev]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditProject = (project: Project) => {
|
||||||
|
setProjectToEdit(project);
|
||||||
|
setIsEditSheetOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditSuccess = (updatedProject: Project) => {
|
||||||
|
setProjects(prev => prev.map(p => p.id === updatedProject.id ? updatedProject : p));
|
||||||
|
setProjectToEdit(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteProject = (project: Project) => {
|
||||||
|
setProjectToDelete(project);
|
||||||
|
setIsDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmDelete = async () => {
|
||||||
|
if (!projectToDelete) return;
|
||||||
|
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteProject(projectToDelete.id);
|
||||||
|
setProjects(prev => prev.filter(p => p.id !== projectToDelete.id));
|
||||||
|
setIsDeleteDialogOpen(false);
|
||||||
|
setProjectToDelete(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting project:", error);
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Crear columnas con callbacks
|
||||||
|
const columns = useMemo(() => createProjectColumns({
|
||||||
|
onEdit: handleEditProject,
|
||||||
|
onDelete: handleDeleteProject,
|
||||||
|
}), []);
|
||||||
|
|
||||||
// Filtrar proyectos
|
// Filtrar proyectos
|
||||||
const filteredProjects = useMemo(() => {
|
const filteredProjects = useMemo(() => {
|
||||||
let result = [...projects];
|
let result = [...projects];
|
||||||
|
|
@ -97,31 +149,79 @@ export function ProjectsTable() {
|
||||||
// Estado vacío
|
// Estado vacío
|
||||||
if (projects.length === 0) {
|
if (projects.length === 0) {
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<>
|
||||||
title="No hay proyectos"
|
<EmptyState
|
||||||
description="Aún no tienes proyectos creados. Comienza creando tu primer proyecto."
|
title="No hay proyectos"
|
||||||
actionLabel="Crear proyecto"
|
description="Aún no tienes proyectos creados. Comienza creando tu primer proyecto."
|
||||||
onAction={() => console.log("Create project")}
|
actionLabel="Crear proyecto"
|
||||||
/>
|
onAction={() => setIsCreateSheetOpen(true)}
|
||||||
|
/>
|
||||||
|
<ProjectFormSheet
|
||||||
|
open={isCreateSheetOpen}
|
||||||
|
onOpenChange={setIsCreateSheetOpen}
|
||||||
|
mode="create"
|
||||||
|
onSuccess={handleProjectCreated}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<DataTableToolbar
|
<div className="flex items-center justify-between">
|
||||||
searchValue={searchValue}
|
<DataTableToolbar
|
||||||
onSearchChange={setSearchValue}
|
searchValue={searchValue}
|
||||||
statusFilter={statusFilter}
|
onSearchChange={setSearchValue}
|
||||||
onStatusFilterChange={setStatusFilter}
|
statusFilter={statusFilter}
|
||||||
selectedCount={selectedCount}
|
onStatusFilterChange={setStatusFilter}
|
||||||
onClearFilters={handleClearFilters}
|
selectedCount={selectedCount}
|
||||||
/>
|
onClearFilters={handleClearFilters}
|
||||||
|
/>
|
||||||
|
<Button onClick={() => setIsCreateSheetOpen(true)} size="sm">
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Nuevo proyecto
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={projectColumns}
|
columns={columns}
|
||||||
data={filteredProjects}
|
data={filteredProjects}
|
||||||
searchKey="name"
|
searchKey="name"
|
||||||
searchValue={searchValue}
|
searchValue={searchValue}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Sheet de creación */}
|
||||||
|
<ProjectFormSheet
|
||||||
|
open={isCreateSheetOpen}
|
||||||
|
onOpenChange={setIsCreateSheetOpen}
|
||||||
|
mode="create"
|
||||||
|
onSuccess={handleProjectCreated}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Sheet de edición */}
|
||||||
|
<ProjectFormSheet
|
||||||
|
open={isEditSheetOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setIsEditSheetOpen(open);
|
||||||
|
if (!open) setProjectToEdit(null);
|
||||||
|
}}
|
||||||
|
mode="edit"
|
||||||
|
project={projectToEdit}
|
||||||
|
onSuccess={handleEditSuccess}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Diálogo de confirmación de eliminación */}
|
||||||
|
<DeleteConfirmationDialog
|
||||||
|
open={isDeleteDialogOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setIsDeleteDialogOpen(open);
|
||||||
|
if (!open) setProjectToDelete(null);
|
||||||
|
}}
|
||||||
|
onConfirm={handleConfirmDelete}
|
||||||
|
title="¿Eliminar proyecto?"
|
||||||
|
itemName={projectToDelete?.name}
|
||||||
|
description={`Esta acción eliminará el proyecto "${projectToDelete?.name}" y todas sus tareas asociadas. Esta acción no se puede deshacer.`}
|
||||||
|
isDeleting={isDeleting}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1059,7 +1059,7 @@ export default function StatisticsPage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<header className="bg-card border-b sticky top-0 z-10">
|
<header className="bg-card border-b sticky top-0 z-10">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
<div className="px-6 py-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg">
|
<div className="p-2 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg">
|
||||||
<BarChart3 className="w-6 h-6 text-white" />
|
<BarChart3 className="w-6 h-6 text-white" />
|
||||||
|
|
@ -1072,7 +1072,7 @@ export default function StatisticsPage() {
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<main className="px-6 py-8">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<StatisticsSkeleton />
|
<StatisticsSkeleton />
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,9 @@ import {
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { DeleteConfirmationDialog } from "@/components/ui/delete-confirmation-dialog";
|
||||||
import { TaskFormSheet } from "@/components/task-form/task-form-sheet";
|
import { TaskFormSheet } from "@/components/task-form/task-form-sheet";
|
||||||
|
import { deleteTask } from "@/lib/tasksService";
|
||||||
import {
|
import {
|
||||||
Task,
|
Task,
|
||||||
TaskStatus,
|
TaskStatus,
|
||||||
|
|
@ -28,6 +30,7 @@ interface TaskHeaderProps {
|
||||||
task: Task;
|
task: Task;
|
||||||
project: Project | null;
|
project: Project | null;
|
||||||
onTaskUpdated?: (task: Task) => void;
|
onTaskUpdated?: (task: Task) => void;
|
||||||
|
onTaskDeleted?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Badge de estado
|
// Badge de estado
|
||||||
|
|
@ -52,9 +55,11 @@ function TaskPriorityBadge({ priority }: { priority: TaskPriority }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TaskHeader({ task, project, onTaskUpdated }: TaskHeaderProps) {
|
export function TaskHeader({ task, project, onTaskUpdated, onTaskDeleted }: TaskHeaderProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isEditSheetOpen, setIsEditSheetOpen] = useState(false);
|
const [isEditSheetOpen, setIsEditSheetOpen] = useState(false);
|
||||||
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
const handleCopyRef = () => {
|
const handleCopyRef = () => {
|
||||||
navigator.clipboard.writeText(task.ref);
|
navigator.clipboard.writeText(task.ref);
|
||||||
|
|
@ -64,6 +69,25 @@ export function TaskHeader({ task, project, onTaskUpdated }: TaskHeaderProps) {
|
||||||
onTaskUpdated?.(updatedTask);
|
onTaskUpdated?.(updatedTask);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleConfirmDelete = async () => {
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteTask(task.id);
|
||||||
|
onTaskDeleted?.();
|
||||||
|
// Navegar al proyecto o a la lista de proyectos
|
||||||
|
if (project) {
|
||||||
|
router.push(`/proyectos/${project.id}`);
|
||||||
|
} else {
|
||||||
|
router.push('/proyectos');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting task:", error);
|
||||||
|
// TODO: Mostrar toast de error
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-b bg-card">
|
<div className="border-b bg-card">
|
||||||
<div className="px-6 py-4">
|
<div className="px-6 py-4">
|
||||||
|
|
@ -151,7 +175,7 @@ export function TaskHeader({ task, project, onTaskUpdated }: TaskHeaderProps) {
|
||||||
Registrar tiempo
|
Registrar tiempo
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem className="text-destructive focus:text-destructive">
|
<DropdownMenuItem className="text-destructive focus:text-destructive" onClick={() => setIsDeleteDialogOpen(true)}>
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
Eliminar
|
Eliminar
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
@ -170,6 +194,16 @@ export function TaskHeader({ task, project, onTaskUpdated }: TaskHeaderProps) {
|
||||||
task={task}
|
task={task}
|
||||||
onSuccess={handleEditSuccess}
|
onSuccess={handleEditSuccess}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Diálogo de confirmación de eliminación */}
|
||||||
|
<DeleteConfirmationDialog
|
||||||
|
open={isDeleteDialogOpen}
|
||||||
|
onOpenChange={setIsDeleteDialogOpen}
|
||||||
|
onConfirm={handleConfirmDelete}
|
||||||
|
title="¿Eliminar tarea?"
|
||||||
|
itemName={task.title}
|
||||||
|
isDeleting={isDeleting}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
// lib/projectsService.ts
|
||||||
import { dolibarrFetch } from "./dolibarrClient";
|
import { dolibarrFetch } from "./dolibarrClient";
|
||||||
import { DolibarrProject, Project, mapDolibarrProject } from "@/types/project";
|
import {
|
||||||
|
DolibarrProject,
|
||||||
|
Project,
|
||||||
|
mapDolibarrProject,
|
||||||
|
CreateProjectData,
|
||||||
|
UpdateProjectData
|
||||||
|
} from "@/types/project";
|
||||||
|
|
||||||
// Obtener proyectos crudos de Dolibarr
|
// Obtener proyectos crudos de Dolibarr
|
||||||
export async function getDolibarrProjects(): Promise<DolibarrProject[]> {
|
export async function getDolibarrProjects(): Promise<DolibarrProject[]> {
|
||||||
|
|
@ -21,17 +27,143 @@ export async function getProjects(): Promise<Project[]> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Obtener un proyecto específico por ID
|
// Obtener un proyecto específico por ID
|
||||||
export async function getProjectById(id: number): Promise<Project | undefined> {
|
export async function getProjectById(id: number): Promise<Project | null> {
|
||||||
try {
|
try {
|
||||||
const projects = await getProjects();
|
const dolibarrProject = await getDolibarrProjectById(id);
|
||||||
return projects.find(p => p.id === id);
|
return mapDolibarrProject(dolibarrProject);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching project by id:', error);
|
console.error('Error fetching project by id:', error);
|
||||||
throw error;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si necesitas obtener un proyecto específico directamente de Dolibarr
|
// Si necesitas obtener un proyecto específico directamente de Dolibarr
|
||||||
export async function getDolibarrProjectById(id: number): Promise<DolibarrProject> {
|
export async function getDolibarrProjectById(id: number): Promise<DolibarrProject> {
|
||||||
return dolibarrFetch(`projects/${id}`);
|
return dolibarrFetch(`projects/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== FUNCIONES DE ESCRITURA (CREATE/UPDATE/DELETE) ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crear un nuevo proyecto
|
||||||
|
* Endpoint: POST /projects
|
||||||
|
* @returns El proyecto creado con su ID
|
||||||
|
*/
|
||||||
|
export async function createProject(data: CreateProjectData): Promise<Project> {
|
||||||
|
try {
|
||||||
|
// Limpiar datos undefined/null para la API
|
||||||
|
const cleanData = Object.fromEntries(
|
||||||
|
Object.entries(data).filter(([, v]) => v !== undefined && v !== null && v !== '')
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await dolibarrFetch('projects', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(cleanData),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dolibarr devuelve el ID del proyecto creado
|
||||||
|
const projectId = typeof response === 'object' ? response.id || response : response;
|
||||||
|
|
||||||
|
// Obtener el proyecto completo
|
||||||
|
const createdProject = await getProjectById(Number(projectId));
|
||||||
|
if (!createdProject) {
|
||||||
|
throw new Error('No se pudo obtener el proyecto creado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdProject;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating project:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualizar un proyecto existente
|
||||||
|
* Endpoint: PUT /projects/{id}
|
||||||
|
* @returns El proyecto actualizado
|
||||||
|
*/
|
||||||
|
export async function updateProject(projectId: number, data: UpdateProjectData): Promise<Project> {
|
||||||
|
try {
|
||||||
|
// Limpiar datos undefined/null para la API
|
||||||
|
const cleanData = Object.fromEntries(
|
||||||
|
Object.entries(data).filter(([, v]) => v !== undefined && v !== null)
|
||||||
|
);
|
||||||
|
|
||||||
|
await dolibarrFetch(`projects/${projectId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(cleanData),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Obtener el proyecto actualizado
|
||||||
|
const updatedProject = await getProjectById(projectId);
|
||||||
|
if (!updatedProject) {
|
||||||
|
throw new Error('No se pudo obtener el proyecto actualizado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedProject;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating project:', projectId, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eliminar un proyecto
|
||||||
|
* Endpoint: DELETE /projects/{id}
|
||||||
|
*/
|
||||||
|
export async function deleteProject(projectId: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
await dolibarrFetch(`projects/${projectId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting project:', projectId, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validar (cambiar estado a 1 - abierto) un proyecto
|
||||||
|
* Endpoint: POST /projects/{id}/validate
|
||||||
|
*/
|
||||||
|
export async function validateProject(projectId: number): Promise<Project> {
|
||||||
|
try {
|
||||||
|
await dolibarrFetch(`projects/${projectId}/validate`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ notrigger: 0 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const project = await getProjectById(projectId);
|
||||||
|
if (!project) {
|
||||||
|
throw new Error('No se pudo obtener el proyecto validado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return project;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error validating project:', projectId, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cerrar un proyecto (cambiar estado a 2)
|
||||||
|
* Endpoint: POST /projects/{id}/close
|
||||||
|
*/
|
||||||
|
export async function closeProject(projectId: number): Promise<Project> {
|
||||||
|
try {
|
||||||
|
await dolibarrFetch(`projects/${projectId}/close`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ notrigger: 0 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const project = await getProjectById(projectId);
|
||||||
|
if (!project) {
|
||||||
|
throw new Error('No se pudo obtener el proyecto cerrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return project;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error closing project:', projectId, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
"name": "trello_fake",
|
"name": "trello_fake",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
|
@ -17,6 +18,7 @@
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
|
@ -1289,6 +1291,90 @@
|
||||||
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-alert-dialog": {
|
||||||
|
"version": "1.1.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz",
|
||||||
|
"integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-dialog": "1.1.15",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-slot": "1.2.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-context": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-primitive": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "1.2.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-arrow": {
|
"node_modules/@radix-ui/react-arrow": {
|
||||||
"version": "1.1.7",
|
"version": "1.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
|
||||||
|
|
@ -2507,6 +2593,91 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-switch": {
|
||||||
|
"version": "1.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
|
||||||
|
"integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||||
|
"@radix-ui/react-use-previous": "1.1.1",
|
||||||
|
"@radix-ui/react-use-size": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-context": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-primitive": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "1.2.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-tabs": {
|
"node_modules/@radix-ui/react-tabs": {
|
||||||
"version": "1.1.13",
|
"version": "1.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
"diagnose": "node scripts/diagnose.js"
|
"diagnose": "node scripts/diagnose.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
|
@ -22,6 +23,7 @@
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
|
|
||||||
141
types/project.ts
141
types/project.ts
|
|
@ -39,6 +39,7 @@ export interface Project {
|
||||||
description: string;
|
description: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
socid?: number; // ID del tercero/cliente
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configuración de estados - AHORA CON FUNCIÓN QUE RETORNA LAS CLASES COMPLETAS
|
// Configuración de estados - AHORA CON FUNCIÓN QUE RETORNA LAS CLASES COMPLETAS
|
||||||
|
|
@ -96,5 +97,145 @@ export function mapDolibarrProject(dolibarr: DolibarrProject): Project {
|
||||||
description: dolibarr.description || '',
|
description: dolibarr.description || '',
|
||||||
createdAt: timestampToDate(dolibarr.date_c),
|
createdAt: timestampToDate(dolibarr.date_c),
|
||||||
updatedAt: timestampToDate(dolibarr.date_m),
|
updatedAt: timestampToDate(dolibarr.date_m),
|
||||||
|
socid: dolibarr.socid ? (typeof dolibarr.socid === 'string' ? parseInt(dolibarr.socid) : dolibarr.socid) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== TIPOS PARA API DE CREAR/ACTUALIZAR ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Datos para crear un nuevo proyecto
|
||||||
|
* Campos requeridos por Dolibarr: ref, title
|
||||||
|
*/
|
||||||
|
export interface CreateProjectData {
|
||||||
|
ref: string; // Referencia única (requerido)
|
||||||
|
title: string; // Nombre del proyecto (requerido)
|
||||||
|
description?: string;
|
||||||
|
date_start?: number; // timestamp en segundos
|
||||||
|
date_end?: number; // timestamp en segundos
|
||||||
|
opp_amount?: number; // presupuesto
|
||||||
|
opp_percent?: number; // progreso (0-100)
|
||||||
|
budget_amount?: number;
|
||||||
|
socid?: number; // ID del tercero/cliente
|
||||||
|
public?: number; // 0: privado, 1: público
|
||||||
|
usage_opportunity?: number;
|
||||||
|
usage_task?: number;
|
||||||
|
usage_bill_time?: number;
|
||||||
|
usage_organize_event?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Datos para actualizar un proyecto existente
|
||||||
|
* Todos los campos son opcionales
|
||||||
|
*/
|
||||||
|
export interface UpdateProjectData {
|
||||||
|
ref?: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
date_start?: number; // timestamp en segundos
|
||||||
|
date_end?: number; // timestamp en segundos
|
||||||
|
opp_amount?: number; // presupuesto
|
||||||
|
opp_percent?: number; // progreso (0-100)
|
||||||
|
budget_amount?: number;
|
||||||
|
socid?: number; // ID del tercero/cliente
|
||||||
|
status?: ProjectStatus;
|
||||||
|
public?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface para el formulario de proyectos (UI)
|
||||||
|
* Usa formatos amigables
|
||||||
|
*/
|
||||||
|
export interface ProjectFormData {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
status: ProjectStatus;
|
||||||
|
progress: number;
|
||||||
|
budget: number;
|
||||||
|
startDate: string; // YYYY-MM-DD
|
||||||
|
endDate: string; // YYYY-MM-DD
|
||||||
|
socid?: number; // ID del cliente
|
||||||
|
isPublic: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper para convertir fecha string a timestamp (segundos)
|
||||||
|
export function dateStringToTimestamp(dateStr: string | null | undefined): number | undefined {
|
||||||
|
if (!dateStr || dateStr === '') return undefined;
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
if (isNaN(date.getTime())) return undefined;
|
||||||
|
return Math.floor(date.getTime() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper para generar una referencia única para proyectos
|
||||||
|
function generateProjectRef(): string {
|
||||||
|
const timestamp = Date.now().toString(36).toUpperCase();
|
||||||
|
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||||
|
return `PJ-${timestamp}-${random}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convierte datos del formulario UI al formato para crear proyecto en Dolibarr
|
||||||
|
*/
|
||||||
|
export function formDataToCreateProject(formData: ProjectFormData): CreateProjectData {
|
||||||
|
return {
|
||||||
|
ref: generateProjectRef(),
|
||||||
|
title: formData.name,
|
||||||
|
description: formData.description || undefined,
|
||||||
|
date_start: dateStringToTimestamp(formData.startDate),
|
||||||
|
date_end: dateStringToTimestamp(formData.endDate),
|
||||||
|
opp_amount: formData.budget > 0 ? formData.budget : undefined,
|
||||||
|
opp_percent: formData.progress,
|
||||||
|
budget_amount: formData.budget > 0 ? formData.budget : undefined,
|
||||||
|
socid: formData.socid || undefined,
|
||||||
|
public: formData.isPublic ? 1 : 0,
|
||||||
|
usage_task: 1, // Habilitar tareas por defecto
|
||||||
|
usage_opportunity: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convierte datos del formulario UI al formato para actualizar proyecto en Dolibarr
|
||||||
|
*/
|
||||||
|
export function formDataToUpdateProject(formData: Partial<ProjectFormData>): UpdateProjectData {
|
||||||
|
const updateData: UpdateProjectData = {};
|
||||||
|
|
||||||
|
if (formData.name !== undefined) updateData.title = formData.name;
|
||||||
|
if (formData.description !== undefined) updateData.description = formData.description;
|
||||||
|
if (formData.status !== undefined) updateData.status = formData.status;
|
||||||
|
if (formData.progress !== undefined) updateData.opp_percent = formData.progress;
|
||||||
|
if (formData.budget !== undefined) {
|
||||||
|
updateData.opp_amount = formData.budget;
|
||||||
|
updateData.budget_amount = formData.budget;
|
||||||
|
}
|
||||||
|
if (formData.startDate !== undefined) {
|
||||||
|
updateData.date_start = dateStringToTimestamp(formData.startDate);
|
||||||
|
}
|
||||||
|
if (formData.endDate !== undefined) {
|
||||||
|
updateData.date_end = dateStringToTimestamp(formData.endDate);
|
||||||
|
}
|
||||||
|
if (formData.socid !== undefined) {
|
||||||
|
updateData.socid = formData.socid;
|
||||||
|
}
|
||||||
|
if (formData.isPublic !== undefined) {
|
||||||
|
updateData.public = formData.isPublic ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return updateData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convierte un proyecto existente a datos del formulario
|
||||||
|
*/
|
||||||
|
export function projectToFormData(project: Project): ProjectFormData {
|
||||||
|
return {
|
||||||
|
name: project.name,
|
||||||
|
description: project.description || '',
|
||||||
|
status: project.status,
|
||||||
|
progress: project.progress,
|
||||||
|
budget: project.budget,
|
||||||
|
startDate: project.startDate || '',
|
||||||
|
endDate: project.endDate || '',
|
||||||
|
socid: project.socid,
|
||||||
|
isPublic: true, // Por defecto público
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue