diff --git a/app/proyectos/[id]/page.tsx b/app/proyectos/[id]/page.tsx index 32cc14c..8c06bc6 100644 --- a/app/proyectos/[id]/page.tsx +++ b/app/proyectos/[id]/page.tsx @@ -1,19 +1,27 @@ -import { notFound } from 'next/navigation'; -import { getProjectById } from '@/lib/projectsService'; -import ProjectDetail from '@/components/dashboard/project-detail'; +import { notFound } from "next/navigation"; +import { getProjectById } from "@/lib/projectsService"; +import { ProjectDetailView } from "@/components/project-detail"; interface PageProps { - params: { + params: Promise<{ id: string; - }; + }>; } /** * Página de detalle de proyecto * Ruta dinámica: /proyectos/[id] + * + * Muestra información completa del proyecto incluyendo: + * - Header con información principal + * - Estadísticas (progreso, presupuesto, fechas, tareas) + * - Tabs con resumen, tareas y detalles + * - Tabla de tareas con filtros y ordenación */ export default async function ProjectDetailPage({ params }: PageProps) { - const projectId = parseInt(params.id); + // Await params en Next.js 16 + const resolvedParams = await params; + const projectId = parseInt(resolvedParams.id); if (isNaN(projectId)) { notFound(); @@ -25,5 +33,30 @@ export default async function ProjectDetailPage({ params }: PageProps) { notFound(); } - return ; + return ; +} + +// Metadata dinámica para SEO +export async function generateMetadata({ params }: PageProps) { + const resolvedParams = await params; + const projectId = parseInt(resolvedParams.id); + + if (isNaN(projectId)) { + return { title: "Proyecto no encontrado" }; + } + + try { + const project = await getProjectById(projectId); + + if (!project) { + return { title: "Proyecto no encontrado" }; + } + + return { + title: `${project.name} - Proyecto`, + description: project.description || `Detalles del proyecto ${project.ref}`, + }; + } catch { + return { title: "Proyecto" }; + } } diff --git a/app/proyectos/page.tsx b/app/proyectos/page.tsx new file mode 100644 index 0000000..2e5018a --- /dev/null +++ b/app/proyectos/page.tsx @@ -0,0 +1,22 @@ +import { FolderKanban } from "lucide-react"; +import { ProjectsTable } from "@/components/projects/projects-table"; + +export default function ProjectsPage() { + return ( +
+ {/* Header */} +
+
+ +

Proyectos

+
+

+ Gestiona y visualiza todos tus proyectos en un solo lugar. +

+
+ + {/* Data Table */} + +
+ ); +} diff --git a/components/project-detail/index.ts b/components/project-detail/index.ts new file mode 100644 index 0000000..c64eeb6 --- /dev/null +++ b/components/project-detail/index.ts @@ -0,0 +1,6 @@ +export { ProjectHeader } from "./project-header"; +export { ProjectStats } from "./project-stats"; +export { ProjectInfo } from "./project-info"; +export { TasksSection } from "./tasks-section"; +export { ProjectDetailView, ProjectDetailSkeleton } from "./project-detail-view"; +export { taskColumns } from "./task-columns"; diff --git a/components/project-detail/project-detail-view.tsx b/components/project-detail/project-detail-view.tsx new file mode 100644 index 0000000..3815f6e --- /dev/null +++ b/components/project-detail/project-detail-view.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { FolderKanban, RefreshCw, LayoutDashboard, ListTodo, FileText } from "lucide-react"; + +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Card, CardContent } from "@/components/ui/card"; + +import { ProjectHeader } from "./project-header"; +import { ProjectStats } from "./project-stats"; +import { ProjectInfo } from "./project-info"; +import { TasksSection } from "./tasks-section"; + +import { Project } from "@/types/project"; +import { Task } from "@/types/task"; +import { getTasksByProjectId, calculateTaskStats } from "@/lib/tasksService"; + +interface ProjectDetailViewProps { + project: Project; +} + +// Skeleton para loading (exportado para uso externo) +export function ProjectDetailSkeleton() { + return ( +
+ {/* Header skeleton */} +
+ +
+ + +
+
+ + {/* Stats skeleton */} +
+ {Array.from({ length: 4 }).map((_, i) => ( + + + + + + + ))} +
+ + {/* Content skeleton */} +
+
+ + +
+
+ + +
+
+
+ ); +} + +// Estado de error +function ErrorState({ onRetry }: { onRetry: () => void }) { + return ( +
+
+ +
+

Error al cargar las tareas

+

+ No se pudieron cargar las tareas del proyecto. Por favor, intenta de nuevo. +

+ +
+ ); +} + +export function ProjectDetailView({ project }: ProjectDetailViewProps) { + const [tasks, setTasks] = useState([]); + const [isLoadingTasks, setIsLoadingTasks] = useState(true); + const [tasksError, setTasksError] = useState(null); + + // Cargar tareas + const fetchTasks = useCallback(async () => { + setIsLoadingTasks(true); + setTasksError(null); + try { + const projectTasks = await getTasksByProjectId(project.id); + setTasks(projectTasks); + } catch (error) { + console.error("Error fetching tasks:", error); + setTasksError("Error al cargar las tareas"); + } finally { + setIsLoadingTasks(false); + } + }, [project.id]); + + useEffect(() => { + fetchTasks(); + }, [fetchTasks]); + + // Calcular estadísticas de tareas + const taskStats = calculateTaskStats(tasks); + + return ( +
+ {/* Header del proyecto */} + + + {/* Contenido principal */} +
+ {/* Stats cards */} + + + {/* Tabs de contenido */} + + + + + Resumen + + + + Tareas + {tasks.length > 0 && ( + + {tasks.length} + + )} + + + + Detalles + + + + {/* Tab: Resumen */} + + + + {/* Preview de tareas */} + {!isLoadingTasks && tasks.length > 0 && ( + + +
+

+ + Tareas recientes +

+ +
+ +
+
+ )} +
+ + {/* Tab: Tareas */} + + {isLoadingTasks ? ( +
+
+ + + +
+
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+
+ ) : tasksError ? ( + + ) : ( + + )} +
+ + {/* Tab: Detalles */} + + + +
+
+
+ ); +} diff --git a/components/project-detail/project-header.tsx b/components/project-detail/project-header.tsx new file mode 100644 index 0000000..0705359 --- /dev/null +++ b/components/project-detail/project-header.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { ArrowLeft, MoreHorizontal, Pencil, Trash2, Share2, Copy } from "lucide-react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; + +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Project, ProjectStatus } from "@/types/project"; + +interface ProjectHeaderProps { + project: Project; +} + +// Función para generar iniciales +function getInitials(name: string): string { + return name + .split(" ") + .map((word) => word[0]) + .join("") + .toUpperCase() + .slice(0, 2); +} + +// Componente para el badge de estado +function StatusBadge({ status }: { status: ProjectStatus }) { + const statusConfig: Record = { + "0": { + label: "Borrador", + className: "bg-gray-100 text-gray-700 border-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700", + }, + "1": { + label: "Abierto", + className: "bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:border-blue-800", + }, + "2": { + label: "Cerrado", + className: "bg-green-100 text-green-700 border-green-200 dark:bg-green-900/30 dark:text-green-400 dark:border-green-800", + }, + }; + + const config = statusConfig[status] || statusConfig["0"]; + + return ( + + {config.label} + + ); +} + +export function ProjectHeader({ project }: ProjectHeaderProps) { + const router = useRouter(); + const initials = getInitials(project.name); + + const handleCopyRef = () => { + navigator.clipboard.writeText(project.ref); + }; + + return ( +
+
+ {/* Navegación */} +
+ + Proyectos + + / + {project.ref} +
+ + {/* Header principal */} +
+
+ {/* Avatar */} + + + {initials} + + + + {/* Info principal */} +
+
+

{project.name}

