import React, { useState, useMemo } from 'react'; import { Calendar, BarChart2, ListTodo, ChevronLeft, ChevronRight, ChevronDown, Flag, CheckCircle2, AlertCircle, Upload, ChevronsUpDown, ZoomIn, ZoomOut, Download, Loader2, Pencil, FileDown, PlayCircle } from 'lucide-react'; // Datos ficticios orientados a Producto que sirven de guía/plantilla const INITIAL_PROJECT_DATA = [ { id: 1, type: 'Hito', title: 'Discovery e Investigación de Producto', start: '', end: '' }, { id: 2, type: 'Tarea', parentId: 1, title: 'Entrevistas con usuarios objetivo (2 semanas)', start: '2026-05-01', end: '2026-05-14' }, { id: 3, type: 'Tarea', parentId: 1, title: 'Análisis de competencia', start: '2026-05-10', end: '2026-05-15' }, { id: 4, type: 'Tarea', parentId: 1, title: 'Definición de KPIs y métricas de éxito (Hito de 1 día)', start: '2026-05-15', end: '2026-05-15' }, { id: 5, type: 'Tarea', parentId: 1, title: 'Aprobación del Product Requirements Document (Ejemplo sin fechas)', start: '', end: '' }, { id: 6, type: 'Hito', title: 'Desarrollo del MVP (Producto Mínimo Viable)', start: '', end: '' }, { id: 7, type: 'Tarea', parentId: 6, title: 'Diseño UX/UI y Prototipado', start: '2026-05-18', end: '2026-05-29' }, { id: 8, type: 'Tarea', parentId: 6, title: 'Desarrollo de features Core (Backend & Frontend)', start: '2026-05-25', end: '2026-06-12' }, { id: 9, type: 'Tarea', parentId: 6, title: 'Integración de pasarela de pagos', start: '2026-06-05', end: '2026-06-15' }, { id: 10, type: 'Hito', title: 'Beta Testing y QA', start: '', end: '' }, { id: 11, type: 'Tarea', parentId: 10, title: 'Pruebas internas (QA) y corrección de bugs', start: '2026-06-16', end: '2026-06-26' }, { id: 12, type: 'Tarea', parentId: 10, title: 'Lanzamiento a grupo Beta cerrado', start: '2026-06-29', end: '2026-06-29' }, { id: 13, type: 'Tarea', parentId: 10, title: 'Recolección y análisis de feedback', start: '2026-06-30', end: '2026-07-10' }, { id: 14, type: 'Hito', title: 'Lanzamiento Oficial al Mercado (Go-To-Market)', start: '', end: '' }, { id: 15, type: 'Tarea', parentId: 14, title: 'Campaña de marketing y PR', start: '2026-07-06', end: '2026-07-17' }, { id: 16, type: 'Tarea', parentId: 14, title: 'Publicación en App Store y Google Play', start: '2026-07-20', end: '2026-07-20' }, { id: 17, type: 'Tarea', parentId: 14, title: 'Monitorización de primeros usuarios y Onboarding', start: '2026-07-20', end: '2026-07-31' }, ]; const CSV_TEMPLATE = `Tipo,Descripcion,Fecha Inicio,Fecha Fin Hito,Fase 1: Preparación y Planificación,, Tarea,Definir el alcance del proyecto,01/05/2026,14/05/2026 Tarea,Reunión de Kick-off,15/05/2026,15/05/2026 Tarea,Tarea pendiente de planificar,, Hito,Fase 2: Ejecución y Desarrollo,, Tarea,Diseño UX/UI y Prototipado,18/05/2026,29/05/2026 Tarea,Desarrollo de features Core,25/05/2026,12/06/2026 Hito,Fase 3: Pruebas y Lanzamiento,, Tarea,Pruebas de calidad y UAT,22/06/2026,03/07/2026 Tarea,Despliegue en producción,06/07/2026,06/07/2026`; // Fecha dinámica: Lee el día actual exacto del sistema const TODAY = new Date(); TODAY.setHours(0, 0, 0, 0); // Festivos Nacionales (Calendario base 2026) const SPANISH_HOLIDAYS = [ '2026-01-01', // Año Nuevo '2026-01-06', // Epifanía del Señor '2026-04-02', // Jueves Santo '2026-04-03', // Viernes Santo '2026-05-01', // Fiesta del Trabajo '2026-08-15', // Asunción de la Virgen '2026-10-12', // Fiesta Nacional de España '2026-11-01', // Todos los Santos '2026-12-06', // Día de la Constitución '2026-12-08', // Inmaculada Concepción '2026-12-25', // Natividad del Señor ]; const isSpanishHoliday = (date) => { const formattedDate = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; return SPANISH_HOLIDAYS.includes(formattedDate); }; const calculateProgress = (startStr, endStr) => { const s = new Date(startStr); const e = new Date(endStr); if (TODAY > e) return 100; if (TODAY < s) return 0; const total = e.getTime() - s.getTime(); const elapsed = TODAY.getTime() - s.getTime(); if (total <= 0) return 100; return Math.max(0, Math.min(100, Math.round((elapsed / total) * 100))); }; const adjustHitoDates = (data) => { const hitoTasks = data.filter(d => d.type === 'Tarea'); return data.map(item => { if (item.type === 'Hito') { const children = hitoTasks.filter(t => t.parentId === item.id); // Filtramos solo las tareas que SÍ tienen fechas válidas const validChildren = children.filter(t => t.start && t.end); if (validChildren.length > 0) { let minStart = validChildren[0].start; let maxEnd = validChildren[0].end; validChildren.forEach(child => { if (new Date(child.start) < new Date(minStart)) minStart = child.start; if (new Date(child.end) > new Date(maxEnd)) maxEnd = child.end; }); // Respeta la fecha del hito si existe en el CSV; si estaba vacía, usa la calculada desde las tareas válidas const finalStart = item.start || minStart; const finalEnd = item.end || maxEnd; return { ...item, start: finalStart, end: finalEnd, progress: calculateProgress(finalStart, finalEnd) }; } else { // Si el hito NO tiene tareas o ninguna tiene fecha, se respetan las fechas que vinieran en el CSV let finalStart = item.start || TODAY.toISOString().split('T')[0]; let finalEnd = item.end || finalStart; return { ...item, start: finalStart, end: finalEnd, progress: calculateProgress(finalStart, finalEnd) }; } } // Si es una tarea, se calcula su progreso normal (si tiene fechas) return { ...item, progress: item.start && item.end ? calculateProgress(item.start, item.end) : 0 }; }); }; // Función auxiliar para convertir DD/MM/YYYY a YYYY-MM-DD const parseCSVDate = (dateStr) => { if (!dateStr) return ''; let str = dateStr.trim(); // Si ya está en formato YYYY-MM-DD if (str.includes('-') && str.split('-')[0].length === 4) return str; // Convertimos desde DD/MM/YYYY const parts = str.split(/[\/\-]/); if (parts.length >= 3) { let [day, month, year] = parts; day = day.split(' ')[0].padStart(2, '0'); month = month.padStart(2, '0'); year = year.split(' ')[0]; if (year.length === 2) year = `20${year}`; // Por si el año viene con 2 dígitos return `${year}-${month}-${day}`; } return str; }; // Lector experto de CSV (maneja saltos de línea y comas dentro de descripciones con comillas) const parseCSV = (text) => { const rows = []; let currentRow = []; let currentCell = ''; let inQuotes = false; for (let i = 0; i < text.length; i++) { const char = text[i]; const nextChar = text[i + 1]; if (char === '"' && inQuotes && nextChar === '"') { currentCell += '"'; // Comilla escapada ("") i++; } else if (char === '"') { inQuotes = !inQuotes; // Abrir o cerrar comillas } else if (char === ',' && !inQuotes) { currentRow.push(currentCell.trim()); // Fin de celda currentCell = ''; } else if ((char === '\n' || char === '\r') && !inQuotes) { if (char === '\r' && nextChar === '\n') i++; // Evitar doble salto en \r\n currentRow.push(currentCell.trim()); // Fin de celda y de fila if (currentRow.some(cell => cell !== '')) rows.push(currentRow); currentRow = []; currentCell = ''; } else { currentCell += char; // Seguir leyendo contenido } } if (currentCell || currentRow.length > 0) { currentRow.push(currentCell.trim()); if (currentRow.some(cell => cell !== '')) rows.push(currentRow); } return rows; }; export default function App() { const [projectData, setProjectData] = useState(() => adjustHitoDates(INITIAL_PROJECT_DATA)); // Inicia la visualización en el mes actual del sistema const [currentDate, setCurrentDate] = useState(() => { const now = new Date(); return new Date(now.getFullYear(), now.getMonth(), 1); }); const [viewMode, setViewMode] = useState('gantt'); // 'gantt' | 'list' const [ganttMode, setGanttMode] = useState('monthly'); // 'monthly' | 'full' const [fullZoom, setFullZoom] = useState('months'); // 'months' | 'days' const [errorMsg, setErrorMsg] = useState(''); const [isExporting, setIsExporting] = useState(false); // Título editable que sirve de guía const [projectName, setProjectName] = useState('Tu nombre del proyecto va aquí'); const [isEditingName, setIsEditingName] = useState(false); const [tempName, setTempName] = useState('Tu nombre del proyecto va aquí'); // Estado para controlar qué hitos están contraídos const [collapsedHitos, setCollapsedHitos] = useState({}); const handleFileUpload = (e) => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (evt) => { const text = evt.target.result; const rows = parseCSV(text); // Llamada al nuevo intérprete const newProjectData = []; let currentHitoId = null; let idCounter = 1; for (let i = 0; i < rows.length; i++) { const cols = rows[i]; // Identificar índice de la columna "Tipo" const tipoIdx = cols.findIndex(c => c && (c.toLowerCase() === 'hito' || c.toLowerCase() === 'tarea')); if (tipoIdx === -1) continue; const tipo = cols[tipoIdx]; const title = cols[tipoIdx + 1]; // El .trim() aquí es CRUCIAL para evitar que espacios en blanco fantasma en el CSV rompan las fechas const startRaw = (cols[tipoIdx + 2] || '').trim(); let endRaw = (cols[tipoIdx + 3] || '').trim(); // Si es una tarea y no hay fecha fin, pero sí de inicio, asumimos que es igual a la fecha inicio (tareas de 1 día) if (tipo.toLowerCase() === 'tarea' && !endRaw && startRaw) endRaw = startRaw; let start = parseCSVDate(startRaw); let end = parseCSVDate(endRaw); // Si es una Tarea y la fecha es inválida/vacía, permitimos que pase PERO le quitamos las fechas para no renderizar su barra if (tipo.toLowerCase() === 'tarea' && (!start || !start.match(/^\d{4}-\d{2}-\d{2}$/))) { start = ''; end = ''; } // Si es un Hito con fechas vacías o inválidas, le permitimos pasar para que adjustHitoDates recalcule if (tipo.toLowerCase() === 'hito') { if (!start || !start.match(/^\d{4}-\d{2}-\d{2}$/)) start = ''; if (!end || !end.match(/^\d{4}-\d{2}-\d{2}$/)) end = ''; } const item = { id: idCounter++, type: tipo.charAt(0).toUpperCase() + tipo.slice(1).toLowerCase(), title: title || 'Sin Título', start: start, end: end, progress: 0 // Será calculado posteriormente en adjustHitoDates }; if (item.type === 'Hito') { currentHitoId = item.id; newProjectData.push(item); } else if (item.type === 'Tarea') { item.parentId = currentHitoId; newProjectData.push(item); } } if (newProjectData.length > 0) { setProjectData(adjustHitoDates(newProjectData)); setErrorMsg(''); } else { setErrorMsg("No se encontraron hitos o tareas válidas en el CSV."); } }; reader.readAsText(file); // Reiniciar el valor para permitir subir el mismo archivo varias veces si se modifica e.target.value = null; }; const handleDownloadTemplate = () => { // Añadimos BOM (Byte Order Mark) para que Excel reconozca correctamente la codificación UTF-8 const bom = new Uint8Array([0xEF, 0xBB, 0xBF]); const blob = new Blob([bom, CSV_TEMPLATE], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.setAttribute('href', url); link.setAttribute('download', 'Plantilla_Roadmap.csv'); document.body.appendChild(link); link.click(); document.body.removeChild(link); }; const handleDownloadPDF = async () => { setIsExporting(true); // Damos tiempo a React para que re-renderice sin barras de scroll y controles await new Promise(resolve => setTimeout(resolve, 300)); try { if (!window.html2pdf) { const script = document.createElement('script'); script.src = 'https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js'; await new Promise((resolve, reject) => { script.onload = resolve; script.onerror = reject; document.head.appendChild(script); }); } const element = document.getElementById('roadmap-pdf-content'); const elementWidth = element.scrollWidth; const elementHeight = element.scrollHeight; const safeProjectName = projectName.replace(/[^a-z0-9áéíóúñ]/gi, '_'); const opt = { margin: 10, filename: `Roadmap_Directivo_${safeProjectName}.pdf`, image: { type: 'jpeg', quality: 0.98 }, html2canvas: { scale: 2, useCORS: true, windowWidth: elementWidth }, // Formato dinámico para forzar 1 sola página horizontal jsPDF: { unit: 'px', format: [elementWidth + 40, elementHeight + 40], orientation: 'landscape' } }; await window.html2pdf().set(opt).from(element).save(); } catch (error) { console.error('Error al generar el PDF:', error); setErrorMsg('Error al descargar el PDF. Inténtalo de nuevo.'); } finally { setIsExporting(false); } }; const currentYear = currentDate.getFullYear(); const currentMonth = currentDate.getMonth(); const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate(); const monthName = new Intl.DateTimeFormat('es-ES', { month: 'long' }).format(currentDate); const prevMonth = () => setCurrentDate(new Date(currentYear, currentMonth - 1, 1)); const nextMonth = () => setCurrentDate(new Date(currentYear, currentMonth + 1, 1)); const goToToday = () => setCurrentDate(new Date(TODAY.getFullYear(), TODAY.getMonth(), 1)); const toggleCollapse = (id) => { setCollapsedHitos(prev => ({ ...prev, [id]: !prev[id] })); }; const toggleCollapseAll = () => { const hitos = projectData.filter(d => d.type === 'Hito'); const allCollapsed = hitos.every(h => collapsedHitos[h.id]); if (allCollapsed) { // Expandir todos setCollapsedHitos({}); } else { // Contraer todos const newCollapsed = {}; hitos.forEach(h => newCollapsed[h.id] = true); setCollapsedHitos(newCollapsed); } }; // Calcular los límites completos del proyecto const projectBounds = useMemo(() => { let min = new Date('2099-01-01'); let max = new Date('2000-01-01'); if (projectData.length === 0) return { start: new Date(), end: new Date(), totalDays: 1 }; projectData.forEach(item => { // Ignoramos tareas que no tienen fecha para que no desvirtúen el inicio o fin del calendario if (!item.start || !item.end) return; const s = new Date(item.start); const e = new Date(item.end); if (s < min) min = s; if (e > max) max = e; }); const start = new Date(min.getFullYear(), min.getMonth(), 1); const end = new Date(max.getFullYear(), max.getMonth() + 1, 0); const totalDays = Math.ceil((end - start) / (1000 * 60 * 60 * 24)); return { start, end, totalDays }; }, [projectData]); // Preparar datos visuales para Gantt dependiendo del modo (mensual o completo) const ganttItems = useMemo(() => { const rangeStart = ganttMode === 'monthly' ? new Date(currentYear, currentMonth, 1) : projectBounds.start; const rangeEnd = ganttMode === 'monthly' ? new Date(currentYear, currentMonth, daysInMonth) : projectBounds.end; const totalDays = ganttMode === 'monthly' ? daysInMonth : projectBounds.totalDays; const items = []; // Organizar jerarquía teniendo en cuenta hitos contraídos const hitos = projectData.filter(d => d.type === 'Hito'); hitos.forEach(hito => { items.push({ ...hito, isParent: true }); // Solo añadimos las tareas si el hito NO está contraído if (!collapsedHitos[hito.id]) { const tareas = projectData.filter(d => d.parentId === hito.id); tareas.forEach(tarea => { items.push({ ...tarea, isParent: false }); }); } }); return items.map(item => { // Si la tarea no tiene fechas, la marcamos como visible (para la lista) pero sin estilo de barra if (!item.start || !item.end) { return { ...item, isVisible: true, style: { display: 'none', width: '0%', left: '0%' }, isContinuedLeft: false, isContinuedRight: false }; } const start = new Date(item.start); const end = new Date(item.end); let drawStart = start < rangeStart ? rangeStart : start; let drawEnd = end > rangeEnd ? rangeEnd : end; const isVisible = !(start > rangeEnd || end < rangeStart); let left = 0; let width = 0; if (isVisible) { const leftDays = (drawStart - rangeStart) / (1000 * 60 * 60 * 24); const widthDays = (drawEnd - drawStart) / (1000 * 60 * 60 * 24) + (ganttMode === 'monthly' ? 1 : 0); left = (leftDays / totalDays) * 100; width = (widthDays / totalDays) * 100; } return { ...item, isVisible, style: { left: `${left}%`, width: `${width}%`, opacity: isVisible ? 1 : 0, }, isContinuedLeft: start < rangeStart, isContinuedRight: end > rangeEnd, }; }).filter(i => i.isVisible); }, [currentYear, currentMonth, daysInMonth, ganttMode, projectBounds, projectData, collapsedHitos]); const ganttColumns = useMemo(() => { if (ganttMode === 'monthly') { return Array.from({ length: daysInMonth }, (_, i) => { const dDate = new Date(currentYear, currentMonth, i+1); return { key: `day-${i+1}`, label: i + 1, subLabel: new Intl.DateTimeFormat('es-ES', { weekday: 'narrow' }).format(dDate), widthPct: (1 / daysInMonth) * 100, isWeekend: dDate.getDay() === 0 || dDate.getDay() === 6, isHoliday: isSpanishHoliday(dDate), isToday: TODAY.getFullYear() === currentYear && TODAY.getMonth() === currentMonth && (i+1) === TODAY.getDate(), isFirstOfMonth: i === 0 }; }); } else { if (fullZoom === 'months') { const cols = []; let curr = new Date(projectBounds.start); while(curr <= projectBounds.end) { const mDate = new Date(curr); const daysInThisMonth = new Date(mDate.getFullYear(), mDate.getMonth() + 1, 0).getDate(); cols.push({ key: `month-${mDate.getFullYear()}-${mDate.getMonth()}`, label: new Intl.DateTimeFormat('es-ES', { month: 'short' }).format(mDate), subLabel: mDate.getFullYear().toString(), widthPct: (daysInThisMonth / projectBounds.totalDays) * 100, isWeekend: false, isToday: TODAY.getFullYear() === mDate.getFullYear() && TODAY.getMonth() === mDate.getMonth() }); curr.setMonth(curr.getMonth() + 1); } return cols; } else { const cols = []; let curr = new Date(projectBounds.start); while(curr <= projectBounds.end) { const dDate = new Date(curr); cols.push({ key: `day-${dDate.getFullYear()}-${dDate.getMonth()}-${dDate.getDate()}`, label: dDate.getDate(), subLabel: new Intl.DateTimeFormat('es-ES', { weekday: 'narrow' }).format(dDate), widthPct: (1 / projectBounds.totalDays) * 100, isWeekend: dDate.getDay() === 0 || dDate.getDay() === 6, isHoliday: isSpanishHoliday(dDate), isToday: TODAY.getFullYear() === dDate.getFullYear() && TODAY.getMonth() === dDate.getMonth() && TODAY.getDate() === dDate.getDate(), isFirstOfMonth: dDate.getDate() === 1 }); curr.setDate(curr.getDate() + 1); } return cols; } } }, [ganttMode, fullZoom, currentYear, currentMonth, daysInMonth, projectBounds]); const kpis = useMemo(() => { // 1. Vencimientos en los próximos 7 días const upcomingDeadlines = projectData.filter(t => { if (!t.end) return false; const end = new Date(t.end); const diff = (end - TODAY) / (1000 * 60 * 60 * 24); return diff >= 0 && diff <= 7; }); // 2. Hitos actualmente en curso (han empezado y no han terminado) const activeHitos = projectData.filter(t => { if (t.type !== 'Hito' || !t.start || !t.end) return false; const start = new Date(t.start); const end = new Date(t.end); return start <= TODAY && end >= TODAY; }); // 3. Hitos próximos a iniciar (empiezan en los próximos 15 días) const upcomingHitos = projectData.filter(t => { if (t.type !== 'Hito' || !t.start) return false; const start = new Date(t.start); const diff = (start - TODAY) / (1000 * 60 * 60 * 24); return diff > 0 && diff <= 15; }); return { upcomingCount: upcomingDeadlines.length, activeHitosCount: activeHitos.length, upcomingHitosCount: upcomingHitos.length }; }, [projectData]); const renderGanttTimeline = () => { // Calculamos la anchura dinámica para aplicar el Zoom real (Scroll Horizontal) const rightSideMinWidth = ganttMode === 'full' && fullZoom === 'days' ? Math.max(projectBounds.totalDays * 35, 600) // 35px por día asegura que haya espacio para hacer scroll : 600; const getTodayPosition = () => { if (ganttMode === 'monthly') { if (TODAY.getFullYear() === currentYear && TODAY.getMonth() === currentMonth) { return ((TODAY.getDate() - 1) / daysInMonth) * 100; } } else { if (TODAY >= projectBounds.start && TODAY <= projectBounds.end) { const daysFromStart = (TODAY - projectBounds.start) / (1000 * 60 * 60 * 24); return (daysFromStart / projectBounds.totalDays) * 100; } } return null; }; const todayPosition = getTodayPosition(); const renderItemRow = (item) => (
!isExporting && item.isParent && toggleCollapse(item.id)} className={`sticky left-0 z-20 w-[350px] flex-shrink-0 p-3 border-r border-slate-200 flex items-center gap-2 shadow-[2px_0_5px_-2px_rgba(0,0,0,0.1)] ${item.isParent ? 'font-semibold text-slate-800 bg-slate-50 cursor-pointer group-hover:bg-slate-100 transition-colors' : 'text-slate-600 bg-white group-hover:bg-slate-50 pl-8 text-sm'}`} > {item.isParent && ( collapsedHitos[item.id] ? : )} {!item.isParent && } {item.isParent ? : }
{item.title} {/* Lógica unificada para mostrar correctamente fechas en Hitos y Tareas */} {item.start && item.end ? ( {item.start.split('-').reverse().join('/')} - {item.end.split('-').reverse().join('/')} ) : ( Sin fechas asignadas )}
{/* Rejilla de fondo adaptativa */}
{ganttColumns.map(col => (
))}
{/* Barra de progreso (Solo se renderiza si la tarea tiene fechas válidas) */} {item.start && item.end && (
{parseFloat(item.style.width) > 10 ? `${item.progress}%` : ''} {parseFloat(item.style.width) > 15 && item.start && item.end ? `${item.start.split('-').reverse().join('/')} - ${item.end.split('-').reverse().join('/')}` : ''}
)}
); return ( // En modo exportación, quitamos el overflow para que el canvas de html2pdf capture todo lo que está escondido a la derecha
{/* Cabecera del calendario */}
Descripción de Hitos y Tareas {!isExporting && ( )}
{ganttColumns.map(col => { const colWidthPx = (col.widthPct / 100) * rightSideMinWidth; // En modo mensual SIEMPRE mostramos el texto, en modo completo dependemos del zoom y el espacio const showText = ganttMode === 'monthly' || colWidthPx > 20 || col.isFirstOfMonth || col.isToday; const isHighlightedToday = col.isToday && (ganttMode === 'monthly' || fullZoom === 'days'); return (
{showText && ( <> {col.subLabel} {col.label} )}
); })}
{/* Cuerpo del Gantt */}
{/* Línea del día actual con estructura flex para alinear perfectamente con el calendario */} {todayPosition !== null && (
Hoy
)}
{ganttMode === 'monthly' ? 'Tareas del periodo seleccionado' : 'Roadmap Completo del Proyecto'}
{ganttItems.length === 0 ? (
No hay actividades programadas para este periodo.
) : ( ganttItems.map((item) => renderItemRow(item)) )}
); }; const renderExecutiveList = () => { return (
{/* KPI Dashboard */}
{kpis.activeHitosCount}
Hitos en Curso
{kpis.upcomingHitosCount}
Próximos a Iniciar (15d)
{kpis.upcomingCount}
Vencimientos (7d)

Próximos Vencimientos (7 Días)

{projectData.filter(t => { if (!t.end) return false; const end = new Date(t.end); const diff = (end - TODAY) / (1000 * 60 * 60 * 24); return diff >= 0 && diff <= 7; }).map(item => (
{item.title}
Vence: {item.end}
{Math.ceil((new Date(item.end) - TODAY) / (1000 * 60 * 60 * 24))} días
))} {kpis.upcomingCount === 0 && (
No hay vencimientos inminentes.
)}

Estado de Hitos Principales

{projectData.filter(t => t.type === 'Hito').map(hito => { const diasRestantes = hito.end ? Math.ceil((new Date(hito.end) - TODAY) / (1000 * 60 * 60 * 24)) : 0; const tareasDelHito = projectData.filter(t => t.parentId === hito.id); return (
{hito.title} {diasRestantes <= 0 ? '0 días' : `Faltan ${diasRestantes} días`}
Inicio: {hito.start || 'N/A'} Fecha Límite: {hito.end || 'N/A'}
{tareasDelHito.length > 0 && (
Tareas Asociadas
{tareasDelHito.map(tarea => { if (!tarea.end) { return (
{tarea.title} (Sin fecha)
); } const diasRestantesTarea = Math.ceil((new Date(tarea.end) - TODAY) / (1000 * 60 * 60 * 24)); return (
{tarea.title}
{diasRestantesTarea <= 0 ? '0 días' : `Faltan ${diasRestantesTarea} d`}
); })}
)}
); })}
); }; return (
{/* Contenedor que será capturado para el PDF */}
{isEditingName && !isExporting ? ( setTempName(e.target.value)} onBlur={() => { setProjectName(tempName.trim() || 'Proyecto Sin Título'); setIsEditingName(false); }} onKeyDown={(e) => { if (e.key === 'Enter') { setProjectName(tempName.trim() || 'Proyecto Sin Título'); setIsEditingName(false); } }} autoFocus className="text-2xl font-bold text-indigo-900 tracking-tight bg-slate-50 border border-indigo-200 rounded px-2 py-0.5 outline-none focus:ring-2 focus:ring-indigo-500 w-full md:w-96" /> ) : ( <>

Roadmap Directivo: {projectName}

{!isExporting && ( )} )}

Visión estratégica y seguimiento de hitos

{!isExporting && (
)}
{errorMsg && !isExporting && (
{errorMsg}
)} {/* Controles y Navegación - Se ocultan durante la exportación */} {!isExporting && (
{viewMode === 'gantt' && (
{/* Selector de Modo de Gantt */}
{/* Controles de Zoom (solo en modo completo) */} {ganttMode === 'full' && (
)} {/* Controles de mes (solo en modo mensual) */} {ganttMode === 'monthly' && (
{monthName} {currentYear}
)}
)}
)} {/* Contenido Principal */}
{viewMode === 'gantt' ? renderGanttTimeline() : renderExecutiveList()}
{/* Leyenda */} {viewMode === 'gantt' && !isExporting && (
Hito Principal
Tarea Específica
Día Actual (Hoy)
)}
); }