Actualizar dominios a dashboardapi y dashboardv1

This commit is contained in:
Administrator
2026-08-29 12:21:21 -05:00
parent 191b5bb1ba
commit 54581c21ab
77 changed files with 7679 additions and 11728 deletions

View File

@@ -0,0 +1,423 @@
import { useState, useEffect, useMemo } from "react";
import { api } from "../lib/api";
import { Loader, ErrorBox, Filters, Select } from "../components/UI";
import {
AreaChart, Area, BarChart, Bar, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
} from "recharts";
const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
export default function Vendedores() {
const _hoy = new Date();
const [vend, setVend] = useState("TODOS");
const [ano, setAno] = useState(String(_hoy.getFullYear())); // año actual
const [mes, setMes] = useState(String(_hoy.getMonth() + 1)); // mes actual
const [dia, setDia] = useState("TODOS");
const [modoTR, setModoTR] = useState("asig"); // "asig" | "resp" para el grafico de tiempo
const [filtros, setFiltros] = useState({ anos: [2026] });
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [recargando, setRecargando] = useState(false);
const [error, setError] = useState(null);
const [reload, setReload] = useState(0);
useEffect(() => { api.leadsFiltros().then(setFiltros).catch(() => {}); }, []);
useEffect(() => {
const id = setInterval(() => setReload((n) => n + 1), 15 * 60 * 1000);
const onActualizar = () => setReload((n) => n + 1); // recarga al guardar en Config
window.addEventListener("datos-actualizar", onActualizar);
return () => { clearInterval(id); window.removeEventListener("datos-actualizar", onActualizar); };
}, []);
useEffect(() => {
let activo = true;
if (!data) setLoading(true);
setRecargando(true); // atenuar contenido mientras se recalcula
setError(null);
api.vendedores(ano, mes, dia)
.then((res) => { if (activo) { setData(res); setLoading(false); setRecargando(false); } })
.catch((e) => { if (activo) { setError(e.message); setLoading(false); setRecargando(false); } });
return () => { activo = false; };
}, [ano, mes, dia, reload]);
const optVend = useMemo(() => ["TODOS", ...((data && data.vendedores) || [])], [data]);
const optAnos = useMemo(() => {
const anos = (filtros.anos || []).map(String);
const actual = String(_hoy.getFullYear());
if (!anos.includes(actual)) anos.push(actual); // asegura que el año actual sea opcion
return ["TODOS", ...anos];
}, [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))], []);
// Filas visibles: si hay vendedor filtrado, solo esa; si no, todas
const filas = useMemo(() => {
if (!data) return [];
if (vend === "TODOS") return data.filas;
return data.filas.filter((f) => f.vendedor === vend);
}, [data, vend]);
const num = (v) => Number(v || 0).toLocaleString("es-PE");
// Resumen para las tarjetas: si hay 1 vendedor filtrado -> esa fila; si TODOS -> el total
const resumen = useMemo(() => {
if (!data) return null;
if (vend !== "TODOS") return data.filas.find((f) => f.vendedor === vend) || null;
return data.total || null;
}, [data, vend]);
// Matriz por programa: si hay 1 vendedor -> la suya; si TODOS -> el total
const matrizProg = useMemo(() => {
if (!data) return [];
if (vend !== "TODOS") return (data.matriz_prog && data.matriz_prog[vend]) || [];
return data.matriz_prog_total || [];
}, [data, vend]);
// Matriz Estado/Objeción (Ultima_Etiqueta): por vendedor o total
const matrizEtiq = useMemo(() => {
if (!data) return [];
if (vend !== "TODOS") return (data.matriz_etiq && data.matriz_etiq[vend]) || [];
return data.matriz_etiq_total || [];
}, [data, vend]);
// Serie diaria (asignados + matriculas): por vendedor o total
const serieDia = useMemo(() => {
if (!data) return [];
if (vend !== "TODOS") return (data.serie_dia && data.serie_dia[vend]) || [];
return data.serie_dia_total || [];
}, [data, vend]);
// Serie tiempo de respuesta (promedio min por dia de asignacion)
const serieTR = useMemo(() => {
if (!data) return [];
if (vend !== "TODOS") return (data.tiempo_resp && data.tiempo_resp[vend]) || [];
return data.tiempo_resp_total || [];
}, [data, vend]);
// Serie tiempo de respuesta (promedio min por dia de RESPUESTA)
const serieTRr = useMemo(() => {
if (!data) return [];
if (vend !== "TODOS") return (data.tiempo_resp_r && data.tiempo_resp_r[vend]) || [];
return data.tiempo_resp_r_total || [];
}, [data, vend]);
return (
<div>
<h1 className="page-title">🧑‍💼 Vendedores</h1>
<Filters>
<Select label="Vendedor" value={vend} options={optVend} onChange={setVend} />
<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 vendedores..." /> :
error ? <ErrorBox msg={error} /> :
!data ? <ErrorBox msg="Sin datos" /> :
<div style={{ opacity: recargando ? 0.45 : 1,
filter: recargando ? "grayscale(0.2)" : "none",
pointerEvents: recargando ? "none" : "auto",
transition: "opacity 0.25s ease, filter 0.25s ease" }}>
{resumen && <ResumenTarjetas r={resumen} num={num} />}
{/* Gráfico día a día + Estado/Objeción lado a lado */}
<div style={{ display: "grid", gridTemplateColumns: "minmax(380px, 2fr) minmax(300px, 1fr)",
gap: 16, marginTop: 16 }}>
{/* Gráfico día a día: Leads Procesados vs Matrículas */}
<div className="card">
<div className="card-title">
Leads Asignados y Matrículas por Día{vend !== "TODOS" ? ` — ${vend}` : ""}
</div>
<ResponsiveContainer width="100%" height={360}>
<AreaChart data={serieDia} margin={{ top: 10, right: 24, left: -6, bottom: 8 }}>
<defs>
<linearGradient id="gradProcV" 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="gradMatV" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#0f766e" stopOpacity={0.55} />
<stop offset="100%" stopColor="#0f766e" stopOpacity={0.08} />
</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="asignados" name="Leads Asignados" stroke="#60a5fa"
fill="url(#gradProcV)" strokeWidth={2.5}
dot={{ r: 2.5, fill: "#60a5fa", stroke: "#fff", strokeWidth: 1 }}
activeDot={{ r: 5 }} />
<Area type="linear" dataKey="matriculas" name="Matrículas" stroke="#0f766e"
fill="url(#gradMatV)" strokeWidth={2.5}
dot={{ r: 2.5, fill: "#0f766e", stroke: "#fff", strokeWidth: 1 }}
activeDot={{ r: 5 }} />
</AreaChart>
</ResponsiveContainer>
</div>
{/* Matriz Estado/Objeción (Ultima_Etiqueta) — al costado del gráfico */}
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
<div className="card-title" style={{ padding: "14px 16px 6px" }}>
Estado/Objeción{vend !== "TODOS" ? ` — ${vend}` : ""}
</div>
<div className="table-wrap" style={{ border: "none", maxHeight: 360, overflow: "auto" }}>
<table>
<thead>
<tr>
<th className="col-name" style={{ position: "sticky", top: 0, zIndex: 2, minWidth: 160 }}>Estado/Objeción</th>
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Leads Asignados</th>
</tr>
</thead>
<tbody>
{matrizEtiq.map((e) => (
<tr key={e.estado}>
<td className="col-name">{e.estado}</td>
<td>{num(e.cantidad)}</td>
</tr>
))}
{matrizEtiq.length > 0 && (
<tr className="total-row">
<td className="col-name">Total</td>
<td>{num(matrizEtiq.reduce((s, e) => s + (e.cantidad || 0), 0))}</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
{/* Tiempo de respuesta + Sin responder, lado a lado */}
<div style={{ display: "grid", gridTemplateColumns: "1.5fr 1fr", gap: 16, marginTop: 16 }}>
{(() => {
const esResp = modoTR === "resp";
const serie = esResp ? serieTRr : serieTR;
const color = esResp ? "#2563eb" : "#f59e0b";
const colorSoft = esResp ? "#3b82f6" : "#fbbf24";
return (
<div>
<div className="card" style={{ display: "flex", flexDirection: "column", height: 400 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center",
height: 40, marginBottom: 8, gap: 8 }}>
<div style={{ fontSize: 15, fontWeight: 700, color: "#0f172a" }}>
Tiempo de Respuesta por Día (min){vend !== "TODOS" ? ` — ${vend}` : ""}
</div>
<select value={modoTR} onChange={(e) => setModoTR(e.target.value)}
style={{ fontSize: 12.5, padding: "6px 10px", borderRadius: 8,
border: "1px solid #e2e8f0", background: "#f8fafc",
color: "#334155", fontWeight: 600, cursor: "pointer" }}>
<option value="asig">📌 Según Asignación</option>
<option value="resp">✅ Según Respuesta</option>
</select>
</div>
<div style={{ flex: 1 }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={serie} margin={{ top: 12, right: 24, left: -6, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" 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={38} />
<Tooltip content={({ active, payload, label }) => {
if (!active || !payload || !payload.length) return null;
const d = payload[0].payload || {};
return (
<div style={{ background: "#fff", borderRadius: 10, border: "1px solid #e2e8f0",
boxShadow: "0 8px 24px rgba(0,0,0,0.12)", fontSize: 12,
padding: "10px 13px", lineHeight: 1.7 }}>
<div style={{ fontWeight: 700, color: "#0f172a", marginBottom: 4 }}>Día {label}</div>
<div style={{ color, fontWeight: 700 }}>Promedio: {d.promedio_min || 0} min</div>
<div style={{ color: "#0f766e" }}>Respondidos{esResp ? " ese día" : ""}: {d.respondidos || 0}</div>
{esResp
? <div style={{ color: "#94a3b8", fontSize: 11.5 }}>⚠ Respuesta tardía (+2 días): {d.tardios || 0}</div>
: <div style={{ color: "#b91c1c" }}>Sin responder: {d.no_respondidos || 0}</div>}
</div>
);
}} />
<Line type="linear" dataKey="promedio_min" name="Promedio (min)" stroke={color}
strokeWidth={2.5}
dot={{ r: 3, fill: color, stroke: "#fff", strokeWidth: 1.5 }}
activeDot={{ r: 6, fill: colorSoft, stroke: "#fff", strokeWidth: 2 }} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
})()}
{/* Leads SIN responder por día (fecha de asignación) — barras */}
<div>
<div className="card" style={{ display: "flex", flexDirection: "column", height: 400 }}>
<div className="card-title" style={{ display: "flex", alignItems: "center", gap: 6, height: 40, marginBottom: 8 }}>
<span style={{ width: 10, height: 10, borderRadius: 3, background: "#f43f5e", display: "inline-block" }} />
Leads Sin Responder por Día{vend !== "TODOS" ? ` — ${vend}` : ""}
</div>
<div style={{ flex: 1 }}>
{(() => {
const datosNR = serieTR.filter((d) => (d.no_respondidos || 0) >= 1);
if (datosNR.length === 0) {
return (
<div style={{ height: "100%", display: "flex", flexDirection: "column",
alignItems: "center", justifyContent: "center", color: "#94a3b8", gap: 8 }}>
<div style={{ fontSize: 34 }}>✅</div>
<div style={{ fontSize: 13.5, fontWeight: 600, color: "#0f766e" }}>Sin leads pendientes</div>
<div style={{ fontSize: 12 }}>Todos los leads del periodo fueron respondidos.</div>
</div>
);
}
return (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={datosNR} margin={{ top: 12, right: 24, left: -6, bottom: 8 }}>
<defs>
<linearGradient id="gradNoResp" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#fb7185" stopOpacity={1} />
<stop offset="100%" stopColor="#f43f5e" stopOpacity={0.85} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" 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 cursor={{ fill: "#fef2f2" }} content={({ active, payload, label }) => {
if (!active || !payload || !payload.length) return null;
const d = payload[0].payload || {};
return (
<div style={{ background: "#fff", borderRadius: 10, border: "1px solid #e2e8f0",
boxShadow: "0 8px 24px rgba(0,0,0,0.12)", fontSize: 12,
padding: "10px 13px", lineHeight: 1.7 }}>
<div style={{ fontWeight: 700, color: "#0f172a", marginBottom: 4 }}>Día {label}</div>
<div style={{ color: "#e11d48", fontWeight: 700 }}>Sin responder: {d.no_respondidos || 0}</div>
<div style={{ color: "#0f766e" }}>Respondidos: {d.respondidos || 0}</div>
</div>
);
}} />
<Bar dataKey="no_respondidos" name="Sin responder" fill="url(#gradNoResp)" radius={[4, 4, 0, 0]} maxBarSize={26} />
</BarChart>
</ResponsiveContainer>
);
})()}
</div>
</div>
</div>
</div>
{/* Matriz por tipo de programa (al final) */}
<div style={{ marginTop: 16 }}>
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
<div className="card-title" style={{ padding: "14px 16px 6px" }}>
Matrículas por Tipo de Programa{vend !== "TODOS" ? ` — ${vend}` : ""}
</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 }}>Tipo de Programa</th>
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Total Matrículas</th>
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Retiradas</th>
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>% Retirados</th>
</tr>
</thead>
<tbody>
{matrizProg.map((p) => (
<tr key={p.programa}>
<td className="col-name">{p.programa}</td>
<td style={{ fontWeight: 600 }}>{num(p.total)}</td>
<td>{num(p.retiradas)}</td>
<td>{Number(p.pct_retiradas || 0).toLocaleString("es-PE")}%</td>
</tr>
))}
{matrizProg.length > 0 && (
<tr className="total-row">
<td className="col-name">Total</td>
<td>{num(matrizProg.reduce((s, p) => s + (p.total || 0), 0))}</td>
<td>{num(matrizProg.reduce((s, p) => s + (p.retiradas || 0), 0))}</td>
<td>{(() => {
const t = matrizProg.reduce((s, p) => s + (p.total || 0), 0);
const r = matrizProg.reduce((s, p) => s + (p.retiradas || 0), 0);
return `${(t ? Math.round(r / t * 1000) / 10 : 0).toLocaleString("es-PE")}%`;
})()}</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
</div>}
</div>
);
}
// ── Tarjetas de resumen: 3 grupos (Cartera / Matrículas / Leads) en lista ─────
function ResumenTarjetas({ r, num }) {
const pct = Number(r.pct_retiradas || 0);
const pctCont = Number(r.pct_contactados || 0);
const grupos = [
{
titulo: "Cartera", acento: "#2563eb",
filas: [
{ label: "Total", valor: num(r.cartera_total) },
{ label: "Copito", valor: num(r.cartera_copito) },
{ label: "Otros", valor: num(r.cartera_otros) },
],
},
{
titulo: "Matrículas", acento: "#0f766e",
filas: [
{ label: "Total", valor: num(r.mat_total) },
{ label: "Retiradas", valor: num(r.mat_retiradas) },
{ label: "% Retirados", valor: `${pct.toLocaleString("es-PE")}%`, alerta: true },
],
},
{
titulo: "Leads", acento: "#7c3aed",
filas: [
{ label: "Asignados", valor: num(r.leads_asignados) },
{ label: "Contactados", valor: num(r.leads_contactados) },
{ label: "% Contactados", valor: `${pctCont.toLocaleString("es-PE")}%`, resalta: "#7c3aed" },
],
},
];
return (
<div style={{ display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
gap: 12, marginBottom: 18 }}>
{grupos.map((g) => (
<div key={g.titulo} style={{
background: "#fff", border: "1px solid #e8ecf3", borderRadius: 12,
padding: "14px 16px", boxShadow: "0 1px 3px rgba(16,24,40,0.05)" }}>
<div style={{ fontSize: 11, fontWeight: 700, color: g.acento,
textTransform: "uppercase", letterSpacing: 0.5, marginBottom: 10 }}>
{g.titulo}
</div>
{g.filas.map((f, i) => (
<div key={f.label} style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
padding: "6px 0",
borderBottom: i < g.filas.length - 1 ? "1px solid #f1f5f9" : "none" }}>
<span style={{ fontSize: 12.5, color: "#64748b" }}>{f.label}</span>
<span style={{ fontSize: 15, fontWeight: 700,
color: f.alerta ? "#b91c1c" : (f.resalta || "#0f172a") }}>
{f.valor}
</span>
</div>
))}
</div>
))}
</div>
);
}