// ROCA GOLF — v4.0 · Importar Presupuesto por IA (Claude) // Sube el .xlsx del presupuesto de un proyecto; la IA extrae subtotal/total/total+impuesto // y los insumos del catálogo. El usuario revisa, ajusta en masa y aprueba: se llena // ADMINISTRADOR DE PROYECTOS y se crean en masa las alertas (Presupuesto Global por Insumo). function moneyP(n) { try { return '$' + fmtMoney(Number(n) || 0); } catch { return '$' + (Number(n)||0).toFixed(2); } } function ScoreBadgeP({ score }) { if (score === null || score === undefined) return null; const c = score >= 85 ? 'var(--rg-flag-ok)' : (score >= 60 ? 'var(--rg-flag-warn)' : 'var(--rg-flag-over)'); return {score}%; } function ImportPresupuestoView({ onNav, user, onLogout }) { const { data: proyectos } = useApiData('proyectos.php'); const fileRef = useRef(null); const [moneda, setMoneda] = useState('MXN'); const [tc, setTc] = useState(1); const [uploading, setUploading] = useState(false); const [saving, setSaving] = useState(false); const [prev, setPrev] = useState(null); // respuesta de action=preview (editable) const [debugMode, setDebugMode] = useState(false); const [debug, setDebug] = useState(null); // respuesta de action=debug (paso a paso) // Selección de proyecto destino const [projMode, setProjMode] = useState('existing'); // 'existing' | 'nuevo' const [selProjId, setSelProjId] = useState(''); const [nuevoNombre, setNuevoNombre] = useState(''); const [seedProy, setSeedProy] = useState(false); const [reemplazarAlertas, setReemplazarAlertas] = useState(false); // borrar alertas previas del proyecto // Controles masivos const [bulkFi, setBulkFi] = useState(''); const [bulkFf, setBulkFf] = useState(''); const [bulkUmb, setBulkUmb] = useState(80); async function handleFile(e) { const file = e.target.files && e.target.files[0]; e.target.value = ''; if (!file) return; setUploading(true); try { const fd = new FormData(); fd.append('file', file); fd.append('moneda', moneda); fd.append('tc', moneda === 'MXN' ? 1 : (Number(tc) || 1)); const act = debugMode ? 'debug' : 'preview'; const res = await fetch('api/import_presupuesto.php?action=' + act, { method: 'POST', credentials: 'same-origin', headers: getCsrfToken() ? { 'X-CSRF-Token': getCsrfToken() } : {}, body: fd, }); const json = await res.json(); if (!json.ok) throw new Error(json.error || 'Error al analizar el archivo'); if (debugMode) { setDebug(json.data); setPrev(null); const fail = (json.data.steps || []).find(s => !s.ok); if (fail) toast.error('Diagnóstico: falló en “' + fail.label + '”'); else toast.success('Diagnóstico completado sin errores'); return; } setDebug(null); const d = json.data; setPrev(d); // Inicializar selección de proyecto if (d.proyecto && d.proyecto.id) { setProjMode('existing'); setSelProjId(String(d.proyecto.id)); } else { setProjMode('nuevo'); setSelProjId(''); } setNuevoNombre((d.proyecto && d.proyecto.nombre_detectado) || ''); toast.success(`IA: ${d.resumen_ia.insumos_detectados} insumos (${d.resumen_ia.con_match} con match). Revisa y aprueba.`); } catch (err) { toast.error(err.message); } finally { setUploading(false); } } const setAdmin = (k, v) => setPrev(p => ({ ...p, admin: { ...p.admin, [k]: v } })); const setUtil = (k, v) => setPrev(p => ({ ...p, utilidad: { ...p.utilidad, [k]: v } })); const setRow = (i, k, v) => setPrev(p => ({ ...p, alertas: p.alertas.map((r, idx) => idx === i ? { ...r, [k]: v } : r) })); const setGrupo = (i, k, v) => setPrev(p => ({ ...p, grupos_costos: (p.grupos_costos || []).map((g, idx) => idx === i ? { ...g, [k]: v } : g) })); const addGrupo = () => setPrev(p => ({ ...p, grupos_costos: [...(p.grupos_costos || []), { nombre: '', total_mxn: 0, total_orig: 0 }] })); const delGrupo = (i) => setPrev(p => ({ ...p, grupos_costos: (p.grupos_costos || []).filter((_, idx) => idx !== i) })); function applyBulk() { setPrev(p => ({ ...p, alertas: p.alertas.map(r => (r.incluir && !r.sin_match) ? { ...r, fecha_inicio: bulkFi || r.fecha_inicio, fecha_fin: bulkFf || r.fecha_fin, pct_umbral: Number(bulkUmb) || r.pct_umbral, } : r) })); toast.success('Aplicado a las filas incluidas'); } async function commit() { if (!prev) return; if (projMode === 'nuevo' && !nuevoNombre.trim()) { toast.error('Escribe el nombre del proyecto nuevo'); return; } const incluidas = prev.alertas.filter(r => r.incluir && r.insumoid); if (!incluidas.length && projMode === 'existing') { if (!window.confirm('No hay alertas a crear. ¿Guardar solo el presupuesto/utilidad del proyecto?')) return; } // Autorización para el reemplazo total de alertas (solo si hay algo que poner). if (reemplazarAlertas && incluidas.length > 0) { const nom = projMode === 'existing' ? ((proyectos || []).find(p => p.id === Number(selProjId))?.nombre || 'el proyecto seleccionado') : nuevoNombre.trim(); if (!window.confirm(`Se ELIMINARÁN todas las alertas actuales de "${nom}" y se reemplazarán por las ${incluidas.length} nuevas. Esta acción no se puede deshacer. ¿Continuar?`)) return; } setSaving(true); try { const body = { proyecto: projMode === 'existing' ? { id: Number(selProjId) || 0, nombre_detectado: prev.proyecto?.nombre_detectado || '' } : { id: 0, nombre_nuevo: nuevoNombre.trim(), nombre_detectado: prev.proyecto?.nombre_detectado || '' }, moneda: prev.moneda, tc: prev.tc, presupuesto_total: Math.round((Number(prev.admin.total)||0) * (1 + (Number(prev.admin.impuesto_pct)||0)/100) * 100) / 100, seed_proyeccion: seedProy, reemplazar_alertas: reemplazarAlertas, utilidad: { original: Number(prev.utilidad.original) || 0, revisado: Number(prev.utilidad.revisado) || 0, proyectado: Number(prev.utilidad.proyectado) || 0, }, alertas: prev.alertas.map(r => ({ insumoid: r.insumoid, clave: r.clave, codigofamilia: r.codigofamilia, descripcion: r.descripcion, monto_mxn: r.monto_mxn, pct_umbral: r.pct_umbral, fecha_inicio: r.fecha_inicio, fecha_fin: r.fecha_fin, incluir: r.incluir, reemplazar: r.reemplazar, })), grupos_costos: (prev.grupos_costos || []).map(g => ({ nombre: g.nombre, total_mxn: g.total_mxn })), }; const res = await apiFetch('import_presupuesto.php?action=commit', 'POST', body); toast.success(`Listo: ${res.creadas} creadas, ${res.actualizadas} actualizadas, ${res.omitidas} omitidas${res.alertas_borradas ? `, ${res.alertas_borradas} anteriores eliminadas` : ''}.`); setPrev(null); onNav && onNav('admin_proy'); } catch (err) { toast.error(err.message); } finally { setSaving(false); } } const a = prev?.admin || {}; const admTotal = Number(a.total) || 0; // Total sin IVA (MXN) const admIva = Number(a.impuesto_pct) || 0; // % de IVA indicado const admPresupuesto = Math.round(admTotal * (1 + admIva/100) * 100) / 100; // Presupuesto = Total × (1+IVA%) const incluidasCount = prev ? prev.alertas.filter(r => r.incluir && r.insumoid).length : 0; const sinMatchCount = prev ? prev.alertas.filter(r => r.sin_match).length : 0; const yaExisteCount = prev ? prev.alertas.filter(r => r.ya_existe).length : 0; return (
{typeof s.detail === 'string' ? s.detail : JSON.stringify(s.detail, null, 2)}
)}
Sube el Excel del presupuesto de un proyecto. La IA (Claude) lee la primera hoja para obtener subtotal, total y total + impuesto, y la hoja CATALOGO DE INSUMOS para los importes por clave. Luego revisas y ajustas en masa (fechas, umbral) y al aprobar se llenan ADMINISTRADOR DE PROYECTOS y las alertas de Presupuesto Global por Insumo. Selecciona la moneda y el tipo de cambio antes de subir.
| Grupo | Proyectado Final (MXN) | |
|---|---|---|
| setGrupo(i,'nombre', e.target.value)} style={{ height:28 }}/> |
setGrupo(i,'total_mxn', e.target.value)} style={{ width:170, textAlign:'right', height:28 }}/>
{prev.moneda!=='MXN' && Number(g.total_orig)>0 && {prev.moneda} {fmtMoney(g.total_orig)} }
|
|
| Σ Proyectado Final | {moneyP((prev.grupos_costos||[]).reduce((s,g)=>s+(Number(g.total_mxn)||0),0))} |
| Incl. | Clave | Descripción | Monto (MXN) | Umbral% | Inicio | Fin | Estado |
|---|---|---|---|---|---|---|---|
| setRow(i,'incluir', e.target.checked)}/> |
{r.clave}
{r.match_por === 'descripcion' && =75 ? 'var(--rg-flag-ok)' : 'var(--rg-flag-warn)' }} title="Clave asignada por coincidencia de descripción">≈ desc {r.match_score}% }
{r.match_por === 'clave' && por clave }
{r.clave_excel && r.clave_excel !== r.clave && Excel: {r.clave_excel} }
|
{r.descripcion}
{r.match_por === 'descripcion' && r.descripcion_excel && (r.descripcion_excel||'').toUpperCase() !== (r.descripcion||'').toUpperCase() &&
Excel: {r.descripcion_excel} }
|
setRow(i,'monto_mxn', e.target.value)} style={{ width:120, textAlign:'right', height:28 }}/>
{prev.moneda!=='MXN' && {prev.moneda} {fmtMoney(r.total_orig)} }
|
setRow(i,'pct_umbral', e.target.value)} style={{ width:60, textAlign:'center', height:28 }}/> | setRow(i,'fecha_inicio', e.target.value)} style={{ height:28 }}/> | setRow(i,'fecha_fin', e.target.value)} style={{ height:28 }}/> | {r.sin_match ? sin match : r.ya_existe ? : nueva} |