// 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]}
)}
{IcoEdit(13)}
{pp.id != null && (
{confirmDel
? OK?
: Icon.x()}
)}
{/* 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%
{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
{Icon.x()}
{/* Proyecto */}
{proyecto?.nombre}
{/* Insumo */}
Insumo
{editing.insumo_clave}
{editing.insumo_descripcion}
{/* Monto + umbral */}
{/* Fechas */}
{/* Descripción */}
Descripción (opcional)
setDescripcion(e.target.value)}/>
Cancelar
{saving ? 'Guardando…' : 'Guardar'}
);
}
// ── 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]) => (
))}
);
}
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 (
Cancelar
{saving ? 'Guardando…' : 'Crear'} >}>
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 (
Cancelar
{saving ? 'Guardando…' : 'Agregar'} >}>
{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 (
Cancelar
{saving ? 'Guardando…' : 'Guardar'} >}>
);
}
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]} }
{IcoEdit(13)}
{confirmDel ? OK? : Icon.x()}
Ejercido: ${fmtMoney(c.gastado, { compact: true })} / ${fmtMoney(c.monto, { compact: true })}
Disponible: {c.disponible < 0 ? '-' : ''}${fmtMoney(Math.abs(c.disponible), { compact: true })}
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 || '')} }
setExpanded(e => !e)}>{expanded ? 'Ocultar claves' : 'Ver claves'}
{Icon.plus(11)} Agregar claves
{expanded && (
{c.miembros.map(m => (
{m.clave}
{m.descripcion}
onRemoveInsumo(m.insumoid)}>{Icon.x()}
))}
)}
);
}
// ── 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")'}
setShowCombinadoForm(true)}>
{Icon.plus(13)} Presupuesto combinado
{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.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 (
setActiveProj(p.id)}
style={{ width: '100%', justifyContent: 'flex-start' }}>
{p.nombre}
{total > 0 && (
{total}
)}
);
})}
{/* 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 });