Merge origin/main into main with Estadisticas branch
This commit is contained in:
commit
11fbef5595
|
|
@ -1,19 +1,27 @@
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from "next/navigation";
|
||||||
import { getProjectById } from '@/lib/projectsService';
|
import { getProjectById } from "@/lib/projectsService";
|
||||||
import ProjectDetail from '@/components/dashboard/project-detail';
|
import { ProjectDetailView } from "@/components/project-detail";
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: {
|
params: Promise<{
|
||||||
id: string;
|
id: string;
|
||||||
};
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Página de detalle de proyecto
|
* Página de detalle de proyecto
|
||||||
* Ruta dinámica: /proyectos/[id]
|
* 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) {
|
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)) {
|
if (isNaN(projectId)) {
|
||||||
notFound();
|
notFound();
|
||||||
|
|
@ -25,5 +33,30 @@ export default async function ProjectDetailPage({ params }: PageProps) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ProjectDetail project={project} />;
|
return <ProjectDetailView project={project} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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" };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { FolderKanban } from "lucide-react";
|
||||||
|
import { ProjectsTable } from "@/components/projects/projects-table";
|
||||||
|
|
||||||
|
export default function ProjectsPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 p-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FolderKanban className="h-6 w-6 text-primary" />
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Proyectos</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Gestiona y visualiza todos tus proyectos en un solo lugar.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Data Table */}
|
||||||
|
<ProjectsTable />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
{/* Header skeleton */}
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<Skeleton className="h-16 w-16 rounded-full" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-64" />
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats skeleton */}
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<Skeleton className="h-4 w-20 mb-2" />
|
||||||
|
<Skeleton className="h-8 w-24" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content skeleton */}
|
||||||
|
<div className="grid grid-cols-3 gap-6">
|
||||||
|
<div className="col-span-2 space-y-4">
|
||||||
|
<Skeleton className="h-48 w-full" />
|
||||||
|
<Skeleton className="h-48 w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
<Skeleton className="h-48 w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estado de error
|
||||||
|
function ErrorState({ onRetry }: { onRetry: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<div className="p-3 rounded-full bg-destructive/10 mb-4">
|
||||||
|
<FolderKanban className="h-8 w-8 text-destructive" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold mb-1">Error al cargar las tareas</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4 max-w-sm">
|
||||||
|
No se pudieron cargar las tareas del proyecto. Por favor, intenta de nuevo.
|
||||||
|
</p>
|
||||||
|
<Button onClick={onRetry} variant="outline">
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Reintentar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectDetailView({ project }: ProjectDetailViewProps) {
|
||||||
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
|
const [isLoadingTasks, setIsLoadingTasks] = useState(true);
|
||||||
|
const [tasksError, setTasksError] = useState<string | null>(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 (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
{/* Header del proyecto */}
|
||||||
|
<ProjectHeader project={project} />
|
||||||
|
|
||||||
|
{/* Contenido principal */}
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Stats cards */}
|
||||||
|
<ProjectStats project={project} taskStats={taskStats} />
|
||||||
|
|
||||||
|
{/* Tabs de contenido */}
|
||||||
|
<Tabs defaultValue="overview" className="space-y-6">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="overview" className="gap-2">
|
||||||
|
<LayoutDashboard className="h-4 w-4" />
|
||||||
|
Resumen
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="tasks" className="gap-2">
|
||||||
|
<ListTodo className="h-4 w-4" />
|
||||||
|
Tareas
|
||||||
|
{tasks.length > 0 && (
|
||||||
|
<span className="ml-1 px-1.5 py-0.5 text-xs rounded-full bg-muted">
|
||||||
|
{tasks.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="details" className="gap-2">
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
Detalles
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Tab: Resumen */}
|
||||||
|
<TabsContent value="overview" className="space-y-6">
|
||||||
|
<ProjectInfo project={project} />
|
||||||
|
|
||||||
|
{/* Preview de tareas */}
|
||||||
|
{!isLoadingTasks && tasks.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="font-semibold flex items-center gap-2">
|
||||||
|
<ListTodo className="h-4 w-4" />
|
||||||
|
Tareas recientes
|
||||||
|
</h3>
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<a href="#tasks">Ver todas</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<TasksSection tasks={tasks.slice(0, 5)} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Tab: Tareas */}
|
||||||
|
<TabsContent value="tasks">
|
||||||
|
{isLoadingTasks ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Skeleton className="h-9 w-[250px]" />
|
||||||
|
<Skeleton className="h-9 w-[130px]" />
|
||||||
|
<Skeleton className="h-9 w-[130px]" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-16 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : tasksError ? (
|
||||||
|
<ErrorState onRetry={fetchTasks} />
|
||||||
|
) : (
|
||||||
|
<TasksSection tasks={tasks} />
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Tab: Detalles */}
|
||||||
|
<TabsContent value="details">
|
||||||
|
<ProjectInfo project={project} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<ProjectStatus, { label: string; className: string }> = {
|
||||||
|
"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 (
|
||||||
|
<Badge variant="outline" className={`${config.className} text-sm px-3 py-1`}>
|
||||||
|
{config.label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectHeader({ project }: ProjectHeaderProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const initials = getInitials(project.name);
|
||||||
|
|
||||||
|
const handleCopyRef = () => {
|
||||||
|
navigator.clipboard.writeText(project.ref);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b bg-card">
|
||||||
|
<div className="px-6 py-4">
|
||||||
|
{/* Navegación */}
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-4">
|
||||||
|
<Link href="/proyectos" className="hover:text-foreground transition-colors">
|
||||||
|
Proyectos
|
||||||
|
</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-foreground font-medium">{project.ref}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header principal */}
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
{/* Avatar */}
|
||||||
|
<Avatar className="h-16 w-16 shrink-0">
|
||||||
|
<AvatarFallback className="bg-gradient-to-br from-blue-500 to-purple-600 text-white font-semibold text-xl">
|
||||||
|
{initials}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
{/* Info principal */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">{project.name}</h1>
|
||||||
|
<StatusBadge status={project.status} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||||
|
<button
|
||||||
|
onClick={handleCopyRef}
|
||||||
|
className="flex items-center gap-1.5 hover:text-foreground transition-colors font-mono"
|
||||||
|
title="Copiar referencia"
|
||||||
|
>
|
||||||
|
{project.ref}
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{project.client !== "Sin cliente" && (
|
||||||
|
<>
|
||||||
|
<span className="text-muted-foreground/50">|</span>
|
||||||
|
<span>{project.client}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Acciones */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => router.back()}>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
|
Volver
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon" className="h-9 w-9">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Pencil className="h-4 w-4 mr-2" />
|
||||||
|
Editar proyecto
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Share2 className="h-4 w-4 mr-2" />
|
||||||
|
Compartir
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem className="text-destructive focus:text-destructive">
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
Eliminar
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Columna principal - 2/3 */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{/* Descripción */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||||
|
Descripción
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{project.description ? (
|
||||||
|
<p className="text-sm text-muted-foreground whitespace-pre-wrap leading-relaxed">
|
||||||
|
{project.description}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground italic">
|
||||||
|
Sin descripción disponible
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Información financiera */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base">Información Financiera</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Barra de progreso del presupuesto */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm font-medium">Uso del presupuesto</span>
|
||||||
|
<span className={`text-sm font-medium ${budgetUsed > 90 ? "text-red-500" : ""}`}>
|
||||||
|
{budgetUsed.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
value={Math.min(budgetUsed, 100)}
|
||||||
|
className={`h-2 ${budgetUsed > 100 ? "[&>div]:bg-red-500" : ""}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desglose */}
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="text-center p-4 rounded-lg bg-muted/50">
|
||||||
|
<p className="text-sm text-muted-foreground mb-1">Presupuesto</p>
|
||||||
|
<p className="text-xl font-bold">{formatCurrency(project.budget)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 rounded-lg bg-muted/50">
|
||||||
|
<p className="text-sm text-muted-foreground mb-1">Gastado</p>
|
||||||
|
<p className="text-xl font-bold text-blue-600 dark:text-blue-400">
|
||||||
|
{formatCurrency(project.spent)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 rounded-lg bg-muted/50">
|
||||||
|
<p className="text-sm text-muted-foreground mb-1">Restante</p>
|
||||||
|
<p className={`text-xl font-bold ${budgetRemaining < 0 ? "text-red-500" : "text-green-600 dark:text-green-400"}`}>
|
||||||
|
{formatCurrency(budgetRemaining)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Timeline */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||||
|
Timeline del Proyecto
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="relative">
|
||||||
|
{/* Línea de progreso */}
|
||||||
|
<div className="absolute top-4 left-0 right-0 h-1 bg-muted rounded-full">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-blue-500 to-purple-600 rounded-full transition-all"
|
||||||
|
style={{ width: `${project.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Puntos */}
|
||||||
|
<div className="relative flex justify-between pt-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-blue-500 mx-auto -mt-5 relative z-10" />
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">Inicio</p>
|
||||||
|
<p className="text-sm font-medium">{formatDate(project.startDate)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className={`w-3 h-3 rounded-full mx-auto -mt-5 relative z-10 ${project.progress >= 100 ? "bg-green-500" : "bg-gray-300 dark:bg-gray-600"}`} />
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">Fin previsto</p>
|
||||||
|
<p className="text-sm font-medium">{formatDate(project.endDate)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex items-center justify-center gap-6 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground">Duración:</span>
|
||||||
|
<span className="font-medium">{duration} días</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">Progreso:</span>
|
||||||
|
<span className="font-medium">{project.progress}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar - 1/3 */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Detalles generales */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base">Detalles</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Cliente */}
|
||||||
|
{project.client !== "Sin cliente" && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Building2 className="h-4 w-4 text-muted-foreground mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Cliente</p>
|
||||||
|
<p className="text-sm font-medium">{project.client}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Referencia */}
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Hash className="h-4 w-4 text-muted-foreground mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Referencia</p>
|
||||||
|
<p className="text-sm font-medium font-mono">{project.ref}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* ID */}
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Hash className="h-4 w-4 text-muted-foreground mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">ID del proyecto</p>
|
||||||
|
<p className="text-sm font-medium font-mono">{project.id}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Fechas */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base">Fechas</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Calendar className="h-4 w-4 text-muted-foreground mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Fecha de inicio</p>
|
||||||
|
<p className="text-sm font-medium">{formatDate(project.startDate)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Calendar className="h-4 w-4 text-muted-foreground mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Fecha de fin</p>
|
||||||
|
<p className="text-sm font-medium">{formatDate(project.endDate)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Clock className="h-4 w-4 text-muted-foreground mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Creado</p>
|
||||||
|
<p className="text-sm font-medium">{formatDate(project.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Clock className="h-4 w-4 text-muted-foreground mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Última actualización</p>
|
||||||
|
<p className="text-sm font-medium">{formatDate(project.updatedAt)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
{/* Progreso */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900/30">
|
||||||
|
<TrendingUp className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-muted-foreground">Progreso</p>
|
||||||
|
<p className="text-2xl font-bold">{project.progress}%</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Progress value={project.progress} className="mt-3 h-1.5" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Presupuesto */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-green-100 dark:bg-green-900/30">
|
||||||
|
<DollarSign className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-muted-foreground">Presupuesto</p>
|
||||||
|
<p className="text-2xl font-bold">{formatCurrency(project.budget)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center justify-between text-xs">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Usado: {formatCurrency(project.spent)}
|
||||||
|
</span>
|
||||||
|
<span className={budgetUsedPercent > 90 ? "text-red-500" : "text-muted-foreground"}>
|
||||||
|
{budgetUsedPercent.toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Fecha límite */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`p-2 rounded-lg ${isOverdue ? "bg-red-100 dark:bg-red-900/30" : "bg-purple-100 dark:bg-purple-900/30"}`}>
|
||||||
|
<Calendar className={`h-5 w-5 ${isOverdue ? "text-red-600 dark:text-red-400" : "text-purple-600 dark:text-purple-400"}`} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-muted-foreground">Fecha límite</p>
|
||||||
|
<p className="text-2xl font-bold">{formatDate(project.endDate)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className={`mt-3 text-xs ${isOverdue ? "text-red-500" : "text-muted-foreground"}`}>
|
||||||
|
{isOverdue
|
||||||
|
? `${Math.abs(daysRemaining)} días de retraso`
|
||||||
|
: `${daysRemaining} días restantes`
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Tareas */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-orange-100 dark:bg-orange-900/30">
|
||||||
|
<ListTodo className="h-5 w-5 text-orange-600 dark:text-orange-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-muted-foreground">Tareas</p>
|
||||||
|
<p className="text-2xl font-bold">{taskStats.completed}/{taskStats.total}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center gap-3 text-xs">
|
||||||
|
{taskStats.highPriority > 0 && (
|
||||||
|
<span className="flex items-center gap-1 text-red-500">
|
||||||
|
<AlertTriangle className="h-3 w-3" />
|
||||||
|
{taskStats.highPriority} alta prioridad
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{taskStats.overdue > 0 && (
|
||||||
|
<span className="flex items-center gap-1 text-yellow-600 dark:text-yellow-500">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{taskStats.overdue} vencidas
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{taskStats.highPriority === 0 && taskStats.overdue === 0 && (
|
||||||
|
<span className="flex items-center gap-1 text-green-600 dark:text-green-500">
|
||||||
|
<CheckCircle2 className="h-3 w-3" />
|
||||||
|
Todo en orden
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<Badge variant="outline" className={config.bgClass}>
|
||||||
|
{status === '2' && <CheckCircle2 className="h-3 w-3 mr-1" />}
|
||||||
|
{config.label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Badge de prioridad
|
||||||
|
function TaskPriorityBadge({ priority }: { priority: TaskPriority }) {
|
||||||
|
const config = TASK_PRIORITY_CONFIG[priority];
|
||||||
|
|
||||||
|
if (priority === '0') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className={config.bgClass}>
|
||||||
|
{priority === '3' && <AlertTriangle className="h-3 w-3 mr-1" />}
|
||||||
|
{config.label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Progress value={progress} className={`w-16 h-1.5 ${getColorClass(progress)}`} />
|
||||||
|
<span className="text-xs text-muted-foreground w-8">{progress}%</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<Task>[] = [
|
||||||
|
// Checkbox
|
||||||
|
{
|
||||||
|
id: "select",
|
||||||
|
header: ({ table }) => (
|
||||||
|
<Checkbox
|
||||||
|
checked={
|
||||||
|
table.getIsAllPageRowsSelected() ||
|
||||||
|
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||||
|
}
|
||||||
|
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||||
|
aria-label="Seleccionar todo"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Checkbox
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||||
|
aria-label="Seleccionar fila"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
enableHiding: false,
|
||||||
|
},
|
||||||
|
// Título
|
||||||
|
{
|
||||||
|
accessorKey: "title",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
Tarea
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="max-w-[300px]">
|
||||||
|
<p className="font-medium truncate">{row.getValue("title")}</p>
|
||||||
|
{row.original.ref && (
|
||||||
|
<p className="text-xs text-muted-foreground font-mono">{row.original.ref}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
// Estado
|
||||||
|
{
|
||||||
|
accessorKey: "status",
|
||||||
|
header: "Estado",
|
||||||
|
cell: ({ row }) => <TaskStatusBadge status={row.getValue("status")} />,
|
||||||
|
filterFn: (row, id, value) => value.includes(row.getValue(id)),
|
||||||
|
},
|
||||||
|
// Prioridad
|
||||||
|
{
|
||||||
|
accessorKey: "priority",
|
||||||
|
header: "Prioridad",
|
||||||
|
cell: ({ row }) => <TaskPriorityBadge priority={row.getValue("priority")} />,
|
||||||
|
filterFn: (row, id, value) => value.includes(row.getValue(id)),
|
||||||
|
},
|
||||||
|
// Progreso
|
||||||
|
{
|
||||||
|
accessorKey: "progress",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
Progreso
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => <TaskProgress progress={row.getValue("progress")} />,
|
||||||
|
},
|
||||||
|
// Horas
|
||||||
|
{
|
||||||
|
id: "hours",
|
||||||
|
header: () => (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Clock className="h-3.5 w-3.5" />
|
||||||
|
Horas
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="font-medium">{formatHours(row.original.workedHours)}</span>
|
||||||
|
{row.original.plannedHours > 0 && (
|
||||||
|
<span className="text-muted-foreground"> / {formatHours(row.original.plannedHours)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
// Fecha límite
|
||||||
|
{
|
||||||
|
accessorKey: "endDate",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
Fecha límite
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const endDate = row.original.endDate || row.original.plannedEndDate;
|
||||||
|
const isOverdue = endDate && new Date(endDate) < new Date() && row.original.status !== '2';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`text-sm ${isOverdue ? 'text-red-500 font-medium' : 'text-muted-foreground'}`}>
|
||||||
|
{formatDate(endDate)}
|
||||||
|
{isOverdue && <AlertTriangle className="h-3 w-3 ml-1 inline" />}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Acciones
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
enableHiding: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
|
<span className="sr-only">Abrir menú</span>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>Acciones</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
|
Ver detalles
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
|
Editar
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Clock className="mr-2 h-4 w-4" />
|
||||||
|
Registrar tiempo
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem className="text-destructive focus:text-destructive">
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Eliminar
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -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 (
|
||||||
|
<Card className={`hover:shadow-md transition-shadow ${isOverdue ? 'border-red-200 dark:border-red-900' : ''}`}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium truncate">{task.title}</p>
|
||||||
|
{task.ref && (
|
||||||
|
<p className="text-xs text-muted-foreground font-mono">{task.ref}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{task.priority !== '0' && (
|
||||||
|
<Badge variant="outline" className={priorityConfig.bgClass}>
|
||||||
|
{task.priority === '3' && <AlertTriangle className="h-3 w-3 mr-1" />}
|
||||||
|
{priorityConfig.label}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progreso */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-muted-foreground">Progreso</span>
|
||||||
|
<span className="font-medium">{task.progress}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all ${
|
||||||
|
task.progress >= 100 ? 'bg-green-500' :
|
||||||
|
task.progress >= 50 ? 'bg-blue-500' : 'bg-gray-400'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${task.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-between pt-2">
|
||||||
|
<Badge variant="outline" className={statusConfig.bgClass}>
|
||||||
|
{task.status === '2' && <CheckCircle2 className="h-3 w-3 mr-1" />}
|
||||||
|
{statusConfig.label}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
{(task.endDate || task.plannedEndDate) && (
|
||||||
|
<span className={`text-xs flex items-center gap-1 ${isOverdue ? 'text-red-500' : 'text-muted-foreground'}`}>
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{new Date(task.endDate || task.plannedEndDate!).toLocaleDateString('es-ES', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
})}
|
||||||
|
{isOverdue && <AlertTriangle className="h-3 w-3" />}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estado vacío
|
||||||
|
function EmptyTasks() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<div className="p-3 rounded-full bg-muted mb-4">
|
||||||
|
<ListTodo className="h-8 w-8 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold mb-1">Sin tareas</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4 max-w-sm">
|
||||||
|
Este proyecto aún no tiene tareas asignadas. Crea la primera tarea para comenzar.
|
||||||
|
</p>
|
||||||
|
<Button>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Crear tarea
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <EmptyTasks />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Stats rápidos */}
|
||||||
|
<div className="flex flex-wrap items-center gap-4 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ListTodo className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground">Total:</span>
|
||||||
|
<span className="font-medium">{stats.total}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||||
|
<span className="text-muted-foreground">Completadas:</span>
|
||||||
|
<span className="font-medium">{stats.completed}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="h-4 w-4 text-blue-500" />
|
||||||
|
<span className="text-muted-foreground">En progreso:</span>
|
||||||
|
<span className="font-medium">{stats.inProgress}</span>
|
||||||
|
</div>
|
||||||
|
{stats.highPriority > 0 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-red-500" />
|
||||||
|
<span className="text-muted-foreground">Alta prioridad:</span>
|
||||||
|
<span className="font-medium text-red-500">{stats.highPriority}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex flex-1 items-center gap-2">
|
||||||
|
{/* Búsqueda */}
|
||||||
|
<div className="relative w-full sm:w-[250px]">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Buscar tareas..."
|
||||||
|
value={searchValue}
|
||||||
|
onChange={(e) => setSearchValue(e.target.value)}
|
||||||
|
className="pl-8 h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filtro estado */}
|
||||||
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
|
<SelectTrigger className="w-[130px] h-9">
|
||||||
|
<SelectValue placeholder="Estado" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Todos</SelectItem>
|
||||||
|
<SelectItem value="0">Borrador</SelectItem>
|
||||||
|
<SelectItem value="1">Validada</SelectItem>
|
||||||
|
<SelectItem value="2">Completada</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* Filtro prioridad */}
|
||||||
|
<Select value={priorityFilter} onValueChange={setPriorityFilter}>
|
||||||
|
<SelectTrigger className="w-[130px] h-9">
|
||||||
|
<SelectValue placeholder="Prioridad" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Todas</SelectItem>
|
||||||
|
<SelectItem value="3">Alta</SelectItem>
|
||||||
|
<SelectItem value="2">Media</SelectItem>
|
||||||
|
<SelectItem value="1">Baja</SelectItem>
|
||||||
|
<SelectItem value="0">Sin prioridad</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* Limpiar */}
|
||||||
|
{isFiltered && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleClearFilters} className="h-9 px-2">
|
||||||
|
Limpiar
|
||||||
|
<X className="ml-1 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Toggle vista */}
|
||||||
|
<div className="flex items-center border rounded-md">
|
||||||
|
<Button
|
||||||
|
variant={viewMode === "table" ? "secondary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-2 rounded-r-none"
|
||||||
|
onClick={() => setViewMode("table")}
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={viewMode === "grid" ? "secondary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-2 rounded-l-none"
|
||||||
|
onClick={() => setViewMode("grid")}
|
||||||
|
>
|
||||||
|
<LayoutGrid className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nueva tarea */}
|
||||||
|
<Button size="sm" className="h-9">
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Nueva tarea
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contenido */}
|
||||||
|
{viewMode === "table" ? (
|
||||||
|
<DataTable columns={taskColumns} data={filteredTasks} />
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{filteredTasks.map((task) => (
|
||||||
|
<TaskCard key={task.id} task={task} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mensaje sin resultados */}
|
||||||
|
{filteredTasks.length === 0 && tasks.length > 0 && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
No se encontraron tareas con los filtros aplicados.
|
||||||
|
</p>
|
||||||
|
<Button variant="link" onClick={handleClearFilters}>
|
||||||
|
Limpiar filtros
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<ProjectStatus, string> = {
|
||||||
|
'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 (
|
||||||
|
<Badge variant="outline" className={colorClasses[status]}>
|
||||||
|
{config?.label || 'Desconocido'}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-24 h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all ${getProgressColor(progress)}`}
|
||||||
|
style={{ width: `${Math.min(progress, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-muted-foreground w-10">
|
||||||
|
{progress.toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<Project>[] = [
|
||||||
|
// Columna de selección
|
||||||
|
{
|
||||||
|
id: "select",
|
||||||
|
header: ({ table }) => (
|
||||||
|
<Checkbox
|
||||||
|
checked={
|
||||||
|
table.getIsAllPageRowsSelected() ||
|
||||||
|
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||||
|
}
|
||||||
|
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||||
|
aria-label="Seleccionar todo"
|
||||||
|
className="translate-y-[2px]"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Checkbox
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||||
|
aria-label="Seleccionar fila"
|
||||||
|
className="translate-y-[2px]"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
enableHiding: false,
|
||||||
|
},
|
||||||
|
// Referencia
|
||||||
|
{
|
||||||
|
accessorKey: "ref",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
Referencia
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-sm text-muted-foreground">
|
||||||
|
{row.getValue("ref")}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
// Nombre del proyecto
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
Nombre
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="max-w-[250px]">
|
||||||
|
<Link
|
||||||
|
href={`/proyectos/${row.original.id}`}
|
||||||
|
className="font-medium hover:underline hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
{row.getValue("name")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
// Cliente
|
||||||
|
{
|
||||||
|
accessorKey: "client",
|
||||||
|
header: "Cliente",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-sm">
|
||||||
|
{row.getValue("client")}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
// Estado
|
||||||
|
{
|
||||||
|
accessorKey: "status",
|
||||||
|
header: "Estado",
|
||||||
|
cell: ({ row }) => <StatusBadge status={row.getValue("status")} />,
|
||||||
|
filterFn: (row, id, value) => {
|
||||||
|
return value.includes(row.getValue(id));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Progreso
|
||||||
|
{
|
||||||
|
accessorKey: "progress",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
Progreso
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => <ProgressBar progress={row.getValue("progress")} />,
|
||||||
|
},
|
||||||
|
// Presupuesto
|
||||||
|
{
|
||||||
|
accessorKey: "budget",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
Presupuesto
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-medium tabular-nums">
|
||||||
|
{formatCurrency(row.getValue("budget"))}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
// Fecha inicio
|
||||||
|
{
|
||||||
|
accessorKey: "startDate",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
Inicio
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{formatDate(row.getValue("startDate"))}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
// Fecha fin
|
||||||
|
{
|
||||||
|
accessorKey: "endDate",
|
||||||
|
header: "Fin",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{formatDate(row.getValue("endDate"))}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
// Acciones
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
enableHiding: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const project = row.original;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
|
<span className="sr-only">Abrir menú</span>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>Acciones</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href={`/proyectos/${project.id}`} className="cursor-pointer">
|
||||||
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
|
Ver detalles
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className="cursor-pointer">
|
||||||
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
|
Editar
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem className="cursor-pointer text-destructive focus:text-destructive">
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Eliminar
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Toolbar skeleton */}
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex flex-1 items-center gap-2">
|
||||||
|
<Skeleton className="h-9 w-[300px]" />
|
||||||
|
<Skeleton className="h-9 w-[150px]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Skeleton className="h-9 w-[80px]" />
|
||||||
|
<Skeleton className="h-9 w-[100px]" />
|
||||||
|
<Skeleton className="h-9 w-[90px]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table skeleton */}
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[40px]">
|
||||||
|
<Skeleton className="h-4 w-4" />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<Skeleton className="h-4 w-14" />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[40px]" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{Array.from({ length: 10 }).map((_, index) => (
|
||||||
|
<TableRow key={index}>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-4" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-6 w-16 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Skeleton className="h-2 w-24 rounded-full" />
|
||||||
|
<Skeleton className="h-4 w-8" />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-8 w-8" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination skeleton */}
|
||||||
|
<div className="flex items-center justify-between px-2">
|
||||||
|
<Skeleton className="h-4 w-[200px]" />
|
||||||
|
<div className="flex items-center space-x-6">
|
||||||
|
<Skeleton className="h-4 w-[150px]" />
|
||||||
|
<Skeleton className="h-4 w-[100px]" />
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Skeleton className="h-8 w-8" />
|
||||||
|
<Skeleton className="h-8 w-8" />
|
||||||
|
<Skeleton className="h-8 w-8" />
|
||||||
|
<Skeleton className="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex flex-1 items-center gap-2">
|
||||||
|
{/* Búsqueda */}
|
||||||
|
<div className="relative w-full sm:w-[300px]">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Buscar proyectos..."
|
||||||
|
value={searchValue}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
className="pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filtro por estado */}
|
||||||
|
<Select value={statusFilter} onValueChange={onStatusFilterChange}>
|
||||||
|
<SelectTrigger className="w-[150px]">
|
||||||
|
<SelectValue placeholder="Estado" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Todos</SelectItem>
|
||||||
|
{(Object.entries(STATUS_CONFIG) as [ProjectStatus, { label: string }][]).map(
|
||||||
|
([value, { label }]) => (
|
||||||
|
<SelectItem key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</SelectItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* Botón limpiar filtros */}
|
||||||
|
{isFiltered && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onClearFilters}
|
||||||
|
className="h-9 px-2 lg:px-3"
|
||||||
|
>
|
||||||
|
Limpiar
|
||||||
|
<X className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Contador de seleccionados */}
|
||||||
|
{selectedCount > 0 && (
|
||||||
|
<Badge variant="secondary" className="rounded-sm px-2 font-normal">
|
||||||
|
{selectedCount} seleccionado(s)
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Opciones de vista */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" className="h-9">
|
||||||
|
<SlidersHorizontal className="mr-2 h-4 w-4" />
|
||||||
|
Vista
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-[180px]">
|
||||||
|
<DropdownMenuLabel>Columnas visibles</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuCheckboxItem checked>
|
||||||
|
Referencia
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
<DropdownMenuCheckboxItem checked>
|
||||||
|
Nombre
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
<DropdownMenuCheckboxItem checked>
|
||||||
|
Cliente
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
<DropdownMenuCheckboxItem checked>
|
||||||
|
Estado
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
<DropdownMenuCheckboxItem checked>
|
||||||
|
Progreso
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
<DropdownMenuCheckboxItem checked>
|
||||||
|
Presupuesto
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
<DropdownMenuCheckboxItem checked>
|
||||||
|
Fechas
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{/* Exportar */}
|
||||||
|
<Button variant="outline" size="sm" className="h-9">
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Exportar
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Nuevo proyecto */}
|
||||||
|
<Button size="sm" className="h-9">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Nuevo
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<TData, TValue> {
|
||||||
|
columns: ColumnDef<TData, TValue>[];
|
||||||
|
data: TData[];
|
||||||
|
searchKey?: string;
|
||||||
|
searchValue?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTable<TData, TValue>({
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
searchKey,
|
||||||
|
searchValue,
|
||||||
|
}: DataTableProps<TData, TValue>) {
|
||||||
|
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||||
|
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);
|
||||||
|
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({});
|
||||||
|
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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Tabla */}
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<TableHead key={header.id}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows?.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
data-state={row.getIsSelected() && "selected"}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext()
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="h-24 text-center"
|
||||||
|
>
|
||||||
|
No se encontraron resultados.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Paginación */}
|
||||||
|
<div className="flex items-center justify-between px-2">
|
||||||
|
<div className="flex-1 text-sm text-muted-foreground">
|
||||||
|
{table.getFilteredSelectedRowModel().rows.length} de{" "}
|
||||||
|
{table.getFilteredRowModel().rows.length} fila(s) seleccionada(s).
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-6 lg:space-x-8">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<p className="text-sm font-medium">Filas por página</p>
|
||||||
|
<Select
|
||||||
|
value={`${table.getState().pagination.pageSize}`}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
table.setPageSize(Number(value));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-[70px]">
|
||||||
|
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent side="top">
|
||||||
|
{[10, 20, 30, 40, 50].map((pageSize) => (
|
||||||
|
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||||
|
{pageSize}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
|
||||||
|
Página {table.getState().pagination.pageIndex + 1} de{" "}
|
||||||
|
{table.getPageCount()}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="hidden h-8 w-8 p-0 lg:flex"
|
||||||
|
onClick={() => table.setPageIndex(0)}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Ir a la primera página</span>
|
||||||
|
<ChevronsLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => table.previousPage()}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Ir a la página anterior</span>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => table.nextPage()}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Ir a la siguiente página</span>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="hidden h-8 w-8 p-0 lg:flex"
|
||||||
|
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Ir a la última página</span>
|
||||||
|
<ChevronsRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center animate-in fade-in-50">
|
||||||
|
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||||
|
<FolderOpen className="h-6 w-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-4 text-lg font-semibold">{title}</h3>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground max-w-sm">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
{onAction && (
|
||||||
|
<Button onClick={onAction} className="mt-4">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
{actionLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -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<Project[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||||
|
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
|
||||||
|
<FolderKanban className="h-6 w-6 text-destructive" />
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-4 text-lg font-semibold">Error al cargar</h3>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground max-w-sm">{error}</p>
|
||||||
|
<Button onClick={fetchProjects} variant="outline" className="mt-4">
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
Reintentar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estado de carga
|
||||||
|
if (isLoading) {
|
||||||
|
return <DataTableSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estado vacío
|
||||||
|
if (projects.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title="No hay proyectos"
|
||||||
|
description="Aún no tienes proyectos creados. Comienza creando tu primer proyecto."
|
||||||
|
actionLabel="Crear proyecto"
|
||||||
|
onAction={() => console.log("Create project")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<DataTableToolbar
|
||||||
|
searchValue={searchValue}
|
||||||
|
onSearchChange={setSearchValue}
|
||||||
|
statusFilter={statusFilter}
|
||||||
|
onStatusFilterChange={setStatusFilter}
|
||||||
|
selectedCount={selectedCount}
|
||||||
|
onClearFilters={handleClearFilters}
|
||||||
|
/>
|
||||||
|
<DataTable
|
||||||
|
columns={projectColumns}
|
||||||
|
data={filteredProjects}
|
||||||
|
searchKey="name"
|
||||||
|
searchValue={searchValue}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<typeof CheckboxPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
className={cn("flex items-center justify-center text-current")}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
))
|
||||||
|
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
|
|
@ -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<typeof ProgressPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||||
|
>(({ className, value, ...props }, ref) => (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
className="h-full w-full flex-1 bg-primary transition-all"
|
||||||
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
))
|
||||||
|
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Progress }
|
||||||
|
|
@ -77,7 +77,7 @@ const SelectContent = React.forwardRef<
|
||||||
className={cn(
|
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]",
|
"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" &&
|
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
|
className
|
||||||
)}
|
)}
|
||||||
position={position}
|
position={position}
|
||||||
|
|
@ -88,7 +88,7 @@ const SelectContent = React.forwardRef<
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-1",
|
"p-1",
|
||||||
position === "popper" &&
|
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}
|
{children}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Table = React.forwardRef<
|
||||||
|
HTMLTableElement,
|
||||||
|
React.HTMLAttributes<HTMLTableElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="relative w-full overflow-auto">
|
||||||
|
<table
|
||||||
|
ref={ref}
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
Table.displayName = "Table"
|
||||||
|
|
||||||
|
const TableHeader = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||||
|
))
|
||||||
|
TableHeader.displayName = "TableHeader"
|
||||||
|
|
||||||
|
const TableBody = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tbody
|
||||||
|
ref={ref}
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableBody.displayName = "TableBody"
|
||||||
|
|
||||||
|
const TableFooter = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tfoot
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableFooter.displayName = "TableFooter"
|
||||||
|
|
||||||
|
const TableRow = React.forwardRef<
|
||||||
|
HTMLTableRowElement,
|
||||||
|
React.HTMLAttributes<HTMLTableRowElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tr
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableRow.displayName = "TableRow"
|
||||||
|
|
||||||
|
const TableHead = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<th
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableHead.displayName = "TableHead"
|
||||||
|
|
||||||
|
const TableCell = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<td
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableCell.displayName = "TableCell"
|
||||||
|
|
||||||
|
const TableCaption = React.forwardRef<
|
||||||
|
HTMLTableCaptionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<caption
|
||||||
|
ref={ref}
|
||||||
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableCaption.displayName = "TableCaption"
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,50 @@
|
||||||
/**
|
/**
|
||||||
* Cliente para la API de Dolibarr
|
* 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 url = `/api/dolibarr/${endpoint}`;
|
||||||
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
|
|
@ -17,9 +57,35 @@ export async function dolibarrFetch(endpoint: string, options: RequestInit = {})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const errorData = await res.json().catch(() => ({ error: 'Unknown error' }));
|
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");
|
throw new Error(errorData.error || "Dolibarr API error");
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<Task[]> {
|
||||||
|
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<Task[]> {
|
||||||
|
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<Task | null> {
|
||||||
|
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<DolibarrTask | null> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -9,13 +9,16 @@
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@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-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.556.0",
|
"lucide-react": "^0.556.0",
|
||||||
|
|
@ -23,7 +26,6 @@
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"recharts": "^3.7.0",
|
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"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": {
|
"node_modules/@radix-ui/react-collection": {
|
||||||
"version": "1.1.7",
|
"version": "1.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
"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": {
|
"node_modules/@radix-ui/react-roving-focus": {
|
||||||
"version": "1.1.11",
|
"version": "1.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
|
"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==",
|
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@rtsao/scc": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||||
|
|
@ -2813,18 +2889,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
|
|
@ -3119,6 +3183,39 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.1",
|
"version": "0.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||||
|
|
@ -3130,69 +3227,6 @@
|
||||||
"tslib": "^2.4.0"
|
"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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
|
|
@ -3244,12 +3278,6 @@
|
||||||
"@types/react": "^19.2.0"
|
"@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": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.48.1",
|
"version": "8.48.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz",
|
||||||
|
|
@ -4482,127 +4510,6 @@
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/damerau-levenshtein": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
"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": {
|
"node_modules/deep-is": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||||
|
|
@ -4992,16 +4893,6 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/escalade": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
|
|
@ -5449,12 +5340,6 @@
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
|
|
@ -5950,16 +5835,6 @@
|
||||||
"node": ">= 4"
|
"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": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
|
|
@ -6002,15 +5877,6 @@
|
||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/is-array-buffer": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||||
|
|
@ -7694,31 +7560,9 @@
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/react-remove-scroll": {
|
||||||
"version": "2.7.2",
|
"version": "2.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||||
|
|
@ -7809,51 +7653,6 @@
|
||||||
"node": ">=8.10.0"
|
"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": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
|
|
@ -7898,12 +7697,6 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
|
|
@ -8618,12 +8411,6 @@
|
||||||
"node": ">=0.8"
|
"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": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
|
|
@ -9021,28 +8808,6 @@
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -8,18 +8,22 @@
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"seed": "node scripts/seed-projects-via-api.js",
|
"seed": "node scripts/seed-projects-via-api.js",
|
||||||
|
"seed:tasks": "node scripts/seed-tasks.js",
|
||||||
"seed:direct": "node scripts/seed-projects.js",
|
"seed:direct": "node scripts/seed-projects.js",
|
||||||
"diagnose": "node scripts/diagnose.js"
|
"diagnose": "node scripts/diagnose.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@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-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.556.0",
|
"lucide-react": "^0.556.0",
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<TaskStatus, { label: string; color: string; bgClass: string }> = {
|
||||||
|
'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<TaskPriority, { label: string; color: string; bgClass: string }> = {
|
||||||
|
'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';
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue