Files
dashboard-completo/frontend/src/pages/Vendedores.jsx
2026-08-29 12:21:21 -05:00

424 lines
21 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.
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>
);
}