// ─── Reestructuración (2026-09-08) ─────────────────────────────────────────────
// Corrige errores de captura de Compras: reasigna a qué proyecto pertenece una
// OC, o a qué clave de insumo pertenece una de sus partidas, dejando un motivo
// obligatorio con historial. Acceso restringido por defecto a Director de
// Proyectos + Administrador (ver api/config.php ROLE_ACCESS / api/permisos.php).
// No crea OCs ni toca montos/proveedor/fecha/pagos — solo estas 2 reasignaciones.
// ── Sub-modal: cambiar el proyecto de la OC completa ──────────────────────────
function CambiarProyectoModal({ open, onClose, oc, proyectos, onSaved }) {
const [proyectoidNuevo, setProyectoidNuevo] = useState('');
const [motivo, setMotivo] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (open) { setProyectoidNuevo(''); setMotivo(''); }
}, [open, oc]);
async function guardar() {
if (!proyectoidNuevo) { toast.error('Selecciona el proyecto nuevo'); return; }
if (motivo.trim().length < 10) { toast.error('Escribe un motivo de al menos 10 caracteres'); return; }
const nombreNuevo = (proyectos || []).find(p => p.id === Number(proyectoidNuevo))?.nombre || '';
if (!window.confirm(`¿Mover la OC #${fmtFolio(oc.folio)} de "${oc.proyecto_nombre}" a "${nombreNuevo}"?\n\nEsto cambia a qué presupuesto se le suma este gasto.`)) return;
setSaving(true);
try {
await apiFetch(`reclasificacion.php?action=proyecto&ocid=${oc.id}`, 'PUT', {
proyectoid_nuevo: Number(proyectoidNuevo), motivo: motivo.trim(),
});
toast.success('Proyecto reasignado');
onSaved();
} catch (e) { toast.error(e.message); }
finally { setSaving(false); }
}
if (!oc) return null;
return (
>}>
);
}
// ── Sub-modal: cambiar la clave de UNA partida (acotado a la matriz vigente) ──
function CambiarClaveModal({ open, onClose, partida, grupo, onSaved, preseleccion, motivoInicial }) {
const [q, setQ] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const [seleccion, setSeleccion] = useState(null);
const [motivo, setMotivo] = useState('');
const [saving, setSaving] = useState(false);
const dq = useDebounce(q, 300);
// preseleccion/motivoInicial: los usa el detector de inconsistencias para abrir
// el modal con la clave sugerida ya elegida y el motivo redactado (editables).
useEffect(() => {
if (open) { setQ(''); setSeleccion(preseleccion || null); setMotivo(motivoInicial || ''); }
}, [open, partida]);
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]);
async function guardar() {
if (!seleccion) { toast.error('Selecciona la clave nueva'); return; }
if (motivo.trim().length < 10) { toast.error('Escribe un motivo de al menos 10 caracteres'); return; }
if (!window.confirm(`¿Cambiar la clave de esta partida de "${partida.clave_vigente || partida.claveinsumo}" a "${seleccion.clave}"?\n\nEsto mueve el gasto de esta partida a otro presupuesto.`)) return;
setSaving(true);
try {
await apiFetch(`reclasificacion.php?action=clave&partidaid=${partida.id}`, 'PUT', {
insumoid_nuevo: seleccion.id, motivo: motivo.trim(),
});
toast.success('Clave reasignada');
onSaved();
} catch (e) { toast.error(e.message); }
finally { setSaving(false); }
}
if (!partida) return null;
return (
>}>
Cada partida se valida por separado contra la matriz del proyecto y guarda su propio registro en el historial.
);
}
// ── Revisión de claves: detecta partidas mal clasificadas y propone la correcta ─
// Dos casos, los mismos que aparecieron al conciliar Torreón y Rancho El Paraguas:
// partidas SIN clave de insumo (la clave quedó solo como texto y no cuenta en
// ningún reporte) y partidas con una clave que NO está en la matriz del proyecto.
function InconsistenciasPanel({ proyectos }) {
const [proyectoid, setProyectoid] = useState('');
const [soloPagadas, setSoloPagadas] = useState(false);
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [sel, setSel] = useState({});
const [ia, setIa] = useState({});
const [iaLoading, setIaLoading] = useState(false);
const [lote, setLote] = useState(null);
const [claveModal, setClaveModal] = useState(null);
const rows = data?.rows || [];
const resumen = data?.resumen || null;
const proyecto = (proyectos || []).find(p => String(p.id) === String(proyectoid));
const sugDe = r => ia[r.partidaid] || r.sugerencia || null;
function analizar(pid) {
const id = pid || proyectoid;
if (!id) { toast.error('Selecciona un proyecto'); return; }
setLoading(true); setError(''); setSel({}); setIa({});
apiFetch('reclasificacion.php', 'GET', null, { action: 'inconsistencias', proyectoid: id, solo_pagadas: soloPagadas ? 1 : 0 })
.then(d => setData(d))
.catch(e => { setError(e.message || 'No se pudo analizar el proyecto.'); setData(null); })
.finally(() => setLoading(false));
}
const seleccionadas = rows.filter(r => sel[r.partidaid]);
const aplicables = seleccionadas.filter(r => sugDe(r)).map(r => ({
partidaid: r.partidaid, folio: r.folio, descripcion: r.descripcion,
clave_actual: r.clave_actual, clave: sugDe(r).clave, insumoid: sugDe(r).insumoid,
}));
async function sugerirIA() {
const ids = seleccionadas.map(r => r.partidaid).slice(0, 60);
if (!ids.length) { toast.error('Selecciona al menos una partida'); return; }
setIaLoading(true);
try {
const r = await apiFetch('reclasificacion.php?action=sugerir_ia', 'POST', { proyectoid: Number(proyectoid), partidaids: ids });
const map = {};
(r.sugerencias || []).forEach(s => { map[s.partidaid] = s; });
setIa(prev => ({ ...prev, ...map }));
toast.success(`La IA revisó ${r.analizadas} partida(s) y propuso ${(r.sugerencias || []).length}`
+ (r.descartadas ? ` (${r.descartadas} descartada(s) por no existir en la matriz)` : ''));
} catch (e) { toast.error(e.message); }
finally { setIaLoading(false); }
}
const tipoBadge = t => t === 'sin_clave'
? Sin clave
: Fuera de matriz;
return (
<>
Busca las partidas mal clasificadas de un proyecto: las que quedaron sin clave de insumo (no cuentan
en ningún reporte) y las que usan una clave fuera de la matriz de Contraloría de ese proyecto.
Propone la clave correcta —con el análisis de IA o por parecido de descripción— y la aplica con un motivo,
igual que cualquier otro cambio del módulo. La IA solo sugiere: toda clave se valida contra la matriz y el cambio lo confirmas tú.
{error && (
{error}
)}
{resumen && (
<>
Hallazgos
{resumen.total}
matriz {data.grupo} · {data.matriz_claves} claves
Fuera de la matriz
{resumen.fuera_matriz}
${fmtMoney(resumen.importe_fuera_matriz)}
Sin clave de insumo
{resumen.sin_clave}
${fmtMoney(resumen.importe_sin_clave)}
Seleccionadas
{seleccionadas.length}
{aplicables.length} con clave propuesta
Partidas a revisar
{resumen.anticipos_caja_omitidos > 0
? `${resumen.anticipos_caja_omitidos} anticipo(s) de caja chica omitidos: por diseño no llevan clave`
: 'Marca las que quieras corregir'}
>
)}
setLote(null)} items={lote || []}
onDone={() => { setLote(null); analizar(); }}/>
setClaveModal(null)}
partida={claveModal ? { id: claveModal.partidaid, claveinsumo: claveModal.clave_actual, clave_vigente: claveModal.clave_actual, descripcion: claveModal.descripcion, insumo_descripcion_vigente: claveModal.insumo_actual } : null}
grupo={data?.grupo}
preseleccion={claveModal && sugDe(claveModal) ? { id: sugDe(claveModal).insumoid, clave: sugDe(claveModal).clave, descripcion: sugDe(claveModal).descripcion } : null}
motivoInicial={claveModal ? `Revisión de claves de ${proyecto?.nombre || 'el proyecto'}: la partida ${claveModal.tipo === 'sin_clave' ? 'no tenía clave de insumo' : 'usaba una clave fuera de la matriz'}; corresponde a ` : ''}
onSaved={() => { setClaveModal(null); analizar(); }}/>
>
);
}
// ── Modal: elegir la clave general de caja chica del grupo ───────────────────
function ClaveGeneralModal({ open, onClose, grupo, actual, onSaved }) {
const [q, setQ] = useState('');
const [results, setResults] = useState([]);
const [sel, setSel] = useState(null);
const [saving, setSaving] = useState(false);
const dq = useDebounce(q, 300);
useEffect(() => {
if (open) { setQ(''); setSel(actual ? { id: actual.insumoid, clave: actual.clave, descripcion: actual.descripcion } : null); }
}, [open, actual]);
useEffect(() => {
if (!open) return;
let cancel = false;
apiFetch('insumos.php', 'GET', null, { q: dq, activo: '1', limit: 50, grupo: grupo || '' })
.then(d => { if (!cancel) setResults(d?.rows || []); })
.catch(() => { if (!cancel) setResults([]); });
return () => { cancel = true; };
}, [open, dq, grupo]);
async function guardar() {
if (!sel) { toast.error('Selecciona una clave'); return; }
setSaving(true);
try {
await apiFetch('reclasificacion.php?action=caja_chica_clave', 'PUT', { grupo, insumoid: sel.id });
toast.success(`Clave general del grupo ${grupo}: ${sel.clave}`);
onSaved();
} catch (e) { toast.error(e.message); }
finally { setSaving(false); }
}
return (
>}>
Es la clave a la que se contabilizan los anticipos de caja chica cuando su comprobación no permite
deducir una. Se guarda para todo el grupo {grupo}, así que aplica a todos sus proyectos.
);
}
// ── Cajas chicas: asignarles clave para que su gasto se contabilice ──────────
// Un anticipo de caja chica se captura sin clave de insumo, así que hoy no entra
// a ningún reporte por clave ni al costo acumulado. La clasificación real está en
// su comprobación (que suele repartirse en varias claves); como una partida solo
// admite una, se propone la clave DOMINANTE de esa comprobación y, cuando no hay,
// la clave general del grupo.
function CajasChicasPanel({ proyectos }) {
const [proyectoid, setProyectoid] = useState('');
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [sel, setSel] = useState({});
const [lote, setLote] = useState(null);
const [claveGen, setClaveGen] = useState(false);
const [claveModal, setClaveModal] = useState(null);
const rows = data?.rows || [];
const resumen = data?.resumen || null;
const general = data?.clave_general || null;
function cargar(pid) {
const id = pid || proyectoid;
if (!id) { toast.error('Selecciona un proyecto'); return; }
setLoading(true); setError(''); setSel({});
apiFetch('reclasificacion.php', 'GET', null, { action: 'cajas_chicas', proyectoid: id })
.then(d => setData(d))
.catch(e => { setError(e.message || 'No se pudieron cargar las cajas chicas.'); setData(null); })
.finally(() => setLoading(false));
}
// Un anticipo puede tener más de una partida sin clave: todas van a la misma clave.
const seleccionadas = rows.filter(r => sel[r.ocid] && r.sugerencia && r.partidas.length);
const items = seleccionadas.flatMap(r => r.partidas.map(p => ({
partidaid: p.partidaid, folio: r.folio, descripcion: p.descripcion,
clave_actual: '(anticipo sin clave)', clave: r.sugerencia.clave, insumoid: r.sugerencia.insumoid,
})));
return (
<>
Los anticipos de caja chica se capturan sin clave de insumo, así que su gasto no entra a ningún
reporte por clave ni al costo acumulado de Admin. de Proyecto. Aquí se les asigna una: la
clave dominante de su propia comprobación cuando se puede deducir, o la clave general del grupo.
Es una aproximación consciente: una comprobación suele repartirse en varias claves y una partida solo admite una.
{error && (
{error}
)}
{resumen && (
<>
Clave general del grupo {data.grupo}:{' '}
{general
? <>{general.clave}{general.descripcion}>
: sin configurar — elígela para poder asignar los anticipos sin comprobación}
Anticipos
{resumen.ocs}
${fmtMoney(resumen.importe)}
Partidas sin clave
{resumen.pendientes}
por asignar
Con comprobación
{resumen.con_comprobacion}
de {resumen.ocs}
Seleccionados
{seleccionadas.length}
{items.length} partida(s)
Anticipos de caja chica
Cada uno se contabilizará en la clave propuesta; puedes cambiarla antes de aplicar
>
)}
setClaveGen(false)} grupo={data?.grupo} actual={general}
onSaved={() => { setClaveGen(false); cargar(); }}/>
setLote(null)} items={lote || []}
onDone={() => { setLote(null); cargar(); }}/>
setClaveModal(null)}
partida={claveModal ? { id: claveModal.partidas[0]?.partidaid, claveinsumo: '(sin clave)', clave_vigente: '(sin clave)', descripcion: claveModal.partidas[0]?.descripcion } : null}
grupo={data?.grupo}
preseleccion={claveModal?.sugerencia ? { id: claveModal.sugerencia.insumoid, clave: claveModal.sugerencia.clave, descripcion: claveModal.sugerencia.descripcion } : null}
motivoInicial={claveModal ? `Caja chica ${claveModal.caja_folio ? 'CAJA-' + String(claveModal.caja_folio).padStart(4, '0') : fmtFolio(claveModal.folio)}: el anticipo no tenía clave de insumo; se contabiliza en ` : ''}
onSaved={() => { setClaveModal(null); cargar(); }}/>
>
);
}
// ── Vista principal ────────────────────────────────────────────────────────────
function moneyR(n) { try { return '$' + fmtMoney(Number(n) || 0); } catch { return '$' + (Number(n)||0).toFixed(2); } }
// ── Histórico importado con clave fuera de la matriz ────────────────────────
// Mismo problema que InconsistenciasPanel resuelve en las partidas vivas, pero
// sobre los folios de rg_oc_importadas: mientras su clave no sea de la matriz del
// grupo, ese gasto no entra a ninguna Proyección de Costos.
function ImportadasPanel({ proyectos }) {
const [proyectoid, setProyectoid] = useState('');
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [sel, setSel] = useState({}); // id -> true
const [elegido, setElegido] = useState({}); // id -> insumoid elegido
const [ia, setIa] = useState({}); // id -> sugerencia de la IA
const [iaLoading, setIaLoading] = useState(false);
const [motivo, setMotivo] = useState('');
const [guardando, setGuardando] = useState(false);
const filas = data?.filas || [];
const matriz = data?.matriz || [];
const porInsumo = useMemo(() => {
const m = {}; matriz.forEach(x => { m[x.id] = x; }); return m;
}, [matriz]);
const seleccionados = filas.filter(f => sel[f.id]);
const listos = seleccionados.filter(f => elegido[f.id]);
function buscar(pid) {
const id = pid || proyectoid;
if (!id) { toast.error('Selecciona un proyecto'); return; }
setLoading(true); setError(''); setSel({}); setIa({}); setElegido({});
apiFetch('reclasificacion.php', 'GET', null, { action: 'importadas_fuera', proyectoid: id })
.then(d => setData(d))
.catch(e => { setError(e.message); setData(null); })
.finally(() => setLoading(false));
}
async function analizarIA() {
const ids = seleccionados.map(f => f.id);
if (!ids.length) { toast.error('Selecciona al menos un folio'); return; }
if (ids.length > 60 && !window.confirm(
`La IA analiza máximo 60 folios por vez y tienes ${ids.length} seleccionados.\n\n` +
`Se analizarán los primeros 60; los otros ${ids.length - 60} quedarán sin propuesta. ¿Continuar?`)) return;
setIaLoading(true);
try {
const r = await apiFetch('reclasificacion.php', 'POST',
{ proyectoid, ids: ids.slice(0, 60) }, { action: 'sugerir_ia_importadas' });
const mapa = {}; (r.sugerencias || []).forEach(x => { mapa[x.id] = x; });
setIa(mapa);
// La sugerencia se precarga como clave elegida, pero NO se aplica sola.
setElegido(prev => {
const n = { ...prev };
(r.sugerencias || []).forEach(x => { n[x.id] = x.insumoid; });
return n;
});
toast.success(`La IA revisó ${r.analizadas} de ${ids.length} y propuso ${(r.sugerencias || []).length}`
+ (ids.length > 60 ? ` — faltan ${ids.length - 60}` : '')
+ (r.descartadas ? ` (${r.descartadas} descartada(s) por no estar en la matriz)` : ''));
} catch (e) { toast.error(e.message); }
finally { setIaLoading(false); }
}
async function aplicar() {
if (!listos.length) { toast.error('Ninguno de los seleccionados tiene clave elegida'); return; }
if (motivo.trim().length < 10) { toast.error('Escribe un motivo de al menos 10 caracteres'); return; }
if (!window.confirm(
`¿Reclasificar ${listos.length} folio(s) del histórico importado?\n\n` +
`Su gasto pasará a contar en la Proyección de Costos de la clave elegida.`)) return;
setGuardando(true);
try {
const r = await apiFetch('reclasificacion.php', 'PUT', {
proyectoid, motivo: motivo.trim(),
items: listos.map(f => ({ id: f.id, insumoid: elegido[f.id] })),
}, { action: 'importada_clave_lote' });
const malas = (r.resultados || []).filter(x => !x.ok);
if (malas.length) {
toast.error(`${r.aplicadas} aplicada(s), ${malas.length} con problema: ${malas[0].error}`);
} else {
toast.success(r.aplicadas === 1 ? 'Folio reclasificado' : `${r.aplicadas} folios reclasificados`);
}
setMotivo('');
buscar(proyectoid);
} catch (e) { toast.error(e.message); }
finally { setGuardando(false); }
}
const motivoFalla = m => m === 'sin_clave' ? 'sin clave'
: m === 'no_existe' ? 'la clave no existe en el catálogo'
: m === 'clave_ambigua' ? 'en este grupo esa clave es de OTRO insumo'
: 'la clave existe, pero su insumo no está en la matriz de este grupo';
return (
<>
Las OCs históricas se importaron con la clave que traía el Excel. Si esa clave apunta a un insumo
que no está en la matriz de Contraloría del grupo del proyecto, su gasto no entra
a ninguna Proyección de Costos: aparece solo como «Pagado que no cae en ninguna proyección».
Aquí la IA propone la clave más cercana dentro de la matriz; tú la revisas y la aplicas.
Histórico importado fuera de la matriz
{data &&
Grupo {data.grupo} · {data.total} folio(s) · {moneyR(data.pagado_mxn)} pagados
{data.ya_cuentan > 0 && <> · {data.ya_cuentan} de ellos ya cuentan hoy porque su clave está en una proyección, aunque no esté en la matriz>}
}
{filas.length > 0 && (
<>
>
)}
{error && (
No se pudo cargar: {error}
)}
{data && !filas.length && !loading && (
Todas las OCs históricas de este proyecto tienen una clave de la matriz. No hay nada que reclasificar.
Esa clave no está en ninguna proyección: {f.cuenta_hoy
? 'este gasto DEJARÍA de contar. Agrégala antes en Admin. de Proyecto.'
: 'el gasto seguiría sin contar hasta que la agregues en Admin. de Proyecto.'}
Reasigna el proyecto o la clave de insumo de una OC cuando Compras la capturó mal en Contraloría.
No crea ni elimina nada — solo mueve a qué presupuesto se contabiliza el gasto. Cada cambio pide un
motivo obligatorio y queda en el historial de la OC.