// ROCA GOLF — Presupuestos por Proyecto (CRUD completo) function pctColor(pct) { if (pct >= 100) return '#b71c1c'; if (pct >= 90) return '#e53935'; if (pct >= 80) return '#f57c00'; if (pct >= 70) return '#f9a825'; return '#2d6a44'; } const SEV_LABEL = { over: 'Excedido', alert: 'Alerta', warn: 'Aviso' }; const IcoEdit = (s = 13) => ( ); // ── Tarjeta de presupuesto ───────────────────────────────────────────────── function PptoCard({ pp, onEdit, onReset }) { const [confirmDel, setConfirmDel] = useState(false); const c = pctColor(pp.pct); const famColor = (GANTT_FAM_COLORS || {})[pp.codigofamilia] || '#888'; function handleReset() { if (!confirmDel) { setConfirmDel(true); setTimeout(() => setConfirmDel(false), 3000); return; } onReset(); } return (
{/* Fila 1: clave · descripción · badges · acciones */}
{pp.insumo_clave}
{pp.insumo_descripcion}
{pp.descripcion && (
{pp.descripcion}
)}
{pp.severidad && ( {SEV_LABEL[pp.severidad]} )} {pp.id != null && ( )}
{/* Fila 2: barra de progreso (o "Sin tope"/"Sin presupuesto" si no aplica) */} {(pp.sin_tope || pp.sin_presupuesto) ? (
Ejercido: ${fmtMoney(pp.gastado, { compact: true })} {pp.sin_tope ? 'Sin tope (Contraloría)' : 'Sin presupuesto asignado'}
) : (
Ejercido: ${fmtMoney(pp.gastado, { compact: true })} {' '}/ ${fmtMoney(pp.monto, { compact: true })} Disponible: {pp.disponible < 0 ? '-' : ''}${fmtMoney(Math.abs(pp.disponible), { compact: true })}
{/* Track con tick de umbral */}
0 ? 3 : 0 }}/>
0% {pp.pct.toFixed(1)}% 100%
)} {/* Fila 3: meta */}
{pp.nombrefamilia} Umbral {pp.pct_umbral}% {(pp.fecha_inicio || pp.fecha_fin) && ( {shortDate(pp.fecha_inicio || '')} → {shortDate(pp.fecha_fin || '')} )}
); } // ── Modal editar (v7.0: ya no crea claves nuevas — aparecen solas por tener OC // real o tope de Contraloría; este modal solo ajusta % umbral/fechas/ // descripción/monto local de una clave que ya se muestra) ──────────────── function PptoFormModal({ open, onClose, proyectoid, proyecto, editing, onSaved }) { const [insumo, setInsumo] = useState(null); const [monto, setMonto] = useState(''); const [umbral, setUmbral] = useState(80); const [fechaInicio, setFechaInicio] = useState(''); const [fechaFin, setFechaFin] = useState(''); const [descripcion, setDescripcion] = useState(''); const [saving, setSaving] = useState(false); const montoEsContraloria = !!(editing && editing.origen_monto === 'contraloria'); useEffect(() => { if (!open || !editing) return; setInsumo({ id: editing.insumoid, clave: editing.insumo_clave, descripcion: editing.insumo_descripcion, nombrefamilia: editing.nombrefamilia }); setMonto(String(editing.monto)); setUmbral(editing.pct_umbral); setFechaInicio(editing.fecha_inicio || ''); setFechaFin(editing.fecha_fin || ''); setDescripcion(editing.descripcion || ''); }, [open, editing]); async function handleSave() { if (!insumo) return; let m; if (montoEsContraloria) { m = parseFloat(editing.monto_contraloria); } else { m = parseFloat(monto); if (!m || m <= 0) { toast.error('El monto debe ser mayor a 0'); return; } } setSaving(true); try { const body = { monto: m, pct_umbral: Math.min(100, Math.max(1, +umbral || 80)), fecha_inicio: fechaInicio || null, fecha_fin: fechaFin || null, descripcion: descripcion.trim(), }; // La clave ya existe (tiene OC/tope) — 'id' solo existe si ya se habían // guardado metadatos locales antes. Sin id: este primer guardado los crea. if (editing && editing.id != null) { await apiFetch('presupuestos_proyecto.php?id=' + editing.id, 'PUT', body); toast('Presupuesto actualizado'); } else { await apiFetch('presupuestos_proyecto.php', 'POST', { ...body, proyectoid, insumoid: insumo.id }); toast('Presupuesto guardado'); } onSaved(); onClose(); } catch (e) { toast.error(e.message); } finally { setSaving(false); } } if (!open || !editing) return null; return (
e.stopPropagation()}>

