// ROCA GOLF — Reporte IA: análisis de presupuesto por proyecto (Claude) // El servidor calcula todos los montos (SQL real); la IA solo narra sobre ellos: // claves de riesgo, oportunidades de eficiencia, una propuesta global de presupuesto // (estimación, no dato oficial) y una calificación de qué tan bien se presupuestó. // Cada clic en "Generar" produce un análisis fresco y lo guarda en el historial. const RIA_SEV_CLASS = { alto: 'over', medio: 'alert', bajo: 'warn' }; const RIA_SEV_LABEL = { alto: 'Riesgo alto', medio: 'Riesgo medio', bajo: 'Riesgo bajo' }; function RiaSevTag({ sev }) { const cls = RIA_SEV_CLASS[sev] || 'alert'; return {RIA_SEV_LABEL[sev] || 'Riesgo'}; } // Lee los colores de severidad de las CSS vars (mismo patrón que rgTheme, que no // expone warn/alert porque esas gráficas no las necesitaban hasta ahora). function riaFlagColors() { const cs = getComputedStyle(document.documentElement); const v = (name, fb) => { const x = (cs.getPropertyValue(name) || '').trim(); return x || fb; }; return { ok: v('--rg-flag-ok', '#2d6a44'), warn: v('--rg-flag-warn', '#d4a017'), alert: v('--rg-flag-alert', '#c8651b'), over: v('--rg-flag-over', '#b22222'), }; } function riaMoney(n) { try { return '$' + fmtMoney(Number(n) || 0); } catch { return '$' + (Number(n) || 0).toFixed(2); } } function RiaKpi({ label, value, valueColor, sub }) { return (
{label}
{value}
{sub &&
{sub}
}
); } function riaScoreColor(score) { return score >= 85 ? 'var(--rg-flag-ok)' : (score >= 60 ? 'var(--rg-flag-warn)' : 'var(--rg-flag-over)'); } // ── Gráficas ───────────────────────────────────────────────────────────────── function RiaGruposChart({ grupos, theme }) { if (!grupos || !grupos.length) { return
Sin Proyección de Costos configurada para este proyecto todavía.
; } return ( g.nombre), datasets: [ { label: 'Presupuesto revisado', data: grupos.map(g => g.presupuesto_revisado), backgroundColor: theme.green, borderRadius: 4, maxBarThickness: 26 }, { label: 'Costo acumulado real', data: grupos.map(g => g.costo_acumulado), backgroundColor: theme.gold, borderRadius: 4, maxBarThickness: 26 }, ], }} options={rgBaseOptions(theme, { money: true, legend: true })}/> ); } function RiaSobreejercicioChart({ rows, theme, flagC }) { if (!rows || !rows.length) { return
Ninguna clave presupuestada excede su presupuesto por ahora.
; } const top = rows.slice(0, 12); const options = { indexAxis: 'y', responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { backgroundColor: theme.ink, titleColor: '#fff', bodyColor: '#fff', borderWidth: 0, padding: 10, cornerRadius: 8, titleFont: { family: theme.font, weight: '600' }, bodyFont: { family: theme.mono }, callbacks: { label: (c) => 'Sobre-ejercicio: $' + (Number(c.parsed.x) || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }, }, }, scales: { x: { grid: { color: theme.line, drawTicks: false }, border: { display: false }, ticks: { color: theme.muted, font: { family: theme.mono, size: 10 }, callback: (v) => rgMoneyShort(v) } }, y: { grid: { display: false }, border: { color: theme.line }, ticks: { color: theme.muted, font: { family: theme.font, size: 10.5 } } }, }, }; return ( c.clave), datasets: [{ data: top.map(c => Math.round((c.gasto - c.presupuestado) * 100) / 100), backgroundColor: top.map(c => { const ratio = c.presupuestado > 0 ? c.gasto / c.presupuestado : 0; return ratio >= 1.5 ? flagC.over : (ratio >= 1.1 ? flagC.alert : flagC.warn); }), borderRadius: 4, maxBarThickness: 18, }], }} options={options}/> ); } // ── Vista principal ─────────────────────────────────────────────────────────── function ReporteIAView({ onNav, user, onLogout }) { const { data: proyectos } = useApiData('proyectos.php'); const [pid, setPid] = useState(null); const [report, setReport] = useState(null); const [generating, setGenerating] = useState(false); const [verId, setVerId] = useState(null); const [debugMode, setDebugMode] = useState(false); const [debug, setDebug] = useState(null); // respuesta de action=debug (paso a paso) // Asignación masiva de presupuesto para "claves con gasto real pero sin presupuesto" const [selSinPresu, setSelSinPresu] = useState({}); // { insumoid: true } const [montoSinPresu, setMontoSinPresu] = useState({}); // { insumoid: "1234.56" } — override editable const [bulkUmbral, setBulkUmbral] = useState(80); const [bulkFi, setBulkFi] = useState(''); const [bulkFf, setBulkFf] = useState(''); const [asignando, setAsignando] = useState(false); // Migración ÚNICA hacia presupuesto_insumo (Contraloría) — v6.0 const [migOpen, setMigOpen] = useState(false); const [migLoading, setMigLoading] = useState(false); const [migItems, setMigItems] = useState(null); // null = aún no cargado const [migIncluir, setMigIncluir] = useState({}); // "proyecto_id:insumo_id" -> bool const [migClave, setMigClave] = useState({}); // "proyecto_id:insumo_id" -> string editable const [migBusy, setMigBusy] = useState(false); const [migResultado, setMigResultado] = useState(null); const { data: historial, loading: histLoading, reload: reloadHist } = useApiData('reporte_ia.php', { action: 'historial', proyectoid: pid }, !pid); useEffect(() => { if (!pid && proyectos && proyectos.length) setPid(proyectos[0].id); }, [proyectos]); useEffect(() => { setReport(null); setSelSinPresu({}); setMontoSinPresu({}); setMigItems(null); setMigIncluir({}); setMigClave({}); setMigResultado(null); }, [pid]); // Al llegar el historial de un proyecto sin reporte mostrado, cargar el más reciente. useEffect(() => { if (!report && historial && historial.length) verReporte(historial[0].id); // eslint-disable-next-line react-hooks/exhaustive-deps }, [historial]); async function verReporte(id) { setVerId(id); try { const d = await apiFetch('reporte_ia.php', 'GET', null, { action: 'ver', id }); setReport(d); } catch (e) { toast.error(e.message); } finally { setVerId(null); } } async function handleGenerar() { if (!pid) return; setGenerating(true); try { if (debugMode) { const d = await apiFetch('reporte_ia.php?action=debug', 'POST', { proyectoid: pid }); setDebug(d); const fail = (d.steps || []).find(s => !s.ok); if (fail) toast.error('Diagnóstico: falló en “' + fail.label + '”'); else toast.success('Diagnóstico completado — el reporte SÍ se guardó (revisa el paso a paso).'); if (d.reporte_id) reloadHist(); return; } setDebug(null); const d = await apiFetch('reporte_ia.php?action=generar', 'POST', { proyectoid: pid }); setReport(d); if (d.insuficiente) toast.info('Este proyecto aún no tiene gasto real registrado para analizar.'); else toast.success('Reporte generado — calificación ' + d.analisis_ia.calificacion_presupuesto.score + '/100'); reloadHist(); } catch (e) { toast.error(e.message); } finally { setGenerating(false); } } function toggleSelSinPresu(insumoid, gasto) { setSelSinPresu(prev => { const next = { ...prev }; if (next[insumoid]) delete next[insumoid]; else next[insumoid] = true; return next; }); setMontoSinPresu(prev => prev[insumoid] != null ? prev : { ...prev, [insumoid]: String(gasto) }); } function toggleSelTodasSinPresu(rows) { const todasSeleccionadas = rows.length > 0 && rows.every(c => selSinPresu[c.insumoid]); if (todasSeleccionadas) { setSelSinPresu({}); return; } const next = {}; const montos = { ...montoSinPresu }; rows.forEach(c => { next[c.insumoid] = true; if (montos[c.insumoid] == null) montos[c.insumoid] = String(c.gasto); }); setSelSinPresu(next); setMontoSinPresu(montos); } async function handleAsignarPresupuestos(rows) { const seleccionadas = rows.filter(c => selSinPresu[c.insumoid]); if (!seleccionadas.length) { toast.error('Selecciona al menos una clave.'); return; } const items = seleccionadas.map(c => ({ insumoid: c.insumoid, monto: c.tiene_limite_contraloria ? c.monto_contraloria : (parseFloat(montoSinPresu[c.insumoid]) || c.gasto), pct_umbral: Number(bulkUmbral) || 80, fecha_inicio: bulkFi || null, fecha_fin: bulkFf || null, descripcion: c.posible_duplicado ? `Detectado por Reporte IA — posible clave equivalente a ${c.posible_duplicado.clave}` : 'Detectado por Reporte IA (gasto real sin presupuesto)', })); setAsignando(true); try { const res = await apiFetch('presupuestos_proyecto.php?action=bulk', 'POST', { proyectoid: pid, items }); toast.success(`Presupuesto asignado: ${res.creadas} nuevas, ${res.reactivadas} actualizadas${res.omitidas ? `, ${res.omitidas} omitidas` : ''}.`); const agregadas = new Set(seleccionadas.map(c => c.insumoid)); setReport(r => r ? { ...r, datos_duros: { ...r.datos_duros, top_sin_presupuesto: r.datos_duros.top_sin_presupuesto.filter(c => !agregadas.has(c.insumoid)) } } : r); setSelSinPresu({}); } catch (e) { toast.error(e.message); } finally { setAsignando(false); } } // ── Migración ÚNICA hacia presupuesto_insumo (Contraloría) — v6.0 ──────────── function migKey(it) { return it.proyecto_id + ':' + it.insumo_id; } async function cargarMigracion() { if (!pid) return; setMigLoading(true); setMigResultado(null); try { const d = await apiFetch('presupuestos_proyecto.php', 'GET', null, { action: 'migrar_preview', proyectoid: pid }); const items = d.items || []; setMigItems(items); const inc = {}; const cla = {}; items.forEach(it => { const k = migKey(it); inc[k] = true; cla[k] = it.clave_grupo; }); setMigIncluir(inc); setMigClave(cla); if (!items.length) toast.info('Sin filas pendientes — todas las alertas activas de este proyecto ya están en Contraloría.'); } catch (e) { toast.error(e.message); } finally { setMigLoading(false); } } function migToggleTodos() { if (!migItems || !migItems.length) return; const todas = migItems.every(it => migIncluir[migKey(it)]); const next = {}; migItems.forEach(it => { next[migKey(it)] = !todas; }); setMigIncluir(next); } function migBuildPayload() { return (migItems || []).filter(it => migIncluir[migKey(it)]).map(it => ({ proyecto_id: it.proyecto_id, insumo_id: it.insumo_id, clave: (migClave[migKey(it)] != null ? migClave[migKey(it)] : it.clave_grupo) || '', grupo: it.grupo, monto: it.monto, moneda: it.moneda || 'MXN', incluir: true, })); } async function migExportarCSV() { const items = migBuildPayload(); if (!items.length) { toast.error('Selecciona al menos una fila.'); return; } setMigBusy(true); try { const d = await apiFetch('presupuestos_proyecto.php?action=migrar_commit', 'POST', { items, modo: 'exportar' }); const blob = new Blob(['' + d.csv], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'migracion_contraloria_presupuesto_insumo.csv'; a.click(); URL.revokeObjectURL(url); toast.success(`CSV generado con ${d.filas} fila(s).`); } catch (e) { toast.error(e.message); } finally { setMigBusy(false); } } async function migAplicarEnContraloria() { const items = migBuildPayload(); if (!items.length) { toast.error('Selecciona al menos una fila.'); return; } setMigBusy(true); try { const d = await apiFetch('presupuestos_proyecto.php?action=migrar_commit', 'POST', { items, modo: 'aplicar' }); setMigResultado(d); if (d.aplicadas > 0) toast.success(`Migradas ${d.aplicadas} fila(s) a Contraloría${d.ya_existian ? ` (${d.ya_existian} ya existían)` : ''}${d.sin_permiso ? ` — ${d.sin_permiso} con error` : ''}.`); else if (d.sin_permiso > 0) toast.error('No se pudo escribir en Contraloría — usa la exportación.'); else toast.info(`Nada nuevo que aplicar (${d.ya_existian} ya existían).`); cargarMigracion(); } catch (e) { toast.error(e.message); } finally { setMigBusy(false); } } const chartTheme = (typeof rgTheme === 'function') ? rgTheme() : {}; const flagC = (typeof window !== 'undefined') ? riaFlagColors() : {}; const dd = report && !report.insuficiente ? report.datos_duros : null; const ia = report && !report.insuficiente ? report.analisis_ia : null; const score = ia ? ia.calificacion_presupuesto.score : null; const proyectoActual = (proyectos || []).find(p => p.id === pid); return (
}/>
{/* Migración ÚNICA hacia presupuesto_insumo (Contraloría) */}
setMigOpen(o => !o)}>

