Dashboard Leads - version limpia para servidor (.env y config de despliegue restaurados)
This commit is contained in:
334
frontend/src/pages/OtrosGeneral.jsx
Normal file
334
frontend/src/pages/OtrosGeneral.jsx
Normal file
@@ -0,0 +1,334 @@
|
||||
import { useState, useEffect, useMemo, Fragment } from "react";
|
||||
import { api } from "../lib/api";
|
||||
import { Loader, ErrorBox, Filters, Select } from "../components/UI";
|
||||
|
||||
const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
|
||||
|
||||
export default function OtrosGeneral() {
|
||||
const hoy = new Date();
|
||||
const [ano, setAno] = useState(String(hoy.getFullYear()));
|
||||
const [mes, setMes] = useState(String(hoy.getMonth() + 1));
|
||||
const [dia, setDia] = useState("TODOS");
|
||||
|
||||
const [filtros, setFiltros] = useState({ anos: [2026] });
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [expAlways, setExpAlways] = useState({});
|
||||
const [expAlwaysProg, setExpAlwaysProg] = useState({});
|
||||
const [expWeb, setExpWeb] = useState({});
|
||||
const [expAsig, setExpAsig] = useState({});
|
||||
const [reload, setReload] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
api.leadsFiltros().then(setFiltros).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Auto-refresco cada 15 min (sin F5) + recarga al guardar en Config
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setReload((n) => n + 1), 15 * 60 * 1000);
|
||||
const onActualizar = () => setReload((n) => n + 1);
|
||||
window.addEventListener("datos-actualizar", onActualizar);
|
||||
return () => { clearInterval(id); window.removeEventListener("datos-actualizar", onActualizar); };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let activo = true;
|
||||
if (!data) setLoading(true); // spinner solo en la primera carga
|
||||
setError(null);
|
||||
api.otrosGeneral(ano, mes, dia)
|
||||
.then((res) => { if (activo) { setData(res); setLoading(false); } })
|
||||
.catch((e) => { if (activo) { setError(e.message); setLoading(false); } });
|
||||
return () => { activo = false; };
|
||||
}, [ano, mes, dia, reload]);
|
||||
|
||||
const optAnos = useMemo(() => (filtros.anos || []).map(String), [filtros]);
|
||||
const optMes = useMemo(() => [{ value: "TODOS", label: "TODOS" },
|
||||
...MESES.map((m,i)=>({ value: String(i+1), label: m }))], []);
|
||||
const optDia = useMemo(() => ["TODOS", ...Array.from({length:31}, (_,i)=>String(i+1))], []);
|
||||
|
||||
const money = (v) => `$${Number(v || 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
const sol = (v) => `S/ ${Number(v || 0).toLocaleString("en-US", { maximumFractionDigits: 0 })}`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">📊 Otros General</h1>
|
||||
|
||||
<Filters>
|
||||
<Select label="Año" value={ano} options={optAnos} onChange={setAno} />
|
||||
<Select label="Mes" value={mes} options={optMes} onChange={setMes} />
|
||||
<Select label="Día" value={dia} options={optDia} onChange={setDia} />
|
||||
</Filters>
|
||||
|
||||
{loading ? <Loader text="Cargando..." /> :
|
||||
error ? <ErrorBox msg={error} /> :
|
||||
!data ? <ErrorBox msg="Sin datos" /> :
|
||||
<>
|
||||
{/* ── Inversión Publicitaria por Sede (Always On) ── */}
|
||||
{data.matriz_always && data.matriz_always.filas && (
|
||||
<div className="card" style={{ marginTop: 4, padding: 0, overflow: "hidden" }}>
|
||||
<div className="card-title" style={{ padding: "14px 16px 6px" }}>Inversión Publicitaria por Sede (Always On)</div>
|
||||
<div className="table-wrap" style={{ border: "none", maxHeight: 420, overflow: "auto" }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="col-name" style={{ position: "sticky", top: 0, zIndex: 2 }}>Sede</th>
|
||||
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Importe Gastado</th>
|
||||
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Resultados</th>
|
||||
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Leads Recibidos</th>
|
||||
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Leads Procesados</th>
|
||||
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Matrículas</th>
|
||||
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Valor Venta</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.matriz_always.filas.map((f) => {
|
||||
const abierto = !!expAlways[f.sede];
|
||||
return (
|
||||
<Fragment key={f.sede}>
|
||||
<tr>
|
||||
<td className="col-name">
|
||||
<span
|
||||
onClick={() => setExpAlways((p) => ({ ...p, [f.sede]: !p[f.sede] }))}
|
||||
style={{ cursor: "pointer", fontWeight: 700, color: "#2563eb",
|
||||
marginRight: 6, userSelect: "none", display: "inline-block", width: 14 }}
|
||||
title="Ver por programa">
|
||||
{abierto ? "−" : "+"}
|
||||
</span>
|
||||
{f.sede}
|
||||
</td>
|
||||
<td style={{ fontWeight: 600 }}>{money(f.importe)}</td>
|
||||
<td>{f.resultados}</td>
|
||||
<td>{f.nuevos}</td>
|
||||
<td>{f.nuevos_asig}</td>
|
||||
<td style={{ fontWeight: 600 }}>{f.matriculas}</td>
|
||||
<td style={{ fontWeight: 600 }}>{sol(f.venta)}</td>
|
||||
</tr>
|
||||
{abierto && (f.subfilas || []).map((s) => {
|
||||
const kProg = f.sede + "|" + s.programa;
|
||||
const abiertoP = !!expAlwaysProg[kProg];
|
||||
return (
|
||||
<Fragment key={kProg}>
|
||||
<tr style={{ background: "#f8fafc" }}>
|
||||
<td className="col-name" style={{ paddingLeft: 28, color: "#475569" }}>
|
||||
<span
|
||||
onClick={() => setExpAlwaysProg((p) => ({ ...p, [kProg]: !p[kProg] }))}
|
||||
style={{ cursor: "pointer", fontWeight: 700, color: "#2563eb",
|
||||
marginRight: 6, userSelect: "none", display: "inline-block", width: 14 }}
|
||||
title="Ver por pauta">
|
||||
{abiertoP ? "−" : "+"}
|
||||
</span>
|
||||
{s.programa}
|
||||
</td>
|
||||
<td style={{ color: "#475569", fontWeight: 600 }}>{money(s.importe)}</td>
|
||||
<td style={{ color: "#475569" }}>{s.resultados}</td>
|
||||
<td style={{ color: "#475569" }}>{s.nuevos}</td>
|
||||
<td style={{ color: "#475569" }}>{s.nuevos_asig}</td>
|
||||
<td style={{ color: "#475569", fontWeight: 600 }}>{s.matriculas}</td>
|
||||
<td style={{ color: "#475569", fontWeight: 600 }}>{sol(s.venta)}</td>
|
||||
</tr>
|
||||
{abiertoP && (s.pautas || []).map((pt) => (
|
||||
<tr key={kProg + pt.pauta} style={{ background: "#eef2f7" }}>
|
||||
<td className="col-name" style={{ paddingLeft: 56, color: "#64748b", fontSize: 12 }}>Pauta {pt.pauta}</td>
|
||||
<td style={{ color: "#64748b", fontSize: 12 }}>{money(pt.importe)}</td>
|
||||
<td style={{ color: "#64748b", fontSize: 12 }}>{pt.resultados}</td>
|
||||
<td style={{ color: "#64748b", fontSize: 12 }}>{pt.nuevos}</td>
|
||||
<td style={{ color: "#64748b", fontSize: 12 }}>{pt.nuevos_asig}</td>
|
||||
<td style={{ color: "#64748b", fontSize: 12 }}>{pt.matriculas}</td>
|
||||
<td style={{ color: "#64748b", fontSize: 12 }}>{sol(pt.venta)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{data.matriz_always.total && (
|
||||
<tr className="total-row">
|
||||
<td className="col-name">Total</td>
|
||||
<td>{money(data.matriz_always.total.importe)}</td>
|
||||
<td>{data.matriz_always.total.resultados}</td>
|
||||
<td>{data.matriz_always.total.nuevos}</td>
|
||||
<td>{data.matriz_always.total.nuevos_asig}</td>
|
||||
<td>{data.matriz_always.total.matriculas}</td>
|
||||
<td>{sol(data.matriz_always.total.venta)}</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Leads Web Formulario por Sede ── */}
|
||||
{data.matriz_webform && data.matriz_webform.filas && (
|
||||
<div className="card" style={{ marginTop: 16, padding: 0, overflow: "hidden" }}>
|
||||
<div className="card-title" style={{ padding: "14px 16px 6px" }}>Leads Web Formulario por Sede</div>
|
||||
<div className="table-wrap" style={{ border: "none", maxHeight: 420, overflow: "auto" }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="col-name" style={{ position: "sticky", top: 0, zIndex: 2, minWidth: 150 }}>Sede</th>
|
||||
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Leads Recibidos</th>
|
||||
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Leads Procesados</th>
|
||||
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Matrículas</th>
|
||||
<th style={{ position: "sticky", top: 0, zIndex: 2, minWidth: 130, whiteSpace: "nowrap" }}>Valor Venta</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.matriz_webform.filas.map((f) => {
|
||||
const abierto = !!expWeb[f.sede];
|
||||
return (
|
||||
<Fragment key={f.sede}>
|
||||
<tr>
|
||||
<td className="col-name">
|
||||
<button onClick={() => setExpWeb((p) => ({ ...p, [f.sede]: !p[f.sede] }))}
|
||||
style={{ border: "none", background: "transparent", cursor: "pointer",
|
||||
fontWeight: 700, marginRight: 6, color: "#2563eb", fontSize: 14 }}>
|
||||
{abierto ? "−" : "+"}
|
||||
</button>
|
||||
{f.sede}
|
||||
</td>
|
||||
<td>{f.recibidos}</td>
|
||||
<td>{f.procesados}</td>
|
||||
<td style={{ fontWeight: 600 }}>{f.matriculas}</td>
|
||||
<td style={{ fontWeight: 600 }}>{sol(f.venta)}</td>
|
||||
</tr>
|
||||
{abierto && (f.subfilas || []).map((s) => (
|
||||
<tr key={f.sede + s.programa} style={{ background: "#f8fafc" }}>
|
||||
<td className="col-name" style={{ paddingLeft: 30, color: "#475569", fontSize: 12 }}>{s.programa}</td>
|
||||
<td style={{ color: "#475569", fontSize: 12 }}>{s.recibidos}</td>
|
||||
<td style={{ color: "#475569", fontSize: 12 }}>{s.procesados}</td>
|
||||
<td style={{ color: "#475569", fontSize: 12 }}>{s.matriculas}</td>
|
||||
<td style={{ color: "#475569", fontSize: 12 }}>{sol(s.venta)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{data.matriz_webform.total && (
|
||||
<tr className="total-row">
|
||||
<td className="col-name">Total</td>
|
||||
<td>{data.matriz_webform.total.recibidos}</td>
|
||||
<td>{data.matriz_webform.total.procesados}</td>
|
||||
<td>{data.matriz_webform.total.matriculas}</td>
|
||||
<td>{sol(data.matriz_webform.total.venta)}</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Leads Asignados por Asesor y Día (pivot) ── */}
|
||||
{data.matriz_asignados && data.matriz_asignados.filas && (
|
||||
<div className="card" style={{ marginTop: 16, padding: 0, overflow: "hidden" }}>
|
||||
<div className="card-title" style={{ padding: "14px 16px 6px" }}>Leads Asignados por Asesor y Día</div>
|
||||
<div className="table-wrap" style={{ border: "none", maxHeight: 460, overflow: "auto" }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="col-name" style={{ position: "sticky", top: 0, left: 0, zIndex: 3, minWidth: 180, background: "#1e3a5f" }}>Asesor</th>
|
||||
{data.matriz_asignados.dias.map((d) => (
|
||||
<th key={d} style={{ position: "sticky", top: 0, zIndex: 2 }}>{d}</th>
|
||||
))}
|
||||
<th style={{ position: "sticky", top: 0, zIndex: 2, minWidth: 70 }}>Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.matriz_asignados.filas.map((f) => {
|
||||
const abierto = !!expAsig[f.asesor];
|
||||
return (
|
||||
<Fragment key={f.asesor}>
|
||||
<tr>
|
||||
<td className="col-name">
|
||||
<button onClick={() => setExpAsig((p) => ({ ...p, [f.asesor]: !p[f.asesor] }))}
|
||||
style={{ border: "none", background: "transparent", cursor: "pointer",
|
||||
fontWeight: 700, marginRight: 6, color: "#2563eb", fontSize: 14 }}>
|
||||
{abierto ? "−" : "+"}
|
||||
</button>
|
||||
{f.asesor}
|
||||
</td>
|
||||
{data.matriz_asignados.dias.map((d) => (
|
||||
<td key={d} style={{ color: f.por_dia[d] ? "#0f172a" : "#cbd5e1" }}>{f.por_dia[d] || ""}</td>
|
||||
))}
|
||||
<td style={{ fontWeight: 700 }}>{f.total}</td>
|
||||
</tr>
|
||||
{abierto && (
|
||||
<tr>
|
||||
<td colSpan={data.matriz_asignados.dias.length + 2} style={{ background: "#f8fafc", padding: "8px 14px" }}>
|
||||
{Object.keys(f.tels_dia || {}).length === 0
|
||||
? <span style={{ color: "#94a3b8" }}>—</span>
|
||||
: Object.entries(f.tels_dia || {}).map(([d, tels]) => (
|
||||
<div key={d} style={{ marginBottom: 6 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: "#475569" }}>Día {d} ({(tels || []).length}): </span>
|
||||
<span style={{ fontSize: 12, color: "#64748b" }}>{(tels || []).join(", ")}</span>
|
||||
</div>
|
||||
))}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
<tr className="total-row">
|
||||
<td className="col-name">Total</td>
|
||||
{data.matriz_asignados.dias.map((d) => (
|
||||
<td key={d}>{data.matriz_asignados.total_por_dia[d] || ""}</td>
|
||||
))}
|
||||
<td>{data.matriz_asignados.total}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Plantillas WhatsApp (cobradas) por Plantilla ── */}
|
||||
{data.matriz_plantillas && data.matriz_plantillas.filas && (
|
||||
<div className="card" style={{ marginTop: 16, padding: 0, overflow: "hidden" }}>
|
||||
<div className="card-title" style={{ padding: "14px 16px 6px" }}>Plantillas WhatsApp (cobradas)</div>
|
||||
<div className="table-wrap" style={{ border: "none", maxHeight: 460, overflow: "auto" }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="col-name" style={{ position: "sticky", top: 0, zIndex: 2, minWidth: 220 }}>Plantilla</th>
|
||||
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Enviadas</th>
|
||||
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Respondidas</th>
|
||||
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Asignadas</th>
|
||||
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Matrículas</th>
|
||||
<th style={{ position: "sticky", top: 0, zIndex: 2, minWidth: 130, whiteSpace: "nowrap" }}>Valor Venta</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.matriz_plantillas.filas.map((f) => (
|
||||
<tr key={f.plantilla}>
|
||||
<td className="col-name">{f.plantilla}</td>
|
||||
<td style={{ fontWeight: 600 }}>{f.enviadas}</td>
|
||||
<td>{f.respondidas}</td>
|
||||
<td>{f.asignadas}</td>
|
||||
<td style={{ fontWeight: 600 }}>{f.matriculas}</td>
|
||||
<td style={{ fontWeight: 600 }}>{sol(f.venta)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{data.matriz_plantillas.total && (
|
||||
<tr className="total-row">
|
||||
<td className="col-name">Total</td>
|
||||
<td>{data.matriz_plantillas.total.enviadas}</td>
|
||||
<td>{data.matriz_plantillas.total.respondidas}</td>
|
||||
<td>{data.matriz_plantillas.total.asignadas}</td>
|
||||
<td>{data.matriz_plantillas.total.matriculas}</td>
|
||||
<td>{sol(data.matriz_plantillas.total.venta)}</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user