From 88781f50948818f9d23787747bb17f877fce3618 Mon Sep 17 00:00:00 2001 From: Levi Planelles Date: Fri, 30 Jan 2026 20:59:44 +0100 Subject: [PATCH] feat: add task seeding script and update dependencies - Introduced a new script `seed-tasks.js` to create test tasks for Dolibarr projects. - Updated `package.json` and `package-lock.json` to include new Radix UI components and TanStack Table. - Added TypeScript types for tasks in `types/task.ts`. - Enhanced project task management with new task templates and improved task creation logic. --- app/proyectos/[id]/page.tsx | 47 ++- app/proyectos/page.tsx | 22 ++ components/project-detail/index.ts | 6 + .../project-detail/project-detail-view.tsx | 195 ++++++++++ components/project-detail/project-header.tsx | 150 ++++++++ components/project-detail/project-info.tsx | 256 +++++++++++++ components/project-detail/project-stats.tsx | 158 ++++++++ components/project-detail/task-columns.tsx | 254 +++++++++++++ components/project-detail/tasks-section.tsx | 312 +++++++++++++++ components/projects/columns.tsx | 279 ++++++++++++++ components/projects/data-table-skeleton.tsx | 120 ++++++ components/projects/data-table-toolbar.tsx | 145 +++++++ components/projects/data-table.tsx | 204 ++++++++++ components/projects/empty-state.tsx | 34 ++ components/projects/index.ts | 6 + components/projects/projects-table.tsx | 127 +++++++ components/ui/checkbox.tsx | 30 ++ components/ui/progress.tsx | 28 ++ components/ui/select.tsx | 159 ++++++++ components/ui/table.tsx | 120 ++++++ components/ui/tabs.tsx | 55 +++ lib/dolibarrClient.ts | 78 +++- lib/tasksService.ts | 118 ++++++ package-lock.json | 354 ++++++++++++++++++ package.json | 6 + scripts/seed-tasks.js | 334 +++++++++++++++++ types/task.ts | 176 +++++++++ 27 files changed, 3760 insertions(+), 13 deletions(-) create mode 100644 app/proyectos/page.tsx create mode 100644 components/project-detail/index.ts create mode 100644 components/project-detail/project-detail-view.tsx create mode 100644 components/project-detail/project-header.tsx create mode 100644 components/project-detail/project-info.tsx create mode 100644 components/project-detail/project-stats.tsx create mode 100644 components/project-detail/task-columns.tsx create mode 100644 components/project-detail/tasks-section.tsx create mode 100644 components/projects/columns.tsx create mode 100644 components/projects/data-table-skeleton.tsx create mode 100644 components/projects/data-table-toolbar.tsx create mode 100644 components/projects/data-table.tsx create mode 100644 components/projects/empty-state.tsx create mode 100644 components/projects/index.ts create mode 100644 components/projects/projects-table.tsx create mode 100644 components/ui/checkbox.tsx create mode 100644 components/ui/progress.tsx create mode 100644 components/ui/select.tsx create mode 100644 components/ui/table.tsx create mode 100644 components/ui/tabs.tsx create mode 100644 lib/tasksService.ts create mode 100644 scripts/seed-tasks.js create mode 100644 types/task.ts 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 new file mode 100644 index 0000000..0cbf77d --- /dev/null +++ b/components/ui/select.tsx @@ -0,0 +1,159 @@ +"use client" + +import * as React from "react" +import * as SelectPrimitive from "@radix-ui/react-select" +import { Check, ChevronDown, ChevronUp } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Select = SelectPrimitive.Root + +const SelectGroup = SelectPrimitive.Group + +const SelectValue = SelectPrimitive.Value + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className + )} + {...props} + > + {children} + + + + +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectSeparator.displayName = SelectPrimitive.Separator.displayName + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +} 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/components/ui/tabs.tsx b/components/ui/tabs.tsx new file mode 100644 index 0000000..0f4caeb --- /dev/null +++ b/components/ui/tabs.tsx @@ -0,0 +1,55 @@ +"use client" + +import * as React from "react" +import * as TabsPrimitive from "@radix-ui/react-tabs" + +import { cn } from "@/lib/utils" + +const Tabs = TabsPrimitive.Root + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } 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 2175811..b7e682a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +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", @@ -1270,6 +1275,12 @@ "node": ">=12.4.0" } }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", @@ -1367,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", @@ -2134,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", @@ -2221,6 +2342,105 @@ } } }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.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-select/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-select/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-select/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-separator": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", @@ -2262,6 +2482,92 @@ } } }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "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-tabs/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-tabs/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-tabs/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-tooltip": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", @@ -2455,6 +2761,21 @@ } } }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "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-use-rect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", @@ -2862,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", diff --git a/package.json b/package.json index 5e8afe9..f3df625 100644 --- a/package.json +++ b/package.json @@ -8,16 +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", 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'; +}