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) => (
Visión estratégica y seguimiento de hitos