// ROCA GOLF — v2.0 · Administrador de Proyecto
// Presupuesto total editable + Conciliación de presupuesto + Proyecciones de costo
// (grupos modulares de claves de insumo). Espejo de la hoja "ADMINISTRADOR DE PROYECTOS".
function money(n) { try { return '$' + fmtMoney(Number(n) || 0); } catch { return '$' + (Number(n)||0).toFixed(2); } }
// Conceptos fijos de la Proyección de Utilidad (la fila "Ajustes por ahorro" y
// "Total" se calculan aparte, no se capturan).
const UTIL_CONCEPTS = ['Utilidad Original', 'Utilidad Orden de Cambio', 'Bono de finalización', 'Daños y Perjuicios', 'Ahorros con cliente'];
function buildUtilRows(saved) {
const byName = {};
(Array.isArray(saved) ? saved : []).forEach(r => { if (r && r.concepto) byName[r.concepto] = r; });
return UTIL_CONCEPTS.map(c => {
const r = byName[c] || {};
return { concepto: c, original: r.original || 0, revisado: r.revisado || 0, pendiente: r.pendiente || 0, proyectado: r.proyectado || 0 };
});
}
// ── Modal: crear / editar proyección (grupo modular) ──────────────────────────
function ProyeccionModal({ open, onClose, proyectoid, initial, onSaved }) {
const editing = !!initial;
const [nombre, setNombre] = useState('');
const [revisado, setRevisado] = useState('');
const [final, setFinal] = useState('');
const [desc, setDesc] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) return;
setNombre(initial?.nombre || '');
setRevisado(initial ? String(initial.presupuesto_revisado) : '');
setFinal(initial ? String(initial.proyectado_final) : '');
setDesc(initial?.descripcion || '');
}, [open, initial]);
async function save() {
if (!nombre.trim()) { toast.error('El nombre es requerido'); return; }
setSaving(true);
try {
if (editing) {
await apiFetch('proyecciones.php?id=' + initial.id, 'PUT', {
nombre: nombre.trim(),
presupuesto_revisado: parseFloat(revisado) || 0,
proyectado_final: parseFloat(final) || 0,
descripcion: desc,
});
toast.success('Proyección actualizada');
} else {
await apiFetch('proyecciones.php', 'POST', {
accion: 'crear', proyectoid,
nombre: nombre.trim(),
presupuesto_revisado: parseFloat(revisado) || 0,
proyectado_final: parseFloat(final) || 0,
descripcion: desc,
});
toast.success('Proyección creada');
}
onSaved();
onClose();
} catch (e) { toast.error(e.message); }
finally { setSaving(false); }
}
return (
>}>
setNombre(e.target.value)} placeholder="Material"/>
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 ───────────────────────
function ClavesModal({ open, onClose, proyeccion, onChanged }) {
const [q, setQ] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [fam, setFam] = useState('');
const dq = useDebounce(q, 300);
const claves = proyeccion?.claves || [];
const yaEnGrupo = new Set(claves.map(c => String(c.clave).toUpperCase()));
// 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 })
.then(d => { if (!cancel) setResults(d?.rows || []); })
.catch(() => { if (!cancel) setResults([]); })
.finally(() => { if (!cancel) setLoading(false); });
return () => { cancel = true; };
}, [open, dq]);
useEffect(() => { if (open) setQ(''); }, [open, proyeccion?.id]);
async function add(clave) {
const c = String(clave).trim().toUpperCase();
if (!c) return;
setBusy(true);
try {
await apiFetch('proyecciones.php', 'POST', { accion:'add_clave', proyeccion_id: proyeccion.id, clave: c });
await onChanged();
toast.success(`Clave ${c} agregada`);
} catch (e) { toast.error(e.message); }
finally { setBusy(false); }
}
async function remove(clave) {
setBusy(true);
try {
await apiFetch('proyecciones.php', 'POST', { accion:'rm_clave', proyeccion_id: proyeccion.id, clave });
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)`);
} catch (e) { toast.error(e.message); }
finally { setBusy(false); }
}
if (!proyeccion) return null;
const disponibles = results.filter(r => !yaEnGrupo.has(String(r.clave).toUpperCase()));
return (
Cerrar}>
{/* Claves ya en el grupo — lista con descripción */}
{claves.length === 0 &&
Sin claves. El costo acumulado será $0.
}
{claves.map(c => (
{c.clave}
{c.insumo_descripcion || '—'}
{c.nombrefamilia || ''}
))}
{/* Agregar familia completa */}
{/* 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.'}
)}
{!loading && disponibles.map(r => (
{r.clave}
{r.descripcion}
{r.codigofamilia}
))}
);
}
// ── Modal: presupuesto adicional (conciliación) ───────────────────────────────
function AdicionalModal({ open, onClose, proyectoid, initial, onSaved }) {
const editing = !!initial;
const [actividad, setActividad] = useState('');
const [fSol, setFSol] = useState('');
const [fTer, setFTer] = useState('');
const [costo, setCosto] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) return;
setActividad(initial?.actividad || '');
setFSol(initial?.fecha_solicitud || '');
setFTer(initial?.fecha_termino || '');
setCosto(initial ? String(initial.costo) : '');
}, [open, initial]);
async function save() {
if (!actividad.trim()) { toast.error('La actividad es requerida'); return; }
setSaving(true);
const body = {
actividad: actividad.trim(),
fecha_solicitud: fSol || null,
fecha_termino: fTer || null,
costo: parseFloat(costo) || 0,
};
try {
if (editing) {
await apiFetch('conciliacion.php?id=' + initial.id, 'PUT', body);
toast.success('Adicional actualizado');
} else {
await apiFetch('conciliacion.php', 'POST', { ...body, proyectoid });
toast.success('Adicional agregado');
}
onSaved();
onClose();
} catch (e) { toast.error(e.message); }
finally { setSaving(false); }
}
return (
>}>
setActividad(e.target.value)}/>
setCosto(e.target.value)} placeholder="0.00"/>
);
}
// ── Vista principal ───────────────────────────────────────────────────────────
function AdminProyectoView({ onNav, user, onLogout }) {
const { data: proyectos } = useApiData('proyectos.php');
const [pid, setPid] = useState(null);
const [cfg, setCfg] = useState(null);
const [proyecciones, setProy] = useState([]);
const [ajustes, setAjustes] = useState(0);
const [adicionales, setAdic] = useState([]);
const [totalAdic, setTotalAdic] = useState(0);
const [loading, setLoading] = useState(false);
// Modales
const [proyModal, setProyModal] = useState({ open:false, initial:null });
const [clavesModal, setClavesModal] = useState({ open:false, proy:null });
const [adicModal, setAdicModal] = useState({ open:false, initial:null });
// Edición de configuración (presupuesto total)
const [editCfg, setEditCfg] = useState(false);
const [cfgForm, setCfgForm] = useState({ presupuesto_total:'' });
// Proyección de Utilidad (rejilla editable)
const [util, setUtil] = useState([]);
const [editUtil, setEditUtil] = useState(false);
const importRef = useRef(null); // input de archivo CSV (oculto)
useEffect(() => {
if (!pid && proyectos && proyectos.length) setPid(proyectos[0].id);
}, [proyectos]);
async function loadAll(projId) {
if (!projId) return;
setLoading(true);
try {
const [c, p, a] = await Promise.all([
apiFetch('proyecto_config.php', 'GET', null, { proyectoid: projId }),
apiFetch('proyecciones.php', 'GET', null, { proyectoid: projId }),
apiFetch('conciliacion.php', 'GET', null, { proyectoid: projId }),
]);
setCfg(c);
setCfgForm({ presupuesto_total: String(c.presupuesto_total || 0) });
setUtil(buildUtilRows(c.utilidad));
setEditUtil(false);
setProy(p.proyecciones || []);
setAjustes(p.ajustes_por_ahorro || 0);
setAdic(a.adicionales || []);
setTotalAdic(a.total_adicional || 0);
} catch (e) { toast.error(e.message); }
finally { setLoading(false); }
}
useEffect(() => { if (pid) loadAll(pid); }, [pid]);
const reloadProy = async () => {
const p = await apiFetch('proyecciones.php', 'GET', null, { proyectoid: pid });
setProy(p.proyecciones || []);
setAjustes(p.ajustes_por_ahorro || 0);
// Mantener el modal de claves sincronizado
setClavesModal(cm => cm.open && cm.proy
? { ...cm, proy: (p.proyecciones || []).find(x => x.id === cm.proy.id) || cm.proy }
: cm);
};
const reloadAdic = async () => {
const a = await apiFetch('conciliacion.php', 'GET', null, { proyectoid: pid });
setAdic(a.adicionales || []);
setTotalAdic(a.total_adicional || 0);
};
async function delProyeccion(p) {
if (!window.confirm(`¿Eliminar la proyección "${p.nombre}"? Se quitará del cálculo.`)) return;
try { await apiFetch('proyecciones.php?id=' + p.id, 'DELETE'); toast.success('Proyección eliminada'); reloadProy(); }
catch (e) { toast.error(e.message); }
}
async function delAdicional(a) {
if (!window.confirm(`¿Eliminar el adicional "${a.actividad}"?`)) return;
try { await apiFetch('conciliacion.php?id=' + a.id, 'DELETE'); toast.success('Adicional eliminado'); reloadAdic(); }
catch (e) { toast.error(e.message); }
}
// Guarda presupuesto total. (La rejilla de utilidad se incluye para no perderla.)
async function saveCfg() {
try {
await apiFetch('proyecto_config.php?proyectoid=' + pid, 'PUT', {
presupuesto_total: parseFloat(cfgForm.presupuesto_total) || 0,
utilidad: util.map(r => ({ ...r })),
});
toast.success('Presupuesto guardado');
setEditCfg(false);
loadAll(pid);
} catch (e) { toast.error(e.message); }
}
// Guarda la rejilla de Proyección de Utilidad.
async function saveUtil() {
try {
await apiFetch('proyecto_config.php?proyectoid=' + pid, 'PUT', {
presupuesto_total: parseFloat(cfgForm.presupuesto_total) || 0,
utilidad: util.map(r => ({
concepto: r.concepto,
original: parseFloat(r.original) || 0,
revisado: parseFloat(r.revisado) || 0,
pendiente: parseFloat(r.pendiente) || 0,
proyectado: parseFloat(r.proyectado) || 0,
})),
});
toast.success('Proyección de utilidad guardada');
setEditUtil(false);
loadAll(pid);
} catch (e) { toast.error(e.message); }
}
const setUtilCell = (i, field, val) => setUtil(u => u.map((r, idx) => idx === i ? { ...r, [field]: val } : r));
// ── Fase 2: plantilla CSV + importación de Proyección de Costos ──
function csvCell(v) { return '"' + String(v ?? '').replace(/"/g, '""') + '"'; }
function downloadPlantilla() {
const header = ['nombre', 'presupuesto_revisado', 'proyectado_final', 'descripcion', 'claves'];
const lines = [header.map(csvCell).join(',')];
if (proyecciones.length === 0) {
lines.push(['Material', '0', '0', 'Ejemplo (edita o borra esta fila)', '02-0202;02-0203'].map(csvCell).join(','));
} else {
proyecciones.forEach(p => {
const claves = (p.claves || []).map(c => c.clave).join(';');
lines.push([p.nombre, p.presupuesto_revisado, p.proyectado_final, p.descripcion || '', claves].map(csvCell).join(','));
});
}
const blob = new Blob(['' + lines.join('\r\n')], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `proyecciones_${proyActual ? proyActual.clave : pid}.csv`;
a.click();
URL.revokeObjectURL(url);
}
async function downloadExcel() {
if (!pid) return;
try {
const token = sessionStorage.getItem('rg_token');
const res = await fetch('api/export_proyecto.php?proyectoid=' + pid, {
headers: token ? { 'Authorization': 'Bearer ' + token } : {},
});
if (!res.ok) { toast.error('No se pudo generar el Excel'); return; }
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `Administrador_de_Proyecto_${proyActual ? proyActual.clave : pid}.xlsx`;
a.click();
URL.revokeObjectURL(url);
} catch (err) { toast.error(err.message); }
}
async function handleImportFile(e) {
const file = e.target.files && e.target.files[0];
e.target.value = '';
if (!file) return;
try {
const text = await file.text();
const res = await apiFetch('proyecciones.php', 'POST', { accion: 'import', proyectoid: pid, csv: text });
toast.success(`Importado: ${res.creadas} nueva(s), ${res.actualizadas} actualizada(s), ${res.claves_agregadas} clave(s)`);
if (res.errores && res.errores.length) {
toast.error(`${res.errores.length} aviso(s). Ej: ${res.errores.slice(0, 2).join(' · ')}`);
}
loadAll(pid);
} catch (err) { toast.error(err.message); }
}
const proyActual = proyectos?.find(p => p.id === pid);
const presupuestoTotal = cfg?.presupuesto_total || 0;
const presupuestoActual = presupuestoTotal + totalAdic; // Original + Adicional
const sumRevisado = proyecciones.reduce((s, p) => s + p.presupuesto_revisado, 0);
const sumCosto = proyecciones.reduce((s, p) => s + p.costo_acumulado, 0);
const sumFinal = proyecciones.reduce((s, p) => s + p.proyectado_final, 0);
// ── Totales de la Proyección de Utilidad ──
const utilCols = ['original','revisado','pendiente','proyectado'];
const utilTot = {};
utilCols.forEach(c => utilTot[c] = util.reduce((s, r) => s + (parseFloat(r[c]) || 0), 0));
// La fila "Ajustes por ahorro" (auto) se suma solo en la columna Proyectado.
utilTot.proyectado += ajustes;
// ── Totales importantes (desglose) ──
// TOTAL = Proyección de Utilidad (Σ filas, col. Proyectado) + Diferencia de
// Proyección de Costos (Σ Diferencia = ajustes) + Presupuesto Adicional.
const utilidadProyectada = utilCols.length ? util.reduce((s, r) => s + (parseFloat(r.proyectado) || 0), 0) : 0;
const totalImportante = utilidadProyectada + ajustes + totalAdic;
return (
}/>
{loading &&
Cargando…
}
{!loading && pid && (
<>
{/* ── Conciliación de presupuesto ── */}
Conciliación de Presupuesto
{proyActual ? proyActual.nombre : ''}
{!editCfg
?
:
}
{/* ── Totales importantes (desglose) ── */}
Totales importantes
Total = Proyección de Utilidad + Diferencia de Proyección de Costos + Presupuesto Adicional
{/* ── Proyección de Costos (grupos modulares) ── */}
| 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(sumFinal - sumRevisado)} |
|
)}
{/* ── Presupuesto adicional (detalle) ── */}
Presupuesto Adicional — detalle
Total: {money(totalAdic)} · en el Excel solo se refleja el número
| 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
{!editUtil
?
:
}
>
)}
setProyModal({ open:false, initial:null })} onSaved={reloadProy}/>
setClavesModal({ open:false, proy:null })} onChanged={reloadProy}/>
setAdicModal({ open:false, initial:null })} onSaved={reloadAdic}/>
);
}
// KPI editable / solo-lectura
function KpiBox({ label, value, edit, field, form, setForm, readOnly, strong, hint, signed }) {
const display = edit && form && field !== undefined ? null : value;
return (
{label}
{edit && field !== undefined ? (
setForm(f => ({ ...f, [field]: e.target.value }))} style={{ marginTop:6 }}/>
) : (
{signed && value > 0 ? '+' : ''}{money(value)}
)}
{hint &&
{hint}
}
);
}
Object.assign(window, { AdminProyectoView });