{Icon.catalog(14)} Migración a Contraloría

Lleva las alertas de presupuesto ya capturadas aquí hacia presupuesto_insumo — herramienta de UNA sola vez, no un sincronizado continuo.
{migOpen && (
{migItems && ( {migItems.length} fila(s) pendientes de migrar para {proyectoActual ? proyectoActual.nombre : 'este proyecto'} )}
{migItems && migItems.length === 0 && (
Sin filas pendientes — todas las alertas activas de este proyecto ya están en Contraloría, o no hay alertas activas.
)} {migItems && migItems.length > 0 && ( <>
{migItems.map(it => { const k = migKey(it); return ( ); })}
migIncluir[migKey(it)])} onChange={migToggleTodos}/> Clave canónica Grupo Clave a usar en Contraloría Descripción Monto
setMigIncluir(prev => ({ ...prev, [k]: !prev[k] }))}/> {it.clave_canonica} {it.grupo} setMigClave(prev => ({ ...prev, [k]: e.target.value }))} style={{ width: 110 }}/> {it.sin_match_grupo && (
⚠ {it.clave_sugerida ? `sugerida (${it.match_score}% similar)` : 'sin coincidencia — revisa manualmente'}
)}
{it.descripcion} {riaMoney(it.monto)}
{migResultado && (
Último resultado: {migResultado.aplicadas} aplicadas · {migResultado.ya_existian} ya existían · {migResultado.sin_permiso} con error/sin permiso {migResultado.errores && migResultado.errores.length > 0 && (
Ver detalle ({migResultado.errores.length})
    {migResultado.errores.map((er, i) =>
  • {er}
  • )}
)}
)} )}
)}
{/* Diagnóstico paso a paso */} {debug && (

Diagnóstico paso a paso

A diferencia del importador, este SÍ intenta guardar de verdad — es la única forma de ver el error real si falla el guardado.
{(debug.steps || []).map((s, i) => (
{s.ok ? '✓' : '!'} {i + 1}. {s.label}
{s.detail != null && (
                        {typeof s.detail === 'string' ? s.detail : JSON.stringify(s.detail, null, 2)}
                      
)}
))}
)} {/* Historial */} {pid && (historial && historial.length > 0) && (

Historial de reportes

{proyectoActual ? proyectoActual.nombre : ''} · más reciente primero
{historial.map(h => ( ))}
FechaGenerado porModeloScore
{h.generado_en} {h.generado_por_nombre || '—'} {h.modelo || '—'} {h.score_calificacion != null ? {h.score_calificacion}/100 : '—'}
)} {/* Estado vacío: sin historial ni reporte */} {pid && !histLoading && (!historial || historial.length === 0) && !report && (
Aún no se ha generado ningún Reporte IA para {proyectoActual ? {proyectoActual.nombre} : 'este proyecto'}.
)} {/* Datos insuficientes */} {report && report.insuficiente && (
Este proyecto todavía no tiene gasto real registrado (órdenes de compra pagadas o históricas importadas). En cuanto haya compras registradas, genera el reporte de nuevo para que la IA tenga algo que analizar.
)} {/* Reporte completo */} {dd && ia && ( <>
Generado el {report.generado_en} · modelo {report.modelo} · confianza de la IA en los datos: {ia.nivel_confianza_datos}
{/* Cobertura de datos */}
{Icon.filter(13)} {dd.cobertura.claves_con_presupuesto} de {dd.cobertura.claves_con_compra} claves con compras registradas tienen presupuesto asignado ({dd.cobertura.pct}%) — el resto son datos que aún faltan por completar. El análisis mejora conforme se agreguen.
{/* KPIs */}
{/* Gráficas */}

Presupuesto vs. costo real por grupo

Proyección de Costos

Claves que más sangran el presupuesto

Gasto real − presupuestado, mayor a menor
{/* Claves con gasto pero sin presupuesto */}

Claves con gasto real pero sin presupuesto asignado

Huecos de datos a completar — no es que estén "mal", es que faltan por presupuestar. Selecciona y asígnales presupuesto en masa para que no se queden olvidadas.
{(!dd.top_sin_presupuesto || dd.top_sin_presupuesto.length === 0) ? (
Todas las claves con gasto ya tienen presupuesto asignado.
) : ( <>
setBulkUmbral(e.target.value)} style={{ width: 70 }}/>
setBulkFi(e.target.value)}/>
setBulkFf(e.target.value)}/>
{dd.top_sin_presupuesto.map(c => ( ))}
0 && dd.top_sin_presupuesto.every(c => selSinPresu[c.insumoid])} onChange={() => toggleSelTodasSinPresu(dd.top_sin_presupuesto)}/> ClaveDescripciónFamiliaGasto realMonto a presupuestar (MXN)
toggleSelSinPresu(c.insumoid, c.gasto)}/> {c.clave} {c.descripcion} {c.posible_duplicado && (
⚠ ¿Quisiste decir {c.posible_duplicado.clave} · {c.posible_duplicado.descripcion} ({Math.round(c.posible_duplicado.score * 100)}% similar)?
)}
{c.familia} {riaMoney(c.gasto)} {c.tiene_limite_contraloria ? ( <>
Definido en Contraloría
) : ( setMontoSinPresu(prev => ({ ...prev, [c.insumoid]: e.target.value }))} style={{ width: 130 }}/> )}
)}
{/* Resumen ejecutivo */}

