// 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 });