// ROCA GOLF — Reportes con datos reales de la API const MES_LABELS = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic']; const MESES_FULL = ['Enero','Febrero','Marzo','Abril','Mayo','Junio','Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre']; const REPORT_PALETTE = ["#1a4d2e","#c9a961","#2d6a44","#a88838","#4a8a63","#0d2818","#7c9885","#b22222"]; // Colores de desempeño (facturado vs objetivo) — como el dashboard de referencia const PERF_GREEN = "#22a34a"; const PERF_ORANGE = "#F47920"; function fmtM(n) { n = Number(n) || 0; return "$" + (n / 1e6).toFixed(1) + "M"; } // KPI con badge de tendencia (▲/▼ %) para "Indicadores clave" function PerfKpi({ label, value, valueColor, sub, pill }) { return (
{label}
{value}
{sub &&
{sub}
} {pill && (
{pill.good ? "▲" : "▼"} {pill.text}
)}
); } function ReportsKpiCard({ label, value, sub }) { return (
{label}
{typeof value === "string" && value.startsWith("$") ? $ : null} {typeof value === "string" && value.startsWith("$") ? value.slice(1) : value}
{sub &&
{sub}
}
); } function MonthlyChart({ data }) { if (!data || data.length === 0) { return
Sin datos de gasto mensual
; } const W = 720, H = 220, pad = { l: 52, r: 12, t: 16, b: 28 }; const vals = data.map(d => d.total); const max = Math.max(...vals, 1); const min = 0; const span = max - min || 1; const stepX = (W - pad.l - pad.r) / Math.max(data.length - 1, 1); const yFor = v => pad.t + (1 - (v - min) / span) * (H - pad.t - pad.b); const xFor = i => pad.l + i * stepX; const points = data.map((d, i) => `${xFor(i)},${yFor(d.total)}`).join(" "); const areaPts = `${xFor(0)},${H - pad.b} ${points} ${xFor(data.length-1)},${H - pad.b}`; const ticks = [0, max * 0.5, max].map(v => Math.round(v)); return ( {ticks.map((t, i) => ( ${t >= 1000 ? (t/1000).toFixed(0)+'k' : t} ))} {data.map((d, i) => ( {MES_LABELS[(d.mes || i+1) - 1] || d.mes} ))} {data.length > 0 && ( )} ); } function StatusDonut({ statusDist }) { const dist = statusDist || []; const mapColor = { PAGADO: "var(--rg-flag-ok)", PAGO_PARCIAL: "var(--rg-flag-warn)", PENDIENTE: "var(--rg-flag-over)" }; const mapLabel = { PAGADO: "Pagado", PAGO_PARCIAL: "Parcial", PENDIENTE: "Pendiente" }; const segs = dist.map(d => ({ k: mapLabel[d.status] || d.status, v: d.monto, c: mapColor[d.status] || "var(--rg-muted)" })); const sum = segs.reduce((a, s) => a + s.v, 0) || 1; const r = 56, c = 2 * Math.PI * r; let off = 0; if (segs.length === 0) { return (
Sin datos de pagos
); } return (
{segs.map(s => { const len = (s.v / sum) * c; const dash = `${len} ${c - len}`; const el = ; off += len; return el; })} ${(sum/1000).toFixed(0)}K TOTAL MXN
{segs.map(s => (
{s.k}
${fmtMoney(s.v, { compact: true })}
))}
); } function TopProveedoresChart({ topProv }) { const sorted = (topProv || []).slice().sort((a,b) => b.monto - a.monto); const max = Math.max(...sorted.map(s => s.monto), 1); if (sorted.length === 0) { return
Sin proveedores con OCs este año
; } return (
{sorted.map((p, i) => (
#{i+1}
{p.nombre}
${fmtMoney(p.monto)}
))}
); } function FamilyTable({ familias, thresholds }) { const data = (familias || []).slice().sort((a,b) => b.gastado - a.gastado); const total = data.reduce((a,f) => a + f.gastado, 0); if (data.length === 0) { return
Sin gasto por familia este año
; } return (
{data.map((f, i) => { const pct = f.pct_budget || (f.budget > 0 ? (f.gastado / f.budget) * 100 : 0); const lv = flagLevel(pct, thresholds); const share = total > 0 ? (f.gastado / total) * 100 : 0; return (
{f.nombre}
Familia {f.codigo} · {f.ocs || 0} OCs
${fmtMoney(f.gastado)} {share.toFixed(1)}% del total
{pct.toFixed(0)}%
); })}
); } // ═══════════════════════════════════════════════════════════════════════════ // PANEL DE TESORERIA (al elegir un proyecto) // Todo CON IVA, que es lo que sale del banco, y por el rango de fechas que se // elija arriba. La vista general sigue siendo anual y sin este detalle. // ═══════════════════════════════════════════════════════════════════════════ // Paleta categorica validada con el verificador de contraste/daltonismo. // El dorado de marca queda por debajo de 3:1 contra el papel, asi que donde se // usa va siempre con su cifra escrita al lado, nunca color solo. const TES_SERIES = { pagado: { light: "#2f8457", dark: "#2f8457", label: "Pagado" }, comprometido: { light: "#3a6fb0", dark: "#3a6fb0", label: "Comprometido" }, }; const TES_AGING = ["#e8c57a", "#d9a441", "#c07c22", "#9c4f1f"]; // secuencial: a mas viejo, mas oscuro function tesEtiquetaPeriodo(iso, gran) { const [a, m, d] = String(iso).split("-").map(Number); if (!a) return iso; if (gran === "mes") return `${MES_LABELS[(m || 1) - 1]} ${String(a).slice(2)}`; if (gran === "semana") return `${d} ${MES_LABELS[(m || 1) - 1]}`; return `${d} ${MES_LABELS[(m || 1) - 1]}`; } function TesKpi({ label, value, sub, color, fuerte }) { return (
{label}
{value}
{sub &&
{sub}
}
); } // Barra horizontal con su cifra escrita: una sola serie, sin leyenda — el titulo // de la tarjeta dice que mide. function TesBarras({ filas, color, max, money = true }) { const tope = max || Math.max(1, ...filas.map(f => Math.abs(f.valor))); return (
{filas.map((f, i) => (
{f.etiqueta} {money ? "$" + fmtMoney(f.valor, { compact: true }) : f.valor}
{Math.abs(f.valor) > 0.005 && (
)}
{f.sub &&
{f.sub}
}
))}
); } function TesoreriaPanel({ proyectoid, desde, hasta, gran, setGran }) { const { data, loading, error } = useApiData( 'tesoreria.php', { proyectoid, desde, hasta, granularidad: gran }, !proyectoid ); const t = (typeof rgTheme === 'function') ? rgTheme() : {}; const k = data?.kpis || {}; const serie = data?.serie || []; const prov = data?.proveedores || []; const fams = data?.familias || []; const aging = data?.aging || []; const labels = serie.map(x => tesEtiquetaPeriodo(x.periodo, data?.granularidad)); const vPag = serie.map(x => x.pagado); const vComp = serie.map(x => x.comprometido); let ap = 0, ac = 0; const accPag = vPag.map(v => (ap += v)); const accComp = vComp.map(v => (ac += v)); const money = (n) => "$" + fmtMoney(n || 0, { compact: true }); const saldoTotal = aging.reduce((a, b) => a + (b.saldo || 0), 0); if (!proyectoid) return null; if (loading) return (
Cargando el detalle de tesorería…
); if (error) return (
No se pudo cargar el detalle de tesorería.
); const baseOpts = (typeof rgBaseOptions === 'function') ? rgBaseOptions(t, { money: true }) : {}; const optsFlujo = { ...baseOpts, plugins: { ...(baseOpts.plugins || {}), legend: { ...((baseOpts.plugins || {}).legend || {}), display: true, position: 'top', align: 'end' } }, scales: { ...(baseOpts.scales || {}), x: { ...((baseOpts.scales || {}).x || {}), // con muchos periodos se rotula uno de cada N: el eje se lee, y el // dato exacto de cada barra sigue estando en el tooltip ticks: { ...(((baseOpts.scales || {}).x || {}).ticks || {}), autoSkip: true, maxRotation: labels.length > 14 ? 45 : 0, minRotation: 0, maxTicksLimit: labels.length > 26 ? 13 : labels.length } } }, }; return ( <> {/* ── Cabecera del panel ── */}
Tesorería · {data?.proyecto?.nombre}
Del {desde} al {hasta} · {data?.dias} días · importes con IVA, convertidos a pesos
{/* ── Los cuatro numeros que pide Finanzas ── */}
{/* ── Flujo por periodo ── */}

Flujo por {data?.granularidad === 'dia' ? 'día' : data?.granularidad}

Lo pagado contra lo comprometido en cada periodo, con IVA
{serie.length === 0 ? (
No hay movimientos en este rango de fechas.
) : typeof RgChart === "function" ? ( ) : null}
{/* ── Acumulado (va en su propia grafica: misma unidad, otra escala) ── */} {serie.length > 1 && typeof RgChart === "function" && (

Acumulado del periodo

Cómo se ha ido acumulando el gasto desde el inicio del rango
)} {/* ── Antiguedad del saldo + proveedores ── */}

Antigüedad del saldo por pagar

Cuánto lleva esperando lo que aún no se liquida · total {money(saldoTotal)}
{saldoTotal <= 0 ? (
No hay saldo pendiente en este rango.
) : ( ({ etiqueta: a.tramo, valor: a.saldo, sub: `${a.ocs} OC${a.ocs === 1 ? "" : "s"}` }))}/> )}

Proveedores del periodo

Por lo comprometido · la cifra en verde es lo ya pagado
{prov.length === 0 ? (
Sin proveedores en el rango.
) : ( ({ etiqueta: p.nombre, valor: p.comprometido, sub: `${p.ocs} OC${p.ocs === 1 ? "" : "s"} · pagado $${fmtMoney(p.pagado, { compact: true })}`, }))}/> )}
{/* ── Familias: tabla, que es lo que Finanzas copia al cierre ── */} {fams.length > 0 && (

Gasto por familia

Con el IVA de cada OC prorrateado entre sus partidas
{fams.map((f, i) => { const pend = Math.max(0, f.comprometido - f.pagado); const pct = f.comprometido > 0 ? (f.pagado / f.comprometido) * 100 : 0; return ( ); })}
Familia Comprometido Pagado Por pagar % pagado
{f.codigo} {f.nombre} ${fmtMoney(f.comprometido, { compact: true })} ${fmtMoney(f.pagado, { compact: true })} 0 ? "var(--rg-flag-warn)" : "var(--rg-muted)" }}>${fmtMoney(pend, { compact: true })}
{pct.toFixed(0)}%
)} {k.importadas > 0 && (
Incluye {k.importadas} OC{k.importadas === 1 ? "" : "s"} del histórico importado por ${fmtMoney(k.importado_total, { compact: true })}, que ya traen su IVA dentro y no tienen desglose de partidas.
)} ); } function ReportsView({ thresholds, setThresholds, onNav, user, onLogout, anio, setAnio }) { const [proyectoid, setProyectoid] = useState(""); const [fechaDesde, setFechaDesde] = useState(`${anio}-01-01`); const [fechaHasta, setFechaHasta] = useState(`${anio}-12-31`); const [gran, setGran] = useState("auto"); // Al cambiar de año, el rango salta a ese año completo: si no, el panel de // tesorería se quedaría consultando fechas del año anterior sin avisar. useEffect(() => { setFechaDesde(`${anio}-01-01`); setFechaHasta(`${anio}-12-31`); }, [anio]); // El selector de Proyecto (y los campos Desde/Hasta) existían en el filtro // pero nunca se mandaban a la API — api/reports.php ahora acepta // ?proyectoid= y acota todas las secciones que tienen esa columna (todas // menos "Ingresos", que es a nivel empresa). Desde/Hasta siguen sin // conectar: la API solo filtra por año completo (?anio=), no por rango de // fechas — pendiente si se necesita ese nivel de detalle. const { data: reportData, loading } = useApiData('reports.php', { anio, proyectoid }); const { data: proyectos } = useApiData('proyectos.php'); const global = reportData?.global || {}; const mensual = reportData?.mensual || []; const statusDist = reportData?.statusDist || []; const topProv = reportData?.topProv || []; const familias = reportData?.familias || []; const ingresos = reportData?.ingresos || []; const gastosPagados = reportData?.gastos_pagados || []; const historico = reportData?.historico || []; // ── v3.0: datos para las gráficas Chart.js de ingresos vs gastos ── const chartTheme = (typeof rgTheme === 'function') ? rgTheme() : {}; const ingVals = ingresos.map(x => x.total); const gastoVals = gastosPagados.map(x => x.total); let _acc = 0; const ingAcum = ingVals.map(v => (_acc += v)); const totalIngresos = ingVals.reduce((a, b) => a + b, 0); const totalGastosPg = gastoVals.reduce((a, b) => a + b, 0); const balanceAnual = totalIngresos - totalGastosPg; // ── Evolución acumulada mensual: facturado real acumulado vs objetivo lineal ── const PROYECCION_MENSUAL_MXN = 15237354; // objetivo lineal por mes (ajustable · $182.8M/año) let _lastMes = 0; ingresos.forEach(x => { if ((x.total || 0) > 0) _lastMes = Math.max(_lastMes, x.mes); }); const evoLabels = ["Inicio"].concat(MES_LABELS.slice(0, _lastMes)); const evoReal = [0].concat(ingAcum.slice(0, _lastMes)); const evoObjetivo = Array.from({ length: _lastMes + 1 }, (_, i) => i * PROYECCION_MENSUAL_MXN); const evoData = { labels: evoLabels, datasets: [ { label: "Facturado acum. (real)", data: evoReal, borderColor: chartTheme.green, backgroundColor: (chartTheme.green || "#1a4d2e") + "22", fill: true, tension: 0.35, borderWidth: 2.5, pointRadius: 4, pointBackgroundColor: chartTheme.green, pointBorderColor: chartTheme.paper, pointBorderWidth: 1.5 }, { label: "Objetivo acumulado", data: evoObjetivo, borderColor: PERF_ORANGE, backgroundColor: "transparent", fill: false, borderDash: [6, 5], tension: 0, borderWidth: 2, pointRadius: 3, pointBackgroundColor: PERF_ORANGE, pointBorderColor: chartTheme.paper }, ], }; const evoOptions = (typeof rgBaseOptions === "function") ? (() => { const o = rgBaseOptions(chartTheme, { money: true, legend: true }); o.layout = { padding: { right: 100, top: 22 } }; // espacio para las etiquetas de fin o.plugins.datalabels = { align: "right", anchor: "end", clamp: true, clip: false, formatter: (v, ctx) => ctx.dataIndex === ctx.dataset.data.length - 1 ? "$" + Math.round(v).toLocaleString("es-MX") + " MXN" : "", color: (ctx) => ctx.dataset.borderColor, font: { family: chartTheme.mono || "monospace", weight: "700", size: 11 }, }; return o; })() : {}; // ── Indicadores clave + Gráficas de desempeño (facturado real vs objetivo) ── const mesIdx = Math.max(0, _lastMes - 1); const mesFull = _lastMes > 0 ? MESES_FULL[mesIdx] : "—"; const mesShort = _lastMes > 0 ? MES_LABELS[mesIdx] : "—"; const facturadoMes = _lastMes > 0 ? ingVals[mesIdx] : 0; const objetivoMes = PROYECCION_MENSUAL_MXN; const pctMes = objetivoMes > 0 ? (facturadoMes / objetivoMes) * 100 : 0; const facturadoAcum = totalIngresos; const objetivoAcum = PROYECCION_MENSUAL_MXN * _lastMes; const pctAcum = objetivoAcum > 0 ? (facturadoAcum / objetivoAcum) * 100 : 0; const difObjetivo = facturadoAcum - objetivoAcum; const objetivoAnual = PROYECCION_MENSUAL_MXN * 12; const perfBarOptions = (typeof rgBaseOptions === "function") ? (() => { const o = rgBaseOptions(chartTheme, { money: true, legend: false }); o.layout = { padding: { top: 30 } }; o.plugins.datalabels = { anchor: "end", align: "top", clamp: true, clip: false, formatter: (v) => "$" + Math.round(v).toLocaleString("es-MX") + " MXN", color: chartTheme.ink || "#14110d", font: { family: chartTheme.mono || "monospace", weight: "700", size: 11 }, }; return o; })() : {}; const perfMesData = { labels: ["Facturado actual", "Objetivo mensual"], datasets: [ { data: [facturadoMes, objetivoMes], backgroundColor: [PERF_GREEN, PERF_ORANGE], borderRadius: 6, maxBarThickness: 130 }, ] }; const perfAcumData = { labels: ["Facturado acum. (real)", "Objetivo acumulado"], datasets: [ { data: [facturadoAcum, objetivoAcum], backgroundColor: [PERF_GREEN, PERF_ORANGE], borderRadius: 6, maxBarThickness: 130 }, ] }; // Antes esto bajaba un CSV con el desglose por familia y nada mas: ni una OC, // ni los pagos, ni el IVA. Ahora baja el reporte de tesoreria completo, que es // un xlsx de seis hojas y respeta el proyecto y el rango de fechas elegidos. const [bajando, setBajando] = useState(false); async function handleExport() { if (bajando) return; setBajando(true); try { const q = new URLSearchParams({ desde: fechaDesde, hasta: fechaHasta, granularidad: gran }); if (proyectoid) q.set('proyectoid', proyectoid); const res = await fetch('api/export_tesoreria.php?' + q.toString(), { credentials: 'same-origin' }); if (!res.ok) { let msg = 'No se pudo generar el reporte.'; try { const j = await res.json(); if (j && j.error) msg = j.error; } catch (_) {} toast.error(msg); return; } const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; const quien = proyectoid ? ((proyectos || []).find(p => String(p.id) === String(proyectoid))?.clave || proyectoid) : 'Global'; a.download = `Reporte_Tesoreria_${quien}_${fechaDesde}_a_${fechaHasta}.xlsx`; a.click(); URL.revokeObjectURL(url); } catch (err) { toast.error(err.message); } finally { setBajando(false); } } return (
{Icon.excel()} {bajando ? 'Generando…' : 'Reporte de Tesorería'} }/>
{/* Filtros */}
setFechaDesde(e.target.value)}/>
setFechaHasta(e.target.value)}/>
{/* Con proyecto elegido se muestra el panel de Tesorería, que es por rango de fechas y con IVA. Sin proyecto, la vista general de siempre, que es anual. Antes salían las dos cosas mezcladas. */} {proyectoid ? ( ) : (<> {/* KPIs globales */} {loading ? (
{[1,2,3,4,5,6].map(i => (
))}
) : (
)} {/* Gráficas */} {loading ? (
{[1,2,3,4,5,6].map(i =>
)}
) : ( <>

Gasto mensual MXN

{anio} · por mes

Status de pago

Distribución MXN · {anio}
{/* ── v3.0 · Ingresos y balance (Chart.js re-tematizado) ── */} {typeof RgChart === "function" && (<>

Ingresos y balance

Ingresos (MXN) vs gasto pagado · {anio}
= 0 ? "superávit" : "déficit"}/>

Gastos vs Ingresos por mes

Barras · {anio}

Ingresos por mes

MXN · {anio}
{/* Evolución acumulada mensual: facturado real vs objetivo lineal */}

Evolución acumulada mensual

Curva real vs objetivo lineal acumulado (~$15.2M/mes) · {anio}
{/* ── Indicadores clave (facturado vs objetivo) ── */}

Indicadores clave

Facturación real vs objetivo · {anio}
= 100, text: Math.abs(pctMes - 100).toFixed(1) + "%" }}/> = 100, text: Math.abs(pctAcum - 100).toFixed(1) + "%" }}/> = 0 ? "+" : "-") + fmtM(Math.abs(difObjetivo))} valueColor={difObjetivo >= 0 ? "#16a34a" : "#b22222"} sub={difObjetivo >= 0 ? "Superando el objetivo" : "Por debajo del objetivo"}/>
{/* ── Gráficas de desempeño ── */}

Gráficas de desempeño

Avance de facturación vs objetivo · {anio}

Avance de facturación — mes activo

{mesFull} {anio}
Facturado actual Objetivo mensual

Avance acumulado ene – mes activo

Enero–{mesFull} {anio} · DIF: {(difObjetivo >= 0 ? "+" : "-") + fmtM(Math.abs(difObjetivo))}
Facturado acum. Objetivo acumulado
)} )} )}
); } Object.assign(window, { ReportsView, REPORT_PALETTE });