Resumen ejecutivo

{ia.resumen_ejecutivo.split(/\n\s*\n/).map((p, i) => (

{p}

))}
{/* Claves de riesgo */}

Claves de riesgo

Según el análisis de la IA sobre los datos reales
{ia.claves_riesgo.length === 0 ? (
La IA no identificó claves de riesgo con los datos disponibles.
) : (
{ia.claves_riesgo.map((r, i) => ( ))}
ClaveDescripciónSeveridadPresupuestadoGasto realMotivoRecomendación
{r.clave} {r.descripcion} {r.presupuestado != null ? riaMoney(r.presupuestado) : '—'} {riaMoney(r.gasto)} {r.motivo} {r.recomendacion}
)}
{/* Oportunidades de eficiencia */}

Oportunidades de eficiencia

{ia.oportunidades_eficiencia.length === 0 ? (
Sin oportunidades identificadas con los datos disponibles.
) : ia.oportunidades_eficiencia.map((o, i) => (
0 ? '1px solid var(--rg-line)' : 'none' }}>
{o.clave} {o.descripcion}
{o.hallazgo}
→ {o.accion_sugerida}
))}
{/* Propuesta global + Calificación */}
Propuesta global · Estimado por IA — no es un dato oficial
{riaMoney(ia.propuesta_global.monto_sugerido)}

{ia.propuesta_global.justificacion}

{ia.propuesta_global.supuestos && (
Supuestos: {ia.propuesta_global.supuestos}
)}
{score}/100
Calificación del presupuesto original

{ia.calificacion_presupuesto.explicacion}

A favor
    {ia.calificacion_presupuesto.factores_positivos.map((f, i) =>
  • {f}
  • )}
En contra
    {ia.calificacion_presupuesto.factores_negativos.map((f, i) =>
  • {f}
  • )}
)}
); } Object.assign(window, { ReporteIAView });