Añadir frontend/src/pages/Programa.jsx
This commit is contained in:
439
frontend/src/pages/Programa.jsx
Normal file
439
frontend/src/pages/Programa.jsx
Normal file
@@ -0,0 +1,439 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { api } from "../lib/api";
|
||||
import { Loader, ErrorBox, Filters, Select } from "../components/UI";
|
||||
import {
|
||||
AreaChart, Area, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend, ReferenceLine,
|
||||
} from "recharts";
|
||||
|
||||
const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
|
||||
|
||||
export default function Programa({ inicial = null, onConsumirInicial } = {}) {
|
||||
const _hoy = new Date();
|
||||
const [ano, setAno] = useState(String(inicial?.ano ?? _hoy.getFullYear()));
|
||||
const [mes, setMes] = useState(String(inicial?.mes ?? (_hoy.getMonth() + 1)));
|
||||
const [programa, setPrograma] = useState("TODOS");
|
||||
|
||||
const [filtros, setFiltros] = useState({ anos: [2026] });
|
||||
const [progOpc, setProgOpc] = useState([]); // lista de programas del periodo
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [recargando, setRecargando] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [reload, setReload] = useState(0);
|
||||
// Programa que se debe seleccionar automaticamente al llegar por enlace (Leads).
|
||||
const [progPendiente, setProgPendiente] = useState(inicial?.programa || null);
|
||||
|
||||
useEffect(() => { api.leadsFiltros().then(setFiltros).catch(() => {}); }, []);
|
||||
useEffect(() => {
|
||||
const onActualizar = () => setReload((n) => n + 1);
|
||||
window.addEventListener("datos-actualizar", onActualizar);
|
||||
return () => window.removeEventListener("datos-actualizar", onActualizar);
|
||||
}, []);
|
||||
|
||||
// Cargar la lista de programas cuando cambia año/mes; resetea el programa elegido.
|
||||
// NO depende de 'reload' para no perder el programa seleccionado al guardar leyenda.
|
||||
useEffect(() => {
|
||||
api.programaOpciones(ano, mes)
|
||||
.then((r) => setProgOpc(r.programas || []))
|
||||
.catch(() => setProgOpc([]));
|
||||
// Si venimos por enlace desde Leads, NO reseteamos a TODOS (el otro effect lo
|
||||
// seleccionara cuando la lista de programas ya lo contenga).
|
||||
if (!progPendiente) { setPrograma("TODOS"); setData(null); }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ano, mes]);
|
||||
|
||||
// Al llegar por enlace: cuando la lista de programas ya incluye el pendiente,
|
||||
// seleccionarlo una sola vez y consumir el estado inicial.
|
||||
useEffect(() => {
|
||||
if (!progPendiente) return;
|
||||
if (progOpc.includes(progPendiente)) {
|
||||
setPrograma(progPendiente);
|
||||
setProgPendiente(null);
|
||||
if (onConsumirInicial) onConsumirInicial();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [progOpc, progPendiente]);
|
||||
|
||||
// Al guardar leyenda (reload), refrescar la lista de programas SIN resetear el elegido.
|
||||
useEffect(() => {
|
||||
if (reload === 0) return;
|
||||
api.programaOpciones(ano, mes)
|
||||
.then((r) => setProgOpc(r.programas || []))
|
||||
.catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [reload]);
|
||||
|
||||
// Cargar datos del programa elegido
|
||||
useEffect(() => {
|
||||
if (programa === "TODOS") { setData(null); return; }
|
||||
let activo = true;
|
||||
if (data) setRecargando(true); else setLoading(true);
|
||||
api.programa(ano, mes, programa)
|
||||
.then((r) => { if (activo) { setData(r); setError(null); } })
|
||||
.catch((e) => { if (activo) setError(e.message); })
|
||||
.finally(() => { if (activo) { setLoading(false); setRecargando(false); } });
|
||||
return () => { activo = false; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ano, mes, programa, reload]);
|
||||
|
||||
const num = (v) => Number(v || 0).toLocaleString("es-PE");
|
||||
|
||||
const optAnos = useMemo(() =>
|
||||
(filtros.anos || [2026]).map((a) => ({ value: String(a), label: String(a) })), [filtros]);
|
||||
const optMes = useMemo(() =>
|
||||
MESES.map((m, i) => ({ value: String(i + 1), label: m })), []);
|
||||
const optProg = useMemo(() =>
|
||||
[{ value: "TODOS", label: "— Elige un programa —" },
|
||||
...progOpc.map((p) => ({ value: p, label: p }))], [progOpc]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">📘 Programa</h1>
|
||||
|
||||
<Filters>
|
||||
<Select label="Año" value={ano} options={optAnos} onChange={setAno} />
|
||||
<Select label="Mes" value={mes} options={optMes} onChange={setMes} />
|
||||
<Select label="Programa" value={programa} options={optProg} onChange={setPrograma} />
|
||||
</Filters>
|
||||
|
||||
{programa === "TODOS" ? (
|
||||
<div style={{ padding: 50, textAlign: "center", color: "#94a3b8", fontSize: 14 }}>
|
||||
Elige un programa del filtro para ver sus datos.
|
||||
</div>
|
||||
) : loading ? <Loader text="Cargando programa..." /> :
|
||||
error ? <ErrorBox msg={error} /> :
|
||||
!data ? <ErrorBox msg="Sin datos" /> :
|
||||
<div style={{ opacity: recargando ? 0.45 : 1,
|
||||
pointerEvents: recargando ? "none" : "auto",
|
||||
transition: "opacity 0.25s ease" }}>
|
||||
<Tarjetas r={data} num={num} />
|
||||
|
||||
{/* Gráfica (ancho de 3 tarjetas) + Estado/Objeción (ancho de 1) */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 14, marginTop: 4,
|
||||
alignItems: "stretch" }}>
|
||||
{/* Gráfica: Leads Recibidos / Procesados / Contactados por fecha */}
|
||||
<div className="card" style={{ padding: "16px 18px 10px", minWidth: 0, gridColumn: "span 3",
|
||||
height: 460, display: "flex", flexDirection: "column" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 4 }}>
|
||||
<div className="card-title" style={{ margin: 0 }}>Leads por Fecha</div>
|
||||
<div style={{ fontSize: 11.5, color: "#94a3b8" }}>{(data.serie || []).length} días con leads</div>
|
||||
</div>
|
||||
{(data.serie || []).length === 0 ? (
|
||||
<div style={{ padding: 40, textAlign: "center", color: "#94a3b8", fontSize: 13 }}>
|
||||
Sin leads para este programa.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ width: "100%", overflowX: "auto", overflowY: "hidden", paddingBottom: 6 }}>
|
||||
<div style={{ width: (data.serie.length * 24) + "px", minWidth: "100%", height: 380 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={data.serie} margin={{ top: 14, right: 26, left: 30, bottom: 24 }}>
|
||||
<defs>
|
||||
<linearGradient id="gRec" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#3b82f6" stopOpacity={0.28} />
|
||||
<stop offset="100%" stopColor="#3b82f6" stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
<linearGradient id="gProc" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#0d9488" stopOpacity={0.26} />
|
||||
<stop offset="100%" stopColor="#0d9488" stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
<linearGradient id="gCont" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#8b5cf6" stopOpacity={0.24} />
|
||||
<stop offset="100%" stopColor="#8b5cf6" stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="4 4" stroke="#f0f3f8" vertical={false} />
|
||||
{/* Lineas verticales al cambiar de mes */}
|
||||
{data.serie.map((p, i) =>
|
||||
(i > 0 && p.mes !== data.serie[i - 1].mes) ? (
|
||||
<ReferenceLine key={"rl" + i} x={p.fecha} stroke="#94a3b8"
|
||||
strokeDasharray="4 3" strokeWidth={1.3} />
|
||||
) : null
|
||||
)}
|
||||
{/* Eje de DIAS (arriba visible). dataKey=fecha (unico) pero muestra el dia. */}
|
||||
<XAxis dataKey="fecha" interval={0} tick={{ fontSize: 9, fill: "#94a3b8" }}
|
||||
tickFormatter={(v) => (v || "").slice(0, 2)}
|
||||
axisLine={{ stroke: "#e2e8f0" }} tickLine={false} height={18} minTickGap={4} />
|
||||
{/* Eje de MES (abajo, una vez por grupo) */}
|
||||
<XAxis dataKey="mes" xAxisId="mes" axisLine={false} tickLine={false}
|
||||
tick={<TickMes serie={data.serie} />} interval={0} height={16} />
|
||||
<YAxis tick={{ fontSize: 11, fill: "#94a3b8" }} allowDecimals={false}
|
||||
axisLine={false} tickLine={false} width={34} />
|
||||
<Tooltip contentStyle={{ borderRadius: 12, border: "1px solid #e2e8f0",
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,0.12)", fontSize: 12.5, padding: "10px 12px" }}
|
||||
labelStyle={{ fontWeight: 700, color: "#0f172a", marginBottom: 4 }}
|
||||
labelFormatter={(v, p) => (p && p[0] ? p[0].payload.fecha : v)} />
|
||||
<Area type="linear" dataKey="recibidos" name="Leads Recibidos" stroke="#3b82f6"
|
||||
strokeWidth={2.4} fill="url(#gRec)" dot={false} activeDot={{ r: 5, strokeWidth: 2, stroke: "#fff" }} />
|
||||
<Area type="linear" dataKey="procesados" name="Leads Procesados" stroke="#0d9488"
|
||||
strokeWidth={2.4} fill="url(#gProc)" dot={false} activeDot={{ r: 5, strokeWidth: 2, stroke: "#fff" }} />
|
||||
<Area type="linear" dataKey="contactados" name="Leads Contactados" stroke="#8b5cf6"
|
||||
strokeWidth={2.4} fill="url(#gCont)" dot={false} activeDot={{ r: 5, strokeWidth: 2, stroke: "#fff" }} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Leyenda fija (fuera del scroll) */}
|
||||
{(data.serie || []).length > 0 && (
|
||||
<div style={{ display: "flex", justifyContent: "center", gap: 20, paddingTop: 8, fontSize: 12 }}>
|
||||
{[["Leads Recibidos", "#3b82f6"], ["Leads Procesados", "#0d9488"], ["Leads Contactados", "#8b5cf6"]].map(([t, c]) => (
|
||||
<span key={t} style={{ display: "inline-flex", alignItems: "center", gap: 6, color: "#475569" }}>
|
||||
<span style={{ width: 10, height: 10, borderRadius: "50%", background: c }} />{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabla Estado/Objeción (1/4) */}
|
||||
<div className="card" style={{ padding: 0, overflow: "hidden", display: "flex", flexDirection: "column", height: 460 }}>
|
||||
<div className="card-title" style={{ padding: "14px 16px 6px" }}>Estado/Objeción</div>
|
||||
<div className="table-wrap" style={{ border: "none", flex: 1, overflow: "auto" }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="col-name" style={{ position: "sticky", top: 0, zIndex: 2 }}>Estado/Objeción</th>
|
||||
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Leads</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(data.matriz_etiq || []).length === 0 ? (
|
||||
<tr><td colSpan={2} style={{ padding: 30, textAlign: "center", color: "#94a3b8" }}>Sin datos</td></tr>
|
||||
) : (data.matriz_etiq || []).map((e) => (
|
||||
<tr key={e.estado}>
|
||||
<td className="col-name">{e.estado}</td>
|
||||
<td>{num(e.cantidad)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{(data.matriz_etiq || []).length > 0 && (
|
||||
<tr className="total-row">
|
||||
<td className="col-name" style={{ position: "sticky", bottom: 0, background: "#eff6ff" }}>Total</td>
|
||||
<td style={{ position: "sticky", bottom: 0, background: "#eff6ff" }}>{num((data.matriz_etiq || []).reduce((s, e) => s + (e.cantidad || 0), 0))}</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Leads Sin Responder + Matrículas por Día, lado a lado */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14, marginTop: 16 }}>
|
||||
{/* Leads Sin Responder por Día */}
|
||||
<div className="card" style={{ padding: "16px 18px 10px", minWidth: 0 }}>
|
||||
<div className="card-title" style={{ margin: 0, marginBottom: 8, display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ width: 11, height: 11, borderRadius: "50%", background: "#f43f5e" }} />
|
||||
Leads Sin Responder por Día
|
||||
</div>
|
||||
{(data.serie_sin_resp || []).length === 0 ? (
|
||||
<div style={{ padding: 30, textAlign: "center", color: "#94a3b8", fontSize: 13 }}>
|
||||
Sin leads pendientes de responder.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ width: "100%", overflowX: "auto", overflowY: "hidden", paddingBottom: 6 }}>
|
||||
<div style={{ width: (data.serie_sin_resp.length * 34) + "px", minWidth: "100%", height: 320 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data.serie_sin_resp} margin={{ top: 14, right: 20, left: 20, bottom: 30 }}>
|
||||
<CartesianGrid strokeDasharray="4 4" stroke="#f0f3f8" vertical={false} />
|
||||
{data.serie_sin_resp.map((p, i) =>
|
||||
(i > 0 && p.mes !== data.serie_sin_resp[i - 1].mes) ? (
|
||||
<ReferenceLine key={"rs" + i} x={p.fecha} stroke="#cbd5e1" strokeDasharray="4 3"
|
||||
strokeWidth={1.3} position="start" />
|
||||
) : null)}
|
||||
<XAxis dataKey="fecha" interval={0} tick={{ fontSize: 9, fill: "#94a3b8" }}
|
||||
tickFormatter={(v) => (v || "").slice(0, 2)}
|
||||
axisLine={{ stroke: "#e2e8f0" }} tickLine={false} height={18} />
|
||||
<XAxis dataKey="mes" xAxisId="mes2" axisLine={false} tickLine={false}
|
||||
tick={<TickMes serie={data.serie_sin_resp} />} interval={0} height={16} />
|
||||
<YAxis tick={{ fontSize: 11, fill: "#94a3b8" }} allowDecimals={false}
|
||||
axisLine={false} tickLine={false} width={34} />
|
||||
<Tooltip contentStyle={{ borderRadius: 12, border: "1px solid #e2e8f0",
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,0.12)", fontSize: 12.5 }}
|
||||
labelFormatter={(v, p) => (p && p[0] ? p[0].payload.fecha : v)} />
|
||||
<Bar dataKey="cantidad" name="Sin Responder" fill="#f43f5e" radius={[4, 4, 0, 0]} maxBarSize={28} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Matrículas por Día */}
|
||||
<div className="card" style={{ padding: "16px 18px 10px", minWidth: 0 }}>
|
||||
<div className="card-title" style={{ margin: 0, marginBottom: 8, display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ width: 11, height: 11, borderRadius: "50%", background: "#0f766e" }} />
|
||||
Matrículas por Día
|
||||
</div>
|
||||
{(data.serie_mat || []).length === 0 ? (
|
||||
<div style={{ padding: 30, textAlign: "center", color: "#94a3b8", fontSize: 13 }}>
|
||||
Sin matrículas.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ width: "100%", overflowX: "auto", overflowY: "hidden", paddingBottom: 6 }}>
|
||||
<div style={{ width: (data.serie_mat.length * 34) + "px", minWidth: "100%", height: 320 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data.serie_mat} margin={{ top: 14, right: 20, left: 20, bottom: 30 }}>
|
||||
<CartesianGrid strokeDasharray="4 4" stroke="#f0f3f8" vertical={false} />
|
||||
{data.serie_mat.map((p, i) =>
|
||||
(i > 0 && p.mes !== data.serie_mat[i - 1].mes) ? (
|
||||
<ReferenceLine key={"rm" + i} x={p.fecha} stroke="#cbd5e1" strokeDasharray="4 3"
|
||||
strokeWidth={1.3} position="start" />
|
||||
) : null)}
|
||||
<XAxis dataKey="fecha" interval={0} tick={{ fontSize: 9, fill: "#94a3b8" }}
|
||||
tickFormatter={(v) => (v || "").slice(0, 2)}
|
||||
axisLine={{ stroke: "#e2e8f0" }} tickLine={false} height={18} />
|
||||
<XAxis dataKey="mes" xAxisId="mesM" axisLine={false} tickLine={false}
|
||||
tick={<TickMes serie={data.serie_mat} />} interval={0} height={16} />
|
||||
<YAxis tick={{ fontSize: 11, fill: "#94a3b8" }} allowDecimals={false}
|
||||
axisLine={false} tickLine={false} width={34} />
|
||||
<Tooltip contentStyle={{ borderRadius: 12, border: "1px solid #e2e8f0",
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,0.12)", fontSize: 12.5 }}
|
||||
labelFormatter={(v, p) => (p && p[0] ? p[0].payload.fecha : v)} />
|
||||
<Bar dataKey="cantidad" name="Matrículas" fill="#0f766e" radius={[4, 4, 0, 0]} maxBarSize={28} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resumen de Matrículas: 4 tarjetitas al final */}
|
||||
<div style={{ marginTop: 16, marginBottom: 6, fontSize: 14, fontWeight: 700, color: "#1e293b" }}>
|
||||
Resumen de Matrículas
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 12 }}>
|
||||
{[
|
||||
{ ico: "⏱️", label: "Tiempo Promedio", valor: `${num(data.tiempo_mat_prom)} días`, sub: "lead → matrícula", color: "#2563eb", bg: "#eff6ff" },
|
||||
{ ico: "❓", label: "No Identificadas", valor: num(data.mat_no_iden), sub: "sin lead en cartera", color: "#b45309", bg: "#fff7ed" },
|
||||
{ ico: "🟢", label: "Matr. Leads Nuevos", valor: num(data.mat_nuevos), sub: `≤ 45 días · prom. ${num(data.prom_nuevos)} días`, color: "#0f766e", bg: "#f0fdfa" },
|
||||
{ ico: "🕰️", label: "Matr. Leads Antiguos", valor: num(data.mat_antiguos), sub: `> 45 días · prom. ${num(data.prom_antiguos)} días`, color: "#64748b", bg: "#f1f5f9" },
|
||||
].map((c) => (
|
||||
<div key={c.label} style={{ background: "#fff", border: "1px solid #eaeef4",
|
||||
borderRadius: 12, padding: "14px 16px", boxShadow: "0 1px 4px rgba(16,24,40,0.06)" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 8 }}>
|
||||
<div style={{ width: 30, height: 30, borderRadius: 9, background: c.bg,
|
||||
display: "flex", alignItems: "center", justifyContent: "center", fontSize: 15 }}>{c.ico}</div>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: "#64748b" }}>{c.label}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 800, color: "#0f172a" }}>{c.valor}</div>
|
||||
<div style={{ fontSize: 11, color: "#94a3b8", marginTop: 2 }}>{c.sub}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Tick del eje inferior: muestra el nombre del mes UNA sola vez, en el punto
|
||||
// central de cada grupo de dias de ese mes.
|
||||
function TickMes({ x, y, payload, serie }) {
|
||||
const mes = payload.value;
|
||||
const idxs = serie.map((p, i) => (p.mes === mes ? i : -1)).filter((i) => i >= 0);
|
||||
const centro = idxs[Math.floor(idxs.length / 2)];
|
||||
// Solo dibuja en el indice central del grupo (evita repetir el mes)
|
||||
if (serie[payload.index] && payload.index !== centro) return null;
|
||||
return (
|
||||
<text x={x} y={y + 12} textAnchor="middle" fontSize={11} fontWeight={700} fill="#475569">
|
||||
{mes}
|
||||
</text>
|
||||
);
|
||||
}
|
||||
|
||||
function Tarjetas({ r, num }) {
|
||||
const pctProc = Number(r.pct_procesados || 0);
|
||||
const pctCont = Number(r.pct_contactados || 0);
|
||||
const soles = (v) => `$${Number(v || 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
const grupos = [
|
||||
...(r.tiene_pauta ? [{
|
||||
titulo: "Pauta", ico: "📣", acento: "#ea580c", suave: "#fff7ed",
|
||||
filas: [
|
||||
{ par: [ { label: "Importe Gastado", valor: soles(r.importe) },
|
||||
{ label: "V. Venta Inicial", valor: `S/ ${num(r.venta_inicial)}` } ] },
|
||||
{ par: [ { label: "Resultados", valor: num(r.resultados) },
|
||||
{ label: "ROAS", valor: `${Number(r.roas || 0).toLocaleString("es-PE")}x` } ] },
|
||||
{ par: [ { label: "CPL", valor: soles(r.cpl) },
|
||||
{ label: "", valor: "" } ] },
|
||||
],
|
||||
}] : []),
|
||||
{
|
||||
titulo: "Cartera", ico: "📇", acento: "#2563eb", suave: "#eff6ff",
|
||||
filas: [
|
||||
{ label: "Total", valor: num(r.cartera_total) },
|
||||
{ label: "Copito", valor: num(r.cartera_copito) },
|
||||
{ label: "Otros", valor: num(r.cartera_otros) },
|
||||
],
|
||||
},
|
||||
{
|
||||
titulo: "Leads", ico: "🎯", acento: "#0f766e", suave: "#f0fdfa",
|
||||
filas: [
|
||||
{ label: "Recibidos", valor: num(r.leads_recibidos) },
|
||||
{ label: "Procesados", valor: num(r.leads_procesados) },
|
||||
{ label: "% Procesados", valor: `${pctProc.toLocaleString("es-PE")}%`, pct: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
titulo: "Contacto", ico: "💬", acento: "#7c3aed", suave: "#f5f3ff",
|
||||
filas: [
|
||||
{ label: "Procesados", valor: num(r.leads_procesados) },
|
||||
{ label: "Contactados", valor: num(r.leads_contactados) },
|
||||
{ label: "% Contactados", valor: `${pctCont.toLocaleString("es-PE")}%`, pct: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid",
|
||||
gridTemplateColumns: "repeat(4, 1fr)",
|
||||
gap: 14, marginBottom: 14 }}>
|
||||
{grupos.map((g) => (
|
||||
<div key={g.titulo} style={{
|
||||
background: "#fff", border: "1px solid #eaeef4", borderRadius: 14,
|
||||
overflow: "hidden", boxShadow: "0 1px 4px rgba(16,24,40,0.06)" }}>
|
||||
{/* Barra de acento superior */}
|
||||
<div style={{ height: 4, background: g.acento }} />
|
||||
<div style={{ padding: "14px 16px 6px" }}>
|
||||
{/* Título con ícono */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
|
||||
<div style={{ width: 30, height: 30, borderRadius: 9, background: g.suave,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: 15 }}>{g.ico}</div>
|
||||
<div style={{ fontSize: 12, fontWeight: 800, color: g.acento,
|
||||
textTransform: "uppercase", letterSpacing: 0.6 }}>{g.titulo}</div>
|
||||
</div>
|
||||
{g.filas.map((f, i) => (
|
||||
f.par ? (
|
||||
// Dos valores lado a lado (ej. CPL | ROAS)
|
||||
<div key={"par" + i} style={{ display: "grid", gridTemplateColumns: "1fr 1fr",
|
||||
gap: 10, padding: "9px 0",
|
||||
borderBottom: i < g.filas.length - 1 ? "1px solid #f4f6fa" : "none" }}>
|
||||
{f.par.map((c, j) => (
|
||||
<div key={c.label || ("v" + j)}>
|
||||
{c.label ? <div style={{ fontSize: 11.5, color: "#64748b", fontWeight: 500 }}>{c.label}</div> : null}
|
||||
{c.valor ? <div style={{ fontSize: 17, fontWeight: 800, color: "#0f172a" }}>{c.valor}</div> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div key={f.label} style={{
|
||||
display: "flex", justifyContent: "space-between", alignItems: "center",
|
||||
padding: "9px 0",
|
||||
borderBottom: i < g.filas.length - 1 ? "1px solid #f4f6fa" : "none" }}>
|
||||
<span style={{ fontSize: 12.5, color: "#64748b", fontWeight: 500 }}>{f.label}</span>
|
||||
{f.pct ? (
|
||||
<span style={{ fontSize: 13, fontWeight: 800, color: g.acento,
|
||||
background: g.suave, borderRadius: 999,
|
||||
padding: "3px 10px" }}>{f.valor}</span>
|
||||
) : (
|
||||
<span style={{ fontSize: 17, fontWeight: 800, color: "#0f172a" }}>{f.valor}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user