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