+ +
+ +
+ + + {project.client !== "Sin cliente" && ( + <> + | + {project.client} + + )} +
+
+
+ + {/* Acciones */} +
+ + + + + + + + + + Editar proyecto + + + + Compartir + + + + + Eliminar + + + +
+
+
+
+ ); +} diff --git a/components/project-detail/project-info.tsx b/components/project-detail/project-info.tsx new file mode 100644 index 0000000..ad997f8 --- /dev/null +++ b/components/project-detail/project-info.tsx @@ -0,0 +1,256 @@ +import { + FileText, + Building2, + Calendar, + Clock, + Hash, +} from "lucide-react"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { Progress } from "@/components/ui/progress"; +import { Project } from "@/types/project"; + +interface ProjectInfoProps { + project: Project; +} + +// Formatear moneda +function formatCurrency(amount: number): string { + return new Intl.NumberFormat("es-ES", { + style: "currency", + currency: "EUR", + minimumFractionDigits: 2, + }).format(amount); +} + +// Formatear fecha +function formatDate(dateString: string): string { + return new Date(dateString).toLocaleDateString("es-ES", { + day: "numeric", + month: "long", + year: "numeric", + }); +} + +// Calcular duración en días +function getDuration(startDate: string, endDate: string): number { + const start = new Date(startDate); + const end = new Date(endDate); + const diff = end.getTime() - start.getTime(); + return Math.ceil(diff / (1000 * 60 * 60 * 24)); +} + +export function ProjectInfo({ project }: ProjectInfoProps) { + const duration = getDuration(project.startDate, project.endDate); + const budgetUsed = project.budget > 0 ? (project.spent / project.budget) * 100 : 0; + const budgetRemaining = project.budget - project.spent; + + return ( +
+ {/* Columna principal - 2/3 */} +
+ {/* Descripción */} + + + + + Descripción + + + + {project.description ? ( +

+ {project.description} +

+ ) : ( +

+ Sin descripción disponible +

+ )} +
+
+ + {/* Información financiera */} + + + Información Financiera + + + {/* Barra de progreso del presupuesto */} +
+
+ Uso del presupuesto + 90 ? "text-red-500" : ""}`}> + {budgetUsed.toFixed(1)}% + +
+ 100 ? "[&>div]:bg-red-500" : ""}`} + /> +
+ + {/* Desglose */} +
+
+

Presupuesto

+

{formatCurrency(project.budget)}

+
+
+

Gastado

+

+ {formatCurrency(project.spent)} +

+
+
+

Restante

+

+ {formatCurrency(budgetRemaining)} +

+
+
+
+
+ + {/* Timeline */} + + + + + Timeline del Proyecto + + + +
+ {/* Línea de progreso */} +
+
+
+ + {/* Puntos */} +
+
+
+

Inicio

+

{formatDate(project.startDate)}

+
+
+
= 100 ? "bg-green-500" : "bg-gray-300 dark:bg-gray-600"}`} /> +

Fin previsto

+

{formatDate(project.endDate)}

+
+
+
+ +
+
+ + Duración: + {duration} días +
+
+ Progreso: + {project.progress}% +
+
+ + +
+ + {/* Sidebar - 1/3 */} +
+ {/* Detalles generales */} + + + Detalles + + + {/* Cliente */} + {project.client !== "Sin cliente" && ( + <> +
+ +
+

Cliente

+

{project.client}

+
+
+ + + )} + + {/* Referencia */} +
+ +
+

Referencia

+

{project.ref}

+
+
+ + + + {/* ID */} +
+ +
+

ID del proyecto

+

{project.id}

+
+
+
+
+ + {/* Fechas */} + + + Fechas + + +
+ +
+

Fecha de inicio

+

{formatDate(project.startDate)}

+
+
+ + + +
+ +
+

Fecha de fin

+

{formatDate(project.endDate)}

+
+
+ + + +
+ +
+

Creado

+

{formatDate(project.createdAt)}

+
+
+ + + +
+ +
+

Última actualización

+

{formatDate(project.updatedAt)}

+
+
+
+
+
+
+ ); +} diff --git a/components/project-detail/project-stats.tsx b/components/project-detail/project-stats.tsx new file mode 100644 index 0000000..28b3153 --- /dev/null +++ b/components/project-detail/project-stats.tsx @@ -0,0 +1,158 @@ +import { + TrendingUp, + DollarSign, + Calendar, + Clock, + CheckCircle2, + ListTodo, + AlertTriangle +} from "lucide-react"; + +import { Card, CardContent } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { Project } from "@/types/project"; + +interface ProjectStatsProps { + project: Project; + taskStats: { + total: number; + completed: number; + inProgress: number; + highPriority: number; + overdue: number; + completionRate: number; + totalPlannedHours: number; + totalWorkedHours: number; + }; +} + +// Formatear moneda +function formatCurrency(amount: number): string { + return new Intl.NumberFormat("es-ES", { + style: "currency", + currency: "EUR", + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(amount); +} + +// Formatear fecha +function formatDate(dateString: string): string { + return new Date(dateString).toLocaleDateString("es-ES", { + day: "numeric", + month: "short", + }); +} + +// Calcular días restantes +function getDaysRemaining(endDate: string): number { + const end = new Date(endDate); + const today = new Date(); + const diff = end.getTime() - today.getTime(); + return Math.ceil(diff / (1000 * 60 * 60 * 24)); +} + +export function ProjectStats({ project, taskStats }: ProjectStatsProps) { + const daysRemaining = getDaysRemaining(project.endDate); + const isOverdue = daysRemaining < 0; + const budgetUsedPercent = project.budget > 0 ? (project.spent / project.budget) * 100 : 0; + + return ( +
+ {/* Progreso */} + + +
+
+ +
+
+

Progreso

+

{project.progress}%

+
+
+ +
+
+ + {/* Presupuesto */} + + +
+
+ +
+
+

Presupuesto

+

{formatCurrency(project.budget)}

+
+
+
+ + Usado: {formatCurrency(project.spent)} + + 90 ? "text-red-500" : "text-muted-foreground"}> + {budgetUsedPercent.toFixed(0)}% + +
+
+
+ + {/* Fecha límite */} + + +
+
+ +
+
+

Fecha límite

+

{formatDate(project.endDate)}

+
+
+

+ {isOverdue + ? `${Math.abs(daysRemaining)} días de retraso` + : `${daysRemaining} días restantes` + } +

+
+
+ + {/* Tareas */} + + +
+
+ +
+
+

Tareas

+

{taskStats.completed}/{taskStats.total}

+
+
+
+ {taskStats.highPriority > 0 && ( + + + {taskStats.highPriority} alta prioridad + + )} + {taskStats.overdue > 0 && ( + + + {taskStats.overdue} vencidas + + )} + {taskStats.highPriority === 0 && taskStats.overdue === 0 && ( + + + Todo en orden + + )} +
+
+
+
+ ); +} diff --git a/components/project-detail/task-columns.tsx b/components/project-detail/task-columns.tsx new file mode 100644 index 0000000..5cb500e --- /dev/null +++ b/components/project-detail/task-columns.tsx @@ -0,0 +1,254 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { + ArrowUpDown, + MoreHorizontal, + Eye, + Pencil, + Trash2, + Clock, + AlertTriangle, + CheckCircle2 +} from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Progress } from "@/components/ui/progress"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Task, + TaskStatus, + TaskPriority, + TASK_STATUS_CONFIG, + TASK_PRIORITY_CONFIG +} from "@/types/task"; + +// Badge de estado +function TaskStatusBadge({ status }: { status: TaskStatus }) { + const config = TASK_STATUS_CONFIG[status]; + + return ( + + {status === '2' && } + {config.label} + + ); +} + +// Badge de prioridad +function TaskPriorityBadge({ priority }: { priority: TaskPriority }) { + const config = TASK_PRIORITY_CONFIG[priority]; + + if (priority === '0') { + return null; + } + + return ( + + {priority === '3' && } + {config.label} + + ); +} + +// Barra de progreso compacta +function TaskProgress({ progress }: { progress: number }) { + const getColorClass = (value: number) => { + if (value >= 100) return '[&>div]:bg-green-500'; + if (value >= 75) return '[&>div]:bg-blue-500'; + if (value >= 50) return '[&>div]:bg-yellow-500'; + return '[&>div]:bg-gray-400'; + }; + + return ( +
+ + {progress}% +
+ ); +} + +// Formatear fecha +function formatDate(dateString: string | null): string { + if (!dateString) return '-'; + return new Date(dateString).toLocaleDateString('es-ES', { + day: '2-digit', + month: 'short', + }); +} + +// Formatear horas +function formatHours(hours: number): string { + if (hours === 0) return '-'; + return `${hours}h`; +} + +export const taskColumns: ColumnDef[] = [ + // Checkbox + { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Seleccionar todo" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Seleccionar fila" + /> + ), + enableSorting: false, + enableHiding: false, + }, + // Título + { + accessorKey: "title", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+

{row.getValue("title")}

+ {row.original.ref && ( +

{row.original.ref}

+ )} +
+ ), + }, + // Estado + { + accessorKey: "status", + header: "Estado", + cell: ({ row }) => , + filterFn: (row, id, value) => value.includes(row.getValue(id)), + }, + // Prioridad + { + accessorKey: "priority", + header: "Prioridad", + cell: ({ row }) => , + filterFn: (row, id, value) => value.includes(row.getValue(id)), + }, + // Progreso + { + accessorKey: "progress", + header: ({ column }) => ( + + ), + cell: ({ row }) => , + }, + // Horas + { + id: "hours", + header: () => ( +
+ + Horas +
+ ), + cell: ({ row }) => ( +
+ {formatHours(row.original.workedHours)} + {row.original.plannedHours > 0 && ( + / {formatHours(row.original.plannedHours)} + )} +
+ ), + }, + // Fecha límite + { + accessorKey: "endDate", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const endDate = row.original.endDate || row.original.plannedEndDate; + const isOverdue = endDate && new Date(endDate) < new Date() && row.original.status !== '2'; + + return ( + + {formatDate(endDate)} + {isOverdue && } + + ); + }, + }, + // Acciones + { + id: "actions", + enableHiding: false, + cell: ({ row }) => { + return ( + + + + + + Acciones + + + + Ver detalles + + + + Editar + + + + Registrar tiempo + + + + + Eliminar + + + + ); + }, + }, +]; diff --git a/components/project-detail/tasks-section.tsx b/components/project-detail/tasks-section.tsx new file mode 100644 index 0000000..d3b39bf --- /dev/null +++ b/components/project-detail/tasks-section.tsx @@ -0,0 +1,312 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { + Search, + X, + Plus, + ListTodo, + CheckCircle2, + Clock, + AlertTriangle, + LayoutGrid, + List +} from "lucide-react"; + +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { DataTable } from "@/components/projects/data-table"; +import { taskColumns } from "./task-columns"; +import { Task, TASK_STATUS_CONFIG, TASK_PRIORITY_CONFIG } from "@/types/task"; + +interface TasksSectionProps { + tasks: Task[]; + isLoading?: boolean; +} + +// Tarjeta de tarea para vista grid +function TaskCard({ task }: { task: Task }) { + const statusConfig = TASK_STATUS_CONFIG[task.status]; + const priorityConfig = TASK_PRIORITY_CONFIG[task.priority]; + + const isOverdue = (() => { + const endDate = task.endDate || task.plannedEndDate; + return endDate && new Date(endDate) < new Date() && task.status !== '2'; + })(); + + return ( + + +
+ {/* Header */} +
+
+

{task.title}

+ {task.ref && ( +

{task.ref}

+ )} +
+ {task.priority !== '0' && ( + + {task.priority === '3' && } + {priorityConfig.label} + + )} +
+ + {/* Progreso */} +
+
+ Progreso + {task.progress}% +
+
+
= 100 ? 'bg-green-500' : + task.progress >= 50 ? 'bg-blue-500' : 'bg-gray-400' + }`} + style={{ width: `${task.progress}%` }} + /> +
+
+ + {/* Footer */} +
+ + {task.status === '2' && } + {statusConfig.label} + + + {(task.endDate || task.plannedEndDate) && ( + + + {new Date(task.endDate || task.plannedEndDate!).toLocaleDateString('es-ES', { + day: '2-digit', + month: 'short', + })} + {isOverdue && } + + )} +
+
+ + + ); +} + +// Estado vacío +function EmptyTasks() { + return ( +
+
+ +
+

Sin tareas

+

+ Este proyecto aún no tiene tareas asignadas. Crea la primera tarea para comenzar. +

+ +
+ ); +} + +export function TasksSection({ tasks, isLoading }: TasksSectionProps) { + const [searchValue, setSearchValue] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + const [priorityFilter, setPriorityFilter] = useState("all"); + const [viewMode, setViewMode] = useState<"table" | "grid">("table"); + + // Filtrar tareas + const filteredTasks = useMemo(() => { + let result = [...tasks]; + + // Búsqueda + if (searchValue) { + const search = searchValue.toLowerCase(); + result = result.filter( + (task) => + task.title.toLowerCase().includes(search) || + task.ref?.toLowerCase().includes(search) || + task.description?.toLowerCase().includes(search) + ); + } + + // Filtro de estado + if (statusFilter !== "all") { + result = result.filter((task) => task.status === statusFilter); + } + + // Filtro de prioridad + if (priorityFilter !== "all") { + result = result.filter((task) => task.priority === priorityFilter); + } + + return result; + }, [tasks, searchValue, statusFilter, priorityFilter]); + + // Estadísticas rápidas + const stats = useMemo(() => ({ + total: tasks.length, + completed: tasks.filter(t => t.status === '2').length, + inProgress: tasks.filter(t => t.status === '1').length, + highPriority: tasks.filter(t => t.priority === '3').length, + }), [tasks]); + + // Limpiar filtros + const handleClearFilters = () => { + setSearchValue(""); + setStatusFilter("all"); + setPriorityFilter("all"); + }; + + const isFiltered = searchValue !== "" || statusFilter !== "all" || priorityFilter !== "all"; + + if (tasks.length === 0 && !isLoading) { + return ; + } + + return ( +
+ {/* Stats rápidos */} +
+
+ + Total: + {stats.total} +
+
+ + Completadas: + {stats.completed} +
+
+ + En progreso: + {stats.inProgress} +
+ {stats.highPriority > 0 && ( +
+ + Alta prioridad: + {stats.highPriority} +
+ )} +
+ + {/* Toolbar */} +
+
+ {/* Búsqueda */} +
+ + setSearchValue(e.target.value)} + className="pl-8 h-9" + /> +
+ + {/* Filtro estado */} + + + {/* Filtro prioridad */} + + + {/* Limpiar */} + {isFiltered && ( + + )} +
+ +
+ {/* Toggle vista */} +
+ + +
+ + {/* Nueva tarea */} + +
+
+ + {/* Contenido */} + {viewMode === "table" ? ( + + ) : ( +
+ {filteredTasks.map((task) => ( + + ))} +
+ )} + + {/* Mensaje sin resultados */} + {filteredTasks.length === 0 && tasks.length > 0 && ( +
+

+ No se encontraron tareas con los filtros aplicados. +

+ +
+ )} +
+ ); +} diff --git a/components/projects/columns.tsx b/components/projects/columns.tsx new file mode 100644 index 0000000..bc3b428 --- /dev/null +++ b/components/projects/columns.tsx @@ -0,0 +1,279 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { ArrowUpDown, MoreHorizontal, Eye, Pencil, Trash2 } from "lucide-react"; +import Link from "next/link"; + +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Project, ProjectStatus, STATUS_CONFIG } from "@/types/project"; + +// Componente para el badge de estado +function StatusBadge({ status }: { status: ProjectStatus }) { + const config = STATUS_CONFIG[status]; + + const colorClasses: Record = { + '0': 'bg-gray-100 text-gray-700 hover:bg-gray-100/80 dark:bg-gray-800 dark:text-gray-300', + '1': 'bg-blue-100 text-blue-700 hover:bg-blue-100/80 dark:bg-blue-900/30 dark:text-blue-400', + '2': 'bg-green-100 text-green-700 hover:bg-green-100/80 dark:bg-green-900/30 dark:text-green-400', + }; + + return ( + + {config?.label || 'Desconocido'} + + ); +} + +// Componente para la barra de progreso +function ProgressBar({ progress }: { progress: number }) { + const getProgressColor = (value: number) => { + if (value >= 75) return 'bg-green-500'; + if (value >= 50) return 'bg-blue-500'; + if (value >= 25) return 'bg-yellow-500'; + return 'bg-gray-400'; + }; + + return ( +
+
+
+
+ + {progress.toFixed(0)}% + +
+ ); +} + +// Formateador de moneda +function formatCurrency(amount: number): string { + return new Intl.NumberFormat('es-ES', { + style: 'currency', + currency: 'EUR', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(amount); +} + +// Formateador de fecha +function formatDate(dateString: string): string { + if (!dateString) return '-'; + try { + return new Date(dateString).toLocaleDateString('es-ES', { + day: '2-digit', + month: 'short', + year: 'numeric', + }); + } catch { + return '-'; + } +} + +export const projectColumns: ColumnDef[] = [ + // Columna de selección + { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Seleccionar todo" + className="translate-y-[2px]" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Seleccionar fila" + className="translate-y-[2px]" + /> + ), + enableSorting: false, + enableHiding: false, + }, + // Referencia + { + accessorKey: "ref", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.getValue("ref")} + + ), + }, + // Nombre del proyecto + { + accessorKey: "name", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ + {row.getValue("name")} + +
+ ), + }, + // Cliente + { + accessorKey: "client", + header: "Cliente", + cell: ({ row }) => ( + + {row.getValue("client")} + + ), + }, + // Estado + { + accessorKey: "status", + header: "Estado", + cell: ({ row }) => , + filterFn: (row, id, value) => { + return value.includes(row.getValue(id)); + }, + }, + // Progreso + { + accessorKey: "progress", + header: ({ column }) => ( + + ), + cell: ({ row }) => , + }, + // Presupuesto + { + accessorKey: "budget", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {formatCurrency(row.getValue("budget"))} + + ), + }, + // Fecha inicio + { + accessorKey: "startDate", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {formatDate(row.getValue("startDate"))} + + ), + }, + // Fecha fin + { + accessorKey: "endDate", + header: "Fin", + cell: ({ row }) => ( + + {formatDate(row.getValue("endDate"))} + + ), + }, + // Acciones + { + id: "actions", + enableHiding: false, + cell: ({ row }) => { + const project = row.original; + + return ( + + + + + + Acciones + + + + + Ver detalles + + + + + Editar + + + + + Eliminar + + + + ); + }, + }, +]; diff --git a/components/projects/data-table-skeleton.tsx b/components/projects/data-table-skeleton.tsx new file mode 100644 index 0000000..ba98913 --- /dev/null +++ b/components/projects/data-table-skeleton.tsx @@ -0,0 +1,120 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +export function DataTableSkeleton() { + return ( +
+ {/* Toolbar skeleton */} +
+
+ + +
+
+ + + +
+
+ + {/* Table skeleton */} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {Array.from({ length: 10 }).map((_, index) => ( + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + + + + + + + + + + +
+ ))} +
+
+
+ + {/* Pagination skeleton */} +
+ +
+ + +
+ + + + +
+
+
+
+ ); +} diff --git a/components/projects/data-table-toolbar.tsx b/components/projects/data-table-toolbar.tsx new file mode 100644 index 0000000..c4cefba --- /dev/null +++ b/components/projects/data-table-toolbar.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { Search, X, SlidersHorizontal, Download, Plus } from "lucide-react"; + +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { ProjectStatus, STATUS_CONFIG } from "@/types/project"; + +interface DataTableToolbarProps { + searchValue: string; + onSearchChange: (value: string) => void; + statusFilter: string; + onStatusFilterChange: (value: string) => void; + selectedCount: number; + onClearFilters: () => void; +} + +export function DataTableToolbar({ + searchValue, + onSearchChange, + statusFilter, + onStatusFilterChange, + selectedCount, + onClearFilters, +}: DataTableToolbarProps) { + const isFiltered = searchValue !== "" || statusFilter !== "all"; + + return ( +
+
+ {/* Búsqueda */} +
+ + onSearchChange(e.target.value)} + className="pl-8" + /> +
+ + {/* Filtro por estado */} + + + {/* Botón limpiar filtros */} + {isFiltered && ( + + )} +
+ +
+ {/* Contador de seleccionados */} + {selectedCount > 0 && ( + + {selectedCount} seleccionado(s) + + )} + + {/* Opciones de vista */} + + + + + + Columnas visibles + + + Referencia + + + Nombre + + + Cliente + + + Estado + + + Progreso + + + Presupuesto + + + Fechas + + + + + {/* Exportar */} + + + {/* Nuevo proyecto */} + +
+
+ ); +} diff --git a/components/projects/data-table.tsx b/components/projects/data-table.tsx new file mode 100644 index 0000000..533ea76 --- /dev/null +++ b/components/projects/data-table.tsx @@ -0,0 +1,204 @@ +"use client"; + +import * as React from "react"; +import { + ColumnDef, + ColumnFiltersState, + SortingState, + VisibilityState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react"; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; + searchKey?: string; + searchValue?: string; +} + +export function DataTable({ + columns, + data, + searchKey, + searchValue, +}: DataTableProps) { + const [sorting, setSorting] = React.useState([]); + const [columnFilters, setColumnFilters] = React.useState([]); + const [columnVisibility, setColumnVisibility] = React.useState({}); + const [rowSelection, setRowSelection] = React.useState({}); + + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + onColumnVisibilityChange: setColumnVisibility, + onRowSelectionChange: setRowSelection, + state: { + sorting, + columnFilters, + columnVisibility, + rowSelection, + }, + }); + + // Aplicar filtro de búsqueda externo + React.useEffect(() => { + if (searchKey && searchValue !== undefined) { + table.getColumn(searchKey)?.setFilterValue(searchValue); + } + }, [searchKey, searchValue, table]); + + return ( +
+ {/* Tabla */} +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + No se encontraron resultados. + + + )} + +
+
+ + {/* Paginación */} +
+
+ {table.getFilteredSelectedRowModel().rows.length} de{" "} + {table.getFilteredRowModel().rows.length} fila(s) seleccionada(s). +
+
+
+

