"use client"; import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { AlertCircle, Calendar, CalendarRange, CheckCircle2, ChevronLeft, ChevronRight, Filter, GanttChartSquare, Layers, PanelLeftClose, PanelLeftOpen, Search, } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; import { getProjects } from "@/lib/projectsService"; import { Project, ProjectStatus } from "@/types/project"; const MONTH_WIDTH_PX = 72; const ROW_HEIGHT_PX = 52; type ProjectStatusFilter = "all" | ProjectStatus; type ZoomLevel = "compact" | "normal" | "detailed"; const STATUS_LABELS: Record = { "0": "Borrador", "1": "Abierto", "2": "Cerrado", }; const STATUS_BADGE_CLASS: Record = { "0": "bg-gray-100 text-gray-700 border-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700", "1": "bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/35 dark:text-blue-300 dark:border-blue-800", "2": "bg-green-100 text-green-700 border-green-200 dark:bg-green-900/35 dark:text-green-300 dark:border-green-800", }; const BAR_COLOR_BY_STATUS: Record = { "0": { base: "bg-gray-200 dark:bg-gray-700", fill: "bg-gray-500 dark:bg-gray-400" }, "1": { base: "bg-blue-100 dark:bg-blue-950/50", fill: "bg-gradient-to-r from-blue-500 to-violet-600" }, "2": { base: "bg-green-100 dark:bg-green-950/50", fill: "bg-gradient-to-r from-emerald-500 to-green-600" }, }; interface TimelineMonth { month: number; year: number; label: string; } interface PlannedProject { project: Project; start: Date; end: Date; startOffsetDays: number; durationDays: number; } function getMonthsBetween(start: Date, end: Date): TimelineMonth[] { const months: TimelineMonth[] = []; const current = new Date(start.getFullYear(), start.getMonth(), 1); const endMonth = new Date(end.getFullYear(), end.getMonth(), 1); while (current <= endMonth) { months.push({ month: current.getMonth(), year: current.getFullYear(), label: current.toLocaleDateString("es-ES", { month: "short", year: "numeric" }), }); current.setMonth(current.getMonth() + 1); } return months; } function getDaysBetween(start: Date, end: Date): number { const msPerDay = 1000 * 60 * 60 * 24; const startMidnight = new Date(start.getFullYear(), start.getMonth(), start.getDate()); const endMidnight = new Date(end.getFullYear(), end.getMonth(), end.getDate()); return Math.floor((endMidnight.getTime() - startMidnight.getTime()) / msPerDay); } function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } function parseProjectDates(project: Project): { start: Date; end: Date } | null { const start = new Date(project.startDate); const end = new Date(project.endDate); if (isNaN(start.getTime()) || isNaN(end.getTime())) return null; if (start <= end) return { start, end }; return { start: end, end: start }; } function formatDate(date: Date): string { return date.toLocaleDateString("es-ES", { day: "2-digit", month: "short", year: "numeric", }); } export default function GanttPage() { const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [statusFilter, setStatusFilter] = useState("all"); const [search, setSearch] = useState(""); const [zoom, setZoom] = useState("normal"); const [selectedIds, setSelectedIds] = useState([]); const [showSelectionPanel, setShowSelectionPanel] = useState(true); useEffect(() => { async function load() { try { setLoading(true); const data = await getProjects(); const sorted = [...data].sort((a, b) => { const da = parseProjectDates(a)?.start.getTime() ?? Number.MAX_SAFE_INTEGER; const db = parseProjectDates(b)?.start.getTime() ?? Number.MAX_SAFE_INTEGER; return da - db; }); setProjects(sorted); setSelectedIds(sorted.map((project) => project.id)); setError(null); } catch (err) { console.error("Error loading projects:", err); setError("No se pudieron cargar los proyectos para el Gantt"); } finally { setLoading(false); } } load(); }, []); const filteredProjects = useMemo(() => { return projects.filter((project) => { const statusOk = statusFilter === "all" || project.status === statusFilter; const searchValue = search.trim().toLowerCase(); const searchOk = searchValue.length === 0 || project.name.toLowerCase().includes(searchValue) || project.client.toLowerCase().includes(searchValue) || project.ref.toLowerCase().includes(searchValue); return statusOk && searchOk; }); }, [projects, search, statusFilter]); useEffect(() => { setSelectedIds((current) => { const available = new Set(filteredProjects.map((project) => project.id)); const kept = current.filter((id) => available.has(id)); if (kept.length === current.length) return current; return kept; }); }, [filteredProjects]); const visibleProjects = useMemo(() => { const selected = new Set(selectedIds); return filteredProjects.filter((project) => selected.has(project.id)); }, [filteredProjects, selectedIds]); const { timelineStart, timelineEnd, months, totalDays } = useMemo(() => { const projectsWithDates = visibleProjects .map((project) => ({ project, parsed: parseProjectDates(project) })) .filter((item): item is { project: Project; parsed: { start: Date; end: Date } } => item.parsed !== null); if (projectsWithDates.length === 0) { const now = new Date(); const start = new Date(now.getFullYear(), now.getMonth() - 2, 1); const end = new Date(now.getFullYear(), now.getMonth() + 3, 0); const monthList = getMonthsBetween(start, end); return { timelineStart: start, timelineEnd: end, months: monthList, totalDays: Math.max(1, getDaysBetween(start, end) + 1), }; } const minStart = new Date( Math.min(...projectsWithDates.map((item) => item.parsed.start.getTime())) ); const maxEnd = new Date( Math.max(...projectsWithDates.map((item) => item.parsed.end.getTime())) ); const start = new Date(minStart.getFullYear(), minStart.getMonth(), 1); const end = new Date(maxEnd.getFullYear(), maxEnd.getMonth() + 1, 0); const monthList = getMonthsBetween(start, end); return { timelineStart: start, timelineEnd: end, months: monthList, totalDays: Math.max(1, getDaysBetween(start, end) + 1), }; }, [visibleProjects]); const todayPercent = useMemo(() => { const now = new Date(); if (now < timelineStart || now > timelineEnd) return null; const offset = getDaysBetween(timelineStart, now); return clamp((offset / totalDays) * 100, 0, 100); }, [timelineEnd, timelineStart, totalDays]); const plannedRows = useMemo(() => { return visibleProjects .map((project) => { const parsed = parseProjectDates(project); if (!parsed) return null; const startOffsetDays = clamp(getDaysBetween(timelineStart, parsed.start), 0, totalDays - 1); const durationDays = Math.max(1, getDaysBetween(parsed.start, parsed.end) + 1); return { project, start: parsed.start, end: parsed.end, startOffsetDays, durationDays, }; }) .filter((item): item is PlannedProject => item !== null); }, [timelineStart, totalDays, visibleProjects]); const timelineWidth = months.length * MONTH_WIDTH_PX; const stats = useMemo(() => { const total = visibleProjects.length; const open = visibleProjects.filter((project) => project.status === "1").length; const closed = visibleProjects.filter((project) => project.status === "2").length; const draft = visibleProjects.filter((project) => project.status === "0").length; return { total, open, closed, draft }; }, [visibleProjects]); const selectAll = () => setSelectedIds(filteredProjects.map((project) => project.id)); const clearSelection = () => setSelectedIds([]); const toggleProjectSelection = (projectId: number, checked: boolean) => { setSelectedIds((current) => { if (checked && current.includes(projectId)) return current; if (!checked && !current.includes(projectId)) return current; return checked ? [...current, projectId] : current.filter((id) => id !== projectId); }); }; if (error) { return (

Error cargando el Gantt

{error}

); } return (

Roadmap de Proyectos

Vista global rediseñada para planificación ejecutiva

setSearch(event.target.value)} placeholder="Buscar proyecto..." className="pl-8 w-56" />
{stats.total} visibles {stats.open} abiertos {stats.closed} cerrados {stats.draft} borradores
{loading ? ( ) : (
{showSelectionPanel && (

Selección de proyectos

{selectedIds.length}/{filteredProjects.length}
{filteredProjects.length === 0 ? (
No hay proyectos con este filtro.
) : ( filteredProjects.map((project) => { const checked = selectedIds.includes(project.id); return ( ); }) )}
)} {plannedRows.length === 0 ? (

No hay proyectos seleccionados para mostrar

Selecciona proyectos en el panel izquierdo para ver el cronograma.

) : (
Proyecto
{months.map((month) => (
{month.label}
))}
{plannedRows.map((row) => { const barStyle = BAR_COLOR_BY_STATUS[row.project.status]; const leftPercent = (row.startOffsetDays / totalDays) * 100; const widthPercent = (row.durationDays / totalDays) * 100; const isCompleted = row.project.progress >= 100; return (

{row.project.name}

{STATUS_LABELS[row.project.status]} {row.project.progress}% {isCompleted && }
{months.map((month) => (
))}
{todayPercent !== null && (
)}
{zoom !== "compact" && ( {zoom === "detailed" ? `${row.project.progress}%` : row.project.progress >= 35 ? `${row.project.progress}%` : ""} )} {zoom === "detailed" && ( {formatDate(row.start)} {formatDate(row.end)} )}
); })}
)}
)}
); } function GanttSkeleton() { return (
{Array.from({ length: 7 }).map((_, index) => ( ))}
{Array.from({ length: 8 }).map((_, index) => ( ))}
); }