Editar presupuesto

{/* Proyecto */}
{proyecto?.nombre}
{/* Insumo */}
{editing.insumo_clave} {editing.insumo_descripcion}
{/* Monto + umbral */}
{montoEsContraloria ? ( <>
{editing.sin_tope ? 'Sin tope — Contraloría autorizó este insumo sin límite en pesos.' : (editing.monto_adicional > 0 ? `Vigente en Contraloría: original $${fmtMoney(editing.monto_original)} + adicional $${fmtMoney(editing.monto_adicional)} — aquí solo se ajusta el % de alarma.` : 'Definido en Contraloría — aquí solo se ajusta el % de alarma.')}
) : ( <> setMonto(e.target.value)}/> {editing.sin_presupuesto && (
Sin presupuesto asignado todavía — captura un monto local.
)} )}
setUmbral(e.target.value)}/>
{/* Fechas */}
setFechaInicio(e.target.value)}/>
setFechaFin(e.target.value)}/>
{/* Descripción */}
setDescripcion(e.target.value)}/>
); } // ── Presupuestos combinados (v8.0) — varias claves bajo un solo tope ──────── // Ej. "Central de riego" = 25,000,000 repartidos entre muchas claves del // catálogo. Mientras una clave esté en un combinado deja de mostrarse suelta // (ver el filtro en api/presupuestos_proyecto.php) — quitarla del combinado // la regresa individual sin pasos extra. // Árbol de selección de claves: familia → subfamilia → clave, con checkbox por // nivel (clic en una subfamilia selecciona/deselecciona TODAS sus claves de un // jalón; clic en una clave selecciona solo esa). Reutilizado por crear y por // "agregar más claves". function InsumoTreePicker({ grupo, yaAsignadosMap, selected, onToggle, onToggleMany }) { const { data: insData, loading } = useApiData('insumos.php', { grupo: grupo || '', limit: 500 }); const rows = insData?.rows || []; const [expandFam, setExpandFam] = useState({}); const tree = useMemo(() => { const t = {}; for (const r of rows) { const famKey = r.codigofamilia + ' · ' + r.nombrefamilia; const subKey = r.codigosubfamilia + ' · ' + r.nombresubfamilia; if (!t[famKey]) t[famKey] = {}; if (!t[famKey][subKey]) t[famKey][subKey] = []; t[famKey][subKey].push(r); } return t; }, [rows]); if (loading) return
Cargando catálogo…
; return (
{Object.entries(tree).map(([fam, subs]) => (
setExpandFam(s => ({ ...s, [fam]: !s[fam] }))} style={{ padding: '8px 12px', fontSize: 11.5, fontWeight: 600, cursor: 'pointer', background: 'var(--rg-cream)', display: 'flex', justifyContent: 'space-between' }}> {fam} {expandFam[fam] ? '▲' : '▼'}
{expandFam[fam] && Object.entries(subs).map(([sub, items]) => { const idsSub = items.map(i => i.id).filter(id => !yaAsignadosMap[id]); const allChecked = idsSub.length > 0 && idsSub.every(id => selected.has(id)); return (
{items.map(it => ( ))}
); })}
))}
); } function CombinadoFormModal({ open, onClose, proyectoid, proyecto, yaAsignadosMap, onSaved }) { const [nombre, setNombre] = useState(''); const [monto, setMonto] = useState(''); const [fechaInicio, setFechaInicio] = useState(''); const [fechaFin, setFechaFin] = useState(''); const [descripcion, setDescripcion] = useState(''); const [selected, setSelected] = useState(() => new Set()); const [saving, setSaving] = useState(false); useEffect(() => { if (open) { setNombre(''); setMonto(''); setDescripcion(''); setFechaInicio(proyecto?.inicio || ''); setFechaFin(proyecto?.fin || ''); setSelected(new Set()); } }, [open]); function toggle(id) { setSelected(s => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; }); } function toggleMany(ids, checked) { setSelected(s => { const n = new Set(s); ids.forEach(id => checked ? n.add(id) : n.delete(id)); return n; }); } async function crear() { if (!nombre.trim()) { toast.error('El nombre es obligatorio'); return; } const m = parseFloat(monto); if (!m || m <= 0) { toast.error('El monto debe ser mayor a 0'); return; } if (selected.size === 0) { toast.error('Selecciona al menos una clave'); return; } setSaving(true); try { const res = await apiFetch('presupuesto_combinado.php', 'POST', { proyectoid, nombre: nombre.trim(), monto: m, fecha_inicio: fechaInicio || null, fecha_fin: fechaFin || null, descripcion: descripcion.trim(), insumoids: Array.from(selected), }); toast.success(`Combinado creado con ${res.agregados} claves` + (res.omitidos ? ` (${res.omitidos} ya estaban en otro combinado)` : '')); onSaved(); onClose(); } catch (e) { toast.error(e.message); } finally { setSaving(false); } } if (!open) return null; return ( }>
setNombre(e.target.value)}/>
setMonto(e.target.value)}/>
Fijo en 80%
setFechaInicio(e.target.value)}/>
setFechaFin(e.target.value)}/>
setDescripcion(e.target.value)}/>
Claves incluidas ({selected.size} seleccionadas)
); } function CombinadoAddInsumosModal({ open, onClose, combinado, proyecto, yaAsignadosMap, onSaved }) { const [selected, setSelected] = useState(() => new Set()); const [saving, setSaving] = useState(false); useEffect(() => { if (open) setSelected(new Set()); }, [open]); function toggle(id) { setSelected(s => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; }); } function toggleMany(ids, checked) { setSelected(s => { const n = new Set(s); ids.forEach(id => checked ? n.add(id) : n.delete(id)); return n; }); } async function agregar() { if (selected.size === 0) { toast.error('Selecciona al menos una clave'); return; } setSaving(true); try { const res = await apiFetch('presupuesto_combinado.php?action=add_insumos&id=' + combinado.id, 'POST', { insumoids: Array.from(selected) }); toast.success(`${res.agregados} claves agregadas` + (res.omitidos ? ` (${res.omitidos} ya estaban en otro combinado)` : '')); onSaved(); onClose(); } catch (e) { toast.error(e.message); } finally { setSaving(false); } } if (!open || !combinado) return null; return ( }>
{selected.size} claves seleccionadas
); } function CombinadoEditModal({ open, onClose, combinado, onSaved }) { const [nombre, setNombre] = useState(''); const [monto, setMonto] = useState(''); const [fechaInicio, setFechaInicio] = useState(''); const [fechaFin, setFechaFin] = useState(''); const [descripcion, setDescripcion] = useState(''); const [saving, setSaving] = useState(false); useEffect(() => { if (open && combinado) { setNombre(combinado.nombre || ''); setMonto(String(combinado.monto)); setFechaInicio(combinado.fecha_inicio || ''); setFechaFin(combinado.fecha_fin || ''); setDescripcion(combinado.descripcion || ''); } }, [open, combinado]); async function guardar() { if (!nombre.trim()) { toast.error('El nombre es obligatorio'); return; } const m = parseFloat(monto); if (!m || m <= 0) { toast.error('El monto debe ser mayor a 0'); return; } setSaving(true); try { await apiFetch('presupuesto_combinado.php?id=' + combinado.id, 'PUT', { nombre: nombre.trim(), monto: m, fecha_inicio: fechaInicio || null, fecha_fin: fechaFin || null, descripcion: descripcion.trim(), }); toast.success('Presupuesto combinado actualizado'); onSaved(); onClose(); } catch (e) { toast.error(e.message); } finally { setSaving(false); } } if (!open || !combinado) return null; return ( }>
setNombre(e.target.value)}/>
setMonto(e.target.value)}/>
Fijo en 80%
setFechaInicio(e.target.value)}/>
setFechaFin(e.target.value)}/>
setDescripcion(e.target.value)}/>
); } function CombinadoCard({ c, onEdit, onDelete, onAddInsumos, onRemoveInsumo }) { const [expanded, setExpanded] = useState(false); const [confirmDel, setConfirmDel] = useState(false); const col = pctColor(c.pct); function handleDelete() { if (!confirmDel) { setConfirmDel(true); setTimeout(() => setConfirmDel(false), 3000); return; } onDelete(); } return (
Combinado
{c.nombre}
{c.descripcion &&
{c.descripcion}
}
{c.severidad && {SEV_LABEL[c.severidad]}}
Ejercido: ${fmtMoney(c.gastado, { compact: true })} / ${fmtMoney(c.monto, { compact: true })} Disponible: {c.disponible < 0 ? '-' : ''}${fmtMoney(Math.abs(c.disponible), { compact: true })}
0 ? 3 : 0 }}/>
0%{c.pct.toFixed(1)}%100%
{c.miembros.length} clave{c.miembros.length !== 1 ? 's' : ''} Umbral 80% {(c.fecha_inicio || c.fecha_fin) && {shortDate(c.fecha_inicio || '')} → {shortDate(c.fecha_fin || '')}}
{expanded && (
{c.miembros.map(m => (
{m.clave} {m.descripcion}
))}
)}
); } // ── Vista principal ──────────────────────────────────────────────────────── function AlertsView({ thresholds, setThresholds, onNav, alertas, setAlertas, user, onLogout }) { const [activeProj, setActiveProj] = useState(null); const [modal, setModal] = useState(false); const [editing, setEditing] = useState(null); const [showCombinadoForm, setShowCombinadoForm] = useState(false); const [editCombinado, setEditCombinado] = useState(null); const [addInsumosCombinado, setAddInsumosCombinado] = useState(null); const { data: proyectos } = useApiData('proyectos.php'); const { data: presupuestosData, reload: reloadPpto } = useApiData( 'presupuestos_proyecto.php', { proyectoid: activeProj }, !activeProj ); const { data: pptoResumen, reload: reloadResumen } = useApiData( 'presupuestos_proyecto.php', { mode: 'resumen' } ); const { data: combinadosData, reload: reloadCombinados } = useApiData( 'presupuesto_combinado.php', { proyectoid: activeProj }, !activeProj ); // Bolsa de presupuesto adicional (rg_presupuesto_adicional, se captura en // Proyección de Costos) vs lo que Contraloría ya repartió entre claves (v6.47). // Solo lectura — el reparto se hace en Contraloría, nunca aquí. const { data: adicionalData } = useApiData( 'conciliacion.php', { proyectoid: activeProj }, !activeProj ); const presupuestos = presupuestosData || []; const combinados = combinadosData || []; const proyectoActivo = (proyectos || []).find(p => p.id === activeProj); const totalActivas = alertas.filter(a => a.activa !== false).length; // insumoid → nombre del combinado al que ya pertenece (para deshabilitar esas // claves en el árbol de selección — una clave solo puede vivir en un combinado). const yaAsignadosMap = useMemo(() => { const m = {}; combinados.forEach(c => (c.miembros || []).forEach(mi => { m[mi.insumoid] = c.nombre; })); return m; }, [combinados]); function reloadAll() { reloadCombinados(); reloadPpto(); reloadResumen(); } async function eliminarCombinado(id) { try { await apiFetch('presupuesto_combinado.php?id=' + id, 'DELETE'); toast('Presupuesto combinado eliminado — sus claves vuelven a verse individuales'); reloadAll(); } catch (e) { toast.error(e.message); } } async function quitarClaveCombinado(combinadoId, insumoid) { try { await apiFetch('presupuesto_combinado.php?id=' + combinadoId + '&insumoid=' + insumoid, 'DELETE'); toast('Clave quitada del combinado'); reloadAll(); } catch (e) { toast.error(e.message); } } useEffect(() => { if (proyectos && proyectos.length > 0 && !activeProj) { setActiveProj(proyectos[0].id); } }, [proyectos]); // KPIs del proyecto activo — inconsistencia encontrada 2026-09-03: estos // totales solo sumaban claves individuales, sin los Presupuestos Combinados // (v8.0). El Dashboard sí los incluye en "Claves en alerta" (rg-dashboard.jsx, // ProjectKpis); esta misma vista debe contar igual, o un combinado grande en // alerta (ej. "Central de riego 25kk") no se reflejaba aquí arriba. const totalAsignado = presupuestos.reduce((s, p) => s + p.monto, 0) + combinados.reduce((s, c) => s + c.monto, 0); const totalEjercido = presupuestos.reduce((s, p) => s + p.gastado, 0) + combinados.reduce((s, c) => s + c.gastado, 0); const totalDisponible = totalAsignado - totalEjercido; const pctPromedio = totalAsignado > 0 ? (totalEjercido / totalAsignado) * 100 : 0; const enAlerta = presupuestos.filter(p => p.severidad).length + combinados.filter(c => c.severidad).length; function openEdit(pp) { setEditing(pp); setModal(true); } // "Restablecer" — la clave sigue apareciendo si aún tiene OC/tope real; solo // borra % umbral/fechas/descripción/monto local capturados aquí. async function handleReset(id) { try { await apiFetch('presupuestos_proyecto.php?id=' + id, 'DELETE'); reloadPpto(); reloadResumen(); toast('Presupuesto restablecido'); } catch (e) { toast.error(e.message); } } return (
{/* ── KPIs ── */}
Presupuestos
{presupuestos.length}
{proyectoActivo?.nombre || '—'}
Total asignado
${fmtMoney(totalAsignado, { compact: true })}
Total ejercido
${fmtMoney(totalEjercido, { compact: true })}
{pctPromedio.toFixed(1)}% ejercido
Disponible
{totalDisponible < 0 ? '-' : ''}${fmtMoney(Math.abs(totalDisponible), { compact: true })}
{enAlerta > 0 && (
{enAlerta} en alerta
)}
{/* ── Presupuesto adicional: bolsa vs repartido en Contraloría (v6.47) ── */} {activeProj && adicionalData && adicionalData.repartido != null && (adicionalData.total_adicional > 0 || adicionalData.repartido > 0) && (
Presupuesto adicional Bolsa: ${fmtMoney(adicionalData.total_adicional)} Repartido a claves: ${fmtMoney(adicionalData.repartido)} Disponible para repartir:{' '} {adicionalData.disponible_repartir < 0 ? '-' : ''}${fmtMoney(Math.abs(adicionalData.disponible_repartir))}
)} {/* ── Presupuestos combinados (v8.0) ── */} {activeProj && (

Presupuestos combinados

{combinados.length > 0 ? `${combinados.length} combinado${combinados.length !== 1 ? 's' : ''} · varias claves bajo un solo tope` : 'Agrupa varias claves bajo un tope general (ej. "Central de riego")'}
{combinados.length > 0 && (
{combinados.map(c => ( setEditCombinado(c)} onDelete={() => eliminarCombinado(c.id)} onAddInsumos={() => setAddInsumosCombinado(c)} onRemoveInsumo={(insumoid) => quitarClaveCombinado(c.id, insumoid)}/> ))}
)}
)} {/* ── Contenido ── */}
{/* Proyectos */}

