El Costo acumulado se calcula solo (suma de OC pagadas de las claves del grupo). La Diferencia = Presupuesto revisado − Proyectado final.
);
}
// ── Modal: gestionar claves de insumo de una proyección ───────────────────────
// (v7.0-clave-por-id) `grupo` es el catalogo_grupo del proyecto — se manda a
// insumos.php para que clave/descripción devueltas sean las VIGENTES del grupo
// (ej. sufijo "N" en UNIVERSAL), no las del catálogo base. El enlace real hacia
// proyecciones.php es por insumo_id, la clave es solo lo que se muestra.
function ClavesModal({ open, onClose, proyeccion, grupo, onChanged }) {
const [q, setQ] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [fam, setFam] = useState('');
// Las familias ya NO salen de la constante FAMILIAS de rg-shared.jsx: esa lista
// estaba congelada en 01..07 y Contraloría ya creó la 08 (RECURSOS HUMANOS,
// 2026-07-10) y la 09 (CAJAS CHICAS). Por eso "no se podía elegir la familia 8".
// Ahora se piden al servidor, acotadas al grupo de catálogo del proyecto, con el
// conteo de claves de cada una para que no se pueda elegir una familia que iba a
// responder "0 clave(s) agregada(s)" sin decir por qué.
const [famOpts, setFamOpts] = useState([]);
const dq = useDebounce(q, 300);
const claves = proyeccion?.claves || [];
const yaEnGrupo = new Set(claves.map(c => c.insumo_id));
// Cargar lista del catálogo (buscable). Sin texto = primeros activos.
useEffect(() => {
if (!open) return;
let cancel = false;
setLoading(true);
apiFetch('insumos.php', 'GET', null, { q: dq, activo: '1', limit: 50, grupo: grupo || '' })
.then(d => { if (!cancel) setResults(d?.rows || []); })
.catch(() => { if (!cancel) setResults([]); })
.finally(() => { if (!cancel) setLoading(false); });
return () => { cancel = true; };
}, [open, dq, grupo]);
useEffect(() => { if (open) setQ(''); }, [open, proyeccion?.id]);
// Familias disponibles para este proyecto (matriz del grupo + claves fuera de la
// matriz que ya tienen gasto real aquí). Si la llamada falla, se cae a la
// constante FAMILIAS para no dejar el desplegable vacío.
useEffect(() => {
if (!open || !proyeccion?.proyectoid) return;
let cancel = false;
apiFetch('proyecciones.php', 'POST', { accion: 'familias_grupo', proyectoid: proyeccion.proyectoid })
.then(d => { if (!cancel) setFamOpts(d?.familias || []); })
.catch(() => { if (!cancel) setFamOpts(FAMILIAS.map(f => ({ codigo: f.codigo, nombre: f.nombre, en_matriz: null, con_gasto: null }))); });
return () => { cancel = true; };
}, [open, proyeccion?.proyectoid]);
async function add(insumoId) {
if (!insumoId) return;
setBusy(true);
try {
await apiFetch('proyecciones.php', 'POST', { accion:'add_clave', proyeccion_id: proyeccion.id, insumo_id: insumoId });
await onChanged();
toast.success('Clave agregada');
} catch (e) { toast.error(e.message); }
finally { setBusy(false); }
}
async function remove(insumoId) {
setBusy(true);
try {
await apiFetch('proyecciones.php', 'POST', { accion:'rm_clave', proyeccion_id: proyeccion.id, insumo_id: insumoId });
await onChanged();
toast.success('Clave quitada');
} catch (e) { toast.error(e.message); }
finally { setBusy(false); }
}
async function addFamilia() {
if (!fam) { toast.error('Elige una familia'); return; }
setBusy(true);
try {
const res = await apiFetch('proyecciones.php', 'POST', { accion:'add_familia', proyeccion_id: proyeccion.id, codigofamilia: fam });
await onChanged();
toast.success(`Familia ${fam}: ${res?.added ?? 0} clave(s) agregada(s) (matriz de Contraloría de este grupo más las claves que ya tienen gasto en el proyecto)`);
} catch (e) { toast.error(e.message); }
finally { setBusy(false); }
}
async function removeFamilia() {
if (!fam) { toast.error('Elige una familia'); return; }
const nombreFam = (famOpts.find(f => f.codigo === fam)
|| FAMILIAS.find(f => f.codigo === fam) || {}).nombre || fam;
if (!window.confirm(`¿Quitar TODAS las claves de "${nombreFam}" de esta proyección? No se puede deshacer.`)) return;
setBusy(true);
try {
const res = await apiFetch('proyecciones.php', 'POST', { accion:'rm_familia', proyeccion_id: proyeccion.id, codigofamilia: fam });
await onChanged();
toast.success(`Familia ${fam}: ${res?.removed ?? 0} clave(s) quitada(s)`);
} catch (e) { toast.error(e.message); }
finally { setBusy(false); }
}
if (!proyeccion) return null;
const disponibles = results.filter(r => !yaEnGrupo.has(r.id));
return (
Cerrar}>
{/* Claves ya en el grupo — lista con descripción */}
{(() => {
const f = famOpts.find(x => x.codigo === fam);
if (!f || f.disponibles == null) return null;
const partes = [];
if (f.en_matriz > 0) partes.push(`${f.en_matriz} de la matriz de Contraloría`);
if (f.sin_grupo > 0) partes.push(`${f.sin_grupo} sin grupo asignado todavía`);
if (f.con_gasto > 0) partes.push(`${f.con_gasto} fuera de la matriz pero con gasto en este proyecto`);
return (
{f.disponibles > 0
? <>Se agregarán {f.disponibles} clave{f.disponibles === 1 ? '' : 's'}: {partes.join(' · ')}.>
: <>Esta familia no aporta claves a este proyecto.>}
{f.otros_grupos > 0 && (
<> Quedan fuera {f.otros_grupos} que pertenecen a la matriz de otro grupo
y aquí no tienen gasto; si alguna hace falta, Contraloría debe agregarla a la matriz
de este grupo.>
)}
);
})()}
{/* Buscador del catálogo (clave individual) */}
setQ(e.target.value)}
placeholder="Buscar por clave o descripción… (ej. 02-0202 o "cemento")"/>
{/* Lista seleccionable */}
{loading &&
Cargando…
}
{!loading && disponibles.length === 0 && (
{results.length > 0 ? 'Todas las coincidencias ya están en el grupo.' : 'Sin coincidencias en el catálogo.'}
Total = Proyección de Utilidad + Diferencia de Proyección de Costos + Presupuesto Adicional
{/* ── Proyección de Costos (grupos modulares) ── */}
Proyección de Costos
{proyecciones.length} grupo(s) · costo acumulado = OC pagadas de sus claves
Concepto
Claves
Ppto. revisado
Costo acumulado
Proyectado final
Diferencia
Acciones
{proyecciones.length === 0 && (
Sin proyecciones. Crea una (p.ej. “Material”) y agrégale claves de insumo.
)}
{proyecciones.map(p => (
{p.nombre}
{money(p.presupuesto_revisado)}
{money(p.costo_acumulado)}
{money(p.proyectado_final)}
{money(p.diferencia)}
))}
{proyecciones.length > 0 && (
Subtotal
{money(sumRevisado)}
{money(sumCosto)}
{money(sumFinal)}
{money(sumRevisado - sumFinal)}
{sinProy && sinProy.total > 0 && (
Sin proyección asignada
0 ? ", partidas sin clave de insumo " + money(sinProy.sin_clave) : "") +
(sinProy.importado_fuera > 0 ? ", OCs importadas fuera " + money(sinProy.importado_fuera) : "") +
". No se suma al Subtotal de las proyecciones: es justo lo que les falta. Se corrige asignando esas claves a una " +
"proyección o reclasificándolas en Reestructuración." +
(sinProy.detalle && sinProy.detalle.length
? " Principales: " + sinProy.detalle.slice(0, 4).map(d => d.clave + " " + d.descripcion + " " + money(d.monto)).join(" · ")
: "")
}/>
Total pagado por clave, sin IVA (proyecciones + sin proyección)
{money(sumCosto + sinProy.total)}
)}
{/* Conciliación contra pagos (2026-10-07): lleva el costo sin IVA a
lo pagado con IVA, que es lo que reporta Finanzas o el banco. */}
{conc && (
<>
Total: {money(totalAdic)} · en el Excel solo se refleja el número
{repartoAdic && (
Repartido a claves en Contraloría: {money(repartoAdic.repartido)} · Disponible para repartir:{' '}
{money(repartoAdic.disponible)}
{repartoAdic.disponible < 0 && ' — la bolsa quedó por debajo de lo ya repartido; ajusta el reparto en Contraloría'}
)}
Actividad
Solicitud
Término
Costo
Acciones
{adicionales.length === 0 && (
Sin adicionales registrados.
)}
{adicionales.map(a => (
{a.actividad}
{a.fecha_solicitud || '—'}
{a.fecha_termino || '—'}
{money(a.costo)}
))}
{/* ── Proyección de Utilidad ── */}
Proyección de Utilidad
Columnas Original · Revisado · Pendient/aprox · Proyectado