feat: add @radix-ui/react-label and recharts dependencies

refactor: update task types to support string and number for date and duration fields

chore: remove unused postcss configuration

feat: enhance task mapping functions for better data handling and conversion
This commit is contained in:
Levi Planelles 2026-01-31 13:56:51 +01:00
parent 11fbef5595
commit 9331e7e890
32 changed files with 2483 additions and 489 deletions

View File

@ -97,17 +97,33 @@ export async function POST(
body: JSON.stringify(body),
});
const responseText = await response.text();
if (!response.ok) {
const errorText = await response.text();
console.error('Dolibarr API error:', response.status, errorText);
console.error('Dolibarr API error:', response.status, responseText);
// Intentar parsear como JSON para obtener detalles del error
let errorDetails = responseText;
try {
const errorJson = JSON.parse(responseText);
errorDetails = errorJson.error?.message || errorJson.error || responseText;
} catch {
// Mantener el texto original si no es JSON
}
return NextResponse.json(
{ error: 'Error al comunicarse con Dolibarr', details: errorText },
{ error: errorDetails },
{ status: response.status }
);
}
const data = await response.json();
// Parsear respuesta exitosa
let data;
try {
data = JSON.parse(responseText);
} catch {
data = responseText;
}
return NextResponse.json(data);
} catch (error) {

116
app/tareas/[id]/loading.tsx Normal file
View File

@ -0,0 +1,116 @@
import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
export default function TaskDetailLoading() {
return (
<div className="flex flex-col min-h-screen">
{/* Header skeleton */}
<div className="border-b bg-card">
<div className="px-6 py-4">
{/* Breadcrumb */}
<div className="flex items-center gap-2 mb-4">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-24" />
</div>
{/* Header principal */}
<div className="flex items-start justify-between gap-4">
<div className="space-y-3 flex-1">
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-6 w-20" />
<Skeleton className="h-6 w-16" />
</div>
<div className="flex items-center gap-4">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-4 w-40" />
</div>
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-9 w-24" />
<Skeleton className="h-9 w-9" />
</div>
</div>
</div>
</div>
{/* Contenido */}
<div className="flex-1 p-6 space-y-6">
{/* Stats skeleton */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-4" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16 mb-2" />
<Skeleton className="h-2 w-full mb-2" />
<Skeleton className="h-3 w-24" />
</CardContent>
</Card>
))}
</div>
{/* Info skeleton */}
<div className="grid gap-6 lg:grid-cols-2">
{/* Descripción */}
<Card className="lg:col-span-2">
<CardHeader>
<Skeleton className="h-6 w-32" />
</CardHeader>
<CardContent>
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
</CardContent>
</Card>
{/* Info General */}
<Card>
<CardHeader>
<Skeleton className="h-6 w-40" />
<Skeleton className="h-4 w-48" />
</CardHeader>
<CardContent className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-start gap-3">
<Skeleton className="h-8 w-8 rounded-lg" />
<div className="flex-1">
<Skeleton className="h-3 w-16 mb-1" />
<Skeleton className="h-4 w-32" />
</div>
</div>
))}
</CardContent>
</Card>
{/* Fechas y Tiempos */}
<Card>
<CardHeader>
<Skeleton className="h-6 w-36" />
<Skeleton className="h-4 w-40" />
</CardHeader>
<CardContent className="space-y-4">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex items-start gap-3">
<Skeleton className="h-8 w-8 rounded-lg" />
<div className="flex-1">
<Skeleton className="h-3 w-20 mb-1" />
<Skeleton className="h-4 w-36" />
</div>
</div>
))}
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,36 @@
import Link from "next/link";
import { FileQuestion, ArrowLeft, Home } from "lucide-react";
import { Button } from "@/components/ui/button";
export default function TaskNotFound() {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] px-4">
<div className="p-4 rounded-full bg-muted mb-6">
<FileQuestion className="h-12 w-12 text-muted-foreground" />
</div>
<h1 className="text-2xl font-bold mb-2">Tarea no encontrada</h1>
<p className="text-muted-foreground text-center max-w-md mb-6">
La tarea que buscas no existe o ha sido eliminada.
Verifica el ID de la tarea o vuelve a la lista de proyectos.
</p>
<div className="flex items-center gap-3">
<Button variant="outline" asChild>
<Link href="/proyectos">
<ArrowLeft className="h-4 w-4 mr-2" />
Ver proyectos
</Link>
</Button>
<Button asChild>
<Link href="/">
<Home className="h-4 w-4 mr-2" />
Ir al inicio
</Link>
</Button>
</div>
</div>
);
}

76
app/tareas/[id]/page.tsx Normal file
View File

@ -0,0 +1,76 @@
import { notFound } from "next/navigation";
import { getTaskById } from "@/lib/tasksService";
import { getProjectById } from "@/lib/projectsService";
import { TaskDetailView } from "@/components/task-detail";
interface PageProps {
params: Promise<{
id: string;
}>;
}
/**
* Página de detalle de tarea
* Ruta dinámica: /tareas/[id]
*
* Muestra información completa de la tarea incluyendo:
* - Header con breadcrumb, título, estado y prioridad
* - Estadísticas (progreso, tiempo, fecha límite, rendimiento)
* - Información detallada (descripción, fechas, metadatos)
* - Navegación al proyecto padre
*/
export default async function TaskDetailPage({ params }: PageProps) {
// Await params en Next.js 16
const resolvedParams = await params;
const taskId = parseInt(resolvedParams.id);
if (isNaN(taskId)) {
notFound();
}
// Obtener la tarea
const task = await getTaskById(taskId);
if (!task) {
notFound();
}
// Obtener el proyecto asociado (puede ser null)
let project = null;
if (task.projectId) {
try {
const fetchedProject = await getProjectById(task.projectId);
project = fetchedProject ?? null;
} catch (error) {
console.error('Error fetching project for task:', error);
// Continuar sin proyecto
}
}
return <TaskDetailView initialTask={task} project={project} />;
}
// Metadata dinámica para SEO
export async function generateMetadata({ params }: PageProps) {
const resolvedParams = await params;
const taskId = parseInt(resolvedParams.id);
if (isNaN(taskId)) {
return { title: "Tarea no encontrada" };
}
try {
const task = await getTaskById(taskId);
if (!task) {
return { title: "Tarea no encontrada" };
}
return {
title: `${task.title} - Tarea`,
description: task.description || `Detalles de la tarea ${task.ref}`,
};
} catch {
return { title: "Tarea" };
}
}

View File

@ -17,37 +17,37 @@ export default function DashboardHeader({
onViewModeChange
}: DashboardHeaderProps) {
return (
<header className="bg-white border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<header className="bg-card border-b">
<div className="px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-gray-900">Dashboard de Proyectos</h1>
<p className="text-sm text-gray-500 mt-1">Gestiona y visualiza todos tus proyectos</p>
<h1 className="text-2xl font-semibold text-foreground">Dashboard de Proyectos</h1>
<p className="text-sm text-muted-foreground mt-1">Gestiona y visualiza todos tus proyectos</p>
</div>
<div className="flex items-center gap-3">
<ThemeToggle />
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
<input
type="text"
placeholder="Buscar proyectos..."
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent w-64 text-sm"
className="pl-10 pr-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent w-64 text-sm bg-background text-foreground"
/>
</div>
<div className="flex gap-1 border border-gray-200 rounded-lg p-1">
<div className="flex gap-1 border rounded-lg p-1">
<button
onClick={() => onViewModeChange('grid')}
className={`p-2 rounded ${viewMode === 'grid' ? 'bg-gray-100' : 'hover:bg-gray-50'}`}
className={`p-2 rounded ${viewMode === 'grid' ? 'bg-muted' : 'hover:bg-muted/50'}`}
>
<LayoutGrid className="w-4 h-4 text-gray-600" />
<LayoutGrid className="w-4 h-4 text-muted-foreground" />
</button>
<button
onClick={() => onViewModeChange('list')}
className={`p-2 rounded ${viewMode === 'list' ? 'bg-gray-100' : 'hover:bg-gray-50'}`}
className={`p-2 rounded ${viewMode === 'list' ? 'bg-muted' : 'hover:bg-muted/50'}`}
>
<List className="w-4 h-4 text-gray-600" />
<List className="w-4 h-4 text-muted-foreground" />
</button>
</div>
</div>

View File

@ -3,9 +3,9 @@
import Link from 'next/link';
import { Calendar, DollarSign, TrendingUp, FileText } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Project, ProjectStatus } from '@/types/project';
import { ProjectStatusBadge } from '@/components/ui/project-status-badge';
import { Project } from '@/types/project';
interface ProjectCardProps {
project: Project;
@ -21,46 +21,12 @@ function getInitials(name: string): string {
.slice(0, 2);
}
// Componente para el badge de estado con estilos directos
function StatusBadge({ status }: { status: ProjectStatus }) {
if (status === '0') {
return (
<Badge variant="outline" className="mt-3 border bg-gray-100 text-gray-800 border-gray-200">
Borrador
</Badge>
);
}
if (status === '1') {
return (
<Badge variant="outline" className="mt-3 border bg-blue-100 text-blue-800 border-blue-200">
Abierto
</Badge>
);
}
if (status === '2') {
return (
<Badge variant="outline" className="mt-3 border bg-red-100 text-red-800 border-red-200">
Cerrado
</Badge>
);
}
// Fallback
return (
<Badge variant="outline" className="mt-3 border bg-gray-100 text-gray-800 border-gray-200">
Desconocido
</Badge>
);
}
export default function ProjectCard({ project }: ProjectCardProps) {
const initials = getInitials(project.name);
return (
<Link href={`/proyectos/${project.id}`}>
<Card className="border-gray-200 hover:shadow-lg transition-all duration-300 hover:-translate-y-1 cursor-pointer">
<Card className="hover:shadow-lg transition-all duration-300 hover:-translate-y-1 cursor-pointer h-full">
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
@ -70,32 +36,32 @@ export default function ProjectCard({ project }: ProjectCardProps) {
</AvatarFallback>
</Avatar>
<div>
<CardTitle className="text-lg text-gray-900">{project.name}</CardTitle>
<CardTitle className="text-lg">{project.name}</CardTitle>
<CardDescription className="text-sm">{project.ref}</CardDescription>
</div>
</div>
</div>
<StatusBadge status={project.status} />
<ProjectStatusBadge status={project.status} className="mt-3 w-fit" />
</CardHeader>
<CardContent>
{/* Description */}
{project.description && (
<div className="mb-4">
<div className="flex items-center gap-1 text-gray-500 mb-1">
<div className="flex items-center gap-1 text-muted-foreground mb-1">
<FileText className="w-3 h-3" />
<span className="text-xs">Descripción</span>
</div>
<p className="text-sm text-gray-700 line-clamp-2">{project.description}</p>
<p className="text-sm text-muted-foreground line-clamp-2">{project.description}</p>
</div>
)}
{/* Progress Bar */}
<div className="mb-4">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-gray-600">Progreso</span>
<span className="text-xs font-semibold text-gray-900">{project.progress}%</span>
<span className="text-xs font-medium text-muted-foreground">Progreso</span>
<span className="text-xs font-semibold">{project.progress}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div className="w-full bg-muted rounded-full h-2">
<div
className="bg-gradient-to-r from-blue-500 to-purple-600 h-2 rounded-full transition-all duration-500"
style={{ width: `${project.progress}%` }}
@ -106,34 +72,34 @@ export default function ProjectCard({ project }: ProjectCardProps) {
{/* Budget Info */}
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<div className="flex items-center gap-1 text-gray-500 mb-1">
<div className="flex items-center gap-1 text-muted-foreground mb-1">
<DollarSign className="w-3 h-3" />
<span className="text-xs">Presupuesto</span>
</div>
<p className="text-sm font-semibold text-gray-900">
<p className="text-sm font-semibold">
€{project.budget.toLocaleString('es-ES', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}
</p>
</div>
<div>
<div className="flex items-center gap-1 text-gray-500 mb-1">
<div className="flex items-center gap-1 text-muted-foreground mb-1">
<TrendingUp className="w-3 h-3" />
<span className="text-xs">Estimado</span>
</div>
<p className="text-sm font-semibold text-gray-900">
<p className="text-sm font-semibold">
€{project.spent.toLocaleString('es-ES', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}
</p>
</div>
</div>
{/* Footer Info */}
<div className="pt-4 border-t border-gray-100 flex items-center justify-between">
<div className="flex items-center gap-1 text-gray-500">
<div className="pt-4 border-t flex items-center justify-between">
<div className="flex items-center gap-1 text-muted-foreground">
<Calendar className="w-4 h-4" />
<span className="text-xs">
{new Date(project.startDate).toLocaleDateString('es-ES', { day: '2-digit', month: 'short', year: 'numeric' })}
</span>
</div>
<div className="flex items-center gap-1 text-gray-500">
<div className="flex items-center gap-1 text-muted-foreground">
<Calendar className="w-4 h-4" />
<span className="text-xs">
{new Date(project.endDate).toLocaleDateString('es-ES', { day: '2-digit', month: 'short', year: 'numeric' })}
@ -143,9 +109,9 @@ export default function ProjectCard({ project }: ProjectCardProps) {
{/* Cliente */}
{project.client !== 'Sin cliente' && (
<div className="mt-3 pt-3 border-t border-gray-100">
<span className="text-xs text-gray-500">Cliente: </span>
<span className="text-xs font-medium text-gray-700">{project.client}</span>
<div className="mt-3 pt-3 border-t">
<span className="text-xs text-muted-foreground">Cliente: </span>
<span className="text-xs font-medium">{project.client}</span>
</div>
)}
</CardContent>

View File

@ -41,7 +41,7 @@ export default function ProjectDashboard() {
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">
<div className="min-h-screen bg-background">
<DashboardHeader
searchTerm=""
onSearchChange={() => {}}
@ -49,11 +49,11 @@ export default function ProjectDashboard() {
onViewModeChange={setViewMode}
/>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<main className="px-4 sm:px-6 lg:px-8 py-8">
{/* Stats skeleton */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="bg-white border border-gray-200 rounded-lg p-6">
<div key={i} className="bg-card border rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-4 rounded" />
@ -73,9 +73,9 @@ export default function ProjectDashboard() {
if (error) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 flex items-center justify-center">
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center">
<p className="text-red-600">{error}</p>
<p className="text-red-600 dark:text-red-400">{error}</p>
<button
onClick={() => window.location.reload()}
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
@ -88,7 +88,7 @@ export default function ProjectDashboard() {
}
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">
<div className="min-h-screen bg-background">
<DashboardHeader
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
@ -96,7 +96,7 @@ export default function ProjectDashboard() {
onViewModeChange={setViewMode}
/>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<main className="px-4 sm:px-6 lg:px-8 py-8">
<StatsOverview projects={projects} />
<ProjectGrid projects={filteredProjects} viewMode={viewMode} />
</main>

View File

@ -3,11 +3,11 @@
import { ArrowLeft, Calendar, DollarSign, TrendingUp, FileText, Building2 } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Project, ProjectStatus } from '@/types/project';
import { ProjectStatusBadge } from '@/components/ui/project-status-badge';
import { Project } from '@/types/project';
interface ProjectDetailProps {
project: Project;
@ -23,39 +23,6 @@ function getInitials(name: string): string {
.slice(0, 2);
}
// Componente para el badge de estado
function StatusBadge({ status }: { status: ProjectStatus }) {
if (status === '0') {
return (
<Badge variant="outline" className="border bg-gray-100 text-gray-800 border-gray-200 text-base px-4 py-1">
Borrador
</Badge>
);
}
if (status === '1') {
return (
<Badge variant="outline" className="border bg-blue-100 text-blue-800 border-blue-200 text-base px-4 py-1">
Abierto
</Badge>
);
}
if (status === '2') {
return (
<Badge variant="outline" className="border bg-red-100 text-red-800 border-red-200 text-base px-4 py-1">
Cerrado
</Badge>
);
}
return (
<Badge variant="outline" className="border bg-gray-100 text-gray-800 border-gray-200 text-base px-4 py-1">
Desconocido
</Badge>
);
}
/**
* Componente de detalle completo de un proyecto
* Muestra toda la información disponible del proyecto
@ -65,9 +32,9 @@ export default function ProjectDetail({ project }: ProjectDetailProps) {
const initials = getInitials(project.name);
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">
<div className="min-h-screen bg-background">
{/* Header */}
<div className="bg-white border-b border-gray-200">
<div className="bg-card border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<Button
variant="ghost"
@ -88,9 +55,9 @@ export default function ProjectDetail({ project }: ProjectDetailProps) {
<div className="flex-1">
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">{project.name}</h1>
<p className="text-gray-500 mb-3">{project.ref}</p>
<StatusBadge status={project.status} />
<h1 className="text-3xl font-bold mb-2">{project.name}</h1>
<p className="text-muted-foreground mb-3">{project.ref}</p>
<ProjectStatusBadge status={project.status} className="text-base px-4 py-1" />
</div>
</div>
</div>
@ -113,7 +80,7 @@ export default function ProjectDetail({ project }: ProjectDetailProps) {
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-700 whitespace-pre-wrap">{project.description}</p>
<p className="text-muted-foreground whitespace-pre-wrap">{project.description}</p>
</CardContent>
</Card>
)}
@ -129,25 +96,25 @@ export default function ProjectDetail({ project }: ProjectDetailProps) {
<CardContent>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-600">Avance Total</span>
<span className="text-2xl font-bold text-gray-900">{project.progress}%</span>
<span className="text-sm font-medium text-muted-foreground">Avance Total</span>
<span className="text-2xl font-bold">{project.progress}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-4">
<div className="w-full bg-muted rounded-full h-4">
<div
className="bg-gradient-to-r from-blue-500 to-purple-600 h-4 rounded-full transition-all duration-500"
style={{ width: `${project.progress}%` }}
/>
</div>
<div className="grid grid-cols-2 gap-4 pt-4">
<div className="text-center p-4 bg-gray-50 rounded-lg">
<p className="text-sm text-gray-600 mb-1">Estado</p>
<p className="text-lg font-semibold text-gray-900">
<div className="text-center p-4 bg-muted/50 rounded-lg">
<p className="text-sm text-muted-foreground mb-1">Estado</p>
<p className="text-lg font-semibold">
{project.status === '0' ? 'Borrador' : project.status === '1' ? 'En Progreso' : 'Finalizado'}
</p>
</div>
<div className="text-center p-4 bg-gray-50 rounded-lg">
<p className="text-sm text-gray-600 mb-1">Completado</p>
<p className="text-lg font-semibold text-gray-900">
<div className="text-center p-4 bg-muted/50 rounded-lg">
<p className="text-sm text-muted-foreground mb-1">Completado</p>
<p className="text-lg font-semibold">
{project.progress === 100 ? 'Sí' : 'No'}
</p>
</div>
@ -167,22 +134,22 @@ export default function ProjectDetail({ project }: ProjectDetailProps) {
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<p className="text-sm text-gray-600">Presupuesto Total</p>
<p className="text-3xl font-bold text-gray-900">
<p className="text-sm text-muted-foreground">Presupuesto Total</p>
<p className="text-3xl font-bold">
€{project.budget.toLocaleString('es-ES', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
</div>
<div className="space-y-2">
<p className="text-sm text-gray-600">Estimado Gastado</p>
<p className="text-3xl font-bold text-blue-600">
<p className="text-sm text-muted-foreground">Estimado Gastado</p>
<p className="text-3xl font-bold text-blue-600 dark:text-blue-400">
€{project.spent.toLocaleString('es-ES', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
</div>
</div>
<Separator className="my-4" />
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<span className="text-sm font-medium text-gray-600">Restante</span>
<span className="text-xl font-bold text-green-600">
<div className="flex items-center justify-between p-4 bg-muted/50 rounded-lg">
<span className="text-sm font-medium text-muted-foreground">Restante</span>
<span className="text-xl font-bold text-green-600 dark:text-green-400">
€{(project.budget - project.spent).toLocaleString('es-ES', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
</div>
@ -201,10 +168,10 @@ export default function ProjectDetail({ project }: ProjectDetailProps) {
{/* Cliente */}
{project.client !== 'Sin cliente' && (
<div className="flex items-start gap-3">
<Building2 className="w-5 h-5 text-gray-400 mt-0.5" />
<Building2 className="w-5 h-5 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm text-gray-600">Cliente</p>
<p className="font-medium text-gray-900">{project.client}</p>
<p className="text-sm text-muted-foreground">Cliente</p>
<p className="font-medium">{project.client}</p>
</div>
</div>
)}
@ -213,10 +180,10 @@ export default function ProjectDetail({ project }: ProjectDetailProps) {
{/* Fecha de Inicio */}
<div className="flex items-start gap-3">
<Calendar className="w-5 h-5 text-gray-400 mt-0.5" />
<Calendar className="w-5 h-5 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm text-gray-600">Fecha de Inicio</p>
<p className="font-medium text-gray-900">
<p className="text-sm text-muted-foreground">Fecha de Inicio</p>
<p className="font-medium">
{new Date(project.startDate).toLocaleDateString('es-ES', {
day: 'numeric',
month: 'long',
@ -230,10 +197,10 @@ export default function ProjectDetail({ project }: ProjectDetailProps) {
{/* Fecha de Fin */}
<div className="flex items-start gap-3">
<Calendar className="w-5 h-5 text-gray-400 mt-0.5" />
<Calendar className="w-5 h-5 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm text-gray-600">Fecha de Fin</p>
<p className="font-medium text-gray-900">
<p className="text-sm text-muted-foreground">Fecha de Fin</p>
<p className="font-medium">
{new Date(project.endDate).toLocaleDateString('es-ES', {
day: 'numeric',
month: 'long',
@ -247,10 +214,10 @@ export default function ProjectDetail({ project }: ProjectDetailProps) {
{/* Duración */}
<div className="flex items-start gap-3">
<Calendar className="w-5 h-5 text-gray-400 mt-0.5" />
<Calendar className="w-5 h-5 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm text-gray-600">Duración</p>
<p className="font-medium text-gray-900">
<p className="text-sm text-muted-foreground">Duración</p>
<p className="font-medium">
{Math.ceil((new Date(project.endDate).getTime() - new Date(project.startDate).getTime()) / (1000 * 60 * 60 * 24))} días
</p>
</div>
@ -265,18 +232,18 @@ export default function ProjectDetail({ project }: ProjectDetailProps) {
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div>
<p className="text-gray-600">ID del Proyecto</p>
<p className="font-mono text-gray-900">{project.id}</p>
<p className="text-muted-foreground">ID del Proyecto</p>
<p className="font-mono">{project.id}</p>
</div>
<Separator />
<div>
<p className="text-gray-600">Referencia</p>
<p className="font-mono text-gray-900">{project.ref}</p>
<p className="text-muted-foreground">Referencia</p>
<p className="font-mono">{project.ref}</p>
</div>
<Separator />
<div>
<p className="text-gray-600">Creado</p>
<p className="text-gray-900">
<p className="text-muted-foreground">Creado</p>
<p>
{new Date(project.createdAt).toLocaleDateString('es-ES', {
day: 'numeric',
month: 'short',
@ -286,8 +253,8 @@ export default function ProjectDetail({ project }: ProjectDetailProps) {
</div>
<Separator />
<div>
<p className="text-gray-600">Última Actualización</p>
<p className="text-gray-900">
<p className="text-muted-foreground">Última Actualización</p>
<p>
{new Date(project.updatedAt).toLocaleDateString('es-ES', {
day: 'numeric',
month: 'short',

View File

@ -13,7 +13,7 @@ export default function ProjectGrid({ projects, viewMode }: ProjectGridProps) {
if (projects.length === 0) {
return (
<div className="text-center py-12">
<p className="text-gray-500">No se encontraron proyectos</p>
<p className="text-muted-foreground">No se encontraron proyectos</p>
</div>
);
}

View File

@ -2,9 +2,9 @@
import Link from 'next/link';
import { Calendar, DollarSign, TrendingUp } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Project, ProjectStatus } from '@/types/project';
import { ProjectStatusBadge } from '@/components/ui/project-status-badge';
import { Project } from '@/types/project';
interface ProjectListItemProps {
project: Project;
@ -20,40 +20,6 @@ function getInitials(name: string): string {
.slice(0, 2);
}
// Componente para el badge de estado con estilos directos
function StatusBadge({ status }: { status: ProjectStatus }) {
if (status === '0') {
return (
<Badge variant="outline" className="border bg-gray-100 text-gray-800 border-gray-200">
Borrador
</Badge>
);
}
if (status === '1') {
return (
<Badge variant="outline" className="border bg-blue-100 text-blue-800 border-blue-200">
Abierto
</Badge>
);
}
if (status === '2') {
return (
<Badge variant="outline" className="border bg-red-100 text-red-800 border-red-200">
Cerrado
</Badge>
);
}
// Fallback
return (
<Badge variant="outline" className="border bg-gray-100 text-gray-800 border-gray-200">
Desconocido
</Badge>
);
}
/**
* Componente de fila de proyecto para vista de lista
* Diseño horizontal y compacto
@ -63,7 +29,7 @@ export default function ProjectListItem({ project }: ProjectListItemProps) {
return (
<Link href={`/proyectos/${project.id}`}>
<div className="bg-white border border-gray-200 rounded-lg p-4 hover:shadow-md transition-all duration-300 cursor-pointer">
<div className="bg-card border rounded-lg p-4 hover:shadow-md transition-all duration-300 cursor-pointer">
<div className="flex items-center gap-4">
{/* Avatar */}
<Avatar className="w-12 h-12 flex-shrink-0">
@ -77,30 +43,30 @@ export default function ProjectListItem({ project }: ProjectListItemProps) {
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-base font-semibold text-gray-900 truncate">
<h3 className="text-base font-semibold truncate">
{project.name}
</h3>
<span className="text-xs text-gray-500 flex-shrink-0">{project.ref}</span>
<span className="text-xs text-muted-foreground flex-shrink-0">{project.ref}</span>
</div>
{project.client !== 'Sin cliente' && (
<p className="text-sm text-gray-600 truncate">{project.client}</p>
<p className="text-sm text-muted-foreground truncate">{project.client}</p>
)}
</div>
{/* Estado */}
<StatusBadge status={project.status} />
<ProjectStatusBadge status={project.status} />
</div>
</div>
{/* Progreso */}
<div className="hidden md:flex items-center gap-2 flex-shrink-0 w-32">
<TrendingUp className="w-4 h-4 text-gray-400" />
<TrendingUp className="w-4 h-4 text-muted-foreground" />
<div className="flex-1">
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-gray-600">Progreso</span>
<span className="text-xs font-semibold text-gray-900">{project.progress}%</span>
<span className="text-xs text-muted-foreground">Progreso</span>
<span className="text-xs font-semibold">{project.progress}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-1.5">
<div className="w-full bg-muted rounded-full h-1.5">
<div
className="bg-gradient-to-r from-blue-500 to-purple-600 h-1.5 rounded-full transition-all duration-500"
style={{ width: `${project.progress}%` }}
@ -113,21 +79,21 @@ export default function ProjectListItem({ project }: ProjectListItemProps) {
<div className="hidden lg:flex items-center gap-2 flex-shrink-0">
<DollarSign className="w-4 h-4 text-green-500" />
<div className="text-right">
<div className="text-sm font-semibold text-gray-900">
<div className="text-sm font-semibold">
€{project.budget.toLocaleString('es-ES', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}
</div>
<div className="text-xs text-gray-500">Presupuesto</div>
<div className="text-xs text-muted-foreground">Presupuesto</div>
</div>
</div>
{/* Fechas */}
<div className="hidden xl:flex items-center gap-2 flex-shrink-0">
<Calendar className="w-4 h-4 text-gray-400" />
<Calendar className="w-4 h-4 text-muted-foreground" />
<div className="text-right">
<div className="text-sm text-gray-900">
<div className="text-sm">
{new Date(project.startDate).toLocaleDateString('es-ES', { day: '2-digit', month: 'short' })}
</div>
<div className="text-xs text-gray-500">Inicio</div>
<div className="text-xs text-muted-foreground">Inicio</div>
</div>
</div>
</div>

View File

@ -20,49 +20,49 @@ export default function StatsOverview({ projects }: StatsOverviewProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<Card className="border-gray-200 hover:shadow-md transition-shadow">
<Card className="border hover:shadow-md transition-shadow">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-gray-600">Total Proyectos</CardTitle>
<LayoutGrid className="w-4 h-4 text-gray-400" />
<CardTitle className="text-sm font-medium text-muted-foreground">Total Proyectos</CardTitle>
<LayoutGrid className="w-4 h-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-gray-900">{stats.total}</div>
<p className="text-xs text-gray-500 mt-1">Todos los proyectos</p>
<div className="text-3xl font-bold text-foreground">{stats.total}</div>
<p className="text-xs text-muted-foreground mt-1">Todos los proyectos</p>
</CardContent>
</Card>
<Card className="border-gray-200 hover:shadow-md transition-shadow">
<Card className="border hover:shadow-md transition-shadow">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-gray-600">Activos</CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground">Activos</CardTitle>
<TrendingUp className="w-4 h-4 text-blue-500" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-gray-900">{stats.activos}</div>
<p className="text-xs text-gray-500 mt-1">En progreso ahora</p>
<div className="text-3xl font-bold text-foreground">{stats.activos}</div>
<p className="text-xs text-muted-foreground mt-1">En progreso ahora</p>
</CardContent>
</Card>
<Card className="border-gray-200 hover:shadow-md transition-shadow">
<Card className="border hover:shadow-md transition-shadow">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-gray-600">Presupuesto Total</CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground">Presupuesto Total</CardTitle>
<DollarSign className="w-4 h-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-gray-900">
<div className="text-3xl font-bold text-foreground">
€{(stats.totalBudget / 1000).toFixed(0)}k
</div>
<p className="text-xs text-gray-500 mt-1">Suma de presupuestos</p>
<p className="text-xs text-muted-foreground mt-1">Suma de presupuestos</p>
</CardContent>
</Card>
<Card className="border-gray-200 hover:shadow-md transition-shadow">
<Card className="border hover:shadow-md transition-shadow">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-gray-600">Progreso Medio</CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground">Progreso Medio</CardTitle>
<Clock className="w-4 h-4 text-purple-500" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-gray-900">{stats.avgProgress}%</div>
<p className="text-xs text-gray-500 mt-1">De todos los proyectos</p>
<div className="text-3xl font-bold text-foreground">{stats.avgProgress}%</div>
<p className="text-xs text-muted-foreground mt-1">De todos los proyectos</p>
</CardContent>
</Card>
</div>

View File

@ -265,10 +265,10 @@ export default function GanttPage() {
if (error) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 flex items-center justify-center">
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center">
<AlertCircle className="w-16 h-16 text-red-400 mx-auto mb-4" />
<p className="text-red-600 text-lg">{error}</p>
<AlertCircle className="w-16 h-16 text-red-400 dark:text-red-500 mx-auto mb-4" />
<p className="text-red-600 dark:text-red-400 text-lg">{error}</p>
<button
onClick={() => window.location.reload()}
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
@ -281,9 +281,9 @@ export default function GanttPage() {
}
return (
<div className="h-screen flex flex-col bg-gradient-to-br from-gray-50 to-gray-100">
<div className="h-screen flex flex-col bg-background">
{/* Header - siempre visible */}
<header className="bg-white border-b border-gray-200 flex-shrink-0 z-10">
<header className="bg-card border-b flex-shrink-0 z-10">
<div className="px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-3">
@ -291,8 +291,8 @@ export default function GanttPage() {
<GanttIcon className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">Diagrama de Gantt</h1>
<p className="text-sm text-gray-500">
<h1 className="text-2xl font-bold text-foreground">Diagrama de Gantt</h1>
<p className="text-sm text-muted-foreground">
Timeline de {filteredProjects.length} proyectos
</p>
</div>
@ -377,22 +377,22 @@ export default function GanttPage() {
{loading ? (
<GanttSkeleton />
) : filteredProjects.length === 0 ? (
<Card className="border-gray-200">
<Card className="border">
<CardContent className="py-16 text-center">
<GanttIcon className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-xl font-semibold text-gray-700">No hay proyectos</h3>
<p className="text-gray-500 mt-2">No se encontraron proyectos con los filtros seleccionados</p>
<GanttIcon className="w-16 h-16 text-muted-foreground/50 mx-auto mb-4" />
<h3 className="text-xl font-semibold text-foreground">No hay proyectos</h3>
<p className="text-muted-foreground mt-2">No se encontraron proyectos con los filtros seleccionados</p>
</CardContent>
</Card>
) : (
<Card className="border-gray-200 w-full">
<Card className="border w-full">
<CardContent className="p-0 overflow-hidden">
<div className="flex w-full overflow-hidden">
{/* Columna de nombres de proyectos - fija */}
<div className="w-64 flex-shrink-0 border-r border-gray-200 bg-gray-50">
<div className="w-64 flex-shrink-0 border-r bg-muted/50">
{/* Header */}
<div className="h-16 border-b border-gray-200 flex items-center px-4">
<span className="font-semibold text-gray-700">Proyecto</span>
<div className="h-16 border-b flex items-center px-4">
<span className="font-semibold text-foreground">Proyecto</span>
</div>
{/* Lista de proyectos */}
{filteredProjects.map((project, index) => {
@ -400,15 +400,15 @@ export default function GanttPage() {
return (
<div
key={project.id}
className="h-14 border-b border-gray-100 flex items-center px-4 hover:bg-gray-100 transition-colors"
className="h-14 border-b flex items-center px-4 hover:bg-muted transition-colors"
>
<div className="flex items-center gap-3 min-w-0">
<div className={`w-3 h-3 rounded-full ${color.bg} flex-shrink-0`} />
<div className="min-w-0">
<p className="text-sm font-medium text-gray-800 truncate" title={project.name}>
<p className="text-sm font-medium text-foreground truncate" title={project.name}>
{project.name}
</p>
<p className="text-xs text-gray-500">{project.progress}%</p>
<p className="text-xs text-muted-foreground">{project.progress}%</p>
</div>
</div>
</div>
@ -423,18 +423,18 @@ export default function GanttPage() {
>
<div style={{ width: timeRange === "all" ? `${visibleMonths.length * 100}px` : "100%" }}>
{/* Header con meses */}
<div className="h-16 border-b border-gray-200 flex">
<div className="h-16 border-b flex">
{visibleMonths.map((month) => {
const isCurrentMonth = new Date().getMonth() === month.month && new Date().getFullYear() === month.year;
return (
<div
key={`${month.year}-${month.month}`}
className={`flex-1 border-r border-gray-200 flex flex-col justify-center px-2 ${
isCurrentMonth ? "bg-blue-50" : "bg-white"
className={`flex-1 border-r flex flex-col justify-center px-2 ${
isCurrentMonth ? "bg-blue-50 dark:bg-blue-950/30" : "bg-card"
}`}
>
<span className={`text-xs font-medium ${isCurrentMonth ? "text-blue-700" : "text-gray-600"}`}>
<span className={`text-xs font-medium ${isCurrentMonth ? "text-blue-700 dark:text-blue-400" : "text-muted-foreground"}`}>
{month.label}
</span>
</div>
@ -463,7 +463,7 @@ export default function GanttPage() {
return (
<div
key={project.id}
className="h-14 border-b border-gray-100 relative"
className="h-14 border-b relative"
>
{/* Grid de meses */}
<div className="absolute inset-0 flex">
@ -473,8 +473,8 @@ export default function GanttPage() {
return (
<div
key={`grid-${month.year}-${month.month}`}
className={`flex-1 border-r border-gray-100 ${
isCurrentMonth ? "bg-blue-50/30" : ""
className={`flex-1 border-r ${
isCurrentMonth ? "bg-blue-50/30 dark:bg-blue-950/20" : ""
}`}
/>
);
@ -546,15 +546,15 @@ export default function GanttPage() {
function GanttSkeleton() {
return (
<Card className="border-gray-200">
<Card className="border">
<CardContent className="p-0">
<div className="flex">
<div className="w-64 flex-shrink-0 border-r border-gray-200 bg-gray-50">
<div className="h-16 border-b border-gray-200 flex items-center px-4">
<div className="w-64 flex-shrink-0 border-r bg-muted/50">
<div className="h-16 border-b flex items-center px-4">
<Skeleton className="h-4 w-20" />
</div>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-14 border-b border-gray-100 flex items-center px-4">
<div key={i} className="h-14 border-b flex items-center px-4">
<div className="flex items-center gap-3">
<Skeleton className="w-3 h-3 rounded-full" />
<div>
@ -566,15 +566,15 @@ function GanttSkeleton() {
))}
</div>
<div className="flex-1">
<div className="h-16 border-b border-gray-200 flex">
<div className="h-16 border-b flex">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex-1 border-r border-gray-200 flex items-center justify-center">
<div key={i} className="flex-1 border-r flex items-center justify-center">
<Skeleton className="h-4 w-20" />
</div>
))}
</div>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-14 border-b border-gray-100 px-4 flex items-center">
<div key={i} className="h-14 border-b px-4 flex items-center">
<Skeleton className="h-8 w-full max-w-md rounded-md" />
</div>
))}

View File

@ -104,6 +104,11 @@ export function ProjectDetailView({ project }: ProjectDetailViewProps) {
fetchTasks();
}, [fetchTasks]);
// Handler para cuando se crea una tarea
const handleTaskCreated = (newTask: Task) => {
setTasks(prevTasks => [newTask, ...prevTasks]);
};
// Calcular estadísticas de tareas
const taskStats = calculateTaskStats(tasks);
@ -156,7 +161,11 @@ export function ProjectDetailView({ project }: ProjectDetailViewProps) {
<a href="#tasks">Ver todas</a>
</Button>
</div>
<TasksSection tasks={tasks.slice(0, 5)} />
<TasksSection
tasks={tasks.slice(0, 5)}
projectId={project.id}
onTaskCreated={handleTaskCreated}
/>
</CardContent>
</Card>
)}
@ -180,7 +189,11 @@ export function ProjectDetailView({ project }: ProjectDetailViewProps) {
) : tasksError ? (
<ErrorState onRetry={fetchTasks} />
) : (
<TasksSection tasks={tasks} />
<TasksSection
tasks={tasks}
projectId={project.id}
onTaskCreated={handleTaskCreated}
/>
)}
</TabsContent>

View File

@ -5,7 +5,6 @@ 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,
@ -14,7 +13,8 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Project, ProjectStatus } from "@/types/project";
import { ProjectStatusBadge } from "@/components/ui/project-status-badge";
import { Project } from "@/types/project";
interface ProjectHeaderProps {
project: Project;
@ -30,31 +30,7 @@ function getInitials(name: string): string {
.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();
@ -90,7 +66,7 @@ export function ProjectHeader({ project }: ProjectHeaderProps) {
<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} />
<ProjectStatusBadge status={project.status} className="text-sm px-3 py-1" />
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground">

View File

@ -11,6 +11,7 @@ import {
AlertTriangle,
CheckCircle2
} from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@ -132,7 +133,12 @@ export const taskColumns: ColumnDef<Task>[] = [
),
cell: ({ row }) => (
<div className="max-w-[300px]">
<p className="font-medium truncate">{row.getValue("title")}</p>
<Link
href={`/tareas/${row.original.id}`}
className="font-medium truncate hover:text-blue-600 hover:underline transition-colors block"
>
{row.getValue("title")}
</Link>
{row.original.ref && (
<p className="text-xs text-muted-foreground font-mono">{row.original.ref}</p>
)}
@ -218,6 +224,8 @@ export const taskColumns: ColumnDef<Task>[] = [
id: "actions",
enableHiding: false,
cell: ({ row }) => {
const task = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@ -229,9 +237,11 @@ export const taskColumns: ColumnDef<Task>[] = [
<DropdownMenuContent align="end">
<DropdownMenuLabel>Acciones</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Eye className="mr-2 h-4 w-4" />
Ver detalles
<DropdownMenuItem asChild>
<Link href={`/tareas/${task.id}`} className="cursor-pointer">
<Eye className="mr-2 h-4 w-4" />
Ver detalles
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<Pencil className="mr-2 h-4 w-4" />

View File

@ -12,6 +12,7 @@ import {
LayoutGrid,
List
} from "lucide-react";
import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@ -26,11 +27,14 @@ import {
} from "@/components/ui/select";
import { DataTable } from "@/components/projects/data-table";
import { taskColumns } from "./task-columns";
import { TaskFormSheet } from "@/components/task-form/task-form-sheet";
import { Task, TASK_STATUS_CONFIG, TASK_PRIORITY_CONFIG } from "@/types/task";
interface TasksSectionProps {
tasks: Task[];
projectId: number;
isLoading?: boolean;
onTaskCreated?: (task: Task) => void;
}
// Tarjeta de tarea para vista grid
@ -44,68 +48,70 @@ function TaskCard({ task }: { task: Task }) {
})();
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>
<Link href={`/tareas/${task.id}`}>
<Card className={`hover:shadow-md hover:border-blue-300 dark:hover:border-blue-700 transition-all cursor-pointer ${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 hover:text-blue-600 transition-colors">{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>
{task.priority !== '0' && (
<Badge variant="outline" className={priorityConfig.bgClass}>
{task.priority === '3' && <AlertTriangle className="h-3 w-3 mr-1" />}
{priorityConfig.label}
{/* 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>
)}
</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}%` }}
/>
{(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>
{/* 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>
</CardContent>
</Card>
</Link>
);
}
// Estado vacío
function EmptyTasks() {
function EmptyTasks({ onCreateClick }: { onCreateClick: () => void }) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="p-3 rounded-full bg-muted mb-4">
@ -115,7 +121,7 @@ function EmptyTasks() {
<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>
<Button onClick={onCreateClick}>
<Plus className="h-4 w-4 mr-2" />
Crear tarea
</Button>
@ -123,11 +129,17 @@ function EmptyTasks() {
);
}
export function TasksSection({ tasks, isLoading }: TasksSectionProps) {
export function TasksSection({ tasks, projectId, isLoading, onTaskCreated }: TasksSectionProps) {
const [searchValue, setSearchValue] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [priorityFilter, setPriorityFilter] = useState("all");
const [viewMode, setViewMode] = useState<"table" | "grid">("table");
const [isCreateSheetOpen, setIsCreateSheetOpen] = useState(false);
// Handler para cuando se crea una tarea exitosamente
const handleCreateSuccess = (task: Task) => {
onTaskCreated?.(task);
};
// Filtrar tareas
const filteredTasks = useMemo(() => {
@ -175,7 +187,18 @@ export function TasksSection({ tasks, isLoading }: TasksSectionProps) {
const isFiltered = searchValue !== "" || statusFilter !== "all" || priorityFilter !== "all";
if (tasks.length === 0 && !isLoading) {
return <EmptyTasks />;
return (
<>
<EmptyTasks onCreateClick={() => setIsCreateSheetOpen(true)} />
<TaskFormSheet
open={isCreateSheetOpen}
onOpenChange={setIsCreateSheetOpen}
mode="create"
projectId={projectId}
onSuccess={handleCreateSuccess}
/>
</>
);
}
return (
@ -278,7 +301,7 @@ export function TasksSection({ tasks, isLoading }: TasksSectionProps) {
</div>
{/* Nueva tarea */}
<Button size="sm" className="h-9">
<Button size="sm" className="h-9" onClick={() => setIsCreateSheetOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Nueva tarea
</Button>
@ -307,6 +330,15 @@ export function TasksSection({ tasks, isLoading }: TasksSectionProps) {
</Button>
</div>
)}
{/* Sheet de creación de tarea */}
<TaskFormSheet
open={isCreateSheetOpen}
onOpenChange={setIsCreateSheetOpen}
mode="create"
projectId={projectId}
onSuccess={handleCreateSuccess}
/>
</div>
);
}

View File

@ -5,7 +5,6 @@ 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,
@ -15,24 +14,8 @@ import {
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>
);
}
import { ProjectStatusBadge } from "@/components/ui/project-status-badge";
import { Project, ProjectStatus } from "@/types/project";
// Componente para la barra de progreso
function ProgressBar({ progress }: { progress: number }) {
@ -45,7 +28,7 @@ function ProgressBar({ progress }: { progress: number }) {
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="w-24 h-2 bg-muted rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${getProgressColor(progress)}`}
style={{ width: `${Math.min(progress, 100)}%` }}
@ -167,7 +150,7 @@ export const projectColumns: ColumnDef<Project>[] = [
{
accessorKey: "status",
header: "Estado",
cell: ({ row }) => <StatusBadge status={row.getValue("status")} />,
cell: ({ row }) => <ProjectStatusBadge status={row.getValue("status")} />,
filterFn: (row, id, value) => {
return value.includes(row.getValue(id));
},

View File

@ -83,14 +83,14 @@ interface StatCardProps {
function StatCard({ title, value, subtitle, icon, highlight }: StatCardProps) {
return (
<Card className={`border-gray-200 hover:shadow-md transition-shadow ${highlight ? "ring-2 ring-blue-500/20" : ""}`}>
<Card className={`border hover:shadow-md transition-shadow ${highlight ? "ring-2 ring-blue-500/20" : ""}`}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-gray-600">{title}</CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
{icon}
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-gray-900">{value}</div>
{subtitle && <p className="text-xs text-gray-500 mt-1">{subtitle}</p>}
<div className="text-3xl font-bold text-foreground">{value}</div>
{subtitle && <p className="text-xs text-muted-foreground mt-1">{subtitle}</p>}
</CardContent>
</Card>
);
@ -115,9 +115,9 @@ function StatusPieChart({ borradores, abiertos, cerrados }: StatusPieChartProps)
const total = borradores + abiertos + cerrados;
return (
<Card className="border-gray-200">
<Card className="border">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600">Distribucion por Estado</CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground">Distribucion por Estado</CardTitle>
</CardHeader>
<CardContent>
<div className="h-64 relative">
@ -144,8 +144,8 @@ function StatusPieChart({ borradores, abiertos, cerrados }: StatusPieChartProps)
</ResponsiveContainer>
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="text-center">
<p className="text-2xl font-bold text-gray-900">{total}</p>
<p className="text-xs text-gray-500">Total</p>
<p className="text-2xl font-bold text-foreground">{total}</p>
<p className="text-xs text-muted-foreground">Total</p>
</div>
</div>
</div>
@ -153,7 +153,7 @@ function StatusPieChart({ borradores, abiertos, cerrados }: StatusPieChartProps)
{data.map((entry) => (
<div key={entry.name} className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: entry.color }} />
<span className="text-xs text-gray-600">{entry.name}: {entry.value}</span>
<span className="text-xs text-muted-foreground">{entry.name}: {entry.value}</span>
</div>
))}
</div>
@ -172,9 +172,9 @@ interface ProgressDonutProps {
function ProgressDonut({ data, title, centerValue, centerLabel }: ProgressDonutProps) {
return (
<Card className="border-gray-200">
<Card className="border">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600">{title}</CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
</CardHeader>
<CardContent>
<div className="h-64 relative">
@ -202,8 +202,8 @@ function ProgressDonut({ data, title, centerValue, centerLabel }: ProgressDonutP
{centerValue !== undefined && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="text-center">
<p className="text-2xl font-bold text-gray-900">{centerValue}</p>
{centerLabel && <p className="text-xs text-gray-500">{centerLabel}</p>}
<p className="text-2xl font-bold text-foreground">{centerValue}</p>
{centerLabel && <p className="text-xs text-muted-foreground">{centerLabel}</p>}
</div>
</div>
)}
@ -212,7 +212,7 @@ function ProgressDonut({ data, title, centerValue, centerLabel }: ProgressDonutP
{data.map((entry) => (
<div key={entry.name} className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: entry.color }} />
<span className="text-xs text-gray-600">{entry.name}</span>
<span className="text-xs text-muted-foreground">{entry.name}</span>
</div>
))}
</div>
@ -233,9 +233,9 @@ function RadialMetric({ value, title, subtitle, color = COLORS.primary }: Radial
const data = [{ name: title, value: Math.min(value, 100), fill: color }];
return (
<Card className="border-gray-200">
<Card className="border">
<CardHeader className="pb-0">
<CardTitle className="text-sm font-medium text-gray-600">{title}</CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
</CardHeader>
<CardContent>
<div className="h-40 relative">
@ -250,7 +250,7 @@ function RadialMetric({ value, title, subtitle, color = COLORS.primary }: Radial
endAngle={0}
>
<RadialBar
background={{ fill: "#f3f4f6" }}
background={{ fill: "hsl(var(--muted))" }}
dataKey="value"
cornerRadius={10}
/>
@ -258,8 +258,8 @@ function RadialMetric({ value, title, subtitle, color = COLORS.primary }: Radial
</ResponsiveContainer>
<div className="absolute inset-0 flex items-center justify-center pointer-events-none" style={{ marginTop: "-15px" }}>
<div className="text-center">
<p className="text-2xl font-bold text-gray-900">{value}%</p>
{subtitle && <p className="text-xs text-gray-500">{subtitle}</p>}
<p className="text-2xl font-bold text-foreground">{value}%</p>
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
</div>
</div>
</div>
@ -287,9 +287,9 @@ function BudgetBarChart({ projects, title, maxItems = 6 }: BudgetBarChartProps)
}));
return (
<Card className="border-gray-200">
<Card className="border">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600">{title}</CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
</CardHeader>
<CardContent>
<div className="h-72">
@ -334,9 +334,9 @@ interface ComparisonBarChartProps {
function ComparisonBarChart({ data, title, formatter }: ComparisonBarChartProps) {
return (
<Card className="border-gray-200">
<Card className="border">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600">{title}</CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
</CardHeader>
<CardContent>
<div className="h-64">
@ -377,9 +377,9 @@ function MultiRadialChart({ data, title, valueLabel = "%" }: MultiRadialProps) {
}));
return (
<Card className="border-gray-200">
<Card className="border">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600">{title}</CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
</CardHeader>
<CardContent>
<div className="h-64">
@ -394,7 +394,7 @@ function MultiRadialChart({ data, title, valueLabel = "%" }: MultiRadialProps) {
endAngle={-270}
>
<RadialBar
background={{ fill: "#f3f4f6" }}
background={{ fill: "hsl(var(--muted))" }}
dataKey="value"
cornerRadius={5}
/>
@ -527,19 +527,19 @@ function AllProjectsStats({ projects }: { projects: Project[] }) {
{(overdueProjects.length > 0 || upcomingDeadlines.length > 0) && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{overdueProjects.length > 0 && (
<Card className="border-red-200 bg-red-50">
<Card className="border-red-200 bg-red-50 dark:bg-red-950/30 dark:border-red-900">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-red-700 flex items-center gap-2">
<p className="text-sm font-medium text-red-700 dark:text-red-400 flex items-center gap-2">
<AlertCircle className="w-4 h-4" /> Proyectos Retrasados
</p>
<p className="text-3xl font-bold text-red-600 mt-2">{overdueProjects.length}</p>
<p className="text-xs text-red-600">Fecha límite superada</p>
<p className="text-3xl font-bold text-red-600 dark:text-red-400 mt-2">{overdueProjects.length}</p>
<p className="text-xs text-red-600 dark:text-red-400">Fecha límite superada</p>
</div>
<div className="text-right">
{overdueProjects.slice(0, 2).map(p => (
<p key={p.id} className="text-xs text-red-500 truncate max-w-32">{p.name}</p>
<p key={p.id} className="text-xs text-red-500 dark:text-red-400 truncate max-w-32">{p.name}</p>
))}
</div>
</div>
@ -547,19 +547,19 @@ function AllProjectsStats({ projects }: { projects: Project[] }) {
</Card>
)}
{upcomingDeadlines.length > 0 && (
<Card className="border-amber-200 bg-amber-50">
<Card className="border-amber-200 bg-amber-50 dark:bg-amber-950/30 dark:border-amber-900">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-amber-700 flex items-center gap-2">
<p className="text-sm font-medium text-amber-700 dark:text-amber-400 flex items-center gap-2">
<Clock className="w-4 h-4" /> Próximos a Vencer
</p>
<p className="text-3xl font-bold text-amber-600 mt-2">{upcomingDeadlines.length}</p>
<p className="text-xs text-amber-600">En los próximos 14 días</p>
<p className="text-3xl font-bold text-amber-600 dark:text-amber-400 mt-2">{upcomingDeadlines.length}</p>
<p className="text-xs text-amber-600 dark:text-amber-400">En los próximos 14 días</p>
</div>
<div className="text-right">
{upcomingDeadlines.slice(0, 2).map(p => (
<p key={p.id} className="text-xs text-amber-600 truncate max-w-32">
<p key={p.id} className="text-xs text-amber-600 dark:text-amber-400 truncate max-w-32">
{p.name} ({getDaysRemaining(p.endDate)}d)
</p>
))}
@ -586,9 +586,9 @@ function AllProjectsStats({ projects }: { projects: Project[] }) {
{/* Timeline de fechas - Próximos vencimientos y retrasados */}
{(upcomingDeadlines.length > 0 || overdueProjects.length > 0) && (
<Card className="border-gray-200">
<Card className="border">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Calendar className="w-4 h-4" /> Timeline de Proyectos
</CardTitle>
</CardHeader>
@ -597,20 +597,20 @@ function AllProjectsStats({ projects }: { projects: Project[] }) {
{/* Proyectos retrasados */}
{overdueProjects.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-red-600 uppercase tracking-wide mb-2 flex items-center gap-1">
<h4 className="text-xs font-semibold text-red-600 dark:text-red-400 uppercase tracking-wide mb-2 flex items-center gap-1">
<AlertCircle className="w-3 h-3" /> Retrasados ({overdueProjects.length})
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
{overdueProjects.slice(0, 6).map((p, index) => {
const daysOverdue = Math.abs(getDaysRemaining(p.endDate));
return (
<div key={p.id} className="flex items-center gap-3 p-3 bg-red-50 border border-red-100 rounded-lg">
<div key={p.id} className="flex items-center gap-3 p-3 bg-red-50 dark:bg-red-950/30 border border-red-100 dark:border-red-900 rounded-lg">
<div className="w-2 h-2 rounded-full bg-red-500" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-800 truncate">{p.name}</p>
<p className="text-xs text-gray-500">{p.progress}% completado</p>
<p className="text-sm font-medium text-foreground truncate">{p.name}</p>
<p className="text-xs text-muted-foreground">{p.progress}% completado</p>
</div>
<Badge variant="outline" className="border-red-300 text-red-600 text-xs">
<Badge variant="outline" className="border-red-300 dark:border-red-700 text-red-600 dark:text-red-400 text-xs">
-{daysOverdue}d
</Badge>
</div>
@ -623,25 +623,25 @@ function AllProjectsStats({ projects }: { projects: Project[] }) {
{/* Próximos vencimientos */}
{upcomingDeadlines.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-amber-600 uppercase tracking-wide mb-2 flex items-center gap-1">
<h4 className="text-xs font-semibold text-amber-600 dark:text-amber-400 uppercase tracking-wide mb-2 flex items-center gap-1">
<Clock className="w-3 h-3" /> Próximos 14 días ({upcomingDeadlines.length})
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
{upcomingDeadlines.slice(0, 6).map((p, index) => {
const days = getDaysRemaining(p.endDate);
return (
<div key={p.id} className="flex items-center gap-3 p-3 bg-gray-50 border border-gray-100 rounded-lg">
<div key={p.id} className="flex items-center gap-3 p-3 bg-muted/50 border rounded-lg">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: getProjectColor(index) }} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-800 truncate">{p.name}</p>
<p className="text-xs text-gray-500">{p.progress}% completado</p>
<p className="text-sm font-medium text-foreground truncate">{p.name}</p>
<p className="text-xs text-muted-foreground">{p.progress}% completado</p>
</div>
<Badge
variant="outline"
className={`text-xs ${
days <= 3 ? 'border-red-300 text-red-600' :
days <= 7 ? 'border-amber-300 text-amber-600' :
'border-gray-300 text-gray-600'
days <= 3 ? 'border-red-300 dark:border-red-700 text-red-600 dark:text-red-400' :
days <= 7 ? 'border-amber-300 dark:border-amber-700 text-amber-600 dark:text-amber-400' :
'border text-muted-foreground'
}`}
>
{days}d
@ -654,8 +654,8 @@ function AllProjectsStats({ projects }: { projects: Project[] }) {
)}
{/* Resumen de fechas */}
<div className="pt-3 border-t border-gray-100">
<div className="flex flex-wrap gap-4 text-xs text-gray-500">
<div className="pt-3 border-t">
<div className="flex flex-wrap gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<div className="w-2 h-2 rounded-full bg-red-500" />
Retrasados: {overdueProjects.length}
@ -689,9 +689,9 @@ function ActiveProjectsStats({ projects }: { projects: Project[] }) {
if (activeProjects.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16">
<TrendingUp className="w-16 h-16 text-gray-300 mb-4" />
<h3 className="text-xl font-semibold text-gray-700">No hay proyectos activos</h3>
<p className="text-gray-500 mt-2">Todos los proyectos están cerrados o en borrador</p>
<TrendingUp className="w-16 h-16 text-muted-foreground/50 mb-4" />
<h3 className="text-xl font-semibold text-foreground">No hay proyectos activos</h3>
<p className="text-muted-foreground mt-2">Todos los proyectos están cerrados o en borrador</p>
</div>
);
}
@ -730,41 +730,41 @@ function ActiveProjectsStats({ projects }: { projects: Project[] }) {
{/* Alertas */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className={`border-red-200 ${overdueProjects.length > 0 ? 'bg-red-50' : 'bg-gray-50 border-gray-200'}`}>
<Card className={`${overdueProjects.length > 0 ? 'border-red-200 dark:border-red-900 bg-red-50 dark:bg-red-950/30' : 'border bg-muted/30'}`}>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className={`text-sm font-medium flex items-center gap-2 ${overdueProjects.length > 0 ? 'text-red-700' : 'text-gray-500'}`}>
<p className={`text-sm font-medium flex items-center gap-2 ${overdueProjects.length > 0 ? 'text-red-700 dark:text-red-400' : 'text-muted-foreground'}`}>
<AlertCircle className="w-4 h-4" /> Retrasados
</p>
<p className={`text-3xl font-bold mt-2 ${overdueProjects.length > 0 ? 'text-red-600' : 'text-gray-400'}`}>{overdueProjects.length}</p>
<p className={`text-xs ${overdueProjects.length > 0 ? 'text-red-600' : 'text-gray-400'}`}>Fecha superada</p>
<p className={`text-3xl font-bold mt-2 ${overdueProjects.length > 0 ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground/50'}`}>{overdueProjects.length}</p>
<p className={`text-xs ${overdueProjects.length > 0 ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground/50'}`}>Fecha superada</p>
</div>
</div>
</CardContent>
</Card>
<Card className={`border-orange-200 ${needsAttention > 0 ? 'bg-orange-50' : 'bg-gray-50 border-gray-200'}`}>
<Card className={`${needsAttention > 0 ? 'border-orange-200 dark:border-orange-900 bg-orange-50 dark:bg-orange-950/30' : 'border bg-muted/30'}`}>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className={`text-sm font-medium flex items-center gap-2 ${needsAttention > 0 ? 'text-orange-700' : 'text-gray-500'}`}>
<p className={`text-sm font-medium flex items-center gap-2 ${needsAttention > 0 ? 'text-orange-700 dark:text-orange-400' : 'text-muted-foreground'}`}>
<Clock className="w-4 h-4" /> Requieren Atención
</p>
<p className={`text-3xl font-bold mt-2 ${needsAttention > 0 ? 'text-orange-600' : 'text-gray-400'}`}>{needsAttention}</p>
<p className={`text-xs ${needsAttention > 0 ? 'text-orange-600' : 'text-gray-400'}`}>Menos del 25%</p>
<p className={`text-3xl font-bold mt-2 ${needsAttention > 0 ? 'text-orange-600 dark:text-orange-400' : 'text-muted-foreground/50'}`}>{needsAttention}</p>
<p className={`text-xs ${needsAttention > 0 ? 'text-orange-600 dark:text-orange-400' : 'text-muted-foreground/50'}`}>Menos del 25%</p>
</div>
</div>
</CardContent>
</Card>
<Card className="border-green-200 bg-green-50">
<Card className="border-green-200 dark:border-green-900 bg-green-50 dark:bg-green-950/30">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-green-700 flex items-center gap-2">
<p className="text-sm font-medium text-green-700 dark:text-green-400 flex items-center gap-2">
<CheckCircle2 className="w-4 h-4" /> Próximos a Completar
</p>
<p className="text-3xl font-bold text-green-600 mt-2">{nearCompletion}</p>
<p className="text-xs text-green-600">75% o más</p>
<p className="text-3xl font-bold text-green-600 dark:text-green-400 mt-2">{nearCompletion}</p>
<p className="text-xs text-green-600 dark:text-green-400">75% o más</p>
</div>
</div>
</CardContent>
@ -779,9 +779,9 @@ function ActiveProjectsStats({ projects }: { projects: Project[] }) {
{/* Próximos vencimientos */}
{upcomingDeadlines.length > 0 && (
<Card className="border-gray-200">
<Card className="border">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Calendar className="w-4 h-4" /> Próximos Vencimientos (14 días)
</CardTitle>
</CardHeader>
@ -790,13 +790,13 @@ function ActiveProjectsStats({ projects }: { projects: Project[] }) {
{upcomingDeadlines.slice(0, 6).map((p, index) => {
const days = getDaysRemaining(p.endDate);
return (
<div key={p.id} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
<div key={p.id} className="flex items-center gap-3 p-3 bg-muted/50 rounded-lg">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: getProjectColor(index) }} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-800 truncate">{p.name}</p>
<p className="text-xs text-gray-500">{p.progress}% completado</p>
<p className="text-sm font-medium text-foreground truncate">{p.name}</p>
<p className="text-xs text-muted-foreground">{p.progress}% completado</p>
</div>
<Badge variant="outline" className={days <= 3 ? 'border-red-300 text-red-600' : days <= 7 ? 'border-amber-300 text-amber-600' : 'border-gray-300'}>
<Badge variant="outline" className={days <= 3 ? 'border-red-300 dark:border-red-700 text-red-600 dark:text-red-400' : days <= 7 ? 'border-amber-300 dark:border-amber-700 text-amber-600 dark:text-amber-400' : ''}>
{days}d
</Badge>
</div>
@ -821,9 +821,9 @@ function ClosedProjectsStats({ projects }: { projects: Project[] }) {
if (closedProjects.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16">
<Lock className="w-16 h-16 text-gray-300 mb-4" />
<h3 className="text-xl font-semibold text-gray-700">No hay proyectos cerrados</h3>
<p className="text-gray-500 mt-2">Aún no se ha cerrado ningún proyecto</p>
<Lock className="w-16 h-16 text-muted-foreground/50 mb-4" />
<h3 className="text-xl font-semibold text-foreground">No hay proyectos cerrados</h3>
<p className="text-muted-foreground mt-2">Aún no se ha cerrado ningún proyecto</p>
</div>
);
}
@ -867,30 +867,30 @@ function ClosedProjectsStats({ projects }: { projects: Project[] }) {
{/* Cumplimiento visual */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card className="border-green-200 bg-gradient-to-br from-green-50 to-emerald-50">
<Card className="border-green-200 dark:border-green-900 bg-gradient-to-br from-green-50 to-emerald-50 dark:from-green-950/30 dark:to-emerald-950/30">
<CardContent className="py-6">
<div className="flex items-center gap-6">
<div className="p-4 bg-green-100 rounded-full">
<CheckCircle2 className="w-8 h-8 text-green-600" />
<div className="p-4 bg-green-100 dark:bg-green-900/50 rounded-full">
<CheckCircle2 className="w-8 h-8 text-green-600 dark:text-green-400" />
</div>
<div>
<p className="text-sm font-medium text-green-700">Dentro del Presupuesto</p>
<p className="text-4xl font-bold text-green-600">{withinBudget}</p>
<p className="text-sm text-green-600">{stats.total > 0 ? Math.round((withinBudget / stats.total) * 100) : 0}% de los cerrados</p>
<p className="text-sm font-medium text-green-700 dark:text-green-400">Dentro del Presupuesto</p>
<p className="text-4xl font-bold text-green-600 dark:text-green-400">{withinBudget}</p>
<p className="text-sm text-green-600 dark:text-green-400">{stats.total > 0 ? Math.round((withinBudget / stats.total) * 100) : 0}% de los cerrados</p>
</div>
</div>
</CardContent>
</Card>
<Card className={`border-red-200 ${overBudget > 0 ? 'bg-gradient-to-br from-red-50 to-orange-50' : 'bg-gray-50 border-gray-200'}`}>
<Card className={`${overBudget > 0 ? 'border-red-200 dark:border-red-900 bg-gradient-to-br from-red-50 to-orange-50 dark:from-red-950/30 dark:to-orange-950/30' : 'border bg-muted/30'}`}>
<CardContent className="py-6">
<div className="flex items-center gap-6">
<div className={`p-4 rounded-full ${overBudget > 0 ? 'bg-red-100' : 'bg-gray-100'}`}>
<AlertCircle className={`w-8 h-8 ${overBudget > 0 ? 'text-red-600' : 'text-gray-400'}`} />
<div className={`p-4 rounded-full ${overBudget > 0 ? 'bg-red-100 dark:bg-red-900/50' : 'bg-muted'}`}>
<AlertCircle className={`w-8 h-8 ${overBudget > 0 ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground/50'}`} />
</div>
<div>
<p className={`text-sm font-medium ${overBudget > 0 ? 'text-red-700' : 'text-gray-500'}`}>Excedieron Presupuesto</p>
<p className={`text-4xl font-bold ${overBudget > 0 ? 'text-red-600' : 'text-gray-400'}`}>{overBudget}</p>
<p className={`text-sm ${overBudget > 0 ? 'text-red-600' : 'text-gray-400'}`}>{stats.total > 0 ? Math.round((overBudget / stats.total) * 100) : 0}% de los cerrados</p>
<p className={`text-sm font-medium ${overBudget > 0 ? 'text-red-700 dark:text-red-400' : 'text-muted-foreground'}`}>Excedieron Presupuesto</p>
<p className={`text-4xl font-bold ${overBudget > 0 ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground/50'}`}>{overBudget}</p>
<p className={`text-sm ${overBudget > 0 ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground/50'}`}>{stats.total > 0 ? Math.round((overBudget / stats.total) * 100) : 0}% de los cerrados</p>
</div>
</div>
</CardContent>
@ -921,9 +921,9 @@ function CompletedProjectsStats({ projects }: { projects: Project[] }) {
if (completedProjects.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16">
<CheckCircle2 className="w-16 h-16 text-gray-300 mb-4" />
<h3 className="text-xl font-semibold text-gray-700">No hay proyectos completados</h3>
<p className="text-gray-500 mt-2">Aún no se ha completado ningún proyecto al 100%</p>
<CheckCircle2 className="w-16 h-16 text-muted-foreground/50 mb-4" />
<h3 className="text-xl font-semibold text-foreground">No hay proyectos completados</h3>
<p className="text-muted-foreground mt-2">Aún no se ha completado ningún proyecto al 100%</p>
</div>
);
}
@ -997,7 +997,7 @@ function StatisticsSkeleton() {
<div className="space-y-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i} className="border-gray-200">
<Card key={i} className="border">
<CardHeader className="pb-2"><Skeleton className="h-4 w-24" /></CardHeader>
<CardContent><Skeleton className="h-8 w-16 mb-2" /><Skeleton className="h-3 w-32" /></CardContent>
</Card>
@ -1005,7 +1005,7 @@ function StatisticsSkeleton() {
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<Card key={i} className="border-gray-200">
<Card key={i} className="border">
<CardHeader className="pb-2"><Skeleton className="h-4 w-32" /></CardHeader>
<CardContent><Skeleton className="h-64 w-full" /></CardContent>
</Card>
@ -1040,10 +1040,10 @@ export default function StatisticsPage() {
if (error) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 flex items-center justify-center">
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center">
<AlertCircle className="w-16 h-16 text-red-400 mx-auto mb-4" />
<p className="text-red-600 text-lg">{error}</p>
<AlertCircle className="w-16 h-16 text-red-400 dark:text-red-500 mx-auto mb-4" />
<p className="text-red-600 dark:text-red-400 text-lg">{error}</p>
<button onClick={() => window.location.reload()} className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
Reintentar
</button>
@ -1057,16 +1057,16 @@ export default function StatisticsPage() {
const completedCount = projects.filter(p => p.progress >= 100).length;
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">
<header className="bg-white border-b border-gray-200 sticky top-0 z-10">
<div className="min-h-screen bg-background">
<header className="bg-card border-b sticky top-0 z-10">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg">
<BarChart3 className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">Estadisticas</h1>
<p className="text-sm text-gray-500">Analisis de {projects.length} proyectos en Dolibarr</p>
<h1 className="text-2xl font-bold text-foreground">Estadisticas</h1>
<p className="text-sm text-muted-foreground">Analisis de {projects.length} proyectos en Dolibarr</p>
</div>
</div>
</div>

View File

@ -0,0 +1,5 @@
// components/task-detail/index.ts
export { TaskHeader } from "./task-header";
export { TaskStats } from "./task-stats";
export { TaskInfo } from "./task-info";
export { TaskDetailView } from "./task-detail-view";

View File

@ -0,0 +1,41 @@
"use client";
import { useState } from "react";
import { Task } from "@/types/task";
import { Project } from "@/types/project";
import { TaskHeader } from "./task-header";
import { TaskStats } from "./task-stats";
import { TaskInfo } from "./task-info";
interface TaskDetailViewProps {
initialTask: Task;
project: Project | null;
}
export function TaskDetailView({ initialTask, project }: TaskDetailViewProps) {
const [task, setTask] = useState<Task>(initialTask);
const handleTaskUpdated = (updatedTask: Task) => {
setTask(updatedTask);
};
return (
<div className="flex flex-col min-h-screen">
{/* Header con breadcrumb, título, badges y acciones */}
<TaskHeader
task={task}
project={project}
onTaskUpdated={handleTaskUpdated}
/>
{/* Contenido principal */}
<div className="flex-1 p-6 space-y-6">
{/* Estadísticas rápidas */}
<TaskStats task={task} />
{/* Información detallada */}
<TaskInfo task={task} project={project} />
</div>
</div>
);
}

View File

@ -0,0 +1,175 @@
"use client";
import { useState } from "react";
import { ArrowLeft, MoreHorizontal, Pencil, Trash2, Clock, Copy, ExternalLink } 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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { TaskFormSheet } from "@/components/task-form/task-form-sheet";
import {
Task,
TaskStatus,
TaskPriority,
TASK_STATUS_CONFIG,
TASK_PRIORITY_CONFIG
} from "@/types/task";
import { Project } from "@/types/project";
interface TaskHeaderProps {
task: Task;
project: Project | null;
onTaskUpdated?: (task: Task) => void;
}
// Badge de estado
function TaskStatusBadge({ status }: { status: TaskStatus }) {
const config = TASK_STATUS_CONFIG[status];
return (
<Badge variant="outline" className={`${config.bgClass} text-sm px-3 py-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} text-sm px-3 py-1`}>
{config.label}
</Badge>
);
}
export function TaskHeader({ task, project, onTaskUpdated }: TaskHeaderProps) {
const router = useRouter();
const [isEditSheetOpen, setIsEditSheetOpen] = useState(false);
const handleCopyRef = () => {
navigator.clipboard.writeText(task.ref);
};
const handleEditSuccess = (updatedTask: Task) => {
onTaskUpdated?.(updatedTask);
};
return (
<div className="border-b bg-card">
<div className="px-6 py-4">
{/* Breadcrumb */}
<nav 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>
{project ? (
<>
<Link
href={`/proyectos/${project.id}`}
className="hover:text-foreground transition-colors"
>
{project.ref}
</Link>
<span>/</span>
</>
) : (
<>
<span className="text-muted-foreground/70">Proyecto #{task.projectId}</span>
<span>/</span>
</>
)}
<span className="text-foreground font-medium">{task.ref}</span>
</nav>
{/* Header principal */}
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-3">
{/* Título y badges */}
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-bold tracking-tight">{task.title}</h1>
<TaskStatusBadge status={task.status} />
<TaskPriorityBadge priority={task.priority} />
</div>
{/* Referencia y proyecto */}
<div className="flex flex-wrap 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"
>
{task.ref}
<Copy className="h-3.5 w-3.5" />
</button>
{project && (
<>
<span className="text-muted-foreground/50">|</span>
<Link
href={`/proyectos/${project.id}`}
className="flex items-center gap-1.5 hover:text-foreground transition-colors"
>
<span>Proyecto: {project.name}</span>
<ExternalLink className="h-3.5 w-3.5" />
</Link>
</>
)}
</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 onClick={() => setIsEditSheetOpen(true)}>
<Pencil className="h-4 w-4 mr-2" />
Editar tarea
</DropdownMenuItem>
<DropdownMenuItem>
<Clock className="h-4 w-4 mr-2" />
Registrar tiempo
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:text-destructive">
<Trash2 className="h-4 w-4 mr-2" />
Eliminar
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
{/* Sheet de edición */}
<TaskFormSheet
open={isEditSheetOpen}
onOpenChange={setIsEditSheetOpen}
mode="edit"
projectId={task.projectId}
task={task}
onSuccess={handleEditSuccess}
/>
</div>
);
}

View File

@ -0,0 +1,303 @@
"use client";
import {
FileText,
Calendar,
User,
FolderOpen,
Tag,
Clock,
AlertTriangle,
CheckCircle2,
Info,
Hash
} from "lucide-react";
import Link from "next/link";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import {
Task,
TASK_STATUS_CONFIG,
TASK_PRIORITY_CONFIG
} from "@/types/task";
import { Project } from "@/types/project";
interface TaskInfoProps {
task: Task;
project: Project | null;
}
// Formatear fecha completa
function formatFullDate(dateString: string | null): string {
if (!dateString) return 'No definida';
return new Date(dateString).toLocaleDateString('es-ES', {
weekday: 'long',
day: '2-digit',
month: 'long',
year: 'numeric',
});
}
// Verificar si la tarea está vencida
function isOverdue(task: Task): boolean {
const endDate = task.endDate || task.plannedEndDate;
if (!endDate) return false;
return new Date(endDate) < new Date() && task.status !== '2';
}
// Item de información
function InfoItem({
icon: Icon,
label,
value,
className = "",
valueClassName = ""
}: {
icon: React.ElementType;
label: string;
value: React.ReactNode;
className?: string;
valueClassName?: string;
}) {
return (
<div className={`flex items-start gap-3 ${className}`}>
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-muted/50 shrink-0">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-0.5">{label}</p>
<div className={`text-sm font-medium ${valueClassName}`}>{value}</div>
</div>
</div>
);
}
export function TaskInfo({ task, project }: TaskInfoProps) {
const overdue = isOverdue(task);
const statusConfig = TASK_STATUS_CONFIG[task.status];
const priorityConfig = TASK_PRIORITY_CONFIG[task.priority];
return (
<div className="grid gap-6 lg:grid-cols-2">
{/* Descripción */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<FileText className="h-5 w-5" />
Descripción
</CardTitle>
</CardHeader>
<CardContent>
{task.description ? (
<p className="text-sm text-muted-foreground whitespace-pre-wrap leading-relaxed">
{task.description}
</p>
) : (
<p className="text-sm text-muted-foreground/60 italic">
Esta tarea no tiene descripción.
</p>
)}
</CardContent>
</Card>
{/* Información General */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Info className="h-5 w-5" />
Información General
</CardTitle>
<CardDescription>Detalles básicos de la tarea</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<InfoItem
icon={Hash}
label="Referencia"
value={<span className="font-mono">{task.ref}</span>}
/>
<Separator />
<InfoItem
icon={Tag}
label="Estado"
value={
<Badge variant="outline" className={statusConfig.bgClass}>
{task.status === '2' && <CheckCircle2 className="h-3 w-3 mr-1" />}
{statusConfig.label}
</Badge>
}
/>
<InfoItem
icon={AlertTriangle}
label="Prioridad"
value={
task.priority !== '0' ? (
<Badge variant="outline" className={priorityConfig.bgClass}>
{priorityConfig.label}
</Badge>
) : (
<span className="text-muted-foreground">Sin prioridad asignada</span>
)
}
/>
<Separator />
<InfoItem
icon={FolderOpen}
label="Proyecto"
value={
project ? (
<Link
href={`/proyectos/${project.id}`}
className="text-blue-600 hover:text-blue-700 hover:underline transition-colors"
>
{project.name}
</Link>
) : (
<span className="text-muted-foreground">Proyecto #{task.projectId}</span>
)
}
/>
{task.parentTaskId && task.parentTaskId > 0 && (
<InfoItem
icon={FolderOpen}
label="Tarea padre"
value={
<Link
href={`/tareas/${task.parentTaskId}`}
className="text-blue-600 hover:text-blue-700 hover:underline transition-colors"
>
Ver tarea padre #{task.parentTaskId}
</Link>
}
/>
)}
</CardContent>
</Card>
{/* Fechas y Tiempos */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Calendar className="h-5 w-5" />
Fechas y Tiempos
</CardTitle>
<CardDescription>Cronograma y dedicación</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<InfoItem
icon={Calendar}
label="Fecha de creación"
value={formatFullDate(task.createdAt)}
/>
<Separator />
<InfoItem
icon={Calendar}
label="Inicio planificado"
value={formatFullDate(task.plannedStartDate)}
/>
<InfoItem
icon={Calendar}
label="Fin planificado"
value={formatFullDate(task.plannedEndDate)}
valueClassName={overdue ? 'text-red-500' : ''}
/>
{(task.startDate || task.endDate) && (
<>
<Separator />
{task.startDate && (
<InfoItem
icon={Calendar}
label="Inicio real"
value={formatFullDate(task.startDate)}
/>
)}
{task.endDate && (
<InfoItem
icon={Calendar}
label="Fin real"
value={formatFullDate(task.endDate)}
/>
)}
</>
)}
<Separator />
<InfoItem
icon={Clock}
label="Tiempo planificado"
value={task.plannedHours > 0 ? `${task.plannedHours} horas` : 'No estimado'}
/>
<InfoItem
icon={Clock}
label="Tiempo trabajado"
value={task.workedHours > 0 ? `${task.workedHours} horas` : 'Sin registrar'}
/>
</CardContent>
</Card>
{/* Metadatos */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<User className="h-5 w-5" />
Metadatos
</CardTitle>
<CardDescription>Información de seguimiento y auditoría</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<InfoItem
icon={User}
label="Creado por"
value={task.createdBy > 0 ? `Usuario #${task.createdBy}` : 'Sistema'}
/>
<InfoItem
icon={Calendar}
label="Creado"
value={formatFullDate(task.createdAt)}
/>
<InfoItem
icon={Calendar}
label="Última actualización"
value={formatFullDate(task.updatedAt)}
/>
<InfoItem
icon={Hash}
label="Orden"
value={task.order > 0 ? `Posición ${task.order}` : 'Sin ordenar'}
/>
</div>
{task.budget > 0 && (
<>
<Separator className="my-4" />
<InfoItem
icon={Tag}
label="Presupuesto asignado"
value={`${task.budget.toLocaleString('es-ES')} €`}
/>
</>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,191 @@
"use client";
import {
Target,
Clock,
Calendar,
TrendingUp,
FileText,
AlertTriangle,
CheckCircle2
} from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Task } from "@/types/task";
interface TaskStatsProps {
task: Task;
}
// Formatear fecha completa
function formatDate(dateString: string | null): string {
if (!dateString) return 'No definida';
return new Date(dateString).toLocaleDateString('es-ES', {
day: '2-digit',
month: 'long',
year: 'numeric',
});
}
// Obtener clase de color según progreso
function getProgressColorClass(value: number): string {
if (value >= 100) return '[&>div]:bg-green-500';
if (value >= 75) return '[&>div]:bg-blue-500';
if (value >= 50) return '[&>div]:bg-yellow-500';
if (value >= 25) return '[&>div]:bg-orange-500';
return '[&>div]:bg-gray-400';
}
// Calcular si está vencida
function isOverdue(task: Task): boolean {
const endDate = task.endDate || task.plannedEndDate;
if (!endDate) return false;
return new Date(endDate) < new Date() && task.status !== '2';
}
// Calcular días restantes
function getDaysRemaining(task: Task): { days: number; label: string } | null {
const endDate = task.endDate || task.plannedEndDate;
if (!endDate) return null;
const end = new Date(endDate);
const today = new Date();
today.setHours(0, 0, 0, 0);
end.setHours(0, 0, 0, 0);
const diffTime = end.getTime() - today.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays < 0) {
return { days: Math.abs(diffDays), label: `${Math.abs(diffDays)} días de retraso` };
} else if (diffDays === 0) {
return { days: 0, label: 'Vence hoy' };
} else if (diffDays === 1) {
return { days: 1, label: 'Vence mañana' };
} else {
return { days: diffDays, label: `${diffDays} días restantes` };
}
}
export function TaskStats({ task }: TaskStatsProps) {
const overdue = isOverdue(task);
const daysInfo = getDaysRemaining(task);
const efficiency = task.plannedHours > 0
? Math.round((task.workedHours / task.plannedHours) * 100)
: 0;
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{/* Progreso */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Progreso</CardTitle>
<Target className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{task.progress}%</div>
<Progress
value={task.progress}
className={`mt-2 h-2 ${getProgressColorClass(task.progress)}`}
/>
<p className="text-xs text-muted-foreground mt-2">
{task.progress >= 100 ? (
<span className="flex items-center gap-1 text-green-600">
<CheckCircle2 className="h-3 w-3" />
Tarea completada
</span>
) : task.progress > 0 ? (
'En progreso'
) : (
'Sin iniciar'
)}
</p>
</CardContent>
</Card>
{/* Tiempo */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Tiempo</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{task.workedHours > 0 ? `${task.workedHours}h` : '-'}
</div>
{task.plannedHours > 0 && (
<Progress
value={Math.min(efficiency, 100)}
className={`mt-2 h-2 ${efficiency > 100 ? '[&>div]:bg-red-500' : '[&>div]:bg-blue-500'}`}
/>
)}
<p className="text-xs text-muted-foreground mt-2">
{task.plannedHours > 0 ? (
<>
de {task.plannedHours}h planificadas
{efficiency > 100 && (
<span className="text-red-500 ml-1">({efficiency}%)</span>
)}
</>
) : (
'Sin estimación'
)}
</p>
</CardContent>
</Card>
{/* Fecha límite */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Fecha límite</CardTitle>
<Calendar className={`h-4 w-4 ${overdue ? 'text-red-500' : 'text-muted-foreground'}`} />
</CardHeader>
<CardContent>
<div className={`text-2xl font-bold ${overdue ? 'text-red-500' : ''}`}>
{formatDate(task.endDate || task.plannedEndDate).split(' de ')[0] || '-'}
</div>
<p className={`text-xs mt-2 ${overdue ? 'text-red-500' : 'text-muted-foreground'}`}>
{daysInfo ? (
<span className="flex items-center gap-1">
{overdue && <AlertTriangle className="h-3 w-3" />}
{daysInfo.label}
</span>
) : (
'Sin fecha límite'
)}
</p>
</CardContent>
</Card>
{/* Eficiencia / Estado */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Rendimiento</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{task.plannedHours > 0 && task.workedHours > 0 ? (
`${Math.round((task.progress / efficiency) * 100)}%`
) : task.progress > 0 ? (
'Activa'
) : (
'-'
)}
</div>
<p className="text-xs text-muted-foreground mt-2">
{task.plannedHours > 0 && task.workedHours > 0 ? (
'Progreso vs tiempo invertido'
) : (
<span className="flex items-center gap-1">
<FileText className="h-3 w-3" />
{task.description ? 'Con descripción' : 'Sin descripción'}
</span>
)}
</p>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,327 @@
"use client";
import { useState, useEffect } from "react";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
Task,
TaskStatus,
TaskPriority,
TaskFormData,
TASK_STATUS_CONFIG,
TASK_PRIORITY_CONFIG,
taskToFormData,
formDataToCreateTask,
formDataToUpdateTask,
} from "@/types/task";
import { createTask, updateTask } from "@/lib/tasksService";
interface TaskFormSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
mode: "create" | "edit";
projectId: number;
task?: Task | null;
onSuccess?: (task: Task) => void;
}
const defaultFormData: TaskFormData = {
title: "",
description: "",
projectId: 0,
parentTaskId: null,
status: "0",
priority: "0",
progress: 0,
plannedHours: 0,
startDate: "",
endDate: "",
budget: 0,
};
export function TaskFormSheet({
open,
onOpenChange,
mode,
projectId,
task,
onSuccess,
}: TaskFormSheetProps) {
const [formData, setFormData] = useState<TaskFormData>({
...defaultFormData,
projectId,
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Initialize form data when task changes (for edit mode)
useEffect(() => {
if (mode === "edit" && task) {
setFormData(taskToFormData(task));
} else {
setFormData({
...defaultFormData,
projectId,
});
}
setError(null);
}, [mode, task, projectId, open]);
const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name, value, type } = e.target;
setFormData((prev) => ({
...prev,
[name]: type === "number" ? parseFloat(value) || 0 : value,
}));
};
const handleSelectChange = (name: string, value: string) => {
setFormData((prev) => ({
...prev,
[name]: value,
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setError(null);
try {
let result: Task;
if (mode === "create") {
const createData = formDataToCreateTask(formData);
result = await createTask(createData);
} else {
if (!task) {
throw new Error("No hay tarea para editar");
}
const updateData = formDataToUpdateTask(formData);
result = await updateTask(task.id, updateData);
}
onSuccess?.(result);
onOpenChange(false);
} catch (err) {
console.error("Error submitting task:", err);
setError(
err instanceof Error ? err.message : "Error al guardar la tarea"
);
} finally {
setIsSubmitting(false);
}
};
const isFormValid = formData.title.trim().length > 0;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="sm:max-w-lg overflow-y-auto">
<SheetHeader>
<SheetTitle>
{mode === "create" ? "Nueva Tarea" : "Editar Tarea"}
</SheetTitle>
<SheetDescription>
{mode === "create"
? "Crea una nueva tarea para este proyecto."
: "Modifica los detalles de la tarea."}
</SheetDescription>
</SheetHeader>
<form onSubmit={handleSubmit} className="mt-6 space-y-6">
{/* Título */}
<div className="space-y-2">
<Label htmlFor="title">
Título <span className="text-red-500">*</span>
</Label>
<Input
id="title"
name="title"
value={formData.title}
onChange={handleInputChange}
placeholder="Nombre de la tarea"
required
/>
</div>
{/* Descripción */}
<div className="space-y-2">
<Label htmlFor="description">Descripción</Label>
<Textarea
id="description"
name="description"
value={formData.description}
onChange={handleInputChange}
placeholder="Descripción detallada de la tarea..."
rows={3}
/>
</div>
{/* Estado y Prioridad - Grid de 2 columnas */}
<div className="grid grid-cols-2 gap-4">
{/* Estado */}
<div className="space-y-2">
<Label htmlFor="status">Estado</Label>
<Select
value={formData.status}
onValueChange={(value) => handleSelectChange("status", value)}
>
<SelectTrigger>
<SelectValue placeholder="Seleccionar estado" />
</SelectTrigger>
<SelectContent>
{(Object.keys(TASK_STATUS_CONFIG) as TaskStatus[]).map(
(status) => (
<SelectItem key={status} value={status}>
{TASK_STATUS_CONFIG[status].label}
</SelectItem>
)
)}
</SelectContent>
</Select>
</div>
{/* Prioridad */}
<div className="space-y-2">
<Label htmlFor="priority">Prioridad</Label>
<Select
value={formData.priority}
onValueChange={(value) => handleSelectChange("priority", value)}
>
<SelectTrigger>
<SelectValue placeholder="Seleccionar prioridad" />
</SelectTrigger>
<SelectContent>
{(Object.keys(TASK_PRIORITY_CONFIG) as TaskPriority[]).map(
(priority) => (
<SelectItem key={priority} value={priority}>
{TASK_PRIORITY_CONFIG[priority].label}
</SelectItem>
)
)}
</SelectContent>
</Select>
</div>
</div>
{/* Progreso */}
<div className="space-y-2">
<Label htmlFor="progress">Progreso (%)</Label>
<Input
id="progress"
name="progress"
type="number"
min="0"
max="100"
value={formData.progress}
onChange={handleInputChange}
placeholder="0"
/>
</div>
{/* Fechas - Grid de 2 columnas */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="startDate">Fecha inicio</Label>
<Input
id="startDate"
name="startDate"
type="date"
value={formData.startDate}
onChange={handleInputChange}
/>
</div>
<div className="space-y-2">
<Label htmlFor="endDate">Fecha fin</Label>
<Input
id="endDate"
name="endDate"
type="date"
value={formData.endDate}
onChange={handleInputChange}
/>
</div>
</div>
{/* Horas planificadas y Presupuesto - Grid de 2 columnas */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="plannedHours">Horas planificadas</Label>
<Input
id="plannedHours"
name="plannedHours"
type="number"
min="0"
step="0.5"
value={formData.plannedHours}
onChange={handleInputChange}
placeholder="0"
/>
</div>
<div className="space-y-2">
<Label htmlFor="budget">Presupuesto (€)</Label>
<Input
id="budget"
name="budget"
type="number"
min="0"
step="0.01"
value={formData.budget}
onChange={handleInputChange}
placeholder="0.00"
/>
</div>
</div>
{/* Error message */}
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 dark:bg-red-950/50 dark:text-red-400 rounded-md">
{error}
</div>
)}
{/* Submit button */}
<SheetFooter className="pt-4">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancelar
</Button>
<Button type="submit" disabled={!isFormValid || isSubmitting}>
{isSubmitting && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{mode === "create" ? "Crear tarea" : "Guardar cambios"}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}

26
components/ui/label.tsx Normal file
View File

@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@ -0,0 +1,56 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { ProjectStatus } from "@/types/project";
/**
* Configuración unificada de colores para estados de proyecto.
*
* Lógica de colores:
* - Borrador (0): Gris - Estado neutro, pendiente de activación
* - Abierto (1): Azul - Proyecto activo, en progreso
* - Cerrado (2): Verde - Proyecto completado exitosamente
*/
export const PROJECT_STATUS_CONFIG: Record<ProjectStatus, {
label: string;
lightClasses: string;
darkClasses: string;
}> = {
'0': {
label: "Borrador",
lightClasses: "bg-gray-100 text-gray-700 border-gray-300",
darkClasses: "dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600",
},
'1': {
label: "Abierto",
lightClasses: "bg-blue-100 text-blue-700 border-blue-300",
darkClasses: "dark:bg-blue-900/40 dark:text-blue-300 dark:border-blue-700",
},
'2': {
label: "Cerrado",
lightClasses: "bg-green-100 text-green-700 border-green-300",
darkClasses: "dark:bg-green-900/40 dark:text-green-300 dark:border-green-700",
}
};
interface ProjectStatusBadgeProps {
status: ProjectStatus;
className?: string;
}
/**
* Badge de estado de proyecto unificado.
* Usar este componente en toda la aplicación para mantener consistencia.
*/
export function ProjectStatusBadge({ status, className = "" }: ProjectStatusBadgeProps) {
const config = PROJECT_STATUS_CONFIG[status] || PROJECT_STATUS_CONFIG['0'];
return (
<Badge
variant="outline"
className={`${config.lightClasses} ${config.darkClasses} ${className}`}
>
{config.label}
</Badge>
);
}

View File

@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
export { Textarea }

View File

@ -1,6 +1,12 @@
// lib/tasksService.ts
import { dolibarrFetch } from "./dolibarrClient";
import { DolibarrTask, Task, mapDolibarrTask } from "@/types/task";
import {
DolibarrTask,
Task,
mapDolibarrTask,
CreateTaskData,
UpdateTaskData
} from "@/types/task";
/**
* Obtener todas las tareas de un proyecto específico
@ -116,3 +122,106 @@ export function calculateTaskStats(tasks: Task[]) {
completionRate: total > 0 ? Math.round((completed / total) * 100) : 0,
};
}
// ==================== FUNCIONES DE ESCRITURA (CREATE/UPDATE/DELETE) ====================
/**
* Crear una nueva tarea
* Endpoint: POST /tasks
* @returns La tarea creada con su ID
*/
export async function createTask(data: CreateTaskData): Promise<Task> {
try {
// Limpiar datos undefined/null para la API
const cleanData = Object.fromEntries(
Object.entries(data).filter(([, v]) => v !== undefined && v !== null && v !== '')
);
const response = await dolibarrFetch('tasks', {
method: 'POST',
body: JSON.stringify(cleanData),
});
// Dolibarr devuelve el ID de la tarea creada
const taskId = typeof response === 'object' ? response.id || response : response;
// Obtener la tarea completa
const createdTask = await getTaskById(Number(taskId));
if (!createdTask) {
throw new Error('No se pudo obtener la tarea creada');
}
return createdTask;
} catch (error) {
console.error('Error creating task:', error);
throw error;
}
}
/**
* Actualizar una tarea existente
* Endpoint: PUT /tasks/{id}
* @returns La tarea actualizada
*/
export async function updateTask(taskId: number, data: UpdateTaskData): Promise<Task> {
try {
// Limpiar datos undefined/null para la API
const cleanData = Object.fromEntries(
Object.entries(data).filter(([, v]) => v !== undefined && v !== null)
);
await dolibarrFetch(`tasks/${taskId}`, {
method: 'PUT',
body: JSON.stringify(cleanData),
});
// Obtener la tarea actualizada
const updatedTask = await getTaskById(taskId);
if (!updatedTask) {
throw new Error('No se pudo obtener la tarea actualizada');
}
return updatedTask;
} catch (error) {
console.error('Error updating task:', taskId, error);
throw error;
}
}
/**
* Eliminar una tarea
* Endpoint: DELETE /tasks/{id}
*/
export async function deleteTask(taskId: number): Promise<void> {
try {
await dolibarrFetch(`tasks/${taskId}`, {
method: 'DELETE',
});
} catch (error) {
console.error('Error deleting task:', taskId, error);
throw error;
}
}
/**
* Validar (cambiar estado a 1) una tarea
* Endpoint: POST /tasks/{id}/validate
*/
export async function validateTask(taskId: number): Promise<Task> {
try {
await dolibarrFetch(`tasks/${taskId}/validate`, {
method: 'POST',
body: JSON.stringify({ notrigger: 0 }),
});
const task = await getTaskById(taskId);
if (!task) {
throw new Error('No se pudo obtener la tarea validada');
}
return task;
} catch (error) {
console.error('Error validating task:', taskId, error);
throw error;
}
}

407
package-lock.json generated
View File

@ -12,6 +12,7 @@
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
@ -26,6 +27,7 @@
"next-themes": "^0.4.6",
"react": "19.2.0",
"react-dom": "19.2.0",
"recharts": "^3.7.0",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7"
},
@ -1935,6 +1937,29 @@
}
}
},
"node_modules/@radix-ui/react-label": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz",
"integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==",
"license": "MIT",
"dependencies": {
"@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-menu": {
"version": "2.1.16",
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz",
@ -2882,6 +2907,42 @@
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.3",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz",
"integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@ -2889,6 +2950,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@ -3227,6 +3300,69 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -3278,6 +3414,12 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.48.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz",
@ -4510,6 +4652,127 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@ -4589,6 +4852,12 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@ -4893,6 +5162,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/es-toolkit": {
"version": "1.44.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
"integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@ -5340,6 +5619,12 @@
"node": ">=0.10.0"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -5835,6 +6120,16 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@ -5877,6 +6172,15 @@
"node": ">= 0.4"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-array-buffer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@ -7560,9 +7864,31 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-remove-scroll": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
@ -7653,6 +7979,51 @@
"node": ">=8.10.0"
}
},
"node_modules/recharts": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
"integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "1.x.x || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@ -7697,6 +8068,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@ -8411,6 +8788,12 @@
"node": ">=0.8"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@ -8808,6 +9191,28 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@ -17,6 +17,7 @@
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",

View File

@ -1,7 +0,0 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@ -14,26 +14,45 @@ export interface DolibarrTask {
description: string;
fk_project: string | number;
fk_task_parent: string | number;
date_start: number | null;
date_end: number | null;
date_start: number | string | null;
date_end: number | string | 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
date_c: number | null; // fecha creación
date_m: number | null; // fecha modificación
duration_effective: number | string; // segundos trabajados
planned_workload: number | string; // segundos planificados
progress: number | string;
priority: string | number;
budget_amount: string | number;
rang: number;
budget_amount: string | number | null;
rang: number | string;
status: string;
note_public: string;
note_private: string;
fk_user_creat: string | number;
fk_user_valid: string | number;
fk_user_valid: string | number | null;
fk_user_modif: string | number | null;
// Campos adicionales que puede devolver la API
entity?: string | number;
billable?: string | number;
timespent?: number;
timespent_total_duration?: number | null;
timespent_total_amount?: number | null;
timespent_nblines?: number | null;
// Referencias a proyecto
projectref?: string | null;
projectlabel?: string | null;
projectstatus?: string | null;
// Referencias a tercero
socid?: string | number | null;
thirdparty_id?: string | number | null;
thirdparty_name?: string | null;
// Tarea padre
task_parent_ref?: string | null;
// Campos extra
array_options?: Record<string, unknown>;
// Datos de tiempo
tms?: string | number | null;
}
// Interface normalizada para la UI
@ -104,17 +123,30 @@ export const TASK_PRIORITY_CONFIG: Record<TaskPriority, { label: string; color:
};
// Helper para convertir timestamp a fecha ISO
function timestampToDateString(timestamp: number | null | undefined): string | null {
if (!timestamp || timestamp === 0) {
function timestampToDateString(timestamp: number | string | null | undefined): string | null {
if (!timestamp || timestamp === 0 || timestamp === '' || timestamp === '0') {
return null;
}
return new Date(timestamp * 1000).toISOString().split('T')[0];
const numericTimestamp = typeof timestamp === 'string' ? parseInt(timestamp) : timestamp;
if (isNaN(numericTimestamp) || numericTimestamp === 0) {
return null;
}
return new Date(numericTimestamp * 1000).toISOString().split('T')[0];
}
// Helper para convertir segundos a horas
function secondsToHours(seconds: number | null | undefined): number {
function secondsToHours(seconds: number | string | null | undefined): number {
if (!seconds) return 0;
return Math.round((seconds / 3600) * 10) / 10; // Redondear a 1 decimal
const numericSeconds = typeof seconds === 'string' ? parseFloat(seconds) : seconds;
if (isNaN(numericSeconds)) return 0;
return Math.round((numericSeconds / 3600) * 10) / 10; // Redondear a 1 decimal
}
// Helper para convertir string/number a número seguro
function toNumber(value: string | number | null | undefined): number {
if (value === null || value === undefined) return 0;
const num = typeof value === 'string' ? parseInt(value) : value;
return isNaN(num) ? 0 : num;
}
// Función para mapear tarea de Dolibarr al formato UI
@ -161,7 +193,7 @@ export function mapDolibarrTask(dolibarr: DolibarrTask): Task {
createdBy: typeof dolibarr.fk_user_creat === 'string'
? parseInt(dolibarr.fk_user_creat)
: (dolibarr.fk_user_creat || 0),
order: dolibarr.rang || 0,
order: toNumber(dolibarr.rang),
};
}
@ -174,3 +206,154 @@ export function getTaskStatusLabel(status: TaskStatus): string {
export function getTaskPriorityLabel(priority: TaskPriority): string {
return TASK_PRIORITY_CONFIG[priority]?.label || 'Sin prioridad';
}
// ==================== TIPOS PARA API DE CREAR/ACTUALIZAR ====================
/**
* Datos para crear una nueva tarea
* Los campos requeridos por Dolibarr: label, fk_project, ref
*/
export interface CreateTaskData {
ref: string; // Referencia única (requerido por Dolibarr)
label: string;
fk_project: number;
description?: string;
priority?: TaskPriority;
progress?: number;
planned_workload?: number; // en segundos
date_start?: number; // timestamp
date_end?: number; // timestamp
dateo?: number; // fecha planificada inicio (timestamp)
datee?: number; // fecha planificada fin (timestamp)
fk_task_parent?: number;
budget_amount?: number;
}
/**
* Datos para actualizar una tarea existente
* Todos los campos son opcionales
*/
export interface UpdateTaskData {
label?: string;
description?: string;
priority?: string;
progress?: number;
status?: TaskStatus;
planned_workload?: number; // en segundos
date_start?: number; // timestamp
date_end?: number; // timestamp
dateo?: number; // fecha planificada inicio (timestamp)
datee?: number; // fecha planificada fin (timestamp)
fk_task_parent?: number;
budget_amount?: number;
}
/**
* Interface para el formulario de tareas (UI)
* Usa formatos amigables (horas en lugar de segundos, fechas como strings)
*/
export interface TaskFormData {
title: string;
description: string;
projectId: number;
parentTaskId: number | null;
status: TaskStatus;
priority: TaskPriority;
progress: number;
plannedHours: number;
startDate: string; // YYYY-MM-DD
endDate: string; // YYYY-MM-DD
budget: number;
}
// Helper para convertir fecha string a timestamp (segundos)
export function dateStringToTimestamp(dateStr: string | null | undefined): number | undefined {
if (!dateStr || dateStr === '') return undefined;
const date = new Date(dateStr);
if (isNaN(date.getTime())) return undefined;
return Math.floor(date.getTime() / 1000);
}
// Helper para convertir horas a segundos
export function hoursToSeconds(hours: number): number {
return Math.round(hours * 3600);
}
// Helper para generar una referencia única para tareas
function generateTaskRef(projectId: number): string {
const timestamp = Date.now().toString(36).toUpperCase();
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
return `TK${projectId}-${timestamp}-${random}`;
}
/**
* Convierte datos del formulario UI al formato para crear tarea en Dolibarr
*/
export function formDataToCreateTask(formData: TaskFormData): CreateTaskData {
return {
ref: generateTaskRef(formData.projectId),
label: formData.title,
fk_project: formData.projectId,
description: formData.description || undefined,
priority: formData.priority,
progress: formData.progress,
planned_workload: formData.plannedHours > 0 ? hoursToSeconds(formData.plannedHours) : undefined,
date_start: dateStringToTimestamp(formData.startDate),
date_end: dateStringToTimestamp(formData.endDate),
dateo: dateStringToTimestamp(formData.startDate),
datee: dateStringToTimestamp(formData.endDate),
fk_task_parent: formData.parentTaskId || undefined,
budget_amount: formData.budget > 0 ? formData.budget : undefined,
};
}
/**
* Convierte datos del formulario UI al formato para actualizar tarea en Dolibarr
*/
export function formDataToUpdateTask(formData: Partial<TaskFormData>): UpdateTaskData {
const updateData: UpdateTaskData = {};
if (formData.title !== undefined) updateData.label = formData.title;
if (formData.description !== undefined) updateData.description = formData.description;
if (formData.priority !== undefined) updateData.priority = formData.priority;
if (formData.progress !== undefined) updateData.progress = formData.progress;
if (formData.status !== undefined) updateData.status = formData.status;
if (formData.plannedHours !== undefined && formData.plannedHours > 0) {
updateData.planned_workload = hoursToSeconds(formData.plannedHours);
}
if (formData.startDate !== undefined) {
updateData.date_start = dateStringToTimestamp(formData.startDate);
updateData.dateo = dateStringToTimestamp(formData.startDate);
}
if (formData.endDate !== undefined) {
updateData.date_end = dateStringToTimestamp(formData.endDate);
updateData.datee = dateStringToTimestamp(formData.endDate);
}
if (formData.parentTaskId !== undefined) {
updateData.fk_task_parent = formData.parentTaskId || undefined;
}
if (formData.budget !== undefined && formData.budget > 0) {
updateData.budget_amount = formData.budget;
}
return updateData;
}
/**
* Convierte una tarea existente a datos del formulario
*/
export function taskToFormData(task: Task): TaskFormData {
return {
title: task.title,
description: task.description || '',
projectId: task.projectId,
parentTaskId: task.parentTaskId,
status: task.status,
priority: task.priority,
progress: task.progress,
plannedHours: task.plannedHours,
startDate: task.startDate || task.plannedStartDate || '',
endDate: task.endDate || task.plannedEndDate || '',
budget: task.budget,
};
}