Files
dashboard-leads/frontend/src/pages/Leads.jsx

765 lines
44 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useMemo, Fragment } from "react";
import { api } from "../lib/api";
import { Loader, ErrorBox, Filters, Select } from "../components/UI";
import { useColumnasAjustables } from "../lib/useColumnasAjustables";
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LabelList,
AreaChart, Area, Legend, ComposedChart,
} from "recharts";
const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
export default function Leads() {
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 [tipoProg, setTipoProg] = useState("TODOS");
const [sede, setSede] = useState("TODOS");
const [filtros, setFiltros] = useState({ anos: [2026], tipos: [] });
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [expandido, setExpandido] = useState({}); // estado -> abierto
const [expAsesor, setExpAsesor] = useState({}); // "estado|asesor" -> abierto
const [expSede, setExpSede] = useState({}); // sede -> abierto (tabla pauta)
const [expTipo, setExpTipo] = useState({}); // "sede|tipo" -> abierto (códigos)
const [expMat, setExpMat] = useState({}); // num_indice -> abierto (subfilas canal)
const [modalCurso, setModalCurso] = useState(null); // curso seleccionado para el pop-up "Ver"
const [editando, setEditando] = useState(false); // modo edición del pop-up
const [editPauta, setEditPauta] = useState(""); // valor pauta en edición
const [editConj, setEditConj] = useState(""); // conjunto elegido
const [editContar, setEditContar] = useState("SI"); // switch contar SI/NO
const [avisoContar, setAvisoContar] = useState(false);// mostró la alerta al cambiar a NO
const [conjOpc, setConjOpc] = useState([]); // conjuntos sin pauta (desplegable)
const [guardando, setGuardando] = useState(false);
const [msgGuardar, setMsgGuardar] = useState("");
const [reload, setReload] = useState(0);
const [avisoUso, setAvisoUso] = useState(null); // {cursos, conjuntos} si la pauta ya se usa
// Vista de columnas de la matriz: TOTAL y/o ASIGNADO. Si ninguno → ambos (todo).
const [verTotal, setVerTotal] = useState(true);
const [verAsig, setVerAsig] = useState(true);
// Definición de columnas de la matriz. modo: "fija" | "total" | "asig".
// key = campo en la fila; label = encabezado; w = ancho; render opcional.
const _fmtMoney = (v) => v ? `$${Number(v).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : "—";
const COLS_MAT = [
{ key: "personalizado", label: "Programa", w: 340, modo: "fija", prog: true },
{ key: "fecha_inicio", label: "Fecha Inicio", w: 110, modo: "fija" },
{ key: "importe_pauta", label: "Importe Pauta", w: 120, modo: "fija", bold: true, render: (c) => _fmtMoney(c.importe_pauta) },
{ key: "importe_pauta_mes", label: "Importe Pauta en el Mes", w: 150, modo: "fija", bold: true, render: (c) => _fmtMoney(c.importe_pauta_mes) },
{ key: "cartera_total", label: "Cartera Total", w: 110, modo: "total" },
{ key: "cartera_total_asig", label: "Cartera Total Asig.", w: 130, modo: "asig", bold: true },
{ key: "leads_nuevos", label: "L. Recibidos", w: 120, modo: "total" },
{ key: "leads_nuevos_asesor", label: "L. Procesados", w: 130, modo: "asig" },
{ key: "leads_nuevos_mes", label: "L. Recibidos del Mes", w: 150, modo: "total" },
{ key: "leads_nuevos_mes_asesor", label: "L. Procesados del Mes", w: 160, modo: "asig" },
{ key: "matriculas_no_iden", label: "Matriculas no iden.", w: 140, modo: "fija", bold: true },
{ key: "mat_leads_nuevos", label: "Matriculas Leads Nuevos", w: 160, modo: "fija", bold: true },
{ key: "mat_leads_antiguos", label: "Matriculas Leads Antiguos", w: 170, modo: "fija", bold: true },
{ key: "__ver", label: "", w: 70, modo: "fija", ver: true },
];
// Si desmarcan ambos → mostrar todo (como si ambos activos)
const _t = verTotal || (!verTotal && !verAsig);
const _a = verAsig || (!verTotal && !verAsig);
const colsVisibles = COLS_MAT.filter(
(c) => c.modo === "fija" || (c.modo === "total" && _t) || (c.modo === "asig" && _a)
);
// Columnas redimensionables de la matriz (según columnas visibles)
const colsMat = useColumnasAjustables(colsVisibles.map((c) => c.w));
// Cargar opciones de filtros una vez
useEffect(() => {
api.leadsFiltros().then(setFiltros).catch(() => {});
}, []);
// Auto-refresco: cada 15 min vuelve a pedir datos frescos (sin que el usuario haga F5)
// y al recibir "datos-actualizar" (tras guardar en Config) recarga al instante.
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); };
}, []);
// Cargar dashboard cuando cambian los filtros (igual que el PBI)
useEffect(() => {
let activo = true;
// Mostrar spinner solo si aún no hay datos (primera carga). En recargas/auto-
// refresco, actualiza en silencio para no tapar la tabla.
if (!data) setLoading(true);
setError(null);
api.leads(ano, mes, dia, tipoProg, sede)
.then((res) => { if (activo) { setData(res); setLoading(false); } })
.catch((e) => { if (activo) { setError(e.message); setLoading(false); } });
return () => { activo = false; };
}, [ano, mes, dia, tipoProg, sede, reload]);
const k = data?.kpis;
const fmtPct = (v) => `${Number(v ?? 0).toFixed(2)} %`;
// Cierra el pop-up y resetea el modo edición (para que reabra limpio)
function cerrarModal() {
setModalCurso(null); setEditando(false); setAvisoUso(null);
setAvisoContar(false); setMsgGuardar("");
}
// Guarda la pauta/conjunto (llamado tras confirmar o si la pauta no está en uso)
async function hacerGuardado() {
setGuardando(true); setMsgGuardar(""); setAvisoUso(null);
try {
await api.guardarPauta(modalCurso.num_indice, editPauta.trim(), editConj || null, editContar);
setMsgGuardar("✓ Guardado. Refrescando datos...");
setTimeout(() => { setModalCurso(null); setEditando(false); setReload((n) => n + 1); }, 800);
} catch (e) {
setMsgGuardar("Error al guardar: " + e.message);
} finally { setGuardando(false); }
}
// opciones de filtros
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 optTipo = useMemo(() => ["TODOS", "TEAC", "TERC", "SEMINARIOS", "OTROS"], []);
const optSede = useMemo(() => ["TODOS", "LIMA", "AREQUIPA", "TRUJILLO", "PIURA"], []);
return (
<div>
<h1 className="page-title">🎯 Leads</h1>
<Filters>
<Select label="Programa" value={tipoProg} options={optTipo} onChange={setTipoProg} />
<Select label="Sede" value={sede} options={optSede} onChange={setSede} />
<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 leads..." /> :
error ? <ErrorBox msg={error} /> :
!k ? <ErrorBox msg="Sin datos" /> :
<>
{/* ── FILA DE KPIs (4 tarjetas compuestas, como el PBI) ── */}
<div className="kpis">
{/* Tarjeta 1: Leads Recibidos / Procesados / % */}
<div className="kpi-multi">
<KpiRow ico="🧲" label="Leads Recibidos" value={k.leads_recibidos} />
<KpiRow ico="🛠️" label="Leads Procesados" value={k.leads_procesados} />
<KpiRow ico="↻" label="% Porcentaje Procesados" value={fmtPct(k.pct_procesados)} />
</div>
{/* Tarjeta 2: Procesados / Contactados / % */}
<div className="kpi-multi">
<KpiRow ico="🔧" label="Leads Procesados" value={k.leads_procesados} />
<KpiRow ico="📞" label="Total Contactados" value={k.leads_contactados} />
<KpiRow ico="↻" label="% Contactabilidad" value={fmtPct(k.pct_contactados)} />
</div>
{/* Tarjeta 3: Matriculados / Matrículas mes / Ocupabilidad */}
<div className="kpi-multi">
<KpiRow ico="🎓" label="Total matriculados" value={k.total_matriculados} />
<KpiRow ico="📅" label="Matrículas en los Cursos del Mes" value={k.matriculas_mes} />
<KpiRow ico="💱" label="Ocupabilidad en los Cursos del Mes" value={fmtPct(k.ocupabilidad)} />
</div>
{/* Tarjeta 4: Cursos programados / reprog / susp / iniciados */}
<div className="kpi-multi">
<KpiRow ico="📚" label="Cursos Programados" value={k.cursos_programados} />
<KpiRow ico="🔁" label="Cursos Reprogramados" value={k.cursos_reprogramados} />
<KpiRow ico="⛔" label="Cursos Suspendidos" value={k.cursos_suspendidos} />
<KpiRow ico="🚀" label="Cursos Iniciados" value={k.cursos_iniciados} />
</div>
</div>
{/* ── FILA INFERIOR: gráfico + tabla ── */}
<div style={{ display: "grid", gridTemplateColumns: "1.4fr 1fr", gap: 16, marginTop: 16 }}>
<div className="card">
<div className="card-title">Matrículas por Día</div>
<ResponsiveContainer width="100%" height={320}>
<ComposedChart data={data.matriculas_por_dia} margin={{ top: 24, right: 24, left: -6, bottom: 8 }}>
<defs>
<linearGradient id="gradMat" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3b82f6" stopOpacity={0.25} />
<stop offset="100%" stopColor="#3b82f6" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#eef2f7" vertical={false} />
<XAxis dataKey="dia" tick={{ fontSize: 10, fill: "#94a3b8" }} interval={0}
axisLine={{ stroke: "#e2e8f0" }} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: "#94a3b8" }} allowDecimals={false}
axisLine={false} tickLine={false} width={32} />
<Tooltip content={<TooltipMatriculas />} cursor={{ stroke: "#cbd5e1", strokeDasharray: "4 4" }} />
<Area type="monotone" dataKey="cantidad" stroke="none" fill="url(#gradMat)" />
<Line type="monotone" dataKey="cantidad" stroke="#2563eb" strokeWidth={2.5}
dot={{ r: 3, fill: "#fff", stroke: "#2563eb", strokeWidth: 2 }}
activeDot={{ r: 5, fill: "#2563eb", stroke: "#fff", strokeWidth: 2 }}>
<LabelList dataKey="cantidad" position="top" fontSize={10} fill="#64748b"
formatter={(v) => (v > 0 ? v : "")} />
</Line>
</ComposedChart>
</ResponsiveContainer>
</div>
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
<div className="table-wrap" style={{ border: "none", maxHeight: 360, overflowY: "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 Procesados</th>
</tr>
</thead>
<tbody>
{(data.estado_objecion?.filas || []).map((f) => {
const abierto = !!expandido[f.estado];
const tieneAsesores = (f.asesores || []).length > 0;
return (
<Fragment key={f.estado}>
<tr>
<td className="col-name">
{tieneAsesores && (
<button
onClick={() => setExpandido((p) => ({ ...p, [f.estado]: !p[f.estado] }))}
style={{ border: "none", background: "transparent", cursor: "pointer",
fontWeight: 700, marginRight: 6, color: "#2563eb", fontSize: 14 }}>
{abierto ? "−" : "+"}
</button>
)}
{f.estado}
</td>
<td>{f.cantidad}</td>
</tr>
{abierto && (f.asesores || []).map((a) => {
const keyA = f.estado + "|" + a.asesor;
const abiertoA = !!expAsesor[keyA];
const tieneTel = (a.telefonos || []).length > 0;
return (
<Fragment key={keyA}>
<tr style={{ background: "#f8fafc" }}>
<td className="col-name" style={{ paddingLeft: 30, color: "#475569", fontSize: 12 }}>
{tieneTel && (
<button
onClick={() => setExpAsesor((p) => ({ ...p, [keyA]: !p[keyA] }))}
style={{ border: "none", background: "transparent", cursor: "pointer",
fontWeight: 700, marginRight: 6, color: "#7c3aed", fontSize: 13 }}>
{abiertoA ? "−" : "+"}
</button>
)}
{a.asesor}
</td>
<td style={{ color: "#475569", fontSize: 12 }}>{a.cantidad}</td>
</tr>
{abiertoA && (a.telefonos || []).map((t, i) => (
<tr key={keyA + "-" + t + "-" + i} style={{ background: "#fcfdff" }}>
<td className="col-name" style={{ paddingLeft: 60, color: "#94a3b8", fontSize: 11 }}>📞 {t}</td>
<td></td>
</tr>
))}
</Fragment>
);
})}
</Fragment>
);
})}
<tr className="total-row">
<td className="col-name">Total</td>
<td>{data.estado_objecion.total}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
{/* ── FILA 2: Leads por Programa (área) + Tabla por Sede ── */}
<div style={{ display: "grid", gridTemplateColumns: "1.4fr 1fr", gap: 16, marginTop: 16 }}>
<div className="card">
<div className="card-title">Leads por Programa</div>
<ResponsiveContainer width="100%" height={320}>
<AreaChart data={data.leads_por_dia} margin={{ top: 10, right: 24, left: -6, bottom: 8 }}>
<defs>
<linearGradient id="gradTot" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#60a5fa" stopOpacity={0.55} />
<stop offset="100%" stopColor="#60a5fa" stopOpacity={0.05} />
</linearGradient>
<linearGradient id="gradProc" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#1e40af" stopOpacity={0.6} />
<stop offset="100%" stopColor="#1e40af" stopOpacity={0.1} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#eef2f7" vertical={false} />
<XAxis dataKey="dia" tick={{ fontSize: 10, fill: "#94a3b8" }} interval={0}
axisLine={{ stroke: "#e2e8f0" }} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: "#94a3b8" }} allowDecimals={false}
axisLine={false} tickLine={false} width={32} />
<Tooltip contentStyle={{ borderRadius: 10, border: "1px solid #e2e8f0",
boxShadow: "0 6px 20px rgba(0,0,0,0.1)", fontSize: 12 }} />
<Legend iconType="circle" wrapperStyle={{ fontSize: 12, paddingTop: 4 }} />
<Area type="linear" dataKey="totales" name="Leads Totales" stroke="#60a5fa"
fill="url(#gradTot)" strokeWidth={2.5}
dot={{ r: 2.5, fill: "#60a5fa", stroke: "#fff", strokeWidth: 1 }}
activeDot={{ r: 5 }} />
<Area type="linear" dataKey="procesados" name="Leads Procesados" stroke="#1e40af"
fill="url(#gradProc)" strokeWidth={2.5}
dot={{ r: 2.5, fill: "#1e40af", stroke: "#fff", strokeWidth: 1 }}
activeDot={{ r: 5 }} />
</AreaChart>
</ResponsiveContainer>
</div>
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
<div className="table-wrap" style={{ border: "none", maxHeight: 360, overflowY: "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 }}>Importe Gastado</th>
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Resultados</th>
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Recibidos</th>
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Procesados</th>
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Matr.</th>
<th style={{ position: "sticky", top: 0, zIndex: 2, minWidth: 130, whiteSpace: "nowrap" }}>Valor Venta</th>
</tr>
</thead>
<tbody>
{(data.tabla_pauta?.filas || []).map((f) => {
const abierto = !!expSede[f.sede];
return (
<Fragment key={f.sede}>
<tr>
<td className="col-name">
<button onClick={() => setExpSede((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 style={{ fontWeight: 600 }}>{f.importe ? `$${Number(f.importe).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : "—"}</td>
<td>{f.resultados}</td>
<td>{f.recibidos}</td>
<td>{f.procesados}</td>
<td>{f.matriculas}</td>
<td>S/ {Number(f.inversion || 0).toLocaleString("es-PE")}</td>
</tr>
{abierto && (f.subfilas || []).map((s) => {
const keyT = f.sede + "|" + s.tipo;
const abiertoT = !!expTipo[keyT];
const tieneCod = (s.codigos || []).length > 0;
return (
<Fragment key={keyT}>
<tr style={{ background: "#f8fafc" }}>
<td className="col-name" style={{ paddingLeft: 30, color: "#475569", fontSize: 12 }}>
{tieneCod && (
<button onClick={() => setExpTipo((p) => ({ ...p, [keyT]: !p[keyT] }))}
style={{ border: "none", background: "transparent", cursor: "pointer",
fontWeight: 700, marginRight: 6, color: "#7c3aed", fontSize: 13 }}>
{abiertoT ? "−" : "+"}
</button>
)}
{s.tipo}
</td>
<td style={{ color: "#475569", fontSize: 12, fontWeight: 600 }}>{s.importe ? `$${Number(s.importe).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : "—"}</td>
<td style={{ color: "#475569", fontSize: 12 }}>{s.resultados}</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 }}>S/ {Number(s.inversion || 0).toLocaleString("es-PE")}</td>
</tr>
{abiertoT && (s.codigos || []).map((cd) => (
<tr key={keyT + "-" + cd.codigo} style={{ background: "#fcfdff" }}>
<td className="col-name" style={{ paddingLeft: 56, color: "#94a3b8", fontSize: 11 }}>cód. {cd.codigo}</td>
<td style={{ color: "#94a3b8", fontSize: 11 }}>{cd.importe ? `$${Number(cd.importe).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : "—"}</td>
<td style={{ color: "#94a3b8", fontSize: 11 }}>{cd.resultados ? cd.resultados : "—"}</td>
<td style={{ color: "#94a3b8", fontSize: 11 }}>{cd.recibidos}</td>
<td style={{ color: "#94a3b8", fontSize: 11 }}>{cd.procesados}</td>
<td style={{ color: "#94a3b8", fontSize: 11 }}>{cd.matriculas}</td>
<td style={{ color: "#94a3b8", fontSize: 11 }}>S/ {Number(cd.inversion || 0).toLocaleString("es-PE")}</td>
</tr>
))}
</Fragment>
);
})}
</Fragment>
);
})}
<tr className="total-row">
<td className="col-name">Total</td>
<td>{`$${Number(data.tabla_pauta.total.importe || 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`}</td>
<td>{data.tabla_pauta.total.resultados}</td>
<td>{data.tabla_pauta.total.recibidos}</td>
<td>{data.tabla_pauta.total.procesados}</td>
<td>{data.tabla_pauta.total.matriculas}</td>
<td>S/ {Number(data.tabla_pauta.total.inversion).toLocaleString("es-PE")}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
{/* ── FILA 3: Matriz por Curso (num_indice) ── */}
<div className="card" style={{ marginTop: 16, padding: 0, overflow: "hidden" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "14px 16px 6px", gap: 12, flexWrap: "wrap" }}>
<div className="card-title" style={{ padding: 0 }}>Detalle por Curso</div>
<div style={{ display: "flex", alignItems: "center", gap: 8,
background: "#f8fafc", border: "1px solid #e2e8f0",
borderRadius: 10, padding: "5px 8px" }}>
<span style={{ fontSize: 10, fontWeight: 700, color: "#94a3b8",
textTransform: "uppercase", letterSpacing: ".5px", marginRight: 2 }}>
Ver
</span>
{[["TOTAL", verTotal, setVerTotal], ["ASIGNADO", verAsig, setVerAsig]].map(([txt, val, set]) => (
<label key={txt} onClick={() => set((v) => !v)}
style={{
display: "flex", alignItems: "center", gap: 7, cursor: "pointer",
padding: "5px 12px", borderRadius: 8, userSelect: "none",
fontSize: 12, fontWeight: 700, letterSpacing: ".3px",
border: val ? "1px solid #1e3a5f" : "1px solid #e2e8f0",
background: val ? "#eef2f9" : "#fff",
color: val ? "#1e3a5f" : "#64748b",
transition: "all .12s ease",
}}>
<span style={{
width: 16, height: 16, borderRadius: 5, flexShrink: 0,
display: "flex", alignItems: "center", justifyContent: "center",
border: val ? "none" : "1.5px solid #cbd5e1",
background: val ? "#1e3a5f" : "#fff",
}}>
{val && (
<svg width="10" height="10" viewBox="0 0 24 24" fill="none"
stroke="#fff" strokeWidth="4" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
)}
</span>
{txt}
</label>
))}
</div>
</div>
<div className="table-wrap" style={{ border: "none", maxHeight: 420, overflow: "auto" }}>
<table className="matriz-grid" {...colsMat.tableProps}>
<colsMat.ColGroup />
<thead>
<tr>
{colsVisibles.map((col, i) => (
<th key={col.key} style={{ position: "sticky", top: 0, zIndex: 2,
textAlign: i === 0 ? "left" : "center" }}>{col.label}<colsMat.Resizer index={i} /></th>
))}
</tr>
</thead>
<tbody>
{(data.matriz_cursos?.filas || []).map((c) => {
const abierto = !!expMat[c.num_indice];
return (
<Fragment key={c.num_indice}>
<tr>
{colsVisibles.map((col) => col.prog ? (
<td key={col.key} className="col-name" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{(c.subfilas_canal && c.subfilas_canal.length > 0) && (
<span
onClick={() => setExpMat((p) => ({ ...p, [c.num_indice]: !p[c.num_indice] }))}
style={{ cursor: "pointer", fontWeight: 700, color: "#2563eb",
marginRight: 6, userSelect: "none", display: "inline-block", width: 14 }}
title="Ver por canal (Pauta / Web / Otros)">
{abierto ? "−" : "+"}
</span>
)}
{c.personalizado}
</td>
) : col.ver ? (
<td key={col.key} style={{ textAlign: "center" }}>
<button onClick={() => setModalCurso(c)}
style={{ border: "1px solid #1e3a5f", background: "#eef2f9", color: "#1e3a5f",
borderRadius: 7, padding: "3px 12px", fontSize: 12, fontWeight: 700,
cursor: "pointer" }}>
Ver
</button>
</td>
) : (
<td key={col.key} style={col.bold ? { fontWeight: 600 } : undefined}>
{col.render ? col.render(c) : c[col.key]}
</td>
))}
</tr>
{abierto && (c.subfilas_canal || []).map((sf) => (
<tr key={c.num_indice + sf.canal} style={{ background: "#f8fafc" }}>
{colsVisibles.map((col) => {
if (col.prog) return (
<td key={col.key} className="col-name" style={{ paddingLeft: 30, color: "#475569", fontSize: 12 }}>{sf.canal}</td>
);
if (col.ver) return <td key={col.key}></td>;
// Estas columnas -> "-" en subfilas (Importe y Leads Recibidos/Procesados)
const GUION = ["importe_pauta", "importe_pauta_mes",
"leads_nuevos", "leads_nuevos_asesor",
"leads_nuevos_mes", "leads_nuevos_mes_asesor"];
if (GUION.includes(col.key)) return (
<td key={col.key} style={{ color: "#94a3b8", fontSize: 12 }}>—</td>
);
const val = sf[col.key];
return (
<td key={col.key} style={{ color: "#475569", fontSize: 12 }}>{val === undefined ? "—" : val}</td>
);
})}
</tr>
))}
</Fragment>
);
})}
</tbody>
</table>
</div>
</div>
</>}
{/* ── Pop-up "Ver": num_indice, pauta y conjuntos de anuncios ── */}
{modalCurso && (
<div onClick={cerrarModal}
style={{ position: "fixed", inset: 0, background: "rgba(15,23,42,0.55)",
display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1000 }}>
<div onClick={(e) => e.stopPropagation()}
style={{ background: "#fff", borderRadius: 16, width: 560, maxWidth: "94vw",
maxHeight: "86vh", overflow: "auto", boxShadow: "0 24px 70px rgba(0,0,0,0.4)",
fontFamily: "system-ui, -apple-system, 'Segoe UI', sans-serif" }}>
<div style={{ background: "#1e3a5f", color: "#fff", padding: "16px 22px",
borderTopLeftRadius: 14, borderTopRightRadius: 14,
display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div style={{ fontWeight: 700, fontSize: 15 }}>{modalCurso.personalizado}</div>
<button onClick={cerrarModal}
style={{ background: "transparent", border: "none", color: "#fff", fontSize: 22,
cursor: "pointer", lineHeight: 1 }}>×</button>
</div>
<div style={{ padding: "18px 22px" }}>
<div style={{ display: "flex", gap: 24, marginBottom: 18, alignItems: "flex-start" }}>
<div>
<div style={{ fontSize: 11, color: "#94a3b8", fontWeight: 700, textTransform: "uppercase" }}>Num Índice</div>
<div style={{ fontSize: 18, fontWeight: 700, color: "#0f172a" }}>{modalCurso.num_indice || "—"}</div>
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 11, color: "#94a3b8", fontWeight: 700, textTransform: "uppercase" }}>Pauta</div>
{editando ? (
<input value={editPauta} onChange={(e) => setEditPauta(e.target.value)}
placeholder="Código de pauta"
style={{ marginTop: 4, padding: "7px 10px", border: "1px solid #cbd5e1",
borderRadius: 8, fontSize: 14, width: "100%" }} />
) : (
<div style={{ fontSize: 18, fontWeight: 700, color: "#1e40af" }}>{modalCurso.pauta || "—"}</div>
)}
</div>
</div>
{editando ? (
<>
<div style={{ fontSize: 11, color: "#94a3b8", fontWeight: 700, textTransform: "uppercase", marginBottom: 6 }}>
Vincular Conjunto de Anuncio (opcional)
</div>
<select value={editConj} onChange={(e) => setEditConj(e.target.value)}
style={{ padding: "8px 10px", border: "1px solid #cbd5e1", borderRadius: 8,
fontSize: 13, width: "100%", background: "#fff" }}>
<option value="">— Ninguno —</option>
{conjOpc.map((cj) => <option key={cj} value={cj}>{cj}</option>)}
</select>
{/* Switch: contar SI/NO */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between",
marginTop: 18, padding: "12px 14px", background: "#f8fafc",
border: "1px solid #e2e8f0", borderRadius: 10 }}>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: "#0f172a" }}>Mostrar en la lista</div>
<div style={{ fontSize: 12, color: "#64748b" }}>
{editContar === "SI" ? "Este programa aparece en el Detalle por Curso" : "Este programa quedará oculto"}
</div>
</div>
<div onClick={() => {
const nuevo = editContar === "SI" ? "NO" : "SI";
setEditContar(nuevo);
if (nuevo === "NO") setAvisoContar(true);
}}
style={{ width: 46, height: 26, borderRadius: 999, cursor: "pointer",
background: editContar === "SI" ? "#2563eb" : "#cbd5e1",
position: "relative", transition: "background .15s", flexShrink: 0 }}>
<div style={{ width: 20, height: 20, borderRadius: "50%", background: "#fff",
position: "absolute", top: 3, left: editContar === "SI" ? 23 : 3,
transition: "left .15s", boxShadow: "0 1px 3px rgba(0,0,0,0.3)" }} />
</div>
</div>
{avisoContar && editContar === "NO" && (
<div style={{ marginTop: 10, padding: "10px 12px", background: "#fffbeb",
border: "1px solid #fde68a", borderRadius: 8, fontSize: 12.5, color: "#92400e" }}>
⚠️ Al poner <b>NO</b> y guardar, este programa ya no aparecerá en la lista.
Solo podrás volver a añadirlo desde Configuración.
</div>
)}
{msgGuardar && <div style={{ marginTop: 10, fontSize: 13, color: msgGuardar.startsWith("✓") ? "#047857" : "#b91c1c" }}>{msgGuardar}</div>}
<div style={{ display: "flex", gap: 8, marginTop: 16, justifyContent: "flex-end" }}>
<button onClick={() => { setEditando(false); setMsgGuardar(""); setAvisoContar(false); }}
style={{ padding: "8px 16px", border: "1px solid #cbd5e1", background: "#fff",
borderRadius: 8, fontSize: 13, fontWeight: 600, cursor: "pointer" }}>
Cancelar
</button>
{(() => {
const cambioContar = editContar !== (modalCurso.contar || "SI");
const puedeGuardar = editPauta.trim() || cambioContar;
return (
<button disabled={guardando || !puedeGuardar}
onClick={async () => {
setMsgGuardar("");
try {
// Verificar uso solo si se ingresó pauta
if (editPauta.trim()) {
const uso = await api.usoPauta(editPauta.trim(), modalCurso.num_indice);
if (uso.en_uso) { setAvisoUso(uso); return; }
}
await hacerGuardado();
} catch (e) { setMsgGuardar("Error: " + e.message); }
}}
style={{ padding: "8px 18px", border: "none", background: "#1e3a5f", color: "#fff",
borderRadius: 8, fontSize: 13, fontWeight: 700,
cursor: guardando ? "wait" : "pointer", opacity: (!puedeGuardar || guardando) ? 0.6 : 1 }}>
{guardando ? "Guardando..." : "Guardar"}
</button>
);
})()}
</div>
</>
) : (
<>
<div style={{ fontSize: 11, color: "#94a3b8", fontWeight: 700, textTransform: "uppercase", marginBottom: 8 }}>
Conjuntos de Anuncios ({(modalCurso.conjuntos || []).length})
</div>
{(modalCurso.conjuntos || []).length === 0 ? (
<div style={{ color: "#94a3b8", fontSize: 13 }}>Sin conjuntos vinculados a esta pauta.</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{modalCurso.conjuntos.map((cj, i) => (
<div key={i} style={{ background: "#f8fafc", border: "1px solid #e2e8f0",
borderRadius: 8, padding: "8px 12px", fontSize: 13, color: "#334155" }}>
{cj}
</div>
))}
</div>
)}
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: 16 }}>
<button onClick={() => {
setEditando(true); setEditPauta(modalCurso.pauta || ""); setEditConj("");
setEditContar(modalCurso.contar || "SI"); setAvisoContar(false); setMsgGuardar("");
api.conjuntosSinPauta().then((r) => setConjOpc(r.conjuntos || [])).catch(() => setConjOpc([]));
}}
style={{ padding: "8px 18px", border: "1px solid #1e3a5f", background: "#eef2f9",
color: "#1e3a5f", borderRadius: 8, fontSize: 13, fontWeight: 700, cursor: "pointer" }}>
Editar
</button>
</div>
</>
)}
</div>
</div>
</div>
)}
{/* ── Advertencia: la pauta ya está en uso ── */}
{avisoUso && (
<div style={{ position: "fixed", inset: 0, background: "rgba(15,23,42,0.6)",
display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1100 }}>
<div style={{ background: "#fff", borderRadius: 14, width: 440, maxWidth: "92vw",
maxHeight: "82vh", overflow: "auto", boxShadow: "0 20px 60px rgba(0,0,0,0.4)" }}>
<div style={{ background: "#b45309", color: "#fff", padding: "14px 20px",
borderTopLeftRadius: 14, borderTopRightRadius: 14, fontWeight: 700, fontSize: 15 }}>
⚠️ Esta pauta ya está en uso
</div>
<div style={{ padding: "18px 22px" }}>
<div style={{ fontSize: 13, color: "#334155", marginBottom: 12 }}>
La pauta <b>{editPauta}</b> ya está asignada a:
</div>
{(avisoUso.cursos || []).length > 0 && (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 11, color: "#94a3b8", fontWeight: 700, textTransform: "uppercase", marginBottom: 4 }}>Programas</div>
{avisoUso.cursos.map((cu, i) => (
<div key={i} style={{ background: "#fffbeb", border: "1px solid #fde68a",
borderRadius: 8, padding: "6px 10px", fontSize: 12, color: "#92400e", marginBottom: 4 }}>
{cu.dsc_det_programa || "(sin nombre)"} <span style={{ color: "#b45309" }}>· índice {cu.num_indice}</span>
</div>
))}
</div>
)}
{(avisoUso.conjuntos || []).length > 0 && (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 11, color: "#94a3b8", fontWeight: 700, textTransform: "uppercase", marginBottom: 4 }}>Conjuntos de anuncios</div>
{avisoUso.conjuntos.map((cj, i) => (
<div key={i} style={{ background: "#f8fafc", border: "1px solid #e2e8f0",
borderRadius: 8, padding: "6px 10px", fontSize: 12, color: "#334155", marginBottom: 4 }}>
{cj}
</div>
))}
</div>
)}
<div style={{ fontSize: 13, color: "#0f172a", fontWeight: 600, marginTop: 10 }}>
¿Estás seguro de continuar?
</div>
<div style={{ display: "flex", gap: 8, marginTop: 16, justifyContent: "flex-end" }}>
<button onClick={() => setAvisoUso(null)}
style={{ padding: "8px 18px", border: "1px solid #cbd5e1", background: "#fff",
borderRadius: 8, fontSize: 13, fontWeight: 600, cursor: "pointer" }}>
No
</button>
<button onClick={hacerGuardado}
style={{ padding: "8px 20px", border: "none", background: "#b45309", color: "#fff",
borderRadius: 8, fontSize: 13, fontWeight: 700, cursor: "pointer" }}>
Sí, guardar
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}
// Tooltip personalizado: mini-gráfico de barras horizontales por tipo de programa
function TooltipMatriculas({ active, payload, label }) {
if (!active || !payload || !payload.length) return null;
const p = payload[0].payload;
const detalle = p.detalle || [];
const max = Math.max(1, ...detalle.map((d) => d.cantidad));
return (
<div style={{ background: "#fff", border: "1px solid #e2e8f0", borderRadius: 10,
padding: "10px 12px", boxShadow: "0 6px 20px rgba(0,0,0,0.12)", minWidth: 220 }}>
<div style={{ fontWeight: 700, fontSize: 12, color: "#1e293b", marginBottom: 2 }}>
Día {label} — {p.cantidad} matrícula(s)
</div>
{detalle.length === 0 ? (
<div style={{ fontSize: 11, color: "#94a3b8" }}>Sin matrículas</div>
) : detalle.map((d) => (
<div key={d.tipo} style={{ display: "flex", alignItems: "center", gap: 6, margin: "4px 0" }}>
<span style={{ fontSize: 10, color: "#475569", width: 90, textAlign: "right",
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{d.tipo}</span>
<div style={{ flex: 1, background: "#eff6ff", borderRadius: 3, height: 14, position: "relative" }}>
<div style={{ width: `${(d.cantidad / max) * 100}%`, background: "#60a5fa",
height: "100%", borderRadius: 3 }} />
</div>
<span style={{ fontSize: 11, fontWeight: 700, color: "#1e40af", width: 18 }}>{d.cantidad}</span>
</div>
))}
</div>
);
}
function KpiRow({ ico, label, value }) {
return (
<div className="kpi-row">
<span className="ico">{ico}</span>
<div className="txt">
<div className="label">{label}</div>
<div className="value">{value}</div>
</div>
</div>
);
}