298 lines
10 KiB
TypeScript
298 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useCallback, useRef } from "react";
|
|
import { FolderKanban, RefreshCw, LayoutDashboard, ListTodo, FileText, GanttChartSquare } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
|
|
import { ProjectHeader } from "./project-header";
|
|
import { ProjectStats } from "./project-stats";
|
|
import { ProjectInfo } from "./project-info";
|
|
import { TasksSection } from "./tasks-section";
|
|
import { ProjectTaskGantt } from "./project-task-gantt";
|
|
|
|
import { Project } from "@/types/project";
|
|
import { Task } from "@/types/task";
|
|
import { getTasksByProjectId, calculateTaskStats } from "@/lib/tasksService";
|
|
|
|
interface ProjectDetailViewProps {
|
|
project: Project;
|
|
}
|
|
|
|
// Skeleton para loading (exportado para uso externo)
|
|
export function ProjectDetailSkeleton() {
|
|
return (
|
|
<div className="space-y-6 p-6">
|
|
{/* Header skeleton */}
|
|
<div className="flex items-start gap-4">
|
|
<Skeleton className="h-16 w-16 rounded-full" />
|
|
<div className="space-y-2">
|
|
<Skeleton className="h-8 w-64" />
|
|
<Skeleton className="h-4 w-32" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats skeleton */}
|
|
<div className="grid grid-cols-4 gap-4">
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
|
<Card key={i}>
|
|
<CardContent className="p-4">
|
|
<Skeleton className="h-4 w-20 mb-2" />
|
|
<Skeleton className="h-8 w-24" />
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
|
|
{/* Content skeleton */}
|
|
<div className="grid grid-cols-3 gap-6">
|
|
<div className="col-span-2 space-y-4">
|
|
<Skeleton className="h-48 w-full" />
|
|
<Skeleton className="h-48 w-full" />
|
|
</div>
|
|
<div className="space-y-4">
|
|
<Skeleton className="h-64 w-full" />
|
|
<Skeleton className="h-48 w-full" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Estado de error
|
|
function ErrorState({ onRetry }: { onRetry: () => void }) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
|
<div className="p-3 rounded-full bg-destructive/10 mb-4">
|
|
<FolderKanban className="h-8 w-8 text-destructive" />
|
|
</div>
|
|
<h3 className="text-lg font-semibold mb-1">Error al cargar las tareas</h3>
|
|
<p className="text-sm text-muted-foreground mb-4 max-w-sm">
|
|
No se pudieron cargar las tareas del proyecto. Por favor, intenta de nuevo.
|
|
</p>
|
|
<Button onClick={onRetry} variant="outline">
|
|
<RefreshCw className="h-4 w-4 mr-2" />
|
|
Reintentar
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ProjectDetailView({ project: initialProject }: ProjectDetailViewProps) {
|
|
const [project, setProject] = useState<Project>(initialProject);
|
|
const [tasks, setTasks] = useState<Task[]>([]);
|
|
const [isLoadingTasks, setIsLoadingTasks] = useState(true);
|
|
const [tasksError, setTasksError] = useState<string | null>(null);
|
|
const hasShownReminders = useRef(false);
|
|
|
|
// Cargar tareas
|
|
const fetchTasks = useCallback(async () => {
|
|
setIsLoadingTasks(true);
|
|
setTasksError(null);
|
|
try {
|
|
const projectTasks = await getTasksByProjectId(project.id);
|
|
setTasks(projectTasks);
|
|
} catch (error) {
|
|
console.error("Error fetching tasks:", error);
|
|
setTasksError("Error al cargar las tareas");
|
|
} finally {
|
|
setIsLoadingTasks(false);
|
|
}
|
|
}, [project.id]);
|
|
|
|
useEffect(() => {
|
|
fetchTasks();
|
|
}, [fetchTasks]);
|
|
|
|
useEffect(() => {
|
|
if (isLoadingTasks || tasksError || hasShownReminders.current) return;
|
|
|
|
const now = new Date();
|
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
const upcomingThreshold = new Date(today);
|
|
upcomingThreshold.setDate(upcomingThreshold.getDate() + 7);
|
|
|
|
const overdueTasks = tasks.filter((task) => {
|
|
const endDate = task.endDate || task.plannedEndDate;
|
|
if (!endDate) return false;
|
|
return new Date(endDate) < today && task.status !== "2";
|
|
});
|
|
|
|
const upcomingTasks = tasks.filter((task) => {
|
|
const endDate = task.endDate || task.plannedEndDate;
|
|
if (!endDate) return false;
|
|
const due = new Date(endDate);
|
|
return due >= today && due <= upcomingThreshold && task.status !== "2";
|
|
});
|
|
|
|
const undatedTasks = tasks.filter((task) => {
|
|
return !task.startDate && !task.endDate && !task.plannedStartDate && !task.plannedEndDate;
|
|
});
|
|
|
|
if (overdueTasks.length === 0 && upcomingTasks.length === 0 && undatedTasks.length === 0) {
|
|
hasShownReminders.current = true;
|
|
return;
|
|
}
|
|
|
|
const lines: string[] = [];
|
|
if (overdueTasks.length > 0) lines.push(`• ${overdueTasks.length} tareas retrasadas`);
|
|
if (upcomingTasks.length > 0) lines.push(`• ${upcomingTasks.length} próximas a vencer (≤ 7 días)`);
|
|
if (undatedTasks.length > 0) lines.push(`• ${undatedTasks.length} sin fecha asignada`);
|
|
|
|
toast("Recordatorios del proyecto", {
|
|
description: lines.join("\n"),
|
|
});
|
|
|
|
hasShownReminders.current = true;
|
|
}, [isLoadingTasks, tasksError, tasks]);
|
|
|
|
// Handler para cuando se actualiza el proyecto
|
|
const handleProjectUpdated = (updatedProject: Project) => {
|
|
setProject(updatedProject);
|
|
};
|
|
|
|
// Handler para cuando se crea una tarea
|
|
const handleTaskCreated = (newTask: Task) => {
|
|
setTasks(prevTasks => [newTask, ...prevTasks]);
|
|
};
|
|
|
|
// Handler para cuando se actualiza una tarea
|
|
const handleTaskUpdated = (updatedTask: Task) => {
|
|
setTasks(prevTasks =>
|
|
prevTasks.map(task => task.id === updatedTask.id ? updatedTask : task)
|
|
);
|
|
};
|
|
|
|
// Handler para cuando se elimina una tarea
|
|
const handleTaskDeleted = (taskId: number) => {
|
|
setTasks(prevTasks => prevTasks.filter(task => task.id !== taskId));
|
|
};
|
|
|
|
// Calcular estadísticas de tareas
|
|
const taskStats = calculateTaskStats(tasks);
|
|
|
|
return (
|
|
<div className="min-h-screen bg-background">
|
|
{/* Header del proyecto */}
|
|
<ProjectHeader
|
|
project={project}
|
|
tasks={tasks}
|
|
onProjectUpdated={handleProjectUpdated}
|
|
/>
|
|
|
|
{/* Contenido principal */}
|
|
<div className="min-w-0 p-6 space-y-6">
|
|
{/* Stats cards */}
|
|
<ProjectStats project={project} taskStats={taskStats} />
|
|
|
|
{/* Tabs de contenido */}
|
|
<Tabs defaultValue="overview" className="space-y-6">
|
|
<TabsList className="flex flex-wrap items-center gap-1">
|
|
<TabsTrigger value="overview" className="gap-2">
|
|
<LayoutDashboard className="h-4 w-4" />
|
|
Resumen
|
|
</TabsTrigger>
|
|
<TabsTrigger value="tasks" className="gap-2">
|
|
<ListTodo className="h-4 w-4" />
|
|
Tareas
|
|
{tasks.length > 0 && (
|
|
<span className="ml-1 px-1.5 py-0.5 text-xs rounded-full bg-muted">
|
|
{tasks.length}
|
|
</span>
|
|
)}
|
|
</TabsTrigger>
|
|
<TabsTrigger value="gantt" className="gap-2">
|
|
<GanttChartSquare className="h-4 w-4" />
|
|
Gantt
|
|
</TabsTrigger>
|
|
<TabsTrigger value="details" className="gap-2">
|
|
<FileText className="h-4 w-4" />
|
|
Detalles
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{/* Tab: Resumen */}
|
|
<TabsContent value="overview" className="space-y-6">
|
|
<ProjectInfo project={project} />
|
|
|
|
{/* Preview de tareas */}
|
|
{!isLoadingTasks && tasks.length > 0 && (
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="font-semibold flex items-center gap-2">
|
|
<ListTodo className="h-4 w-4" />
|
|
Tareas recientes
|
|
</h3>
|
|
<Button variant="ghost" size="sm" asChild>
|
|
<a href="#tasks">Ver todas</a>
|
|
</Button>
|
|
</div>
|
|
<TasksSection
|
|
tasks={tasks.slice(0, 5)}
|
|
projectId={project.id}
|
|
onTaskCreated={handleTaskCreated}
|
|
onTaskUpdated={handleTaskUpdated}
|
|
onTaskDeleted={handleTaskDeleted}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* Tab: Tareas */}
|
|
<TabsContent value="tasks">
|
|
{isLoadingTasks ? (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center gap-2">
|
|
<Skeleton className="h-9 w-[250px]" />
|
|
<Skeleton className="h-9 w-[130px]" />
|
|
<Skeleton className="h-9 w-[130px]" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-16 w-full" />
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : tasksError ? (
|
|
<ErrorState onRetry={fetchTasks} />
|
|
) : (
|
|
<TasksSection
|
|
tasks={tasks}
|
|
projectId={project.id}
|
|
onTaskCreated={handleTaskCreated}
|
|
onTaskUpdated={handleTaskUpdated}
|
|
onTaskDeleted={handleTaskDeleted}
|
|
/>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* Tab: Gantt de tareas */}
|
|
<TabsContent value="gantt">
|
|
{isLoadingTasks ? (
|
|
<div className="space-y-2">
|
|
<Skeleton className="h-10 w-48" />
|
|
<Skeleton className="h-[420px] w-full" />
|
|
</div>
|
|
) : tasksError ? (
|
|
<ErrorState onRetry={fetchTasks} />
|
|
) : (
|
|
<ProjectTaskGantt tasks={tasks} />
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* Tab: Detalles */}
|
|
<TabsContent value="details">
|
|
<ProjectInfo project={project} />
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|