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:
parent
11fbef5595
commit
9331e7e890
|
|
@ -97,17 +97,33 @@ export async function POST(
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const responseText = await response.text();
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
console.error('Dolibarr API error:', response.status, responseText);
|
||||||
console.error('Dolibarr API error:', response.status, errorText);
|
|
||||||
|
// 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(
|
return NextResponse.json(
|
||||||
{ error: 'Error al comunicarse con Dolibarr', details: errorText },
|
{ error: errorDetails },
|
||||||
{ status: response.status }
|
{ 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);
|
return NextResponse.json(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,37 +17,37 @@ export default function DashboardHeader({
|
||||||
onViewModeChange
|
onViewModeChange
|
||||||
}: DashboardHeaderProps) {
|
}: DashboardHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<header className="bg-white border-b border-gray-200">
|
<header className="bg-card border-b">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
<div className="px-4 sm:px-6 lg:px-8 py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold text-gray-900">Dashboard de Proyectos</h1>
|
<h1 className="text-2xl font-semibold text-foreground">Dashboard de Proyectos</h1>
|
||||||
<p className="text-sm text-gray-500 mt-1">Gestiona y visualiza todos tus proyectos</p>
|
<p className="text-sm text-muted-foreground mt-1">Gestiona y visualiza todos tus proyectos</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<div className="relative">
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Buscar proyectos..."
|
placeholder="Buscar proyectos..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
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>
|
||||||
<div className="flex gap-1 border border-gray-200 rounded-lg p-1">
|
<div className="flex gap-1 border rounded-lg p-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => onViewModeChange('grid')}
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={() => onViewModeChange('list')}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Calendar, DollarSign, TrendingUp, FileText } from 'lucide-react';
|
import { Calendar, DollarSign, TrendingUp, FileText } from 'lucide-react';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
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 {
|
interface ProjectCardProps {
|
||||||
project: Project;
|
project: Project;
|
||||||
|
|
@ -21,46 +21,12 @@ function getInitials(name: string): string {
|
||||||
.slice(0, 2);
|
.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) {
|
export default function ProjectCard({ project }: ProjectCardProps) {
|
||||||
const initials = getInitials(project.name);
|
const initials = getInitials(project.name);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`/proyectos/${project.id}`}>
|
<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>
|
<CardHeader>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|
@ -70,32 +36,32 @@ export default function ProjectCard({ project }: ProjectCardProps) {
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div>
|
<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>
|
<CardDescription className="text-sm">{project.ref}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge status={project.status} />
|
<ProjectStatusBadge status={project.status} className="mt-3 w-fit" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
{project.description && (
|
{project.description && (
|
||||||
<div className="mb-4">
|
<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" />
|
<FileText className="w-3 h-3" />
|
||||||
<span className="text-xs">Descripción</span>
|
<span className="text-xs">Descripción</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Progress Bar */}
|
{/* Progress Bar */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<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-medium text-muted-foreground">Progreso</span>
|
||||||
<span className="text-xs font-semibold text-gray-900">{project.progress}%</span>
|
<span className="text-xs font-semibold">{project.progress}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
<div className="w-full bg-muted rounded-full h-2">
|
||||||
<div
|
<div
|
||||||
className="bg-gradient-to-r from-blue-500 to-purple-600 h-2 rounded-full transition-all duration-500"
|
className="bg-gradient-to-r from-blue-500 to-purple-600 h-2 rounded-full transition-all duration-500"
|
||||||
style={{ width: `${project.progress}%` }}
|
style={{ width: `${project.progress}%` }}
|
||||||
|
|
@ -106,34 +72,34 @@ export default function ProjectCard({ project }: ProjectCardProps) {
|
||||||
{/* Budget Info */}
|
{/* Budget Info */}
|
||||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||||
<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">
|
||||||
<DollarSign className="w-3 h-3" />
|
<DollarSign className="w-3 h-3" />
|
||||||
<span className="text-xs">Presupuesto</span>
|
<span className="text-xs">Presupuesto</span>
|
||||||
</div>
|
</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 })}
|
€{project.budget.toLocaleString('es-ES', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<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" />
|
<TrendingUp className="w-3 h-3" />
|
||||||
<span className="text-xs">Estimado</span>
|
<span className="text-xs">Estimado</span>
|
||||||
</div>
|
</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 })}
|
€{project.spent.toLocaleString('es-ES', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer Info */}
|
{/* Footer Info */}
|
||||||
<div className="pt-4 border-t border-gray-100 flex items-center justify-between">
|
<div className="pt-4 border-t flex items-center justify-between">
|
||||||
<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" />
|
<Calendar className="w-4 h-4" />
|
||||||
<span className="text-xs">
|
<span className="text-xs">
|
||||||
{new Date(project.startDate).toLocaleDateString('es-ES', { day: '2-digit', month: 'short', year: 'numeric' })}
|
{new Date(project.startDate).toLocaleDateString('es-ES', { day: '2-digit', month: 'short', year: 'numeric' })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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" />
|
<Calendar className="w-4 h-4" />
|
||||||
<span className="text-xs">
|
<span className="text-xs">
|
||||||
{new Date(project.endDate).toLocaleDateString('es-ES', { day: '2-digit', month: 'short', year: 'numeric' })}
|
{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 */}
|
{/* Cliente */}
|
||||||
{project.client !== 'Sin cliente' && (
|
{project.client !== 'Sin cliente' && (
|
||||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
<div className="mt-3 pt-3 border-t">
|
||||||
<span className="text-xs text-gray-500">Cliente: </span>
|
<span className="text-xs text-muted-foreground">Cliente: </span>
|
||||||
<span className="text-xs font-medium text-gray-700">{project.client}</span>
|
<span className="text-xs font-medium">{project.client}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ export default function ProjectDashboard() {
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">
|
<div className="min-h-screen bg-background">
|
||||||
<DashboardHeader
|
<DashboardHeader
|
||||||
searchTerm=""
|
searchTerm=""
|
||||||
onSearchChange={() => {}}
|
onSearchChange={() => {}}
|
||||||
|
|
@ -49,11 +49,11 @@ export default function ProjectDashboard() {
|
||||||
onViewModeChange={setViewMode}
|
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 */}
|
{/* Stats skeleton */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
{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">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<Skeleton className="h-4 w-24" />
|
<Skeleton className="h-4 w-24" />
|
||||||
<Skeleton className="h-4 w-4 rounded" />
|
<Skeleton className="h-4 w-4 rounded" />
|
||||||
|
|
@ -73,9 +73,9 @@ export default function ProjectDashboard() {
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
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">
|
<div className="text-center">
|
||||||
<p className="text-red-600">{error}</p>
|
<p className="text-red-600 dark:text-red-400">{error}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => window.location.reload()}
|
onClick={() => window.location.reload()}
|
||||||
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">
|
<div className="min-h-screen bg-background">
|
||||||
<DashboardHeader
|
<DashboardHeader
|
||||||
searchTerm={searchTerm}
|
searchTerm={searchTerm}
|
||||||
onSearchChange={setSearchTerm}
|
onSearchChange={setSearchTerm}
|
||||||
|
|
@ -96,7 +96,7 @@ export default function ProjectDashboard() {
|
||||||
onViewModeChange={setViewMode}
|
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} />
|
<StatsOverview projects={projects} />
|
||||||
<ProjectGrid projects={filteredProjects} viewMode={viewMode} />
|
<ProjectGrid projects={filteredProjects} viewMode={viewMode} />
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,11 @@
|
||||||
import { ArrowLeft, Calendar, DollarSign, TrendingUp, FileText, Building2 } from 'lucide-react';
|
import { ArrowLeft, Calendar, DollarSign, TrendingUp, FileText, Building2 } from 'lucide-react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Separator } from '@/components/ui/separator';
|
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 {
|
interface ProjectDetailProps {
|
||||||
project: Project;
|
project: Project;
|
||||||
|
|
@ -23,39 +23,6 @@ function getInitials(name: string): string {
|
||||||
.slice(0, 2);
|
.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
|
* Componente de detalle completo de un proyecto
|
||||||
* Muestra toda la información disponible del proyecto
|
* Muestra toda la información disponible del proyecto
|
||||||
|
|
@ -65,9 +32,9 @@ export default function ProjectDetail({ project }: ProjectDetailProps) {
|
||||||
const initials = getInitials(project.name);
|
const initials = getInitials(project.name);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">
|
<div className="min-h-screen bg-background">
|
||||||
{/* Header */}
|
{/* 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">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -88,9 +55,9 @@ export default function ProjectDetail({ project }: ProjectDetailProps) {
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">{project.name}</h1>
|
<h1 className="text-3xl font-bold mb-2">{project.name}</h1>
|
||||||
<p className="text-gray-500 mb-3">{project.ref}</p>
|
<p className="text-muted-foreground mb-3">{project.ref}</p>
|
||||||
<StatusBadge status={project.status} />
|
<ProjectStatusBadge status={project.status} className="text-base px-4 py-1" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -113,7 +80,7 @@ export default function ProjectDetail({ project }: ProjectDetailProps) {
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-gray-700 whitespace-pre-wrap">{project.description}</p>
|
<p className="text-muted-foreground whitespace-pre-wrap">{project.description}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
@ -129,25 +96,25 @@ export default function ProjectDetail({ project }: ProjectDetailProps) {
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium text-gray-600">Avance Total</span>
|
<span className="text-sm font-medium text-muted-foreground">Avance Total</span>
|
||||||
<span className="text-2xl font-bold text-gray-900">{project.progress}%</span>
|
<span className="text-2xl font-bold">{project.progress}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-gray-200 rounded-full h-4">
|
<div className="w-full bg-muted rounded-full h-4">
|
||||||
<div
|
<div
|
||||||
className="bg-gradient-to-r from-blue-500 to-purple-600 h-4 rounded-full transition-all duration-500"
|
className="bg-gradient-to-r from-blue-500 to-purple-600 h-4 rounded-full transition-all duration-500"
|
||||||
style={{ width: `${project.progress}%` }}
|
style={{ width: `${project.progress}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4 pt-4">
|
<div className="grid grid-cols-2 gap-4 pt-4">
|
||||||
<div className="text-center p-4 bg-gray-50 rounded-lg">
|
<div className="text-center p-4 bg-muted/50 rounded-lg">
|
||||||
<p className="text-sm text-gray-600 mb-1">Estado</p>
|
<p className="text-sm text-muted-foreground mb-1">Estado</p>
|
||||||
<p className="text-lg font-semibold text-gray-900">
|
<p className="text-lg font-semibold">
|
||||||
{project.status === '0' ? 'Borrador' : project.status === '1' ? 'En Progreso' : 'Finalizado'}
|
{project.status === '0' ? 'Borrador' : project.status === '1' ? 'En Progreso' : 'Finalizado'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center p-4 bg-gray-50 rounded-lg">
|
<div className="text-center p-4 bg-muted/50 rounded-lg">
|
||||||
<p className="text-sm text-gray-600 mb-1">Completado</p>
|
<p className="text-sm text-muted-foreground mb-1">Completado</p>
|
||||||
<p className="text-lg font-semibold text-gray-900">
|
<p className="text-lg font-semibold">
|
||||||
{project.progress === 100 ? 'Sí' : 'No'}
|
{project.progress === 100 ? 'Sí' : 'No'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -167,22 +134,22 @@ export default function ProjectDetail({ project }: ProjectDetailProps) {
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-sm text-gray-600">Presupuesto Total</p>
|
<p className="text-sm text-muted-foreground">Presupuesto Total</p>
|
||||||
<p className="text-3xl font-bold text-gray-900">
|
<p className="text-3xl font-bold">
|
||||||
€{project.budget.toLocaleString('es-ES', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
€{project.budget.toLocaleString('es-ES', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-sm text-gray-600">Estimado Gastado</p>
|
<p className="text-sm text-muted-foreground">Estimado Gastado</p>
|
||||||
<p className="text-3xl font-bold text-blue-600">
|
<p className="text-3xl font-bold text-blue-600 dark:text-blue-400">
|
||||||
€{project.spent.toLocaleString('es-ES', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
€{project.spent.toLocaleString('es-ES', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Separator className="my-4" />
|
<Separator className="my-4" />
|
||||||
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
<div className="flex items-center justify-between p-4 bg-muted/50 rounded-lg">
|
||||||
<span className="text-sm font-medium text-gray-600">Restante</span>
|
<span className="text-sm font-medium text-muted-foreground">Restante</span>
|
||||||
<span className="text-xl font-bold text-green-600">
|
<span className="text-xl font-bold text-green-600 dark:text-green-400">
|
||||||
€{(project.budget - project.spent).toLocaleString('es-ES', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
€{(project.budget - project.spent).toLocaleString('es-ES', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -201,10 +168,10 @@ export default function ProjectDetail({ project }: ProjectDetailProps) {
|
||||||
{/* Cliente */}
|
{/* Cliente */}
|
||||||
{project.client !== 'Sin cliente' && (
|
{project.client !== 'Sin cliente' && (
|
||||||
<div className="flex items-start gap-3">
|
<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>
|
<div>
|
||||||
<p className="text-sm text-gray-600">Cliente</p>
|
<p className="text-sm text-muted-foreground">Cliente</p>
|
||||||
<p className="font-medium text-gray-900">{project.client}</p>
|
<p className="font-medium">{project.client}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -213,10 +180,10 @@ export default function ProjectDetail({ project }: ProjectDetailProps) {
|
||||||
|
|
||||||
{/* Fecha de Inicio */}
|
{/* Fecha de Inicio */}
|
||||||
<div className="flex items-start gap-3">
|
<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>
|
<div>
|
||||||
<p className="text-sm text-gray-600">Fecha de Inicio</p>
|
<p className="text-sm text-muted-foreground">Fecha de Inicio</p>
|
||||||
<p className="font-medium text-gray-900">
|
<p className="font-medium">
|
||||||
{new Date(project.startDate).toLocaleDateString('es-ES', {
|
{new Date(project.startDate).toLocaleDateString('es-ES', {
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
|
|
@ -230,10 +197,10 @@ export default function ProjectDetail({ project }: ProjectDetailProps) {
|
||||||
|
|
||||||
{/* Fecha de Fin */}
|
{/* Fecha de Fin */}
|
||||||
<div className="flex items-start gap-3">
|
<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>
|
<div>
|
||||||
<p className="text-sm text-gray-600">Fecha de Fin</p>
|
<p className="text-sm text-muted-foreground">Fecha de Fin</p>
|
||||||
<p className="font-medium text-gray-900">
|
<p className="font-medium">
|
||||||
{new Date(project.endDate).toLocaleDateString('es-ES', {
|
{new Date(project.endDate).toLocaleDateString('es-ES', {
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
|
|
@ -247,10 +214,10 @@ export default function ProjectDetail({ project }: ProjectDetailProps) {
|
||||||
|
|
||||||
{/* Duración */}
|
{/* Duración */}
|
||||||
<div className="flex items-start gap-3">
|
<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>
|
<div>
|
||||||
<p className="text-sm text-gray-600">Duración</p>
|
<p className="text-sm text-muted-foreground">Duración</p>
|
||||||
<p className="font-medium text-gray-900">
|
<p className="font-medium">
|
||||||
{Math.ceil((new Date(project.endDate).getTime() - new Date(project.startDate).getTime()) / (1000 * 60 * 60 * 24))} días
|
{Math.ceil((new Date(project.endDate).getTime() - new Date(project.startDate).getTime()) / (1000 * 60 * 60 * 24))} días
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -265,18 +232,18 @@ export default function ProjectDetail({ project }: ProjectDetailProps) {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3 text-sm">
|
<CardContent className="space-y-3 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-gray-600">ID del Proyecto</p>
|
<p className="text-muted-foreground">ID del Proyecto</p>
|
||||||
<p className="font-mono text-gray-900">{project.id}</p>
|
<p className="font-mono">{project.id}</p>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-gray-600">Referencia</p>
|
<p className="text-muted-foreground">Referencia</p>
|
||||||
<p className="font-mono text-gray-900">{project.ref}</p>
|
<p className="font-mono">{project.ref}</p>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-gray-600">Creado</p>
|
<p className="text-muted-foreground">Creado</p>
|
||||||
<p className="text-gray-900">
|
<p>
|
||||||
{new Date(project.createdAt).toLocaleDateString('es-ES', {
|
{new Date(project.createdAt).toLocaleDateString('es-ES', {
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
|
|
@ -286,8 +253,8 @@ export default function ProjectDetail({ project }: ProjectDetailProps) {
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-gray-600">Última Actualización</p>
|
<p className="text-muted-foreground">Última Actualización</p>
|
||||||
<p className="text-gray-900">
|
<p>
|
||||||
{new Date(project.updatedAt).toLocaleDateString('es-ES', {
|
{new Date(project.updatedAt).toLocaleDateString('es-ES', {
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ export default function ProjectGrid({ projects, viewMode }: ProjectGridProps) {
|
||||||
if (projects.length === 0) {
|
if (projects.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-12">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Calendar, DollarSign, TrendingUp } from 'lucide-react';
|
import { Calendar, DollarSign, TrendingUp } from 'lucide-react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
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 {
|
interface ProjectListItemProps {
|
||||||
project: Project;
|
project: Project;
|
||||||
|
|
@ -20,40 +20,6 @@ function getInitials(name: string): string {
|
||||||
.slice(0, 2);
|
.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
|
* Componente de fila de proyecto para vista de lista
|
||||||
* Diseño horizontal y compacto
|
* Diseño horizontal y compacto
|
||||||
|
|
@ -63,7 +29,7 @@ export default function ProjectListItem({ project }: ProjectListItemProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`/proyectos/${project.id}`}>
|
<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">
|
<div className="flex items-center gap-4">
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
<Avatar className="w-12 h-12 flex-shrink-0">
|
<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 items-start justify-between gap-4">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<h3 className="text-base font-semibold text-gray-900 truncate">
|
<h3 className="text-base font-semibold truncate">
|
||||||
{project.name}
|
{project.name}
|
||||||
</h3>
|
</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>
|
</div>
|
||||||
{project.client !== 'Sin cliente' && (
|
{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>
|
</div>
|
||||||
|
|
||||||
{/* Estado */}
|
{/* Estado */}
|
||||||
<StatusBadge status={project.status} />
|
<ProjectStatusBadge status={project.status} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progreso */}
|
{/* Progreso */}
|
||||||
<div className="hidden md:flex items-center gap-2 flex-shrink-0 w-32">
|
<div className="hidden md:flex items-center gap-2 flex-shrink-0 w-32">
|
||||||
<TrendingUp className="w-4 h-4 text-gray-400" />
|
<TrendingUp className="w-4 h-4 text-muted-foreground" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<span className="text-xs text-gray-600">Progreso</span>
|
<span className="text-xs text-muted-foreground">Progreso</span>
|
||||||
<span className="text-xs font-semibold text-gray-900">{project.progress}%</span>
|
<span className="text-xs font-semibold">{project.progress}%</span>
|
||||||
</div>
|
</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
|
<div
|
||||||
className="bg-gradient-to-r from-blue-500 to-purple-600 h-1.5 rounded-full transition-all duration-500"
|
className="bg-gradient-to-r from-blue-500 to-purple-600 h-1.5 rounded-full transition-all duration-500"
|
||||||
style={{ width: `${project.progress}%` }}
|
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">
|
<div className="hidden lg:flex items-center gap-2 flex-shrink-0">
|
||||||
<DollarSign className="w-4 h-4 text-green-500" />
|
<DollarSign className="w-4 h-4 text-green-500" />
|
||||||
<div className="text-right">
|
<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 })}
|
€{project.budget.toLocaleString('es-ES', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500">Presupuesto</div>
|
<div className="text-xs text-muted-foreground">Presupuesto</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Fechas */}
|
{/* Fechas */}
|
||||||
<div className="hidden xl:flex items-center gap-2 flex-shrink-0">
|
<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-right">
|
||||||
<div className="text-sm text-gray-900">
|
<div className="text-sm">
|
||||||
{new Date(project.startDate).toLocaleDateString('es-ES', { day: '2-digit', month: 'short' })}
|
{new Date(project.startDate).toLocaleDateString('es-ES', { day: '2-digit', month: 'short' })}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500">Inicio</div>
|
<div className="text-xs text-muted-foreground">Inicio</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -20,49 +20,49 @@ export default function StatsOverview({ projects }: StatsOverviewProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
<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">
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-gray-600">Total Proyectos</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">Total Proyectos</CardTitle>
|
||||||
<LayoutGrid className="w-4 h-4 text-gray-400" />
|
<LayoutGrid className="w-4 h-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-3xl font-bold text-gray-900">{stats.total}</div>
|
<div className="text-3xl font-bold text-foreground">{stats.total}</div>
|
||||||
<p className="text-xs text-gray-500 mt-1">Todos los proyectos</p>
|
<p className="text-xs text-muted-foreground mt-1">Todos los proyectos</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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">
|
<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" />
|
<TrendingUp className="w-4 h-4 text-blue-500" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-3xl font-bold text-gray-900">{stats.activos}</div>
|
<div className="text-3xl font-bold text-foreground">{stats.activos}</div>
|
||||||
<p className="text-xs text-gray-500 mt-1">En progreso ahora</p>
|
<p className="text-xs text-muted-foreground mt-1">En progreso ahora</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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">
|
<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" />
|
<DollarSign className="w-4 h-4 text-green-500" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-3xl font-bold text-gray-900">
|
<div className="text-3xl font-bold text-foreground">
|
||||||
€{(stats.totalBudget / 1000).toFixed(0)}k
|
€{(stats.totalBudget / 1000).toFixed(0)}k
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</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">
|
<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" />
|
<Clock className="w-4 h-4 text-purple-500" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-3xl font-bold text-gray-900">{stats.avgProgress}%</div>
|
<div className="text-3xl font-bold text-foreground">{stats.avgProgress}%</div>
|
||||||
<p className="text-xs text-gray-500 mt-1">De todos los proyectos</p>
|
<p className="text-xs text-muted-foreground mt-1">De todos los proyectos</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -265,10 +265,10 @@ export default function GanttPage() {
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
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">
|
<div className="text-center">
|
||||||
<AlertCircle className="w-16 h-16 text-red-400 mx-auto mb-4" />
|
<AlertCircle className="w-16 h-16 text-red-400 dark:text-red-500 mx-auto mb-4" />
|
||||||
<p className="text-red-600 text-lg">{error}</p>
|
<p className="text-red-600 dark:text-red-400 text-lg">{error}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => window.location.reload()}
|
onClick={() => window.location.reload()}
|
||||||
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
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 (
|
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 - 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="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 justify-between flex-wrap gap-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|
@ -291,8 +291,8 @@ export default function GanttPage() {
|
||||||
<GanttIcon className="w-6 h-6 text-white" />
|
<GanttIcon className="w-6 h-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Diagrama de Gantt</h1>
|
<h1 className="text-2xl font-bold text-foreground">Diagrama de Gantt</h1>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-muted-foreground">
|
||||||
Timeline de {filteredProjects.length} proyectos
|
Timeline de {filteredProjects.length} proyectos
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -377,22 +377,22 @@ export default function GanttPage() {
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<GanttSkeleton />
|
<GanttSkeleton />
|
||||||
) : filteredProjects.length === 0 ? (
|
) : filteredProjects.length === 0 ? (
|
||||||
<Card className="border-gray-200">
|
<Card className="border">
|
||||||
<CardContent className="py-16 text-center">
|
<CardContent className="py-16 text-center">
|
||||||
<GanttIcon className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
<GanttIcon className="w-16 h-16 text-muted-foreground/50 mx-auto mb-4" />
|
||||||
<h3 className="text-xl font-semibold text-gray-700">No hay proyectos</h3>
|
<h3 className="text-xl font-semibold text-foreground">No hay proyectos</h3>
|
||||||
<p className="text-gray-500 mt-2">No se encontraron proyectos con los filtros seleccionados</p>
|
<p className="text-muted-foreground mt-2">No se encontraron proyectos con los filtros seleccionados</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card className="border-gray-200 w-full">
|
<Card className="border w-full">
|
||||||
<CardContent className="p-0 overflow-hidden">
|
<CardContent className="p-0 overflow-hidden">
|
||||||
<div className="flex w-full overflow-hidden">
|
<div className="flex w-full overflow-hidden">
|
||||||
{/* Columna de nombres de proyectos - fija */}
|
{/* 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 */}
|
{/* Header */}
|
||||||
<div className="h-16 border-b border-gray-200 flex items-center px-4">
|
<div className="h-16 border-b flex items-center px-4">
|
||||||
<span className="font-semibold text-gray-700">Proyecto</span>
|
<span className="font-semibold text-foreground">Proyecto</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Lista de proyectos */}
|
{/* Lista de proyectos */}
|
||||||
{filteredProjects.map((project, index) => {
|
{filteredProjects.map((project, index) => {
|
||||||
|
|
@ -400,15 +400,15 @@ export default function GanttPage() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={project.id}
|
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="flex items-center gap-3 min-w-0">
|
||||||
<div className={`w-3 h-3 rounded-full ${color.bg} flex-shrink-0`} />
|
<div className={`w-3 h-3 rounded-full ${color.bg} flex-shrink-0`} />
|
||||||
<div className="min-w-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}
|
{project.name}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500">{project.progress}%</p>
|
<p className="text-xs text-muted-foreground">{project.progress}%</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -423,18 +423,18 @@ export default function GanttPage() {
|
||||||
>
|
>
|
||||||
<div style={{ width: timeRange === "all" ? `${visibleMonths.length * 100}px` : "100%" }}>
|
<div style={{ width: timeRange === "all" ? `${visibleMonths.length * 100}px` : "100%" }}>
|
||||||
{/* Header con meses */}
|
{/* Header con meses */}
|
||||||
<div className="h-16 border-b border-gray-200 flex">
|
<div className="h-16 border-b flex">
|
||||||
{visibleMonths.map((month) => {
|
{visibleMonths.map((month) => {
|
||||||
const isCurrentMonth = new Date().getMonth() === month.month && new Date().getFullYear() === month.year;
|
const isCurrentMonth = new Date().getMonth() === month.month && new Date().getFullYear() === month.year;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${month.year}-${month.month}`}
|
key={`${month.year}-${month.month}`}
|
||||||
className={`flex-1 border-r border-gray-200 flex flex-col justify-center px-2 ${
|
className={`flex-1 border-r flex flex-col justify-center px-2 ${
|
||||||
isCurrentMonth ? "bg-blue-50" : "bg-white"
|
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}
|
{month.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -463,7 +463,7 @@ export default function GanttPage() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={project.id}
|
key={project.id}
|
||||||
className="h-14 border-b border-gray-100 relative"
|
className="h-14 border-b relative"
|
||||||
>
|
>
|
||||||
{/* Grid de meses */}
|
{/* Grid de meses */}
|
||||||
<div className="absolute inset-0 flex">
|
<div className="absolute inset-0 flex">
|
||||||
|
|
@ -473,8 +473,8 @@ export default function GanttPage() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`grid-${month.year}-${month.month}`}
|
key={`grid-${month.year}-${month.month}`}
|
||||||
className={`flex-1 border-r border-gray-100 ${
|
className={`flex-1 border-r ${
|
||||||
isCurrentMonth ? "bg-blue-50/30" : ""
|
isCurrentMonth ? "bg-blue-50/30 dark:bg-blue-950/20" : ""
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -546,15 +546,15 @@ export default function GanttPage() {
|
||||||
|
|
||||||
function GanttSkeleton() {
|
function GanttSkeleton() {
|
||||||
return (
|
return (
|
||||||
<Card className="border-gray-200">
|
<Card className="border">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<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">
|
||||||
<div className="h-16 border-b border-gray-200 flex items-center px-4">
|
<div className="h-16 border-b flex items-center px-4">
|
||||||
<Skeleton className="h-4 w-20" />
|
<Skeleton className="h-4 w-20" />
|
||||||
</div>
|
</div>
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
{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">
|
<div className="flex items-center gap-3">
|
||||||
<Skeleton className="w-3 h-3 rounded-full" />
|
<Skeleton className="w-3 h-3 rounded-full" />
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -566,15 +566,15 @@ function GanttSkeleton() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<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) => (
|
{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" />
|
<Skeleton className="h-4 w-20" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
{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" />
|
<Skeleton className="h-8 w-full max-w-md rounded-md" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,11 @@ export function ProjectDetailView({ project }: ProjectDetailViewProps) {
|
||||||
fetchTasks();
|
fetchTasks();
|
||||||
}, [fetchTasks]);
|
}, [fetchTasks]);
|
||||||
|
|
||||||
|
// Handler para cuando se crea una tarea
|
||||||
|
const handleTaskCreated = (newTask: Task) => {
|
||||||
|
setTasks(prevTasks => [newTask, ...prevTasks]);
|
||||||
|
};
|
||||||
|
|
||||||
// Calcular estadísticas de tareas
|
// Calcular estadísticas de tareas
|
||||||
const taskStats = calculateTaskStats(tasks);
|
const taskStats = calculateTaskStats(tasks);
|
||||||
|
|
||||||
|
|
@ -156,7 +161,11 @@ export function ProjectDetailView({ project }: ProjectDetailViewProps) {
|
||||||
<a href="#tasks">Ver todas</a>
|
<a href="#tasks">Ver todas</a>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<TasksSection tasks={tasks.slice(0, 5)} />
|
<TasksSection
|
||||||
|
tasks={tasks.slice(0, 5)}
|
||||||
|
projectId={project.id}
|
||||||
|
onTaskCreated={handleTaskCreated}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
@ -180,7 +189,11 @@ export function ProjectDetailView({ project }: ProjectDetailViewProps) {
|
||||||
) : tasksError ? (
|
) : tasksError ? (
|
||||||
<ErrorState onRetry={fetchTasks} />
|
<ErrorState onRetry={fetchTasks} />
|
||||||
) : (
|
) : (
|
||||||
<TasksSection tasks={tasks} />
|
<TasksSection
|
||||||
|
tasks={tasks}
|
||||||
|
projectId={project.id}
|
||||||
|
onTaskCreated={handleTaskCreated}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
|
|
@ -14,7 +13,8 @@ import {
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} 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 {
|
interface ProjectHeaderProps {
|
||||||
project: Project;
|
project: Project;
|
||||||
|
|
@ -30,31 +30,7 @@ function getInitials(name: string): string {
|
||||||
.slice(0, 2);
|
.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) {
|
export function ProjectHeader({ project }: ProjectHeaderProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -90,7 +66,7 @@ export function ProjectHeader({ project }: ProjectHeaderProps) {
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">{project.name}</h1>
|
<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>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckCircle2
|
CheckCircle2
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
@ -132,7 +133,12 @@ export const taskColumns: ColumnDef<Task>[] = [
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="max-w-[300px]">
|
<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 && (
|
{row.original.ref && (
|
||||||
<p className="text-xs text-muted-foreground font-mono">{row.original.ref}</p>
|
<p className="text-xs text-muted-foreground font-mono">{row.original.ref}</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -218,6 +224,8 @@ export const taskColumns: ColumnDef<Task>[] = [
|
||||||
id: "actions",
|
id: "actions",
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
|
const task = row.original;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
|
|
@ -229,9 +237,11 @@ export const taskColumns: ColumnDef<Task>[] = [
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuLabel>Acciones</DropdownMenuLabel>
|
<DropdownMenuLabel>Acciones</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href={`/tareas/${task.id}`} className="cursor-pointer">
|
||||||
<Eye className="mr-2 h-4 w-4" />
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
Ver detalles
|
Ver detalles
|
||||||
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem>
|
||||||
<Pencil className="mr-2 h-4 w-4" />
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
List
|
List
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
@ -26,11 +27,14 @@ import {
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { DataTable } from "@/components/projects/data-table";
|
import { DataTable } from "@/components/projects/data-table";
|
||||||
import { taskColumns } from "./task-columns";
|
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";
|
import { Task, TASK_STATUS_CONFIG, TASK_PRIORITY_CONFIG } from "@/types/task";
|
||||||
|
|
||||||
interface TasksSectionProps {
|
interface TasksSectionProps {
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
|
projectId: number;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
|
onTaskCreated?: (task: Task) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tarjeta de tarea para vista grid
|
// Tarjeta de tarea para vista grid
|
||||||
|
|
@ -44,13 +48,14 @@ function TaskCard({ task }: { task: Task }) {
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={`hover:shadow-md transition-shadow ${isOverdue ? 'border-red-200 dark:border-red-900' : ''}`}>
|
<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">
|
<CardContent className="p-4">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-medium truncate">{task.title}</p>
|
<p className="font-medium truncate hover:text-blue-600 transition-colors">{task.title}</p>
|
||||||
{task.ref && (
|
{task.ref && (
|
||||||
<p className="text-xs text-muted-foreground font-mono">{task.ref}</p>
|
<p className="text-xs text-muted-foreground font-mono">{task.ref}</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -101,11 +106,12 @@ function TaskCard({ task }: { task: Task }) {
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Estado vacío
|
// Estado vacío
|
||||||
function EmptyTasks() {
|
function EmptyTasks({ onCreateClick }: { onCreateClick: () => void }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
<div className="p-3 rounded-full bg-muted mb-4">
|
<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">
|
<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.
|
Este proyecto aún no tiene tareas asignadas. Crea la primera tarea para comenzar.
|
||||||
</p>
|
</p>
|
||||||
<Button>
|
<Button onClick={onCreateClick}>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
Crear tarea
|
Crear tarea
|
||||||
</Button>
|
</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 [searchValue, setSearchValue] = useState("");
|
||||||
const [statusFilter, setStatusFilter] = useState("all");
|
const [statusFilter, setStatusFilter] = useState("all");
|
||||||
const [priorityFilter, setPriorityFilter] = useState("all");
|
const [priorityFilter, setPriorityFilter] = useState("all");
|
||||||
const [viewMode, setViewMode] = useState<"table" | "grid">("table");
|
const [viewMode, setViewMode] = useState<"table" | "grid">("table");
|
||||||
|
const [isCreateSheetOpen, setIsCreateSheetOpen] = useState(false);
|
||||||
|
|
||||||
|
// Handler para cuando se crea una tarea exitosamente
|
||||||
|
const handleCreateSuccess = (task: Task) => {
|
||||||
|
onTaskCreated?.(task);
|
||||||
|
};
|
||||||
|
|
||||||
// Filtrar tareas
|
// Filtrar tareas
|
||||||
const filteredTasks = useMemo(() => {
|
const filteredTasks = useMemo(() => {
|
||||||
|
|
@ -175,7 +187,18 @@ export function TasksSection({ tasks, isLoading }: TasksSectionProps) {
|
||||||
const isFiltered = searchValue !== "" || statusFilter !== "all" || priorityFilter !== "all";
|
const isFiltered = searchValue !== "" || statusFilter !== "all" || priorityFilter !== "all";
|
||||||
|
|
||||||
if (tasks.length === 0 && !isLoading) {
|
if (tasks.length === 0 && !isLoading) {
|
||||||
return <EmptyTasks />;
|
return (
|
||||||
|
<>
|
||||||
|
<EmptyTasks onCreateClick={() => setIsCreateSheetOpen(true)} />
|
||||||
|
<TaskFormSheet
|
||||||
|
open={isCreateSheetOpen}
|
||||||
|
onOpenChange={setIsCreateSheetOpen}
|
||||||
|
mode="create"
|
||||||
|
projectId={projectId}
|
||||||
|
onSuccess={handleCreateSuccess}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -278,7 +301,7 @@ export function TasksSection({ tasks, isLoading }: TasksSectionProps) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Nueva tarea */}
|
{/* 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" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
Nueva tarea
|
Nueva tarea
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -307,6 +330,15 @@ export function TasksSection({ tasks, isLoading }: TasksSectionProps) {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Sheet de creación de tarea */}
|
||||||
|
<TaskFormSheet
|
||||||
|
open={isCreateSheetOpen}
|
||||||
|
onOpenChange={setIsCreateSheetOpen}
|
||||||
|
mode="create"
|
||||||
|
projectId={projectId}
|
||||||
|
onSuccess={handleCreateSuccess}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import { ArrowUpDown, MoreHorizontal, Eye, Pencil, Trash2 } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
|
|
@ -15,24 +14,8 @@ import {
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Project, ProjectStatus, STATUS_CONFIG } from "@/types/project";
|
import { ProjectStatusBadge } from "@/components/ui/project-status-badge";
|
||||||
|
import { Project, ProjectStatus } from "@/types/project";
|
||||||
// Componente para el badge de estado
|
|
||||||
function StatusBadge({ status }: { status: ProjectStatus }) {
|
|
||||||
const config = STATUS_CONFIG[status];
|
|
||||||
|
|
||||||
const colorClasses: Record<ProjectStatus, string> = {
|
|
||||||
'0': 'bg-gray-100 text-gray-700 hover:bg-gray-100/80 dark:bg-gray-800 dark:text-gray-300',
|
|
||||||
'1': 'bg-blue-100 text-blue-700 hover:bg-blue-100/80 dark:bg-blue-900/30 dark:text-blue-400',
|
|
||||||
'2': 'bg-green-100 text-green-700 hover:bg-green-100/80 dark:bg-green-900/30 dark:text-green-400',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Badge variant="outline" className={colorClasses[status]}>
|
|
||||||
{config?.label || 'Desconocido'}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Componente para la barra de progreso
|
// Componente para la barra de progreso
|
||||||
function ProgressBar({ progress }: { progress: number }) {
|
function ProgressBar({ progress }: { progress: number }) {
|
||||||
|
|
@ -45,7 +28,7 @@ function ProgressBar({ progress }: { progress: number }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<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
|
<div
|
||||||
className={`h-full rounded-full transition-all ${getProgressColor(progress)}`}
|
className={`h-full rounded-full transition-all ${getProgressColor(progress)}`}
|
||||||
style={{ width: `${Math.min(progress, 100)}%` }}
|
style={{ width: `${Math.min(progress, 100)}%` }}
|
||||||
|
|
@ -167,7 +150,7 @@ export const projectColumns: ColumnDef<Project>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: "status",
|
accessorKey: "status",
|
||||||
header: "Estado",
|
header: "Estado",
|
||||||
cell: ({ row }) => <StatusBadge status={row.getValue("status")} />,
|
cell: ({ row }) => <ProjectStatusBadge status={row.getValue("status")} />,
|
||||||
filterFn: (row, id, value) => {
|
filterFn: (row, id, value) => {
|
||||||
return value.includes(row.getValue(id));
|
return value.includes(row.getValue(id));
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -83,14 +83,14 @@ interface StatCardProps {
|
||||||
|
|
||||||
function StatCard({ title, value, subtitle, icon, highlight }: StatCardProps) {
|
function StatCard({ title, value, subtitle, icon, highlight }: StatCardProps) {
|
||||||
return (
|
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">
|
<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}
|
{icon}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-3xl font-bold text-gray-900">{value}</div>
|
<div className="text-3xl font-bold text-foreground">{value}</div>
|
||||||
{subtitle && <p className="text-xs text-gray-500 mt-1">{subtitle}</p>}
|
{subtitle && <p className="text-xs text-muted-foreground mt-1">{subtitle}</p>}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
@ -115,9 +115,9 @@ function StatusPieChart({ borradores, abiertos, cerrados }: StatusPieChartProps)
|
||||||
const total = borradores + abiertos + cerrados;
|
const total = borradores + abiertos + cerrados;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border-gray-200">
|
<Card className="border">
|
||||||
<CardHeader className="pb-2">
|
<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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="h-64 relative">
|
<div className="h-64 relative">
|
||||||
|
|
@ -144,8 +144,8 @@ function StatusPieChart({ borradores, abiertos, cerrados }: StatusPieChartProps)
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-2xl font-bold text-gray-900">{total}</p>
|
<p className="text-2xl font-bold text-foreground">{total}</p>
|
||||||
<p className="text-xs text-gray-500">Total</p>
|
<p className="text-xs text-muted-foreground">Total</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -153,7 +153,7 @@ function StatusPieChart({ borradores, abiertos, cerrados }: StatusPieChartProps)
|
||||||
{data.map((entry) => (
|
{data.map((entry) => (
|
||||||
<div key={entry.name} className="flex items-center gap-1.5">
|
<div key={entry.name} className="flex items-center gap-1.5">
|
||||||
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: entry.color }} />
|
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -172,9 +172,9 @@ interface ProgressDonutProps {
|
||||||
|
|
||||||
function ProgressDonut({ data, title, centerValue, centerLabel }: ProgressDonutProps) {
|
function ProgressDonut({ data, title, centerValue, centerLabel }: ProgressDonutProps) {
|
||||||
return (
|
return (
|
||||||
<Card className="border-gray-200">
|
<Card className="border">
|
||||||
<CardHeader className="pb-2">
|
<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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="h-64 relative">
|
<div className="h-64 relative">
|
||||||
|
|
@ -202,8 +202,8 @@ function ProgressDonut({ data, title, centerValue, centerLabel }: ProgressDonutP
|
||||||
{centerValue !== undefined && (
|
{centerValue !== undefined && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-2xl font-bold text-gray-900">{centerValue}</p>
|
<p className="text-2xl font-bold text-foreground">{centerValue}</p>
|
||||||
{centerLabel && <p className="text-xs text-gray-500">{centerLabel}</p>}
|
{centerLabel && <p className="text-xs text-muted-foreground">{centerLabel}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -212,7 +212,7 @@ function ProgressDonut({ data, title, centerValue, centerLabel }: ProgressDonutP
|
||||||
{data.map((entry) => (
|
{data.map((entry) => (
|
||||||
<div key={entry.name} className="flex items-center gap-1.5">
|
<div key={entry.name} className="flex items-center gap-1.5">
|
||||||
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: entry.color }} />
|
<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>
|
||||||
))}
|
))}
|
||||||
</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 }];
|
const data = [{ name: title, value: Math.min(value, 100), fill: color }];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border-gray-200">
|
<Card className="border">
|
||||||
<CardHeader className="pb-0">
|
<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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="h-40 relative">
|
<div className="h-40 relative">
|
||||||
|
|
@ -250,7 +250,7 @@ function RadialMetric({ value, title, subtitle, color = COLORS.primary }: Radial
|
||||||
endAngle={0}
|
endAngle={0}
|
||||||
>
|
>
|
||||||
<RadialBar
|
<RadialBar
|
||||||
background={{ fill: "#f3f4f6" }}
|
background={{ fill: "hsl(var(--muted))" }}
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
cornerRadius={10}
|
cornerRadius={10}
|
||||||
/>
|
/>
|
||||||
|
|
@ -258,8 +258,8 @@ function RadialMetric({ value, title, subtitle, color = COLORS.primary }: Radial
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none" style={{ marginTop: "-15px" }}>
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none" style={{ marginTop: "-15px" }}>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-2xl font-bold text-gray-900">{value}%</p>
|
<p className="text-2xl font-bold text-foreground">{value}%</p>
|
||||||
{subtitle && <p className="text-xs text-gray-500">{subtitle}</p>}
|
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -287,9 +287,9 @@ function BudgetBarChart({ projects, title, maxItems = 6 }: BudgetBarChartProps)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border-gray-200">
|
<Card className="border">
|
||||||
<CardHeader className="pb-2">
|
<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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="h-72">
|
<div className="h-72">
|
||||||
|
|
@ -334,9 +334,9 @@ interface ComparisonBarChartProps {
|
||||||
|
|
||||||
function ComparisonBarChart({ data, title, formatter }: ComparisonBarChartProps) {
|
function ComparisonBarChart({ data, title, formatter }: ComparisonBarChartProps) {
|
||||||
return (
|
return (
|
||||||
<Card className="border-gray-200">
|
<Card className="border">
|
||||||
<CardHeader className="pb-2">
|
<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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="h-64">
|
<div className="h-64">
|
||||||
|
|
@ -377,9 +377,9 @@ function MultiRadialChart({ data, title, valueLabel = "%" }: MultiRadialProps) {
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border-gray-200">
|
<Card className="border">
|
||||||
<CardHeader className="pb-2">
|
<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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="h-64">
|
<div className="h-64">
|
||||||
|
|
@ -394,7 +394,7 @@ function MultiRadialChart({ data, title, valueLabel = "%" }: MultiRadialProps) {
|
||||||
endAngle={-270}
|
endAngle={-270}
|
||||||
>
|
>
|
||||||
<RadialBar
|
<RadialBar
|
||||||
background={{ fill: "#f3f4f6" }}
|
background={{ fill: "hsl(var(--muted))" }}
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
cornerRadius={5}
|
cornerRadius={5}
|
||||||
/>
|
/>
|
||||||
|
|
@ -527,19 +527,19 @@ function AllProjectsStats({ projects }: { projects: Project[] }) {
|
||||||
{(overdueProjects.length > 0 || upcomingDeadlines.length > 0) && (
|
{(overdueProjects.length > 0 || upcomingDeadlines.length > 0) && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{overdueProjects.length > 0 && (
|
{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">
|
<CardContent className="pt-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<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
|
<AlertCircle className="w-4 h-4" /> Proyectos Retrasados
|
||||||
</p>
|
</p>
|
||||||
<p className="text-3xl font-bold text-red-600 mt-2">{overdueProjects.length}</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">Fecha límite superada</p>
|
<p className="text-xs text-red-600 dark:text-red-400">Fecha límite superada</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
{overdueProjects.slice(0, 2).map(p => (
|
{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>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -547,19 +547,19 @@ function AllProjectsStats({ projects }: { projects: Project[] }) {
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
{upcomingDeadlines.length > 0 && (
|
{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">
|
<CardContent className="pt-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<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
|
<Clock className="w-4 h-4" /> Próximos a Vencer
|
||||||
</p>
|
</p>
|
||||||
<p className="text-3xl font-bold text-amber-600 mt-2">{upcomingDeadlines.length}</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">En los próximos 14 días</p>
|
<p className="text-xs text-amber-600 dark:text-amber-400">En los próximos 14 días</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
{upcomingDeadlines.slice(0, 2).map(p => (
|
{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.name} ({getDaysRemaining(p.endDate)}d)
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
|
|
@ -586,9 +586,9 @@ function AllProjectsStats({ projects }: { projects: Project[] }) {
|
||||||
|
|
||||||
{/* Timeline de fechas - Próximos vencimientos y retrasados */}
|
{/* Timeline de fechas - Próximos vencimientos y retrasados */}
|
||||||
{(upcomingDeadlines.length > 0 || overdueProjects.length > 0) && (
|
{(upcomingDeadlines.length > 0 || overdueProjects.length > 0) && (
|
||||||
<Card className="border-gray-200">
|
<Card className="border">
|
||||||
<CardHeader className="pb-2">
|
<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
|
<Calendar className="w-4 h-4" /> Timeline de Proyectos
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
@ -597,20 +597,20 @@ function AllProjectsStats({ projects }: { projects: Project[] }) {
|
||||||
{/* Proyectos retrasados */}
|
{/* Proyectos retrasados */}
|
||||||
{overdueProjects.length > 0 && (
|
{overdueProjects.length > 0 && (
|
||||||
<div>
|
<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})
|
<AlertCircle className="w-3 h-3" /> Retrasados ({overdueProjects.length})
|
||||||
</h4>
|
</h4>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||||
{overdueProjects.slice(0, 6).map((p, index) => {
|
{overdueProjects.slice(0, 6).map((p, index) => {
|
||||||
const daysOverdue = Math.abs(getDaysRemaining(p.endDate));
|
const daysOverdue = Math.abs(getDaysRemaining(p.endDate));
|
||||||
return (
|
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="w-2 h-2 rounded-full bg-red-500" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-gray-800 truncate">{p.name}</p>
|
<p className="text-sm font-medium text-foreground truncate">{p.name}</p>
|
||||||
<p className="text-xs text-gray-500">{p.progress}% completado</p>
|
<p className="text-xs text-muted-foreground">{p.progress}% completado</p>
|
||||||
</div>
|
</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
|
-{daysOverdue}d
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -623,25 +623,25 @@ function AllProjectsStats({ projects }: { projects: Project[] }) {
|
||||||
{/* Próximos vencimientos */}
|
{/* Próximos vencimientos */}
|
||||||
{upcomingDeadlines.length > 0 && (
|
{upcomingDeadlines.length > 0 && (
|
||||||
<div>
|
<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})
|
<Clock className="w-3 h-3" /> Próximos 14 días ({upcomingDeadlines.length})
|
||||||
</h4>
|
</h4>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||||
{upcomingDeadlines.slice(0, 6).map((p, index) => {
|
{upcomingDeadlines.slice(0, 6).map((p, index) => {
|
||||||
const days = getDaysRemaining(p.endDate);
|
const days = getDaysRemaining(p.endDate);
|
||||||
return (
|
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="w-2 h-2 rounded-full" style={{ backgroundColor: getProjectColor(index) }} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-gray-800 truncate">{p.name}</p>
|
<p className="text-sm font-medium text-foreground truncate">{p.name}</p>
|
||||||
<p className="text-xs text-gray-500">{p.progress}% completado</p>
|
<p className="text-xs text-muted-foreground">{p.progress}% completado</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={`text-xs ${
|
className={`text-xs ${
|
||||||
days <= 3 ? 'border-red-300 text-red-600' :
|
days <= 3 ? 'border-red-300 dark:border-red-700 text-red-600 dark:text-red-400' :
|
||||||
days <= 7 ? 'border-amber-300 text-amber-600' :
|
days <= 7 ? 'border-amber-300 dark:border-amber-700 text-amber-600 dark:text-amber-400' :
|
||||||
'border-gray-300 text-gray-600'
|
'border text-muted-foreground'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{days}d
|
{days}d
|
||||||
|
|
@ -654,8 +654,8 @@ function AllProjectsStats({ projects }: { projects: Project[] }) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Resumen de fechas */}
|
{/* Resumen de fechas */}
|
||||||
<div className="pt-3 border-t border-gray-100">
|
<div className="pt-3 border-t">
|
||||||
<div className="flex flex-wrap gap-4 text-xs text-gray-500">
|
<div className="flex flex-wrap gap-4 text-xs text-muted-foreground">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<div className="w-2 h-2 rounded-full bg-red-500" />
|
<div className="w-2 h-2 rounded-full bg-red-500" />
|
||||||
Retrasados: {overdueProjects.length}
|
Retrasados: {overdueProjects.length}
|
||||||
|
|
@ -689,9 +689,9 @@ function ActiveProjectsStats({ projects }: { projects: Project[] }) {
|
||||||
if (activeProjects.length === 0) {
|
if (activeProjects.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-16">
|
<div className="flex flex-col items-center justify-center py-16">
|
||||||
<TrendingUp className="w-16 h-16 text-gray-300 mb-4" />
|
<TrendingUp className="w-16 h-16 text-muted-foreground/50 mb-4" />
|
||||||
<h3 className="text-xl font-semibold text-gray-700">No hay proyectos activos</h3>
|
<h3 className="text-xl font-semibold text-foreground">No hay proyectos activos</h3>
|
||||||
<p className="text-gray-500 mt-2">Todos los proyectos están cerrados o en borrador</p>
|
<p className="text-muted-foreground mt-2">Todos los proyectos están cerrados o en borrador</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -730,41 +730,41 @@ function ActiveProjectsStats({ projects }: { projects: Project[] }) {
|
||||||
|
|
||||||
{/* Alertas */}
|
{/* Alertas */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<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">
|
<CardContent className="pt-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<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
|
<AlertCircle className="w-4 h-4" /> Retrasados
|
||||||
</p>
|
</p>
|
||||||
<p className={`text-3xl font-bold mt-2 ${overdueProjects.length > 0 ? 'text-red-600' : 'text-gray-400'}`}>{overdueProjects.length}</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' : 'text-gray-400'}`}>Fecha superada</p>
|
<p className={`text-xs ${overdueProjects.length > 0 ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground/50'}`}>Fecha superada</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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">
|
<CardContent className="pt-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<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
|
<Clock className="w-4 h-4" /> Requieren Atención
|
||||||
</p>
|
</p>
|
||||||
<p className={`text-3xl font-bold mt-2 ${needsAttention > 0 ? 'text-orange-600' : 'text-gray-400'}`}>{needsAttention}</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' : 'text-gray-400'}`}>Menos del 25%</p>
|
<p className={`text-xs ${needsAttention > 0 ? 'text-orange-600 dark:text-orange-400' : 'text-muted-foreground/50'}`}>Menos del 25%</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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">
|
<CardContent className="pt-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<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
|
<CheckCircle2 className="w-4 h-4" /> Próximos a Completar
|
||||||
</p>
|
</p>
|
||||||
<p className="text-3xl font-bold text-green-600 mt-2">{nearCompletion}</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">75% o más</p>
|
<p className="text-xs text-green-600 dark:text-green-400">75% o más</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -779,9 +779,9 @@ function ActiveProjectsStats({ projects }: { projects: Project[] }) {
|
||||||
|
|
||||||
{/* Próximos vencimientos */}
|
{/* Próximos vencimientos */}
|
||||||
{upcomingDeadlines.length > 0 && (
|
{upcomingDeadlines.length > 0 && (
|
||||||
<Card className="border-gray-200">
|
<Card className="border">
|
||||||
<CardHeader className="pb-2">
|
<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)
|
<Calendar className="w-4 h-4" /> Próximos Vencimientos (14 días)
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
@ -790,13 +790,13 @@ function ActiveProjectsStats({ projects }: { projects: Project[] }) {
|
||||||
{upcomingDeadlines.slice(0, 6).map((p, index) => {
|
{upcomingDeadlines.slice(0, 6).map((p, index) => {
|
||||||
const days = getDaysRemaining(p.endDate);
|
const days = getDaysRemaining(p.endDate);
|
||||||
return (
|
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="w-2 h-2 rounded-full" style={{ backgroundColor: getProjectColor(index) }} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-gray-800 truncate">{p.name}</p>
|
<p className="text-sm font-medium text-foreground truncate">{p.name}</p>
|
||||||
<p className="text-xs text-gray-500">{p.progress}% completado</p>
|
<p className="text-xs text-muted-foreground">{p.progress}% completado</p>
|
||||||
</div>
|
</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
|
{days}d
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -821,9 +821,9 @@ function ClosedProjectsStats({ projects }: { projects: Project[] }) {
|
||||||
if (closedProjects.length === 0) {
|
if (closedProjects.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-16">
|
<div className="flex flex-col items-center justify-center py-16">
|
||||||
<Lock className="w-16 h-16 text-gray-300 mb-4" />
|
<Lock className="w-16 h-16 text-muted-foreground/50 mb-4" />
|
||||||
<h3 className="text-xl font-semibold text-gray-700">No hay proyectos cerrados</h3>
|
<h3 className="text-xl font-semibold text-foreground">No hay proyectos cerrados</h3>
|
||||||
<p className="text-gray-500 mt-2">Aún no se ha cerrado ningún proyecto</p>
|
<p className="text-muted-foreground mt-2">Aún no se ha cerrado ningún proyecto</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -867,30 +867,30 @@ function ClosedProjectsStats({ projects }: { projects: Project[] }) {
|
||||||
|
|
||||||
{/* Cumplimiento visual */}
|
{/* Cumplimiento visual */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<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">
|
<CardContent className="py-6">
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-6">
|
||||||
<div className="p-4 bg-green-100 rounded-full">
|
<div className="p-4 bg-green-100 dark:bg-green-900/50 rounded-full">
|
||||||
<CheckCircle2 className="w-8 h-8 text-green-600" />
|
<CheckCircle2 className="w-8 h-8 text-green-600 dark:text-green-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-green-700">Dentro del Presupuesto</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">{withinBudget}</p>
|
<p className="text-4xl font-bold text-green-600 dark:text-green-400">{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 text-green-600 dark:text-green-400">{stats.total > 0 ? Math.round((withinBudget / stats.total) * 100) : 0}% de los cerrados</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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">
|
<CardContent className="py-6">
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-6">
|
||||||
<div className={`p-4 rounded-full ${overBudget > 0 ? 'bg-red-100' : 'bg-gray-100'}`}>
|
<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' : 'text-gray-400'}`} />
|
<AlertCircle className={`w-8 h-8 ${overBudget > 0 ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground/50'}`} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className={`text-sm font-medium ${overBudget > 0 ? 'text-red-700' : 'text-gray-500'}`}>Excedieron Presupuesto</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' : 'text-gray-400'}`}>{overBudget}</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' : 'text-gray-400'}`}>{stats.total > 0 ? Math.round((overBudget / stats.total) * 100) : 0}% de los cerrados</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>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -921,9 +921,9 @@ function CompletedProjectsStats({ projects }: { projects: Project[] }) {
|
||||||
if (completedProjects.length === 0) {
|
if (completedProjects.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-16">
|
<div className="flex flex-col items-center justify-center py-16">
|
||||||
<CheckCircle2 className="w-16 h-16 text-gray-300 mb-4" />
|
<CheckCircle2 className="w-16 h-16 text-muted-foreground/50 mb-4" />
|
||||||
<h3 className="text-xl font-semibold text-gray-700">No hay proyectos completados</h3>
|
<h3 className="text-xl font-semibold text-foreground">No hay proyectos completados</h3>
|
||||||
<p className="text-gray-500 mt-2">Aún no se ha completado ningún proyecto al 100%</p>
|
<p className="text-muted-foreground mt-2">Aún no se ha completado ningún proyecto al 100%</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -997,7 +997,7 @@ function StatisticsSkeleton() {
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
{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>
|
<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>
|
<CardContent><Skeleton className="h-8 w-16 mb-2" /><Skeleton className="h-3 w-32" /></CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -1005,7 +1005,7 @@ function StatisticsSkeleton() {
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
{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>
|
<CardHeader className="pb-2"><Skeleton className="h-4 w-32" /></CardHeader>
|
||||||
<CardContent><Skeleton className="h-64 w-full" /></CardContent>
|
<CardContent><Skeleton className="h-64 w-full" /></CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -1040,10 +1040,10 @@ export default function StatisticsPage() {
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
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">
|
<div className="text-center">
|
||||||
<AlertCircle className="w-16 h-16 text-red-400 mx-auto mb-4" />
|
<AlertCircle className="w-16 h-16 text-red-400 dark:text-red-500 mx-auto mb-4" />
|
||||||
<p className="text-red-600 text-lg">{error}</p>
|
<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">
|
<button onClick={() => window.location.reload()} className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||||
Reintentar
|
Reintentar
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -1057,16 +1057,16 @@ export default function StatisticsPage() {
|
||||||
const completedCount = projects.filter(p => p.progress >= 100).length;
|
const completedCount = projects.filter(p => p.progress >= 100).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">
|
<div className="min-h-screen bg-background">
|
||||||
<header className="bg-white border-b border-gray-200 sticky top-0 z-10">
|
<header className="bg-card border-b sticky top-0 z-10">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg">
|
<div className="p-2 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg">
|
||||||
<BarChart3 className="w-6 h-6 text-white" />
|
<BarChart3 className="w-6 h-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Estadisticas</h1>
|
<h1 className="text-2xl font-bold text-foreground">Estadisticas</h1>
|
||||||
<p className="text-sm text-gray-500">Analisis de {projects.length} proyectos en Dolibarr</p>
|
<p className="text-sm text-muted-foreground">Analisis de {projects.length} proyectos en Dolibarr</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
// lib/tasksService.ts
|
// lib/tasksService.ts
|
||||||
import { dolibarrFetch } from "./dolibarrClient";
|
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
|
* 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,
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@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-progress": "^1.1.8",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
|
|
@ -26,6 +27,7 @@
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
|
"recharts": "^3.7.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7"
|
||||||
},
|
},
|
||||||
|
|
@ -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": {
|
"node_modules/@radix-ui/react-menu": {
|
||||||
"version": "2.1.16",
|
"version": "2.1.16",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz",
|
||||||
|
|
@ -2882,6 +2907,42 @@
|
||||||
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit": {
|
||||||
|
"version": "2.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||||
|
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"@standard-schema/utils": "^0.3.0",
|
||||||
|
"immer": "^11.0.0",
|
||||||
|
"redux": "^5.0.1",
|
||||||
|
"redux-thunk": "^3.1.0",
|
||||||
|
"reselect": "^5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||||
|
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||||
|
"version": "11.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz",
|
||||||
|
"integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rtsao/scc": {
|
"node_modules/@rtsao/scc": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||||
|
|
@ -2889,6 +2950,18 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/spec": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@standard-schema/utils": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
|
|
@ -3227,6 +3300,69 @@
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-array": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-ease": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-scale": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-time": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-shape": {
|
||||||
|
"version": "3.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||||
|
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-path": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-time": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-timer": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
|
|
@ -3278,6 +3414,12 @@
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/use-sync-external-store": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.48.1",
|
"version": "8.48.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz",
|
||||||
|
|
@ -4510,6 +4652,127 @@
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-format": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/damerau-levenshtein": {
|
"node_modules/damerau-levenshtein": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||||
|
|
@ -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": {
|
"node_modules/deep-is": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||||
|
|
@ -4893,6 +5162,16 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-toolkit": {
|
||||||
|
"version": "1.44.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
|
||||||
|
"integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"docs",
|
||||||
|
"benchmarks"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/escalade": {
|
"node_modules/escalade": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
|
|
@ -5340,6 +5619,12 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
|
|
@ -5835,6 +6120,16 @@
|
||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "10.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||||
|
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
|
|
@ -5877,6 +6172,15 @@
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-array-buffer": {
|
"node_modules/is-array-buffer": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||||
|
|
@ -7560,9 +7864,31 @@
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/react-redux": {
|
||||||
|
"version": "9.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
|
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^18.2.25 || ^19",
|
||||||
|
"react": "^18.0 || ^19",
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-remove-scroll": {
|
"node_modules/react-remove-scroll": {
|
||||||
"version": "2.7.2",
|
"version": "2.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||||
|
|
@ -7653,6 +7979,51 @@
|
||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/recharts": {
|
||||||
|
"version": "3.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
|
||||||
|
"integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"www"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "1.x.x || 2.x.x",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"decimal.js-light": "^2.5.1",
|
||||||
|
"es-toolkit": "^1.39.3",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"immer": "^10.1.1",
|
||||||
|
"react-redux": "8.x.x || 9.x.x",
|
||||||
|
"reselect": "5.1.1",
|
||||||
|
"tiny-invariant": "^1.3.3",
|
||||||
|
"use-sync-external-store": "^1.2.2",
|
||||||
|
"victory-vendor": "^37.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/redux": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/redux-thunk": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/reflect.getprototypeof": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
|
|
@ -7697,6 +8068,12 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/reselect": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
|
|
@ -8411,6 +8788,12 @@
|
||||||
"node": ">=0.8"
|
"node": ">=0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-invariant": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
|
|
@ -8808,6 +9191,28 @@
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/victory-vendor": {
|
||||||
|
"version": "37.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||||
|
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||||
|
"license": "MIT AND ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-array": "^3.0.3",
|
||||||
|
"@types/d3-ease": "^3.0.0",
|
||||||
|
"@types/d3-interpolate": "^3.0.1",
|
||||||
|
"@types/d3-scale": "^4.0.2",
|
||||||
|
"@types/d3-shape": "^3.1.0",
|
||||||
|
"@types/d3-time": "^3.0.0",
|
||||||
|
"@types/d3-timer": "^3.0.0",
|
||||||
|
"d3-array": "^3.1.6",
|
||||||
|
"d3-ease": "^3.0.1",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
|
"d3-shape": "^3.1.0",
|
||||||
|
"d3-time": "^3.0.0",
|
||||||
|
"d3-timer": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@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-progress": "^1.1.8",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
const config = {
|
|
||||||
plugins: {
|
|
||||||
"@tailwindcss/postcss": {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
213
types/task.ts
213
types/task.ts
|
|
@ -14,26 +14,45 @@ export interface DolibarrTask {
|
||||||
description: string;
|
description: string;
|
||||||
fk_project: string | number;
|
fk_project: string | number;
|
||||||
fk_task_parent: string | number;
|
fk_task_parent: string | number;
|
||||||
date_start: number | null;
|
date_start: number | string | null;
|
||||||
date_end: number | null;
|
date_end: number | string | null;
|
||||||
dateo: number | null; // fecha planificada inicio
|
dateo: number | null; // fecha planificada inicio
|
||||||
datee: number | null; // fecha planificada fin
|
datee: number | null; // fecha planificada fin
|
||||||
date_c: number | null;
|
date_c: number | null; // fecha creación
|
||||||
date_m: number | null;
|
date_m: number | null; // fecha modificación
|
||||||
duration_effective: number; // segundos trabajados
|
duration_effective: number | string; // segundos trabajados
|
||||||
planned_workload: number; // segundos planificados
|
planned_workload: number | string; // segundos planificados
|
||||||
progress: number | string;
|
progress: number | string;
|
||||||
priority: string | number;
|
priority: string | number;
|
||||||
budget_amount: string | number;
|
budget_amount: string | number | null;
|
||||||
rang: number;
|
rang: number | string;
|
||||||
status: string;
|
status: string;
|
||||||
note_public: string;
|
note_public: string;
|
||||||
note_private: string;
|
note_private: string;
|
||||||
fk_user_creat: string | number;
|
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
|
// Campos adicionales que puede devolver la API
|
||||||
|
entity?: string | number;
|
||||||
|
billable?: string | number;
|
||||||
timespent?: 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>;
|
array_options?: Record<string, unknown>;
|
||||||
|
// Datos de tiempo
|
||||||
|
tms?: string | number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interface normalizada para la UI
|
// 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
|
// Helper para convertir timestamp a fecha ISO
|
||||||
function timestampToDateString(timestamp: number | null | undefined): string | null {
|
function timestampToDateString(timestamp: number | string | null | undefined): string | null {
|
||||||
if (!timestamp || timestamp === 0) {
|
if (!timestamp || timestamp === 0 || timestamp === '' || timestamp === '0') {
|
||||||
return null;
|
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
|
// 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;
|
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
|
// 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'
|
createdBy: typeof dolibarr.fk_user_creat === 'string'
|
||||||
? parseInt(dolibarr.fk_user_creat)
|
? parseInt(dolibarr.fk_user_creat)
|
||||||
: (dolibarr.fk_user_creat || 0),
|
: (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 {
|
export function getTaskPriorityLabel(priority: TaskPriority): string {
|
||||||
return TASK_PRIORITY_CONFIG[priority]?.label || 'Sin prioridad';
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue