// 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 (
);
}
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 => (
{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
${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 (
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 ? (