diff --git a/components/gantt/gantt-page.tsx b/components/gantt/gantt-page.tsx index ce3b323..4f4190c 100644 --- a/components/gantt/gantt-page.tsx +++ b/components/gantt/gantt-page.tsx @@ -1,26 +1,25 @@ "use client"; -import { useState, useEffect, useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; import { - GanttChart as GanttIcon, AlertCircle, + Calendar, + CalendarRange, + CheckCircle2, ChevronLeft, ChevronRight, - Calendar, - History, - Clock, Filter, + GanttChartSquare, + Layers, + Search, } from "lucide-react"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; + import { Badge } from "@/components/ui/badge"; -import { Skeleton } from "@/components/ui/skeleton"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; +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, @@ -28,22 +27,15 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; import { getProjects } from "@/lib/projectsService"; import { Project, ProjectStatus } from "@/types/project"; -// Paleta de colores para proyectos -const PROJECT_COLORS = [ - { bg: "bg-blue-500", light: "bg-blue-100", text: "text-blue-700", hex: "#3b82f6" }, - { bg: "bg-violet-500", light: "bg-violet-100", text: "text-violet-700", hex: "#8b5cf6" }, - { bg: "bg-cyan-500", light: "bg-cyan-100", text: "text-cyan-700", hex: "#06b6d4" }, - { bg: "bg-emerald-500", light: "bg-emerald-100", text: "text-emerald-700", hex: "#10b981" }, - { bg: "bg-amber-500", light: "bg-amber-100", text: "text-amber-700", hex: "#f59e0b" }, - { bg: "bg-rose-500", light: "bg-rose-100", text: "text-rose-700", hex: "#f43f5e" }, - { bg: "bg-pink-500", light: "bg-pink-100", text: "text-pink-700", hex: "#ec4899" }, - { bg: "bg-indigo-500", light: "bg-indigo-100", text: "text-indigo-700", hex: "#6366f1" }, - { bg: "bg-teal-500", light: "bg-teal-100", text: "text-teal-700", hex: "#14b8a6" }, - { bg: "bg-orange-500", light: "bg-orange-100", text: "text-orange-700", hex: "#f97316" }, -]; +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", @@ -51,30 +43,37 @@ const STATUS_LABELS: Record = { "2": "Cerrado", }; -type ViewMode = "month" | "quarter" | "year"; -type TimeRange = "future" | "past" | "all"; +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", +}; -function getProjectColor(index: number) { - return PROJECT_COLORS[index % PROJECT_COLORS.length]; +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; } -// Formatear fecha -function formatDate(dateStr: string): string { - const date = new Date(dateStr); - return date.toLocaleDateString("es-ES", { day: "2-digit", month: "short", year: "numeric" }); +interface PlannedProject { + project: Project; + start: Date; + end: Date; + startOffsetDays: number; + durationDays: number; } -// Obtener días entre dos fechas -function getDaysBetween(start: Date, end: Date): number { - return Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)); -} - -// Generar array de meses entre dos fechas -function getMonthsBetween(start: Date, end: Date): { month: number; year: number; label: string }[] { - const months: { month: number; year: number; label: string }[] = []; +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(), @@ -83,225 +82,235 @@ function getMonthsBetween(start: Date, end: Date): { month: number; year: number }); current.setMonth(current.getMonth() + 1); } - + return months; } -// Obtener días en un mes -function getDaysInMonth(month: number, year: number): number { - return new Date(year, month + 1, 0).getDate(); +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 [viewMode, setViewMode] = useState("month"); - const [statusFilter, setStatusFilter] = useState("all"); - const [timeRange, setTimeRange] = useState("future"); - const [viewOffset, setViewOffset] = useState(0); + + 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 loadProjects() { + async function load() { try { setLoading(true); const data = await getProjects(); - setProjects(data); + 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("Error al cargar los proyectos"); + setError("No se pudieron cargar los proyectos para el Gantt"); } finally { setLoading(false); } } - loadProjects(); + + load(); }, []); - // Filtrar proyectos const filteredProjects = useMemo(() => { - let filtered = projects; - - // Filtrar por estado - if (statusFilter !== "all") { - filtered = filtered.filter(p => p.status === statusFilter); - } - - // Filtrar por rango temporal - const now = new Date(); - const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - - if (timeRange === "future") { - // Proyectos que terminan hoy o en el futuro - filtered = filtered.filter(p => new Date(p.endDate) >= today); - } else if (timeRange === "past") { - // Proyectos que ya terminaron - filtered = filtered.filter(p => new Date(p.endDate) < today); - } - // "all" no filtra - - return filtered; - }, [projects, statusFilter, timeRange]); + 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); - // Calcular rango de fechas del timeline basado en los proyectos - // - Futuro: desde el mes actual hasta el fin del proyecto más tardío - // - Pasado: desde el inicio del proyecto más antiguo hasta el mes actual - // - Todo: desde el inicio del proyecto más antiguo hasta el fin del más tardío - const { timelineStart, timelineEnd, months } = useMemo(() => { - const now = new Date(); - const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1); - const currentMonthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0); - - // Si no hay proyectos, mostrar 12 meses desde el actual - if (filteredProjects.length === 0) { - const start = new Date(now.getFullYear(), now.getMonth() - 6, 1); - const end = new Date(now.getFullYear(), now.getMonth() + 6, 0); - return { - timelineStart: start, - timelineEnd: end, - months: getMonthsBetween(start, end), - }; - } - - // Calcular fechas extremas de los proyectos - const allStartDates = filteredProjects.map(p => new Date(p.startDate)); - const allEndDates = filteredProjects.map(p => new Date(p.endDate)); - const minProjectStart = new Date(Math.min(...allStartDates.map(d => d.getTime()))); - const maxProjectEnd = new Date(Math.max(...allEndDates.map(d => d.getTime()))); - - if (timeRange === "future") { - // Desde el mes actual hasta el fin del proyecto más tardío - const start = currentMonthStart; - const end = new Date(maxProjectEnd.getFullYear(), maxProjectEnd.getMonth() + 1, 0); - - return { - timelineStart: start, - timelineEnd: end, - months: getMonthsBetween(start, end), - }; - } else if (timeRange === "past") { - // Desde el inicio del proyecto más antiguo hasta el mes actual - const start = new Date(minProjectStart.getFullYear(), minProjectStart.getMonth(), 1); - const end = currentMonthEnd; - - return { - timelineStart: start, - timelineEnd: end, - months: getMonthsBetween(start, end), - }; - } else { - // "all" - Desde el inicio del proyecto más antiguo hasta el fin del más tardío - const start = new Date(minProjectStart.getFullYear(), minProjectStart.getMonth(), 1); - const end = new Date(maxProjectEnd.getFullYear(), maxProjectEnd.getMonth() + 1, 0); - - return { - timelineStart: start, - timelineEnd: end, - months: getMonthsBetween(start, end), - }; - } - }, [filteredProjects, timeRange]); + return statusOk && searchOk; + }); + }, [projects, search, statusFilter]); - // Calcular meses visibles según el modo de vista - // En modo "todo", mostrar TODOS los meses (scroll horizontal en el timeline) - // En modo "pasado", empezamos desde los meses más recientes - const visibleMonths = useMemo(() => { - // En modo "todo", mostrar todos los meses - if (timeRange === "all") { - return months; - } - - const monthsToShow = viewMode === "month" ? 3 : viewMode === "quarter" ? 6 : 12; - - if (timeRange === "past") { - // En pasado, viewOffset 0 = meses más recientes (final del array) - const maxOffset = Math.max(0, months.length - monthsToShow); - const startIdx = Math.max(0, maxOffset - viewOffset); - return months.slice(startIdx, startIdx + monthsToShow); - } else { - // En futuro, viewOffset 0 = primeros meses - const startIdx = Math.max(0, Math.min(viewOffset, months.length - monthsToShow)); - return months.slice(startIdx, startIdx + monthsToShow); - } - }, [months, viewMode, viewOffset, timeRange]); + 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]); - // Calcular el ancho total en días para los meses visibles - const { totalDays, visibleStart, visibleEnd } = useMemo(() => { - if (visibleMonths.length === 0) { - return { totalDays: 30, visibleStart: new Date(), visibleEnd: new Date() }; + 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 first = visibleMonths[0]; - const last = visibleMonths[visibleMonths.length - 1]; - const start = new Date(first.year, first.month, 1); - const end = new Date(last.year, last.month + 1, 0); - + + 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 { - totalDays: getDaysBetween(start, end), - visibleStart: start, - visibleEnd: end, + timelineStart: start, + timelineEnd: end, + months: monthList, + totalDays: Math.max(1, getDaysBetween(start, end) + 1), }; - }, [visibleMonths]); + }, [visibleProjects]); - // Navegación - const monthsToShow = viewMode === "month" ? 3 : viewMode === "quarter" ? 6 : 12; - const maxOffset = Math.max(0, months.length - monthsToShow); - const canGoBack = viewOffset > 0; - const canGoForward = viewOffset < maxOffset; + 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 goBack = () => { - const step = viewMode === "month" ? 1 : viewMode === "quarter" ? 3 : 6; - setViewOffset(Math.max(0, viewOffset - step)); - }; + const plannedRows = useMemo(() => { + return visibleProjects + .map((project) => { + const parsed = parseProjectDates(project); + if (!parsed) return null; - const goForward = () => { - const step = viewMode === "month" ? 1 : viewMode === "quarter" ? 3 : 6; - setViewOffset(Math.min(maxOffset, viewOffset + step)); - }; + const startOffsetDays = clamp(getDaysBetween(timelineStart, parsed.start), 0, totalDays - 1); + const durationDays = Math.max(1, getDaysBetween(parsed.start, parsed.end) + 1); - const goToToday = () => { - setViewOffset(0); // Volver al mes actual + 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}

- +
+
+ +

Error cargando el Gantt

+

{error}

+
); } return ( -
- {/* Header - siempre visible */} -
-
-
+
+
+
+
-
- +
+
-

Diagrama de Gantt

-

- Timeline de {filteredProjects.length} proyectos -

+

Roadmap de Proyectos

+

Vista global rediseñada para planificación ejecutiva

- {/* Controles */} -
- {/* Filtro de estado */} - setSearch(event.target.value)} + placeholder="Buscar proyecto..." + className="pl-8 w-56" + /> +
+ + - {/* Selector de rango temporal */} -
- - - -
- - {/* Selector de vista */} - setZoom(value)}> + + + - 3 meses - 6 meses - 12 meses + Compacto + Normal + Detallado - - {/* Navegación */} -
- - - -
-
-
- {/* Content - área scrollable */} -
+
+ {stats.total} visibles + {stats.open} abiertos + {stats.closed} cerrados + {stats.draft} borradores +
+
+ + +
{loading ? ( - ) : filteredProjects.length === 0 ? ( - - - -

No hay proyectos

-

No se encontraron proyectos con los filtros seleccionados

-
-
) : ( - - -
- {/* Columna de nombres de proyectos - fija */} -
- {/* Header */} -
- Proyecto +
+ {showSelectionPanel && ( + + +
+

+ + Selección de proyectos +

+ {selectedIds.length}/{filteredProjects.length}
- {/* Lista de proyectos */} - {filteredProjects.map((project, index) => { - const color = getProjectColor(index); - return ( -
-
-
-
-

- {project.name} -

-

{project.progress}%

-
+ +
+ + +
+ +
+ {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} +
+ ))}
- ); - })} -
- {/* Timeline - scroll horizontal en modo "todo" */} -
-
- {/* Header con meses */} -
- {visibleMonths.map((month) => { - const isCurrentMonth = new Date().getMonth() === month.month && new Date().getFullYear() === month.year; - + {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 ( -
- - {month.label} - +
+ +
+

{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)} + + )} +
+ +
); })}
- - {/* Barras del Gantt */} - - {filteredProjects.map((project, index) => { - const color = getProjectColor(index); - const projectStart = new Date(project.startDate); - const projectEnd = new Date(project.endDate); - - // Calcular posición y ancho de la barra basado en días - const startOffset = Math.max(0, getDaysBetween(visibleStart, projectStart)); - const endOffset = Math.min(totalDays, getDaysBetween(visibleStart, projectEnd)); - - const leftPercent = (startOffset / totalDays) * 100; - const widthPercent = Math.max(1, ((endOffset - startOffset) / totalDays) * 100); - - // Verificar si el proyecto está visible en el rango actual - const isVisible = projectEnd >= visibleStart && projectStart <= visibleEnd; - const isOverdue = projectEnd < new Date() && project.progress < 100 && project.status === "1"; - - return ( -
- {/* Grid de meses */} -
- {visibleMonths.map((month) => { - const isCurrentMonth = new Date().getMonth() === month.month && new Date().getFullYear() === month.year; - - return ( -
- ); - })} -
- - {/* Barra del proyecto */} - {isVisible && ( - - -
- {/* Fondo de la barra */} -
- - {/* Progreso */} -
- - {/* Contenido de la barra */} -
- - {project.progress >= 30 ? `${project.progress}%` : ""} - -
-
- - -
-

{project.name}

-
-

Estado: {STATUS_LABELS[project.status]}

-

Progreso: {project.progress}%

-

Inicio: {formatDate(project.startDate)}

-

Fin: {formatDate(project.endDate)}

-

Cliente: {project.client}

- {isOverdue && ( -

Proyecto retrasado

- )} -
-
-
- - )} -
- ); - })} -
-
-
- - + )} + + +
)}
@@ -546,41 +517,27 @@ export default function GanttPage() { function GanttSkeleton() { return ( - - -
-
-
- -
- {Array.from({ length: 5 }).map((_, i) => ( -
-
- -
- - -
-
-
+
+ + + +
+ {Array.from({ length: 7 }).map((_, index) => ( + ))}
-
-
- {Array.from({ length: 3 }).map((_, i) => ( -
- -
- ))} -
- {Array.from({ length: 5 }).map((_, i) => ( -
- -
+ + + + +
+ + {Array.from({ length: 8 }).map((_, index) => ( + ))}
-
-
-
+ + +
); } diff --git a/components/project-detail/index.ts b/components/project-detail/index.ts index c64eeb6..c2df20f 100644 --- a/components/project-detail/index.ts +++ b/components/project-detail/index.ts @@ -1,6 +1,7 @@ export { ProjectHeader } from "./project-header"; export { ProjectStats } from "./project-stats"; export { ProjectInfo } from "./project-info"; +export { ProjectTaskGantt } from "./project-task-gantt"; export { TasksSection } from "./tasks-section"; export { ProjectDetailView, ProjectDetailSkeleton } from "./project-detail-view"; export { taskColumns } from "./task-columns"; diff --git a/components/project-detail/project-detail-view.tsx b/components/project-detail/project-detail-view.tsx index 3fc91ae..b399bbb 100644 --- a/components/project-detail/project-detail-view.tsx +++ b/components/project-detail/project-detail-view.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect, useCallback } from "react"; -import { FolderKanban, RefreshCw, LayoutDashboard, ListTodo, FileText } from "lucide-react"; +import { FolderKanban, RefreshCw, LayoutDashboard, ListTodo, FileText, GanttChartSquare } from "lucide-react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; @@ -12,6 +12,7 @@ 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"; @@ -160,6 +161,10 @@ export function ProjectDetailView({ project: initialProject }: ProjectDetailView )} + + + Gantt + Detalles @@ -223,6 +228,20 @@ export function ProjectDetailView({ project: initialProject }: ProjectDetailView )} + {/* Tab: Gantt de tareas */} + + {isLoadingTasks ? ( +
+ + +
+ ) : tasksError ? ( + + ) : ( + + )} +
+ {/* Tab: Detalles */} diff --git a/components/project-detail/project-task-gantt.tsx b/components/project-detail/project-task-gantt.tsx new file mode 100644 index 0000000..d6b535a --- /dev/null +++ b/components/project-detail/project-task-gantt.tsx @@ -0,0 +1,394 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { + AlertTriangle, + CalendarClock, + CalendarDays, + CheckCircle2, + ChevronLeft, + ChevronRight, + ZoomIn, +} from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { Task, TASK_STATUS_CONFIG } from "@/types/task"; + +type Zoom = "fit" | "week" | "day"; + +interface ProjectTaskGanttProps { + tasks: Task[]; +} + +interface TaskTimelineItem { + task: Task; + start: Date; + end: Date; +} + +interface Tick { + key: string; + label: string; + date: Date; +} + +const LEFT_COL_WIDTH = 340; +const ROW_HEIGHT = 56; + +function startOfDay(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); +} + +function addDays(date: Date, days: number): Date { + const next = new Date(date); + next.setDate(next.getDate() + days); + return next; +} + +function diffDays(start: Date, end: Date): number { + const msPerDay = 1000 * 60 * 60 * 24; + return Math.floor((startOfDay(end).getTime() - startOfDay(start).getTime()) / msPerDay); +} + +function parseTaskRange(task: Task): { start: Date; end: Date } | null { + const startRaw = task.startDate || task.plannedStartDate || task.endDate || task.plannedEndDate; + const endRaw = task.endDate || task.plannedEndDate || task.startDate || task.plannedStartDate; + + if (!startRaw || !endRaw) return null; + + const a = startOfDay(new Date(startRaw)); + const b = startOfDay(new Date(endRaw)); + if (isNaN(a.getTime()) || isNaN(b.getTime())) return null; + + return a <= b ? { start: a, end: b } : { start: b, end: a }; +} + +function formatDate(date: Date): string { + return date.toLocaleDateString("es-ES", { day: "2-digit", month: "short", year: "numeric" }); +} + +function monday(date: Date): Date { + const d = startOfDay(date); + const day = d.getDay(); + const offset = day === 0 ? -6 : 1 - day; + return addDays(d, offset); +} + +function buildWeekTicks(start: Date, end: Date): Tick[] { + const ticks: Tick[] = []; + let cursor = monday(start); + while (cursor <= end) { + ticks.push({ + key: `w-${cursor.getFullYear()}-${cursor.getMonth()}-${cursor.getDate()}`, + label: cursor.toLocaleDateString("es-ES", { day: "2-digit", month: "short" }).toUpperCase(), + date: cursor, + }); + cursor = addDays(cursor, 7); + } + return ticks; +} + +function buildDayTicks(start: Date, end: Date): Tick[] { + const ticks: Tick[] = []; + let cursor = startOfDay(start); + while (cursor <= end) { + ticks.push({ + key: `d-${cursor.getFullYear()}-${cursor.getMonth()}-${cursor.getDate()}`, + label: cursor.toLocaleDateString("es-ES", { day: "2-digit" }), + date: cursor, + }); + cursor = addDays(cursor, 1); + } + return ticks; +} + +export function ProjectTaskGantt({ tasks }: ProjectTaskGanttProps) { + const [zoom, setZoom] = useState("fit"); + const [windowStartOffset, setWindowStartOffset] = useState(0); + const viewportRef = useRef(null); + const [viewportWidth, setViewportWidth] = useState(0); + + useEffect(() => { + if (!viewportRef.current) return; + + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) return; + setViewportWidth(Math.floor(entry.contentRect.width)); + }); + + observer.observe(viewportRef.current); + return () => observer.disconnect(); + }, []); + + const timelineItems = useMemo(() => { + return tasks + .map((task) => { + const range = parseTaskRange(task); + if (!range) return null; + return { task, start: range.start, end: range.end }; + }) + .filter((item): item is TaskTimelineItem => item !== null) + .sort((a, b) => a.start.getTime() - b.start.getTime()); + }, [tasks]); + + if (tasks.length === 0) { + return ( + + + +

No hay tareas para mostrar en el Gantt

+

Crea tareas con fechas para planificar el proyecto.

+
+
+ ); + } + + if (timelineItems.length === 0) { + return ( + + + +

Faltan fechas en las tareas

+

Asigna fecha de inicio y fin para usar el planificador.

+
+
+ ); + } + + const projectStart = new Date(Math.min(...timelineItems.map((item) => item.start.getTime()))); + const projectEnd = new Date(Math.max(...timelineItems.map((item) => item.end.getTime()))); + + const fullStart = addDays(projectStart, -2); + const fullEnd = addDays(projectEnd, 2); + const fullDurationDays = Math.max(1, diffDays(fullStart, fullEnd) + 1); + + const completed = tasks.filter((task) => task.status === "2").length; + const overdue = timelineItems.filter((item) => item.end < startOfDay(new Date()) && item.task.status !== "2").length; + + // Nueva filosofia: + // - fit: todo el rango en pantalla (sin scroll horizontal) + // - week/day: ventana movil con navegacion + const windowDays = zoom === "fit" ? fullDurationDays : zoom === "week" ? 56 : 21; + const clampedOffset = Math.max(0, Math.min(windowStartOffset, Math.max(0, fullDurationDays - windowDays))); + const visibleStart = addDays(fullStart, clampedOffset); + const visibleEnd = addDays(visibleStart, windowDays - 1); + const visibleDurationDays = Math.max(1, diffDays(visibleStart, visibleEnd) + 1); + + const canGoBack = zoom !== "fit" && clampedOffset > 0; + const canGoForward = zoom !== "fit" && clampedOffset + windowDays < fullDurationDays; + + const ticks = zoom === "day" ? buildDayTicks(visibleStart, visibleEnd) : buildWeekTicks(visibleStart, visibleEnd); + + const fitTimelineWidth = Math.max( + 360, + (viewportWidth > 0 ? viewportWidth : LEFT_COL_WIDTH + 640) - LEFT_COL_WIDTH + ); + + const timelineWidth = zoom === "fit" + ? fitTimelineWidth + : zoom === "week" + ? ticks.length * 96 + : ticks.length * 38; + + const today = startOfDay(new Date()); + const showToday = today >= visibleStart && today <= visibleEnd; + const todayPercent = showToday ? (diffDays(visibleStart, today) / visibleDurationDays) * 100 : null; + + const goBack = () => { + const step = zoom === "day" ? 7 : 28; + setWindowStartOffset((current) => Math.max(0, current - step)); + }; + + const goForward = () => { + const step = zoom === "day" ? 7 : 28; + setWindowStartOffset((current) => Math.min(Math.max(0, fullDurationDays - windowDays), current + step)); + }; + + const setZoomAndReset = (next: Zoom) => { + setZoom(next); + setWindowStartOffset(0); + }; + + return ( +
+
+
+ {tasks.length} tareas + {completed} completadas + {formatDate(visibleStart)} - {formatDate(visibleEnd)} + {overdue > 0 && ( + + + {overdue} retrasadas + + )} +
+ +
+
+ + + + +
+ + {zoom !== "fit" && ( +
+ + +
+ )} +
+
+ + + + +
+
+
+
+ Tarea +
+
+ {ticks.map((tick) => ( +
+ {tick.label} +
+ ))} +
+
+ + {timelineItems.map((item) => { + const statusConfig = TASK_STATUS_CONFIG[item.task.status]; + const startsInWindow = item.end >= visibleStart && item.start <= visibleEnd; + + const startOffset = Math.max(0, diffDays(visibleStart, item.start)); + const endOffset = Math.min(visibleDurationDays, diffDays(visibleStart, item.end) + 1); + const leftPercent = (startOffset / visibleDurationDays) * 100; + const widthPercent = Math.max(0.7, ((endOffset - startOffset) / visibleDurationDays) * 100); + const taskDurationDays = Math.max(1, diffDays(item.start, item.end) + 1); + + return ( +
+ +

{item.task.title}

+
+ + {statusConfig.label} + + {item.task.progress}% + {item.task.status === "2" && } +
+ + +
+
+ {ticks.map((tick) => ( +
+ ))} +
+ + {todayPercent !== null && ( +
+ )} + + {startsInWindow && (() => { + const barWidthPx = Math.max((widthPercent / 100) * timelineWidth, zoom === "day" ? 28 : 40); + const showFullLabel = barWidthPx >= 72; + const showCompactLabel = !showFullLabel && barWidthPx >= 52; + + return ( + + + +
+
+
+ {showFullLabel && ( + + {`${item.task.progress}%`} + + )} + {showCompactLabel && ( + + {`${item.task.progress}`} + + )} +
+ + + +
+

{item.task.title}

+

Progreso: {item.task.progress}%

+

Estado: {statusConfig.label}

+

Inicio: {formatDate(item.start)}

+

Fin: {formatDate(item.end)}

+

Duración: {taskDurationDays} día{taskDurationDays === 1 ? "" : "s"}

+
+
+ + ); + })()} +
+
+ ); + })} +
+
+ + + +
+ ); +} diff --git a/components/project-detail/tasks-section.tsx b/components/project-detail/tasks-section.tsx index 7415e2c..6856df0 100644 --- a/components/project-detail/tasks-section.tsx +++ b/components/project-detail/tasks-section.tsx @@ -41,6 +41,7 @@ interface TasksSectionProps { onTaskDeleted?: (taskId: number) => void; } + // Tarjeta de tarea para vista grid function TaskCard({ task }: { task: Task }) { const statusConfig = TASK_STATUS_CONFIG[task.status]; @@ -341,14 +342,14 @@ export function TasksSection({ tasks, projectId, isLoading, onTaskCreated, onTas > - +
{/* Nueva tarea */}