Filas por página

+ +
+
+ Página {table.getState().pagination.pageIndex + 1} de{" "} + {table.getPageCount()} +
+
+ + + + +
+
+
+
+ ); +} diff --git a/components/projects/empty-state.tsx b/components/projects/empty-state.tsx new file mode 100644 index 0000000..74a8731 --- /dev/null +++ b/components/projects/empty-state.tsx @@ -0,0 +1,34 @@ +import { FolderOpen, Plus } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface EmptyStateProps { + title?: string; + description?: string; + actionLabel?: string; + onAction?: () => void; +} + +export function EmptyState({ + title = "No hay proyectos", + description = "Parece que aún no tienes ningún proyecto. Crea uno nuevo para empezar.", + actionLabel = "Crear proyecto", + onAction, +}: EmptyStateProps) { + return ( +
+
+ +
+

{title}

+

+ {description} +

+ {onAction && ( + + )} +
+ ); +} diff --git a/components/projects/index.ts b/components/projects/index.ts new file mode 100644 index 0000000..26a94f0 --- /dev/null +++ b/components/projects/index.ts @@ -0,0 +1,6 @@ +export { DataTable } from "./data-table"; +export { DataTableToolbar } from "./data-table-toolbar"; +export { DataTableSkeleton } from "./data-table-skeleton"; +export { EmptyState } from "./empty-state"; +export { ProjectsTable } from "./projects-table"; +export { projectColumns } from "./columns"; diff --git a/components/projects/projects-table.tsx b/components/projects/projects-table.tsx new file mode 100644 index 0000000..566f939 --- /dev/null +++ b/components/projects/projects-table.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { useState, useEffect, useMemo } from "react"; +import { FolderKanban, RefreshCw } from "lucide-react"; + +import { Project } from "@/types/project"; +import { getProjects } from "@/lib/projectsService"; +import { DataTable } from "./data-table"; +import { DataTableToolbar } from "./data-table-toolbar"; +import { DataTableSkeleton } from "./data-table-skeleton"; +import { EmptyState } from "./empty-state"; +import { projectColumns } from "./columns"; +import { Button } from "@/components/ui/button"; + +export function ProjectsTable() { + const [projects, setProjects] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Filtros + const [searchValue, setSearchValue] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + + // Cargar proyectos + const fetchProjects = async () => { + setIsLoading(true); + setError(null); + try { + const data = await getProjects(); + setProjects(data); + } catch (err) { + console.error("Error fetching projects:", err); + setError("Error al cargar los proyectos. Por favor, intenta de nuevo."); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchProjects(); + }, []); + + // Filtrar proyectos + const filteredProjects = useMemo(() => { + let result = [...projects]; + + // Filtro de búsqueda (nombre o referencia) + if (searchValue) { + const search = searchValue.toLowerCase(); + result = result.filter( + (project) => + project.name.toLowerCase().includes(search) || + project.ref.toLowerCase().includes(search) || + project.client.toLowerCase().includes(search) + ); + } + + // Filtro de estado + if (statusFilter !== "all") { + result = result.filter((project) => project.status === statusFilter); + } + + return result; + }, [projects, searchValue, statusFilter]); + + // Limpiar filtros + const handleClearFilters = () => { + setSearchValue(""); + setStatusFilter("all"); + }; + + // Contar seleccionados (placeholder - el DataTable lo maneja internamente) + const selectedCount = 0; + + // Estado de error + if (error) { + return ( +
+
+ +
+

Error al cargar

+

{error}

+ +
+ ); + } + + // Estado de carga + if (isLoading) { + return ; + } + + // Estado vacío + if (projects.length === 0) { + return ( + console.log("Create project")} + /> + ); + } + + return ( +
+ + +
+ ); +} diff --git a/components/ui/checkbox.tsx b/components/ui/checkbox.tsx new file mode 100644 index 0000000..c6fdd07 --- /dev/null +++ b/components/ui/checkbox.tsx @@ -0,0 +1,30 @@ +"use client" + +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { Check } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/components/ui/progress.tsx b/components/ui/progress.tsx new file mode 100644 index 0000000..4fc3b47 --- /dev/null +++ b/components/ui/progress.tsx @@ -0,0 +1,28 @@ +"use client" + +import * as React from "react" +import * as ProgressPrimitive from "@radix-ui/react-progress" + +import { cn } from "@/lib/utils" + +const Progress = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, value, ...props }, ref) => ( + + + +)) +Progress.displayName = ProgressPrimitive.Root.displayName + +export { Progress } diff --git a/components/ui/select.tsx b/components/ui/select.tsx index a45647c..3a5c29f 100644 --- a/components/ui/select.tsx +++ b/components/ui/select.tsx @@ -77,7 +77,7 @@ const SelectContent = React.forwardRef< className={cn( "relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]", position === "popper" && - "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", + "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )} position={position} @@ -88,7 +88,7 @@ const SelectContent = React.forwardRef< className={cn( "p-1", position === "popper" && - "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]" + "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]" )} > {children} diff --git a/components/ui/table.tsx b/components/ui/table.tsx new file mode 100644 index 0000000..c0df655 --- /dev/null +++ b/components/ui/table.tsx @@ -0,0 +1,120 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+ + +)) +Table.displayName = "Table" + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableHeader.displayName = "TableHeader" + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableBody.displayName = "TableBody" + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0", + className + )} + {...props} + /> +)) +TableFooter.displayName = "TableFooter" + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableRow.displayName = "TableRow" + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
[role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> +)) +TableHead.displayName = "TableHead" + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + [role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> +)) +TableCell.displayName = "TableCell" + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableCaption.displayName = "TableCaption" + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/lib/dolibarrClient.ts b/lib/dolibarrClient.ts index 92ea63e..0edb452 100644 --- a/lib/dolibarrClient.ts +++ b/lib/dolibarrClient.ts @@ -1,10 +1,50 @@ /** * Cliente para la API de Dolibarr - * Ahora usa la API Route de Next.js (/api/dolibarr) en lugar de llamar directamente - * Esto mantiene la API key segura en el servidor + * + * - En el CLIENTE: usa la API Route de Next.js (/api/dolibarr) para mantener la API key segura + * - En el SERVIDOR: llama directamente a Dolibarr con las credenciales del entorno */ -export async function dolibarrFetch(endpoint: string, options: RequestInit = {}) { - // Usar la API Route en lugar de llamar directamente a Dolibarr + +// Detectar si estamos en el servidor o cliente +const isServer = typeof window === 'undefined'; + +/** + * Fetch directo a Dolibarr (usado en el servidor) + */ +async function dolibarrDirectFetch(endpoint: string, options: RequestInit = {}) { + 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) { + throw new Error('Dolibarr configuration missing (DOLIBARR_API_URL or DOLIBARR_API_KEY)'); + } + + const url = `${apiUrl}/${endpoint}?DOLAPIKEY=${apiKey}`; + + const res = await fetch(url, { + ...options, + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + ...options.headers, + }, + // Cache durante 60 segundos en servidor + next: { revalidate: 60 } + }); + + if (!res.ok) { + const errorText = await res.text(); + console.error("Error en la llamada directa a Dolibarr:", res.status, errorText); + throw new Error(`Dolibarr API error: ${res.status}`); + } + + return res.json(); +} + +/** + * Fetch via API Route (usado en el cliente) + */ +async function dolibarrProxyFetch(endpoint: string, options: RequestInit = {}) { const url = `/api/dolibarr/${endpoint}`; const res = await fetch(url, { @@ -17,9 +57,35 @@ export async function dolibarrFetch(endpoint: string, options: RequestInit = {}) if (!res.ok) { const errorData = await res.json().catch(() => ({ error: 'Unknown error' })); - console.error("Error en la llamada Dolibarr:", res.status, errorData); + console.error("Error en la llamada Dolibarr (proxy):", res.status, errorData); throw new Error(errorData.error || "Dolibarr API error"); } return res.json(); -} \ No newline at end of file +} + +/** + * Cliente principal de Dolibarr + * Automáticamente detecta el entorno y usa el método apropiado + */ +export async function dolibarrFetch(endpoint: string, options: RequestInit = {}) { + if (isServer) { + return dolibarrDirectFetch(endpoint, options); + } else { + return dolibarrProxyFetch(endpoint, options); + } +} + +/** + * Forzar fetch directo (útil para Server Components) + */ +export async function dolibarrServerFetch(endpoint: string, options: RequestInit = {}) { + return dolibarrDirectFetch(endpoint, options); +} + +/** + * Forzar fetch via proxy (útil para Client Components) + */ +export async function dolibarrClientFetch(endpoint: string, options: RequestInit = {}) { + return dolibarrProxyFetch(endpoint, options); +} diff --git a/lib/tasksService.ts b/lib/tasksService.ts new file mode 100644 index 0000000..c7ab92e --- /dev/null +++ b/lib/tasksService.ts @@ -0,0 +1,118 @@ +// lib/tasksService.ts +import { dolibarrFetch } from "./dolibarrClient"; +import { DolibarrTask, Task, mapDolibarrTask } from "@/types/task"; + +/** + * Obtener todas las tareas de un proyecto específico + * + * Nota: El endpoint /projects/{id}/tasks de Dolibarr no devuelve tareas correctamente, + * por lo que obtenemos todas las tareas y filtramos por fk_project en el cliente. + */ +export async function getTasksByProjectId(projectId: number): Promise { + try { + // Obtener todas las tareas (Dolibarr no filtra bien por proyecto) + const dolibarrTasks: DolibarrTask[] = await dolibarrFetch('tasks'); + + // Si no hay tareas, devolver array vacío + if (!dolibarrTasks || !Array.isArray(dolibarrTasks)) { + return []; + } + + // Filtrar tareas que pertenecen a este proyecto + const projectTasks = dolibarrTasks.filter( + task => String(task.fk_project) === String(projectId) + ); + + // Mapear las tareas al formato de la UI + return projectTasks.map(mapDolibarrTask); + } catch (error) { + console.error('Error fetching tasks for project:', projectId, error); + // Si el error es 404 (no hay tareas), devolver array vacío + if (error instanceof Error && error.message.includes('404')) { + return []; + } + throw error; + } +} + +/** + * Obtener todas las tareas (sin filtro de proyecto) + * Endpoint: /tasks + */ +export async function getAllTasks(): Promise { + try { + const dolibarrTasks: DolibarrTask[] = await dolibarrFetch('tasks'); + + if (!dolibarrTasks || !Array.isArray(dolibarrTasks)) { + return []; + } + + return dolibarrTasks.map(mapDolibarrTask); + } catch (error) { + console.error('Error fetching all tasks:', error); + throw error; + } +} + +/** + * Obtener una tarea específica por ID + * Endpoint: /tasks/{id} + */ +export async function getTaskById(taskId: number): Promise { + try { + const dolibarrTask: DolibarrTask = await dolibarrFetch(`tasks/${taskId}`); + return mapDolibarrTask(dolibarrTask); + } catch (error) { + console.error('Error fetching task:', taskId, error); + return null; + } +} + +/** + * Obtener datos crudos de Dolibarr para una tarea + */ +export async function getDolibarrTaskById(taskId: number): Promise { + try { + return await dolibarrFetch(`tasks/${taskId}`); + } catch (error) { + console.error('Error fetching Dolibarr task:', taskId, error); + return null; + } +} + +/** + * Calcular estadísticas de las tareas de un proyecto + */ +export function calculateTaskStats(tasks: Task[]) { + const total = tasks.length; + const completed = tasks.filter(t => t.status === '2').length; + const inProgress = tasks.filter(t => t.status === '1').length; + const draft = tasks.filter(t => t.status === '0').length; + + const totalPlannedHours = tasks.reduce((sum, t) => sum + t.plannedHours, 0); + const totalWorkedHours = tasks.reduce((sum, t) => sum + t.workedHours, 0); + + const avgProgress = total > 0 + ? Math.round(tasks.reduce((sum, t) => sum + t.progress, 0) / total) + : 0; + + const highPriority = tasks.filter(t => t.priority === '3').length; + const overdue = tasks.filter(t => { + if (!t.endDate && !t.plannedEndDate) return false; + const endDate = t.endDate || t.plannedEndDate; + return endDate && new Date(endDate) < new Date() && t.status !== '2'; + }).length; + + return { + total, + completed, + inProgress, + draft, + totalPlannedHours, + totalWorkedHours, + avgProgress, + highPriority, + overdue, + completionRate: total > 0 ? Math.round((completed / total) * 100) : 0, + }; +} diff --git a/package-lock.json b/package-lock.json index 1f9d506..b7e682a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,16 @@ "version": "0.1.0", "dependencies": { "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", + "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.556.0", @@ -23,7 +26,6 @@ "next-themes": "^0.4.6", "react": "19.2.0", "react-dom": "19.2.0", - "recharts": "^3.7.0", "tailwind-merge": "^3.4.0", "tailwindcss-animate": "^1.0.7" }, @@ -1376,6 +1378,92 @@ } } }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "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-presence": "1.1.5", + "@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-checkbox/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-checkbox/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-checkbox/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-collection": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", @@ -2143,6 +2231,30 @@ } } }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.8.tgz", + "integrity": "sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.3", + "@radix-ui/react-primitive": "2.1.4" + }, + "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-roving-focus": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", @@ -2770,42 +2882,6 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, - "node_modules/@reduxjs/toolkit": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", - "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@standard-schema/utils": "^0.3.0", - "immer": "^11.0.0", - "redux": "^5.0.1", - "redux-thunk": "^3.1.0", - "reselect": "^5.1.0" - }, - "peerDependencies": { - "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", - "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-redux": { - "optional": true - } - } - }, - "node_modules/@reduxjs/toolkit/node_modules/immer": { - "version": "11.1.3", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz", - "integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -2813,18 +2889,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT" - }, - "node_modules/@standard-schema/utils": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", - "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", - "license": "MIT" - }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -3119,6 +3183,39 @@ "dev": true, "license": "MIT" }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -3130,69 +3227,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@types/d3-array": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", - "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", - "license": "MIT" - }, - "node_modules/@types/d3-color": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", - "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", - "license": "MIT" - }, - "node_modules/@types/d3-ease": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", - "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", - "license": "MIT" - }, - "node_modules/@types/d3-interpolate": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", - "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", - "license": "MIT", - "dependencies": { - "@types/d3-color": "*" - } - }, - "node_modules/@types/d3-path": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", - "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", - "license": "MIT" - }, - "node_modules/@types/d3-scale": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", - "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", - "license": "MIT", - "dependencies": { - "@types/d3-time": "*" - } - }, - "node_modules/@types/d3-shape": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", - "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", - "license": "MIT", - "dependencies": { - "@types/d3-path": "*" - } - }, - "node_modules/@types/d3-time": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", - "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", - "license": "MIT" - }, - "node_modules/@types/d3-timer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", - "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", - "license": "MIT" - }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -3244,12 +3278,6 @@ "@types/react": "^19.2.0" } }, - "node_modules/@types/use-sync-external-store": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", - "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", - "license": "MIT" - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.48.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz", @@ -4482,127 +4510,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/d3-array": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", - "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", - "license": "ISC", - "dependencies": { - "internmap": "1 - 2" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-ease": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", - "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-format": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", - "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-interpolate": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", - "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", - "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-scale": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", - "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", - "license": "ISC", - "dependencies": { - "d3-array": "2.10.0 - 3", - "d3-format": "1 - 3", - "d3-interpolate": "1.2.0 - 3", - "d3-time": "2.1.1 - 3", - "d3-time-format": "2 - 4" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-shape": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", - "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", - "license": "ISC", - "dependencies": { - "d3-path": "^3.1.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", - "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", - "license": "ISC", - "dependencies": { - "d3-array": "2 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", - "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", - "license": "ISC", - "dependencies": { - "d3-time": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-timer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -4682,12 +4589,6 @@ } } }, - "node_modules/decimal.js-light": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", - "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", - "license": "MIT" - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -4992,16 +4893,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/es-toolkit": { - "version": "1.44.0", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz", - "integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==", - "license": "MIT", - "workspaces": [ - "docs", - "benchmarks" - ] - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -5449,12 +5340,6 @@ "node": ">=0.10.0" } }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "license": "MIT" - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5950,16 +5835,6 @@ "node": ">= 4" } }, - "node_modules/immer": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", - "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -6002,15 +5877,6 @@ "node": ">= 0.4" } }, - "node_modules/internmap": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", - "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -7694,31 +7560,9 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, "license": "MIT" }, - "node_modules/react-redux": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", - "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", - "license": "MIT", - "dependencies": { - "@types/use-sync-external-store": "^0.0.6", - "use-sync-external-store": "^1.4.0" - }, - "peerDependencies": { - "@types/react": "^18.2.25 || ^19", - "react": "^18.0 || ^19", - "redux": "^5.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "redux": { - "optional": true - } - } - }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -7809,51 +7653,6 @@ "node": ">=8.10.0" } }, - "node_modules/recharts": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz", - "integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==", - "license": "MIT", - "workspaces": [ - "www" - ], - "dependencies": { - "@reduxjs/toolkit": "1.x.x || 2.x.x", - "clsx": "^2.1.1", - "decimal.js-light": "^2.5.1", - "es-toolkit": "^1.39.3", - "eventemitter3": "^5.0.1", - "immer": "^10.1.1", - "react-redux": "8.x.x || 9.x.x", - "reselect": "5.1.1", - "tiny-invariant": "^1.3.3", - "use-sync-external-store": "^1.2.2", - "victory-vendor": "^37.0.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/redux": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" - }, - "node_modules/redux-thunk": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", - "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", - "license": "MIT", - "peerDependencies": { - "redux": "^5.0.0" - } - }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -7898,12 +7697,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/reselect": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", - "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", - "license": "MIT" - }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -8618,12 +8411,6 @@ "node": ">=0.8" } }, - "node_modules/tiny-invariant": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", - "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", - "license": "MIT" - }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -9021,28 +8808,6 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, - "node_modules/victory-vendor": { - "version": "37.3.6", - "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", - "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", - "license": "MIT AND ISC", - "dependencies": { - "@types/d3-array": "^3.0.3", - "@types/d3-ease": "^3.0.0", - "@types/d3-interpolate": "^3.0.1", - "@types/d3-scale": "^4.0.2", - "@types/d3-shape": "^3.1.0", - "@types/d3-time": "^3.0.0", - "@types/d3-timer": "^3.0.0", - "d3-array": "^3.1.6", - "d3-ease": "^3.0.1", - "d3-interpolate": "^3.0.1", - "d3-scale": "^4.0.2", - "d3-shape": "^3.1.0", - "d3-time": "^3.0.0", - "d3-timer": "^3.0.1" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 39dbb88..446e0e8 100644 --- a/package.json +++ b/package.json @@ -8,18 +8,22 @@ "start": "next start", "lint": "eslint", "seed": "node scripts/seed-projects-via-api.js", + "seed:tasks": "node scripts/seed-tasks.js", "seed:direct": "node scripts/seed-projects.js", "diagnose": "node scripts/diagnose.js" }, "dependencies": { "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", + "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.556.0", @@ -43,4 +47,4 @@ "tailwindcss": "^3.4.18", "typescript": "^5" } -} +} \ No newline at end of file diff --git a/scripts/seed-tasks.js b/scripts/seed-tasks.js new file mode 100644 index 0000000..b7cf992 --- /dev/null +++ b/scripts/seed-tasks.js @@ -0,0 +1,334 @@ +#!/usr/bin/env node + +/** + * Script para crear tareas de prueba en proyectos de Dolibarr + * + * REQUISITO: El servidor de Next.js debe estar corriendo (npm run dev) + * Uso: node scripts/seed-tasks.js + */ + +const NEXT_API_URL = 'http://localhost:3000/api/dolibarr'; + +// Plantillas de tareas variadas para cada proyecto +const taskTemplates = [ + // Tareas de análisis/planificación + { + label: "Análisis de requisitos", + description: "Documentar todos los requisitos funcionales y no funcionales del proyecto con el cliente.", + planned_workload: 28800, // 8 horas en segundos + progress: 100, + priority: 2, + }, + { + label: "Diseño de arquitectura", + description: "Definir la arquitectura técnica, selección de tecnologías y patrones de diseño a utilizar.", + planned_workload: 36000, // 10 horas + progress: 100, + priority: 3, + }, + { + label: "Creación de wireframes", + description: "Diseñar los wireframes y mockups de las principales pantallas de la aplicación.", + planned_workload: 21600, // 6 horas + progress: 80, + priority: 2, + }, + // Tareas de desarrollo + { + label: "Configuración del entorno de desarrollo", + description: "Preparar repositorio, CI/CD, entornos de desarrollo y staging.", + planned_workload: 14400, // 4 horas + progress: 100, + priority: 3, + }, + { + label: "Desarrollo del backend", + description: "Implementar la API REST, modelos de datos y lógica de negocio del servidor.", + planned_workload: 72000, // 20 horas + progress: 60, + priority: 3, + }, + { + label: "Desarrollo del frontend", + description: "Implementar la interfaz de usuario con componentes, estados y conexión con API.", + planned_workload: 64800, // 18 horas + progress: 45, + priority: 3, + }, + { + label: "Integración con servicios externos", + description: "Conectar con APIs de terceros, pasarelas de pago y servicios cloud.", + planned_workload: 28800, // 8 horas + progress: 30, + priority: 2, + }, + // Tareas de testing + { + label: "Pruebas unitarias", + description: "Escribir y ejecutar tests unitarios para componentes críticos del sistema.", + planned_workload: 21600, // 6 horas + progress: 25, + priority: 2, + }, + { + label: "Pruebas de integración", + description: "Realizar pruebas de integración entre módulos y con servicios externos.", + planned_workload: 18000, // 5 horas + progress: 10, + priority: 2, + }, + { + label: "QA y corrección de bugs", + description: "Ejecutar plan de QA, documentar bugs encontrados y corregirlos.", + planned_workload: 36000, // 10 horas + progress: 0, + priority: 1, + }, + // Tareas de documentación + { + label: "Documentación técnica", + description: "Crear documentación de API, guías de instalación y arquitectura del sistema.", + planned_workload: 14400, // 4 horas + progress: 15, + priority: 1, + }, + { + label: "Manual de usuario", + description: "Redactar el manual de usuario con capturas y tutoriales paso a paso.", + planned_workload: 10800, // 3 horas + progress: 0, + priority: 1, + }, + // Tareas de despliegue + { + label: "Configuración de producción", + description: "Preparar servidores, certificados SSL, dominios y configuraciones de producción.", + planned_workload: 18000, // 5 horas + progress: 0, + priority: 3, + }, + { + label: "Despliegue inicial", + description: "Realizar el despliegue a producción y verificar el correcto funcionamiento.", + planned_workload: 7200, // 2 horas + progress: 0, + priority: 3, + }, + { + label: "Formación al cliente", + description: "Sesión de formación al equipo del cliente sobre el uso del sistema.", + planned_workload: 10800, // 3 horas + progress: 0, + priority: 2, + }, +]; + +/** + * Obtener todos los proyectos existentes + */ +async function getProjects() { + const url = `${NEXT_API_URL}/projects`; + + const response = await fetch(url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`Error obteniendo proyectos: ${response.status}`); + } + + return response.json(); +} + +// Contador global para generar refs únicos +let taskCounter = 0; + +/** + * Generar referencia única para una tarea + */ +function generateTaskRef(projectId) { + taskCounter++; + const timestamp = Date.now().toString(36).toUpperCase(); + return `TASK-P${projectId}-${String(taskCounter).padStart(3, '0')}`; +} + +/** + * Crear una tarea en un proyecto + */ +async function createTask(projectId, taskData) { + const url = `${NEXT_API_URL}/tasks`; + + // Calcular fechas basadas en la fecha actual + const now = new Date(); + const startDate = new Date(now); + startDate.setDate(startDate.getDate() - Math.floor(Math.random() * 30)); // Hace 0-30 días + + const endDate = new Date(startDate); + endDate.setDate(endDate.getDate() + Math.floor(Math.random() * 30) + 7); // 7-37 días después + + const payload = { + ref: generateTaskRef(projectId), // Campo requerido por Dolibarr + fk_project: String(projectId), // Dolibarr espera string + label: taskData.label, + description: taskData.description, + planned_workload: taskData.planned_workload, + progress: taskData.progress, + priority: taskData.priority, + dateo: Math.floor(startDate.getTime() / 1000), // Fecha inicio planificada + datee: Math.floor(endDate.getTime() / 1000), // Fecha fin planificada + date_start: taskData.progress > 0 ? Math.floor(startDate.getTime() / 1000) : null, + date_end: taskData.progress >= 100 ? Math.floor(new Date().getTime() / 1000) : null, + }; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(`Error ${response.status}: ${errorData.error || 'Unknown error'}`); + } + + return response.json(); +} + +/** + * Seleccionar tareas aleatorias para un proyecto + */ +function selectTasksForProject(projectProgress, count = 6) { + // Mezclar las tareas aleatoriamente + const shuffled = [...taskTemplates].sort(() => Math.random() - 0.5); + + // Seleccionar las primeras 'count' tareas + const selected = shuffled.slice(0, count); + + // Ajustar el progreso de las tareas según el progreso del proyecto + return selected.map((task, index) => { + let adjustedProgress = task.progress; + + // Si el proyecto tiene poco progreso, reducir el progreso de las tareas + if (projectProgress < 30) { + adjustedProgress = Math.min(task.progress, 30 + Math.random() * 20); + } else if (projectProgress >= 100) { + // Si el proyecto está completo, completar más tareas + adjustedProgress = index < count - 1 ? 100 : Math.max(task.progress, 80); + } else { + // Ajustar proporcionalmente + const factor = projectProgress / 50; + adjustedProgress = Math.min(100, Math.round(task.progress * factor)); + } + + return { + ...task, + progress: Math.round(adjustedProgress), + }; + }); +} + +/** + * Script principal + */ +async function main() { + console.log('\n📋 SEED DE TAREAS PARA PROYECTOS DE DOLIBARR\n'); + console.log('═'.repeat(60)); + console.log(`📡 API Next.js: ${NEXT_API_URL}`); + console.log(`📝 Plantillas de tareas: ${taskTemplates.length}`); + console.log('═'.repeat(60)); + console.log(''); + + // Verificar conexión con Next.js + console.log('🔍 Verificando conexión con Next.js...'); + try { + const testResponse = await fetch('http://localhost:3000'); + if (!testResponse.ok && testResponse.status !== 404) { + throw new Error('Server not responding'); + } + console.log('✅ Servidor Next.js está corriendo\n'); + } catch (error) { + console.error('❌ ERROR: No se puede conectar al servidor Next.js'); + console.error(' Por favor, ejecuta "npm run dev" en otra terminal primero.\n'); + process.exit(1); + } + + // Obtener proyectos existentes + console.log('📂 Obteniendo proyectos existentes...'); + let projects; + try { + projects = await getProjects(); + console.log(`✅ Encontrados ${projects.length} proyectos\n`); + } catch (error) { + console.error('❌ Error obteniendo proyectos:', error.message); + process.exit(1); + } + + if (projects.length === 0) { + console.log('⚠️ No hay proyectos. Ejecuta primero: npm run seed\n'); + process.exit(0); + } + + let totalTasks = 0; + let totalErrors = 0; + const TASKS_PER_PROJECT = 6; + + for (let i = 0; i < projects.length; i++) { + const project = projects[i]; + const projectId = project.id; + const projectTitle = project.title || project.ref || `Proyecto ${projectId}`; + const projectProgress = parseFloat(project.opp_percent || '0'); + + console.log(`\n[${i + 1}/${projects.length}] 📁 ${projectTitle}`); + console.log(` ID: ${projectId} | Progreso: ${projectProgress}%`); + console.log(' Creando tareas:'); + + // Seleccionar tareas para este proyecto + const tasksToCreate = selectTasksForProject(projectProgress, TASKS_PER_PROJECT); + + for (let j = 0; j < tasksToCreate.length; j++) { + const task = tasksToCreate[j]; + + try { + const taskId = await createTask(projectId, task); + const progressBar = '█'.repeat(Math.floor(task.progress / 10)) + '░'.repeat(10 - Math.floor(task.progress / 10)); + console.log(` ✅ [${progressBar}] ${task.progress}% - ${task.label}`); + totalTasks++; + + // Pequeña pausa para no saturar la API + await new Promise(resolve => setTimeout(resolve, 200)); + } catch (error) { + console.log(` ❌ Error: ${task.label} - ${error.message}`); + totalErrors++; + } + } + } + + // Resumen final + console.log('\n'); + console.log('═'.repeat(60)); + console.log('✨ PROCESO COMPLETADO\n'); + console.log(`📊 Proyectos procesados: ${projects.length}`); + console.log(`✅ Tareas creadas: ${totalTasks}`); + console.log(`❌ Errores: ${totalErrors}`); + console.log('═'.repeat(60)); + + if (totalTasks > 0) { + console.log('\n💡 ¡Recarga tu aplicación para ver las tareas!'); + console.log(' Haz clic en cualquier proyecto para ver sus tareas.\n'); + } +} + +// Ejecutar +main().catch(error => { + console.error('\n❌ ERROR FATAL:', error.message); + console.error('\nAsegúrate de que:'); + console.error(' 1. El servidor Next.js está corriendo (npm run dev)'); + console.error(' 2. Dolibarr está accesible'); + console.error(' 3. Existen proyectos (ejecuta "npm run seed" primero)\n'); + process.exit(1); +}); diff --git a/types/task.ts b/types/task.ts new file mode 100644 index 0000000..6e25db3 --- /dev/null +++ b/types/task.ts @@ -0,0 +1,176 @@ +// types/task.ts + +// Estado de la tarea en Dolibarr +export type TaskStatus = '0' | '1' | '2'; // 0: borrador, 1: validada, 2: cerrada/completada + +// Prioridad de la tarea +export type TaskPriority = '0' | '1' | '2' | '3'; // 0: ninguna, 1: baja, 2: media, 3: alta + +// Interface para los datos crudos que vienen de Dolibarr +export interface DolibarrTask { + id: string | number; + ref: string; + label: string; + description: string; + fk_project: string | number; + fk_task_parent: string | number; + date_start: number | null; + date_end: number | null; + dateo: number | null; // fecha planificada inicio + datee: number | null; // fecha planificada fin + date_c: number | null; + date_m: number | null; + duration_effective: number; // segundos trabajados + planned_workload: number; // segundos planificados + progress: number | string; + priority: string | number; + budget_amount: string | number; + rang: number; + status: string; + note_public: string; + note_private: string; + fk_user_creat: string | number; + fk_user_valid: string | number; + // Campos adicionales que puede devolver la API + timespent?: number; + array_options?: Record; +} + +// Interface normalizada para la UI +export interface Task { + id: number; + ref: string; + title: string; + description: string; + projectId: number; + parentTaskId: number | null; + status: TaskStatus; + priority: TaskPriority; + progress: number; + plannedHours: number; + workedHours: number; + budget: number; + startDate: string | null; + endDate: string | null; + plannedStartDate: string | null; + plannedEndDate: string | null; + createdAt: string; + updatedAt: string; + createdBy: number; + order: number; +} + +// Configuración de estados de tarea +export const TASK_STATUS_CONFIG: Record = { + '0': { + label: 'Borrador', + color: 'gray', + bgClass: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300' + }, + '1': { + label: 'Validada', + color: 'blue', + bgClass: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' + }, + '2': { + label: 'Completada', + color: 'green', + bgClass: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' + }, +}; + +// Configuración de prioridades +export const TASK_PRIORITY_CONFIG: Record = { + '0': { + label: 'Sin prioridad', + color: 'gray', + bgClass: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' + }, + '1': { + label: 'Baja', + color: 'blue', + bgClass: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' + }, + '2': { + label: 'Media', + color: 'yellow', + bgClass: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' + }, + '3': { + label: 'Alta', + color: 'red', + bgClass: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' + }, +}; + +// Helper para convertir timestamp a fecha ISO +function timestampToDateString(timestamp: number | null | undefined): string | null { + if (!timestamp || timestamp === 0) { + return null; + } + return new Date(timestamp * 1000).toISOString().split('T')[0]; +} + +// Helper para convertir segundos a horas +function secondsToHours(seconds: number | null | undefined): number { + if (!seconds) return 0; + return Math.round((seconds / 3600) * 10) / 10; // Redondear a 1 decimal +} + +// Función para mapear tarea de Dolibarr al formato UI +export function mapDolibarrTask(dolibarr: DolibarrTask): Task { + const progress = typeof dolibarr.progress === 'string' + ? parseFloat(dolibarr.progress) + : (dolibarr.progress || 0); + + const priority = String(dolibarr.priority || '0') as TaskPriority; + const validPriorities: TaskPriority[] = ['0', '1', '2', '3']; + const safePriority: TaskPriority = validPriorities.includes(priority) ? priority : '0'; + + const status = String(dolibarr.status || '0') as TaskStatus; + const validStatuses: TaskStatus[] = ['0', '1', '2']; + const safeStatus: TaskStatus = validStatuses.includes(status) ? status : '0'; + + return { + id: typeof dolibarr.id === 'string' ? parseInt(dolibarr.id) : dolibarr.id, + ref: dolibarr.ref || '', + title: dolibarr.label || 'Sin título', + description: dolibarr.description || '', + projectId: typeof dolibarr.fk_project === 'string' + ? parseInt(dolibarr.fk_project) + : dolibarr.fk_project, + parentTaskId: dolibarr.fk_task_parent + ? (typeof dolibarr.fk_task_parent === 'string' + ? parseInt(dolibarr.fk_task_parent) + : dolibarr.fk_task_parent) + : null, + status: safeStatus, + priority: safePriority, + progress: Math.min(Math.max(progress, 0), 100), // Asegurar entre 0-100 + plannedHours: secondsToHours(dolibarr.planned_workload), + workedHours: secondsToHours(dolibarr.duration_effective || dolibarr.timespent), + budget: typeof dolibarr.budget_amount === 'string' + ? parseFloat(dolibarr.budget_amount) || 0 + : (dolibarr.budget_amount || 0), + startDate: timestampToDateString(dolibarr.date_start), + endDate: timestampToDateString(dolibarr.date_end), + plannedStartDate: timestampToDateString(dolibarr.dateo), + plannedEndDate: timestampToDateString(dolibarr.datee), + createdAt: timestampToDateString(dolibarr.date_c) || new Date().toISOString().split('T')[0], + updatedAt: timestampToDateString(dolibarr.date_m) || new Date().toISOString().split('T')[0], + createdBy: typeof dolibarr.fk_user_creat === 'string' + ? parseInt(dolibarr.fk_user_creat) + : (dolibarr.fk_user_creat || 0), + order: dolibarr.rang || 0, + }; +} + +// Helper para obtener label de estado +export function getTaskStatusLabel(status: TaskStatus): string { + return TASK_STATUS_CONFIG[status]?.label || 'Desconocido'; +} + +// Helper para obtener label de prioridad +export function getTaskPriorityLabel(priority: TaskPriority): string { + return TASK_PRIORITY_CONFIG[priority]?.label || 'Sin prioridad'; +}