Proyectos

{!(proyectos && proyectos.length) && (
Cargando…
)} {(proyectos || []).map(p => { const manualCount = alertas.filter(a => a.proyectoid === p.id && a.activa !== false).length; const pptoCount = (pptoResumen?.[p.id] || pptoResumen?.[String(p.id)])?.en_alerta || 0; const total = manualCount + pptoCount; const tipParts = []; if (manualCount > 0) tipParts.push(`${manualCount} alerta${manualCount !== 1 ? 's' : ''} manual${manualCount !== 1 ? 'es' : ''}`); if (pptoCount > 0) tipParts.push(`${pptoCount} presupuesto${pptoCount !== 1 ? 's' : ''} en alerta`); return ( ); })}
{/* Tarjetas de presupuesto */}

{proyectoActivo && ( )} {proyectoActivo?.nombre || 'Selecciona un proyecto'}

{presupuestos.length} clave{presupuestos.length !== 1 ? 's' : ''} con OC real o tope de Contraloría
{!activeProj && (
Selecciona un proyecto para ver sus presupuestos.
)} {activeProj && presupuestos.length === 0 && (
Sin claves con OC real ni tope de Contraloría para este proyecto todavía. Aparecerán solas en cuanto haya una orden de compra o Contraloría defina un tope.
)} {presupuestos.map(pp => ( openEdit(pp)} onReset={() => handleReset(pp.id)} /> ))}
{proyectoActivo && ( { setModal(false); setEditing(null); }} proyectoid={activeProj} proyecto={proyectoActivo} editing={editing} onSaved={() => { reloadPpto(); reloadResumen(); }} /> )} {proyectoActivo && ( setShowCombinadoForm(false)} proyectoid={activeProj} proyecto={proyectoActivo} yaAsignadosMap={yaAsignadosMap} onSaved={reloadAll} /> )} setEditCombinado(null)} combinado={editCombinado} onSaved={reloadAll} /> {proyectoActivo && ( setAddInsumosCombinado(null)} combinado={addInsumosCombinado} proyecto={proyectoActivo} yaAsignadosMap={yaAsignadosMap} onSaved={reloadAll} /> )}
); } Object.assign(window, { AlertsView });