// ROCA GOLF — Gantt por insumo/presupuesto + alertas automáticas como pins function daysBetween(a, b) { const da = new Date(a + "T00:00:00"), db = new Date(b + "T00:00:00"); return Math.round((db - da) / 86400000); } function addDaysISO(iso, n) { const d = new Date(iso + "T00:00:00"); d.setDate(d.getDate() + n); return d.toISOString().slice(0, 10); } function monthLabelES(iso) { const m = ["Ene","Feb","Mar","Abr","May","Jun","Jul","Ago","Sep","Oct","Nov","Dic"]; const d = new Date(iso + "T00:00:00"); return `${m[d.getMonth()]} ${d.getFullYear().toString().slice(2)}`; } function shortDate(iso) { const d = new Date(iso + "T00:00:00"); return `${String(d.getDate()).padStart(2,"0")}/${String(d.getMonth()+1).padStart(2,"0")}`; } const GANTT_FAM_COLORS = { "01": "#2d6a44", "02": "#a88838", "03": "#1976d2", "04": "#b22222", "05": "#7c4dff", "06": "#0d2818", "07": "#0e8a7a", "08": "#c8651b" }; // Color de barra según % de utilización del presupuesto function ganttBarColor(pct) { if (pct >= 100) return "#b71c1c"; if (pct >= 90) return "#e53935"; if (pct >= 80) return "#f57c00"; if (pct >= 70) return "#f9a825"; return "#2d6a44"; } function GanttChart({ proyecto, presupuestos, alertas, onAlertClick, onAddAlert }) { const today = new Date().toISOString().slice(0, 10); const [fullscreen, setFullscreen] = useState(false); const [tooltip, setTooltip] = useState(null); const tooltipRef = useRef(null); useEffect(() => { const onKey = (e) => { if (e.key === 'Escape') { setFullscreen(false); setTooltip(null); } }; document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); }, []); useEffect(() => { if (!tooltip) return; function onDown(e) { if (tooltipRef.current && !tooltipRef.current.contains(e.target)) setTooltip(null); } document.addEventListener('mousedown', onDown); document.addEventListener('touchstart', onDown); return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('touchstart', onDown); }; }, [tooltip]); async function openTooltip(pp, e) { e.stopPropagation(); if (tooltip && tooltip.pp.id === pp.id) { setTooltip(null); return; } const rect = e.currentTarget.getBoundingClientRect(); const x = Math.min(rect.left, window.innerWidth - 334); const y = rect.bottom + 6; setTooltip({ pp, ocs: [], loading: true, x, y }); try { const data = await apiFetch(`presupuestos_proyecto.php?mode=ocs&proyectoid=${pp.proyectoid}&insumoid=${pp.insumoid}`); setTooltip(t => t && t.pp.id === pp.id ? { ...t, ocs: Array.isArray(data) ? data : [], loading: false } : t); } catch { setTooltip(t => t && t.pp.id === pp.id ? { ...t, ocs: [], loading: false } : t); } } const startISO = proyecto.inicio; const endISO = proyecto.fin; // Expandir rango para incluir todas las fechas de presupuestos let effectiveStart = startISO; let effectiveEnd = endISO; (presupuestos || []).forEach(pp => { if (pp.fecha_inicio && pp.fecha_inicio < effectiveStart) effectiveStart = pp.fecha_inicio; if (pp.fecha_fin && pp.fecha_fin > effectiveEnd) effectiveEnd = pp.fecha_fin; }); const totalDays = Math.max(1, daysBetween(effectiveStart, effectiveEnd)); const COL_LEFT = 300; const ROW_H = 42; const HEAD_H = 56; // meses(26) + semanas(30); left head forced to same via CSS height:56px const PAD_BARS = 8; const innerW = Math.max(1000, totalDays * 10); const dayW = innerW / totalDays; const rows = presupuestos || []; // Marcas de mes const months = []; let cur = new Date(effectiveStart + "T00:00:00"); cur.setDate(1); while (cur <= new Date(effectiveEnd + "T00:00:00")) { const iso = cur.toISOString().slice(0, 10); const offset = daysBetween(effectiveStart, iso); months.push({ iso, offset: Math.max(0, offset), label: monthLabelES(iso) }); cur.setMonth(cur.getMonth() + 1); } const weeks = []; for (let d = 0; d <= totalDays; d += 7) weeks.push(d); const todayOffset = daysBetween(effectiveStart, today); const rowsH = Math.max(rows.length * ROW_H + 12, 60); const THRESHOLDS = [70, 80, 90, 100]; return (
{/* ── Layout principal: columna fija izquierda + scroll derecho ── */}
{/* Columna izquierda fija */}
{/* Cabecera izquierda: info + botones */}
Insumo · clave
{rows.length} insumos · {proyecto.nombre}
{/* Etiquetas de filas */}
{rows.length === 0 ? (
Sin presupuestos asignados.
Usa Asignar presupuesto para ver insumos aquí.
) : rows.map(pp => { const c = ganttBarColor(pp.pct); return (
{pp.insumo_clave}
{pp.insumo_descripcion}
{(pp.fecha_inicio || pp.fecha_fin) && (
{shortDate(pp.fecha_inicio || startISO)} → {shortDate(pp.fecha_fin || endISO)}
)}
{pp.severidad && ( {pp.pct.toFixed(0)}% )}
); })}
{/* Scroll horizontal único: encabezado de meses/semanas + barras */}
{/* Meses */}
{months.map((m, i) => { const next = months[i + 1]; const w = next ? (next.offset - m.offset) * dayW : (totalDays - m.offset) * dayW; return (
{m.label}
); })}
{/* Semanas */}
{weeks.map((d, i) => (
{shortDate(addDaysISO(effectiveStart, d))}
))}
{/* Área de barras */}
{/* Líneas de semana */} {weeks.map((d, i) =>
)} {/* Marcador HOY */} {todayOffset >= 0 && todayOffset <= totalDays && (
HOY
)} {/* Fondos de fila */} {rows.map((pp, i) => (
))} {/* Barras de presupuesto por insumo */} {rows.map((pp, i) => { const c = ganttBarColor(pp.pct); // Usar fechas del presupuesto si están definidas, si no usar el rango del proyecto const barStart = pp.fecha_inicio || startISO; const barEnd = pp.fecha_fin || endISO; const barOffset = Math.max(0, daysBetween(effectiveStart, barStart)); const barDays = Math.max(1, daysBetween(barStart, barEnd)); const barLeft = barOffset * dayW; const barWidth = barDays * dayW; const fillW = (barWidth * Math.min(pp.pct, 100)) / 100; // Pin de alerta en posición "hoy" relativa al inicio de la barra const todayInBar = todayOffset - barOffset; const pinTodayLeft = todayInBar >= 0 && todayInBar <= barDays ? Math.min(todayInBar * dayW, barWidth - 18) : null; return (
openTooltip(pp, e)} style={{ top: i * ROW_H + PAD_BARS, left: barLeft, width: barWidth, height: ROW_H - PAD_BARS * 2, background: c + "14", borderColor: c + "66", cursor: 'pointer' }}> {/* Fill: porcentaje ejercido */}
{/* Marcadores de umbral: 70%, 80%, 90%, 100% del ancho de la barra */} {THRESHOLDS.map(th => { const tickLeft = (barWidth * th) / 100; const crossed = pp.pct >= th; return (
{th}% ); })} {/* Etiqueta principal: % y montos */}
{pp.pct.toFixed(1)}% ${fmtMoney(pp.gastado, { compact: true })} / ${fmtMoney(pp.monto, { compact: true })}
{/* Pin de alerta automática en posición "hoy" (si supera umbral) */} {pp.severidad && pinTodayLeft !== null && ( )} {/* Alertas manuales asociadas a este insumo */} {(alertas || []).filter(a => a.insumoid === pp.insumoid).map(a => { const pinOffset = daysBetween(barStart, a.fecha); const pinLeft = Math.max(0, Math.min(barWidth - 18, pinOffset * dayW)); return ( ); })}
); })}
{/* Footer leyenda */}
Familias {Object.entries(GANTT_FAM_COLORS).map(([k, c]) => ( {k} ))} Utilización {[["#2d6a44","< 70%"], ["#f9a825","70%"], ["#f57c00","80%"], ["#e53935","90%"], ["#b71c1c","100%+"]].map(([c, lb]) => ( {lb} ))} Alertas {[["over","Excedido"],["alert","Alerta"],["warn","Aviso"]].map(([lv,lb]) => ( {lb} ))}
); } function OcTooltip({ tooltip, setTooltip, tooltipRef }) { if (!tooltip) return null; const pp = tooltip.pp; const c = ganttBarColor(pp.pct); const left = Math.max(8, Math.min(tooltip.x, window.innerWidth - 334)); const top = Math.min(tooltip.y, window.innerHeight - 300); return ReactDOM.createPortal(
{/* Cabecera */}
{pp.insumo_clave} {pp.insumo_descripcion}
Ejercido: ${fmtMoney(pp.gastado)} Ppto: ${fmtMoney(pp.monto)} {pp.pct.toFixed(1)}%
{/* Lista OCs */}
{tooltip.loading && (
Cargando órdenes…
)} {!tooltip.loading && tooltip.ocs.length === 0 && (
Sin órdenes de compra registradas
)} {!tooltip.loading && tooltip.ocs.length > 0 && (
1ª PartidaMontoFecha
{tooltip.ocs.map((oc, i) => (
{oc.folio}
{oc.primera_partida || '—'}
${fmtMoney(oc.monto)}
{oc.fecha ? shortDate(oc.fecha.slice(0,10)) : '—'}
))}
)}
, document.body ); } Object.assign(window, { GanttChart, GANTT_FAM_COLORS, daysBetween, addDaysISO });