Actualizar dominios a dashboardapi y dashboardv1
This commit is contained in:
6
frontend/.dockerignore
Normal file
6
frontend/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.env.local
|
||||
npm-debug.log*
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
@@ -1,5 +1,4 @@
|
||||
# Credenciales públicas de Supabase para el login (frontend)
|
||||
# La URL es la misma de tu proyecto.
|
||||
# La ANON KEY es la PÚBLICA (anon/public), NO la service_role.
|
||||
VITE_SUPABASE_URL=https://ogzjtkxnfswpbmnbhnjd.supabase.co
|
||||
VITE_SUPABASE_ANON_KEY=pega_aqui_tu_anon_public_key
|
||||
# URL base del backend FastAPI que consume el frontend.
|
||||
# Local: http://localhost:8001
|
||||
# Despliegue: https://dashboardapi.escueladerefrigeracion.edu.pe
|
||||
VITE_API_URL=http://localhost:8001
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
VITE_API_URL=https://dashboardapi.escueladerefrigeracion.edu.pe
|
||||
4
frontend/.gitignore
vendored
4
frontend/.gitignore
vendored
@@ -1,4 +0,0 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.env
|
||||
@@ -1,27 +1,20 @@
|
||||
# Usamos Node.js para compilar el proyecto
|
||||
FROM node:18-alpine
|
||||
# ── Etapa 1: build del frontend con Vite ──
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
# Creamos la carpeta de trabajo
|
||||
WORKDIR /app
|
||||
|
||||
# Declaramos que recibiremos estos argumentos durante el build
|
||||
ARG VITE_SUPABASE_URL
|
||||
ARG VITE_SUPABASE_ANON_KEY
|
||||
ARG VITE_API_URL
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci --no-audit --no-fund || npm install --no-audit --no-fund
|
||||
|
||||
# Los convertimos en variables de entorno para que Vite los vea al compilar
|
||||
ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL
|
||||
ENV VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
|
||||
# Copiamos los archivos de configuración e instalamos dependencias
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
# Copiamos el código y construimos la web (ahora Vite sí verá las variables)
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Instalamos el servidor estático y lo ejecutamos
|
||||
RUN npm install -g serve
|
||||
CMD ["serve", "-s", "dist", "-l", "5173"]
|
||||
# ── Etapa 2: servir el build con nginx ──
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
# Frontend — Dashboard React
|
||||
|
||||
## Requisitos
|
||||
- Node.js 18+ (descarga: https://nodejs.org)
|
||||
|
||||
## Instalación (una sola vez)
|
||||
```
|
||||
npm install
|
||||
```
|
||||
|
||||
## Ejecutar
|
||||
```
|
||||
npm run dev
|
||||
```
|
||||
Abre: http://localhost:3000
|
||||
|
||||
⚠️ El backend FastAPI debe estar corriendo en http://localhost:8000
|
||||
(en otra terminal: `py -3.12 main.py`)
|
||||
|
||||
## Estado
|
||||
- ✅ Sidebar + navegación
|
||||
- ✅ Ocupabilidad (conectado al backend)
|
||||
- 🚧 Ventas, Cobranza, Rentabilidad, Saldo Pendiente, Asesores (próximas sesiones)
|
||||
|
||||
## Cambiar IP del backend
|
||||
Si el backend corre en otra máquina, edita `src/lib/api.js` línea 3:
|
||||
```js
|
||||
const BASE_URL = "http://IP_DEL_SERVIDOR:8000";
|
||||
```
|
||||
@@ -1,9 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Dashboard — Escuela de Refrigeración</title>
|
||||
<title>Dashboard Leads — Escuela Refrigeración</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
37
frontend/nginx.conf
Normal file
37
frontend/nginx.conf
Normal file
@@ -0,0 +1,37 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Compresion
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript
|
||||
text/xml application/xml application/xml+rss text/javascript
|
||||
image/svg+xml;
|
||||
gzip_min_length 1024;
|
||||
|
||||
# Cache larga para los assets con hash en el nombre
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# SPA: cualquier ruta desconocida devuelve index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# index.html nunca se cachea, para que los deploys se vean al instante
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
internal;
|
||||
}
|
||||
}
|
||||
952
frontend/package-lock.json
generated
952
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,22 +1,20 @@
|
||||
{
|
||||
"name": "dashboard-frontend",
|
||||
"name": "dashboard-leads",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build --mode production",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.107.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"xlsx": "^0.18.5",
|
||||
"xlsx-js-style": "^1.2.0"
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"recharts": "^2.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"vite": "^5.4.0"
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
19
frontend/public/50x.html
Normal file
19
frontend/public/50x.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Error</title>
|
||||
<style>
|
||||
html { color-scheme: light dark; }
|
||||
body { width: 35em; margin: 0 auto;
|
||||
font-family: Tahoma, Verdana, Arial, sans-serif; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>An error occurred.</h1>
|
||||
<p>Sorry, the page you are looking for is currently unavailable.<br/>
|
||||
Please try again later.</p>
|
||||
<p>If you are the system administrator of this resource then you should check
|
||||
the error log for details.</p>
|
||||
<p><em>Faithfully yours, nginx.</em></p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,67 +1,35 @@
|
||||
// src/App.jsx
|
||||
import { useState, useEffect } from "react";
|
||||
import { useAuth } from "./lib/auth";
|
||||
import { useState } from "react";
|
||||
import Sidebar from "./components/Sidebar";
|
||||
import IndicadorActualizacion from "./components/IndicadorActualizacion";
|
||||
import Login from "./pages/Login";
|
||||
import Ocupabilidad from "./pages/Ocupabilidad";
|
||||
import Ventas from "./pages/Ventas";
|
||||
import Comisiones from "./pages/Comisiones";
|
||||
import Cobranza from "./pages/Cobranza";
|
||||
import Rentabilidad from "./pages/Rentabilidad";
|
||||
import Asesores from "./pages/Asesores";
|
||||
import Usuarios from "./pages/Usuarios";
|
||||
import Leads from "./pages/Leads";
|
||||
import OtrosGeneral from "./pages/OtrosGeneral";
|
||||
import Vendedores from "./pages/Vendedores";
|
||||
import Roas from "./pages/Roas";
|
||||
import CampanitaAlertas from "./components/CampanitaAlertas";
|
||||
import ConfigModal from "./components/ConfigModal";
|
||||
import ErrorBoundary from "./components/ErrorBoundary";
|
||||
|
||||
export default function App() {
|
||||
const { autenticado, cargando, permisos, puedeVer } = useAuth();
|
||||
const [pagina, setPagina] = useState(null);
|
||||
|
||||
// Al entrar, seleccionar el primer módulo permitido
|
||||
useEffect(() => {
|
||||
if (autenticado && permisos.length > 0 && !pagina) {
|
||||
setPagina(permisos[0]);
|
||||
}
|
||||
if (!autenticado) setPagina(null);
|
||||
}, [autenticado, permisos, pagina]);
|
||||
|
||||
if (cargando) {
|
||||
return (
|
||||
<div style={{ minHeight:"100vh", display:"flex", alignItems:"center", justifyContent:"center", background:"#0f172a", color:"#fff" }}>
|
||||
Cargando...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!autenticado) return <Login />;
|
||||
const [pagina, setPagina] = useState("leads");
|
||||
const [config, setConfig] = useState(false);
|
||||
|
||||
function render() {
|
||||
// Seguridad: si la página actual no está permitida, no renderizar
|
||||
if (pagina && !puedeVer(pagina)) return <SinAcceso />;
|
||||
switch (pagina) {
|
||||
case "ocupabilidad": return <Ocupabilidad />;
|
||||
case "ventas": return <Ventas />;
|
||||
case "comisiones": return <Comisiones />;
|
||||
case "cobranza": return <Cobranza />;
|
||||
case "rentabilidad": return <Rentabilidad />;
|
||||
case "asesores": return <Asesores />;
|
||||
case "usuarios": return <Usuarios />;
|
||||
default: return null;
|
||||
case "leads": return <Leads />;
|
||||
case "otros": return <OtrosGeneral />;
|
||||
case "vendedores": return <Vendedores />;
|
||||
case "roas": return <Roas />;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<Sidebar active={pagina} onChange={setPagina} />
|
||||
<main className="main">{render()}</main>
|
||||
<IndicadorActualizacion />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SinAcceso() {
|
||||
return (
|
||||
<div style={{ padding:40, textAlign:"center", color:"#94a3b8" }}>
|
||||
🔒 No tienes acceso a este módulo.
|
||||
<Sidebar active={pagina} onChange={setPagina} onConfig={() => setConfig(true)} />
|
||||
<main className="main">
|
||||
<ErrorBoundary key={pagina}>{render()}</ErrorBoundary>
|
||||
</main>
|
||||
<CampanitaAlertas />
|
||||
{config && <ConfigModal onClose={() => setConfig(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
92
frontend/src/components/CampanitaAlertas.jsx
Normal file
92
frontend/src/components/CampanitaAlertas.jsx
Normal file
@@ -0,0 +1,92 @@
|
||||
// Campanita de alertas: avisa de programas/sedes NO identificados en cartera_junta
|
||||
// (valores que no están en el diccionario alias_normalizacion).
|
||||
import { useState, useEffect } from "react";
|
||||
import { api } from "../lib/api";
|
||||
|
||||
export default function CampanitaAlertas() {
|
||||
const [data, setData] = useState({ programa: [], sede: [] });
|
||||
const [abierto, setAbierto] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let activo = true;
|
||||
api.alertas()
|
||||
.then((r) => { if (activo) setData({ programa: r.programa || [], sede: r.sede || [] }); })
|
||||
.catch(() => { if (activo) setData({ programa: [], sede: [] }); });
|
||||
return () => { activo = false; };
|
||||
}, []);
|
||||
|
||||
const total = (data.programa?.length || 0) + (data.sede?.length || 0);
|
||||
const hay = total > 0;
|
||||
|
||||
return (
|
||||
<div style={{ position: "fixed", right: 18, top: 14, zIndex: 80 }}>
|
||||
<button
|
||||
onClick={() => setAbierto((v) => !v)}
|
||||
title={hay ? `${total} valor(es) sin identificar` : "Sin alertas"}
|
||||
style={{
|
||||
position: "relative", width: 38, height: 38, borderRadius: 10,
|
||||
border: "1px solid " + (hay ? "#f59e0b" : "#334155"),
|
||||
background: hay ? "#78350f" : "rgba(15,23,42,0.9)",
|
||||
color: hay ? "#fde68a" : "#94a3b8",
|
||||
cursor: "pointer", fontSize: 18, lineHeight: 1,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.25)",
|
||||
}}
|
||||
>
|
||||
🔔
|
||||
{hay && (
|
||||
<span style={{
|
||||
position: "absolute", top: -6, right: -6, minWidth: 18, height: 18,
|
||||
padding: "0 4px", borderRadius: 999, background: "#dc2626", color: "#fff",
|
||||
fontSize: 11, fontWeight: 700, display: "flex", alignItems: "center",
|
||||
justifyContent: "center", border: "1px solid #fff",
|
||||
}}>{total}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{abierto && (
|
||||
<div style={{
|
||||
position: "absolute", right: 0, top: 46, width: 320, maxHeight: 420,
|
||||
overflowY: "auto", background: "#fff", color: "#0f172a",
|
||||
border: "1px solid #e2e8f0", borderRadius: 12,
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,0.18)", padding: 14, fontSize: 13,
|
||||
}}>
|
||||
<div style={{ fontWeight: 700, marginBottom: 8 }}>🔔 Alertas de normalización</div>
|
||||
{!hay ? (
|
||||
<div style={{ color: "#059669", fontWeight: 600 }}>✓ Todo identificado, nada por revisar.</div>
|
||||
) : (
|
||||
<>
|
||||
{(data.programa?.length > 0) && (
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<div style={{ fontWeight: 600, color: "#b45309", marginBottom: 4 }}>
|
||||
Nuevo programa no identificado:
|
||||
</div>
|
||||
{data.programa.map((p, i) => (
|
||||
<div key={i} style={{ padding: "3px 0", borderBottom: "1px solid #f1f5f9" }}>
|
||||
<b>{p.valor || "(vacío)"}</b> <span style={{ color: "#94a3b8" }}>· {p.veces}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{(data.sede?.length > 0) && (
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, color: "#b45309", marginBottom: 4 }}>
|
||||
Nueva sede no identificada:
|
||||
</div>
|
||||
{data.sede.map((s, i) => (
|
||||
<div key={i} style={{ padding: "3px 0", borderBottom: "1px solid #f1f5f9" }}>
|
||||
<b>{s.valor || "(vacío)"}</b> <span style={{ color: "#94a3b8" }}>· {s.veces}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: 10, color: "#64748b", fontSize: 12 }}>
|
||||
Agrégalos al diccionario (alias_normalizacion) para clasificarlos.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
969
frontend/src/components/ConfigModal.jsx
Normal file
969
frontend/src/components/ConfigModal.jsx
Normal file
@@ -0,0 +1,969 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { api } from "../lib/api";
|
||||
|
||||
// Desplegable con búsqueda (estilo Claude).
|
||||
function ProgramaSelect({ valor, opciones = [], onSelect }) {
|
||||
const [abierto, setAbierto] = useState(false);
|
||||
const [q, setQ] = useState("");
|
||||
const ref = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
function fuera(e) { if (ref.current && !ref.current.contains(e.target)) setAbierto(false); }
|
||||
document.addEventListener("mousedown", fuera);
|
||||
return () => document.removeEventListener("mousedown", fuera);
|
||||
}, []);
|
||||
|
||||
const filtradas = opciones.filter((o) => o.label.toLowerCase().includes(q.toLowerCase()));
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: "relative", width: "100%" }}>
|
||||
<button onClick={() => setAbierto((v) => !v)}
|
||||
style={{ width: "100%", textAlign: "left", padding: "7px 10px", border: "1px solid #cbd5e1",
|
||||
borderRadius: 8, fontSize: 13, background: "#fff", cursor: "pointer",
|
||||
color: valor ? "#334155" : "#94a3b8", fontFamily: "inherit",
|
||||
display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{valor || "Seleccionar programa..."}
|
||||
</span>
|
||||
<span style={{ color: "#94a3b8", marginLeft: 6 }}>⌄</span>
|
||||
</button>
|
||||
{abierto && (
|
||||
<div style={{ position: "absolute", top: "calc(100% + 6px)", left: 0, right: 0, background: "#fff",
|
||||
border: "1px solid #e2e8f0", borderRadius: 12, boxShadow: "0 12px 34px rgba(0,0,0,0.18)",
|
||||
zIndex: 100, overflow: "hidden" }}>
|
||||
<div style={{ padding: 10, borderBottom: "1px solid #f1f5f9" }}>
|
||||
<input autoFocus value={q} onChange={(e) => setQ(e.target.value)} placeholder="Buscar..."
|
||||
style={{ width: "100%", padding: "8px 11px", border: "1px solid #cbd5e1", borderRadius: 8,
|
||||
fontSize: 13, fontFamily: "inherit", outline: "none" }} />
|
||||
</div>
|
||||
<div style={{ maxHeight: 220, overflow: "auto" }}>
|
||||
{filtradas.length === 0 ? (
|
||||
<div style={{ padding: "12px 14px", fontSize: 13, color: "#94a3b8" }}>Sin opciones</div>
|
||||
) : filtradas.map((o) => (
|
||||
<div key={o.num_indice} style={{ padding: "9px 14px", fontSize: 13, color: "#334155", cursor: "pointer" }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = "#f1f5f9"}
|
||||
onMouseLeave={(e) => e.currentTarget.style.background = "transparent"}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => { onSelect && onSelect(o); setAbierto(false); setQ(""); }}>
|
||||
{o.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Apartado "Sedes de Anuncios": conjuntos del CSV sin sede -> asignar sede manual.
|
||||
// FASE 1: solo vista (dropdown de sede por fila). El guardar se agrega despues.
|
||||
function SedesAnuncios() {
|
||||
const [filas, setFilas] = useState([]);
|
||||
const [cargando, setCargando] = useState(true);
|
||||
const [sel, setSel] = useState({}); // conjunto -> sede elegida (decorativo por ahora)
|
||||
const [selProg, setSelProg] = useState({}); // conjunto -> programa elegido (decorativo)
|
||||
const [busca, setBusca] = useState("");
|
||||
// Opciones fijas (no se pueden borrar) y opciones ampliables con "Agregar".
|
||||
const SEDES_FIJAS = ["LIMA", "PIURA", "AREQUIPA", "TRUJILLO"];
|
||||
const PROGS_FIJOS = ["TEAC", "TERC", "SEMINARIOS", "OTROS"];
|
||||
const [sedes, setSedes] = useState(SEDES_FIJAS);
|
||||
const [programas, setProgramas] = useState(PROGS_FIJOS);
|
||||
const SEDES = sedes;
|
||||
const PROGRAMAS = programas;
|
||||
|
||||
// Quita una opción creada (no fija): la saca del dropdown y de cualquier
|
||||
// conjunto que la tenga asignada (se aplica al Guardar).
|
||||
function quitarOpcion(tipo, val) {
|
||||
if (tipo === "TIPO") {
|
||||
if (SEDES_FIJAS.includes(val)) return;
|
||||
setSedes((s) => s.filter((x) => x !== val));
|
||||
setSel((cur) => {
|
||||
const n = { ...cur };
|
||||
Object.keys(n).forEach((k) => { if (n[k] === val) n[k] = ""; });
|
||||
return n;
|
||||
});
|
||||
} else {
|
||||
if (PROGS_FIJOS.includes(val)) return;
|
||||
setProgramas((p) => p.filter((x) => x !== val));
|
||||
setSelProg((cur) => {
|
||||
const n = { ...cur };
|
||||
Object.keys(n).forEach((k) => { if (n[k] === val) n[k] = ""; });
|
||||
return n;
|
||||
});
|
||||
}
|
||||
}
|
||||
// Pop-up "Agregar"
|
||||
const [popup, setPopup] = useState(false);
|
||||
const [nvTipo, setNvTipo] = useState("TIPO"); // "TIPO" o "PROGRAMA"
|
||||
const [nvValor, setNvValor] = useState("");
|
||||
|
||||
function agregar() {
|
||||
const val = nvValor.trim().toUpperCase();
|
||||
if (!val) return;
|
||||
if (nvTipo === "TIPO") {
|
||||
if (!sedes.includes(val)) setSedes((s) => [...s, val]);
|
||||
} else {
|
||||
if (!programas.includes(val)) setProgramas((p) => [...p, val]);
|
||||
}
|
||||
setNvValor(""); setPopup(false);
|
||||
}
|
||||
|
||||
const [guardando, setGuardando] = useState(false);
|
||||
const [msg, setMsg] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
api.conjuntosSinSede()
|
||||
.then((r) => {
|
||||
const rows = r || [];
|
||||
setFilas(rows);
|
||||
// precargar sede/programa ya asignados + ampliar opciones si hay valores nuevos
|
||||
const s0 = {}, p0 = {}; const sExtra = new Set(), pExtra = new Set();
|
||||
rows.forEach((f) => {
|
||||
if (f.sede) { s0[f.conjunto] = f.sede; sExtra.add(f.sede); }
|
||||
if (f.programa) { p0[f.conjunto] = f.programa; pExtra.add(f.programa); }
|
||||
});
|
||||
setSel(s0); setSelProg(p0);
|
||||
setSedes((cur) => [...cur, ...[...sExtra].filter((x) => !cur.includes(x))]);
|
||||
// Carga los programas ya asignados (incluye los nuevos que crees, ej.
|
||||
// BRANDING). El dashboard ahora SÍ reconoce cualquier programa asignado.
|
||||
setProgramas((cur) => [...cur, ...[...pExtra].filter((x) => !cur.includes(x))]);
|
||||
setCargando(false);
|
||||
})
|
||||
.catch(() => setCargando(false));
|
||||
}, []);
|
||||
|
||||
async function guardar() {
|
||||
setGuardando(true); setMsg("");
|
||||
try {
|
||||
const cambios = filas
|
||||
.filter((f) => sel[f.conjunto] || selProg[f.conjunto])
|
||||
.map((f) => ({ conjunto: f.conjunto, sede: sel[f.conjunto] || "", programa: selProg[f.conjunto] || "" }));
|
||||
await api.guardarConjuntoSede(cambios);
|
||||
setMsg("✓ Guardado. Se reflejará en ROAS.");
|
||||
window.dispatchEvent(new Event("roas-actualizar")); // avisa al ROAS que recargue
|
||||
window.dispatchEvent(new Event("datos-actualizar")); // avisa a TODOS los apartados
|
||||
} catch (e) {
|
||||
setMsg("Error al guardar");
|
||||
}
|
||||
setGuardando(false);
|
||||
}
|
||||
|
||||
async function borrar(conjunto) {
|
||||
// limpia la seleccion en pantalla y borra la asignacion en Supabase
|
||||
setSel((s) => { const n = { ...s }; delete n[conjunto]; return n; });
|
||||
setSelProg((s) => { const n = { ...s }; delete n[conjunto]; return n; });
|
||||
try {
|
||||
await api.borrarConjuntoSede(conjunto);
|
||||
setMsg("✓ Asignación borrada.");
|
||||
window.dispatchEvent(new Event("roas-actualizar")); // avisa al ROAS que recargue
|
||||
window.dispatchEvent(new Event("datos-actualizar")); // avisa a TODOS los apartados
|
||||
} catch (e) {
|
||||
setMsg("Error al borrar");
|
||||
}
|
||||
}
|
||||
|
||||
const vis = filas.filter((f) => f.conjunto.toLowerCase().includes(busca.toLowerCase()));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ padding: "14px 22px 8px" }}>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
<input value={busca} onChange={(e) => setBusca(e.target.value)}
|
||||
placeholder="Buscar conjunto de anuncio..."
|
||||
style={{ flex: 1, padding: "9px 12px", border: "1px solid #cbd5e1",
|
||||
borderRadius: 9, fontSize: 13 }} />
|
||||
<button onClick={() => setPopup(true)}
|
||||
style={{ padding: "9px 16px", border: "none", background: "#1e3a5f", color: "#fff",
|
||||
borderRadius: 9, fontSize: 13, fontWeight: 700, cursor: "pointer", whiteSpace: "nowrap" }}>
|
||||
+ Agregar
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#64748b", marginTop: 8 }}>
|
||||
Conjuntos de anuncios sin sede asignada. Elige la sede y el programa de cada uno.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pop-up Agregar nueva opción (Tipo o Programa) */}
|
||||
{popup && (
|
||||
<div onClick={() => setPopup(false)}
|
||||
style={{ position: "fixed", inset: 0, background: "rgba(15,23,42,0.4)", zIndex: 60,
|
||||
display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<div onClick={(e) => e.stopPropagation()}
|
||||
style={{ background: "#fff", borderRadius: 14, width: 360, padding: 22,
|
||||
boxShadow: "0 20px 50px rgba(0,0,0,0.3)" }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: "#0f172a", marginBottom: 16 }}>
|
||||
Agregar nueva opción
|
||||
</div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, color: "#64748b" }}>Clasificación</label>
|
||||
<select value={nvTipo} onChange={(e) => setNvTipo(e.target.value)}
|
||||
style={{ width: "100%", padding: "9px 10px", border: "1px solid #cbd5e1",
|
||||
borderRadius: 8, fontSize: 14, marginTop: 5, marginBottom: 14 }}>
|
||||
<option value="TIPO">Tipo (Sede)</option>
|
||||
<option value="PROGRAMA">Programa</option>
|
||||
</select>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, color: "#64748b" }}>Nombre</label>
|
||||
<input value={nvValor} onChange={(e) => setNvValor(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && agregar()}
|
||||
placeholder="Ej: CHICLAYO"
|
||||
style={{ width: "100%", padding: "9px 10px", border: "1px solid #cbd5e1",
|
||||
borderRadius: 8, fontSize: 14, marginTop: 5, marginBottom: 14 }} />
|
||||
|
||||
{/* Lista de opciones CREADAS (no fijas) para poder borrarlas */}
|
||||
{(() => {
|
||||
const esSede = nvTipo === "TIPO";
|
||||
const lista = (esSede ? sedes : programas)
|
||||
.filter((x) => !(esSede ? SEDES_FIJAS : PROGS_FIJOS).includes(x));
|
||||
if (lista.length === 0) return null;
|
||||
return (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: "#64748b", marginBottom: 6 }}>
|
||||
{esSede ? "Sedes creadas" : "Programas creados"} (clic en × para borrar)
|
||||
</div>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
|
||||
{lista.map((x) => (
|
||||
<span key={x} style={{ display: "inline-flex", alignItems: "center", gap: 6,
|
||||
background: "#f1f5f9", border: "1px solid #e2e8f0",
|
||||
borderRadius: 999, padding: "4px 6px 4px 11px", fontSize: 12.5,
|
||||
fontWeight: 600, color: "#334155" }}>
|
||||
{x}
|
||||
<button onClick={() => quitarOpcion(nvTipo, x)} title="Borrar"
|
||||
style={{ width: 18, height: 18, borderRadius: "50%", border: "none",
|
||||
background: "#fee2e2", color: "#ef4444", cursor: "pointer",
|
||||
fontSize: 13, lineHeight: 1, display: "inline-flex",
|
||||
alignItems: "center", justifyContent: "center" }}>×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
|
||||
<button onClick={() => setPopup(false)}
|
||||
style={{ padding: "8px 16px", border: "1px solid #cbd5e1", background: "#fff",
|
||||
color: "#64748b", borderRadius: 8, fontSize: 13, fontWeight: 600, cursor: "pointer" }}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button onClick={agregar}
|
||||
style={{ padding: "8px 18px", border: "none", background: "#1e3a5f", color: "#fff",
|
||||
borderRadius: 8, fontSize: 13, fontWeight: 700, cursor: "pointer" }}>
|
||||
Guardar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1, overflow: "hidden", padding: "0 22px 4px" }}>
|
||||
{cargando ? (
|
||||
<div style={{ padding: 40, textAlign: "center", color: "#94a3b8" }}>Cargando...</div>
|
||||
) : (
|
||||
<div style={{ height: "100%", border: "1px solid #e2e8f0", borderRadius: 12, overflow: "auto" }}>
|
||||
<table style={{ width: "100%", borderCollapse: "separate", borderSpacing: 0, tableLayout: "fixed" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: "center", padding: "11px 14px", position: "sticky", top: 0,
|
||||
background: "#1e3a5f", color: "#fff", fontSize: 12.5, fontWeight: 700,
|
||||
borderRight: "1px solid rgba(255,255,255,0.12)" }}>Conjunto de Anuncio</th>
|
||||
<th style={{ textAlign: "center", padding: "11px 14px", position: "sticky", top: 0,
|
||||
background: "#1e3a5f", color: "#fff", fontSize: 12.5, fontWeight: 700, width: 150,
|
||||
borderRight: "1px solid rgba(255,255,255,0.12)" }}>Tipo</th>
|
||||
<th style={{ textAlign: "center", padding: "11px 14px", position: "sticky", top: 0,
|
||||
background: "#1e3a5f", color: "#fff", fontSize: 12.5, fontWeight: 700, width: 150,
|
||||
borderRight: "1px solid rgba(255,255,255,0.12)" }}>Programa</th>
|
||||
<th style={{ padding: "11px 8px", position: "sticky", top: 0,
|
||||
background: "#1e3a5f", color: "#fff", fontSize: 12.5, fontWeight: 700, width: 48 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{vis.map((f, i) => (
|
||||
<tr key={f.conjunto} style={{ background: i % 2 ? "#f8fafc" : "#fff" }}>
|
||||
<td style={{ padding: "10px 14px", fontSize: 13, color: "#1e293b",
|
||||
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
|
||||
borderRight: "1px solid #eef2f7" }}>{f.conjunto}</td>
|
||||
<td style={{ padding: "10px 14px", borderRight: "1px solid #eef2f7" }}>
|
||||
<select value={sel[f.conjunto] || ""}
|
||||
onChange={(e) => setSel((s) => ({ ...s, [f.conjunto]: e.target.value }))}
|
||||
style={{ width: "100%", padding: "6px 8px", border: "1px solid #cbd5e1",
|
||||
borderRadius: 7, fontSize: 13 }}>
|
||||
<option value="">— Sin asignar —</option>
|
||||
{SEDES.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
<td style={{ padding: "10px 14px", borderRight: "1px solid #eef2f7" }}>
|
||||
<select value={selProg[f.conjunto] || ""}
|
||||
onChange={(e) => setSelProg((s) => ({ ...s, [f.conjunto]: e.target.value }))}
|
||||
style={{ width: "100%", padding: "6px 8px", border: "1px solid #cbd5e1",
|
||||
borderRadius: 7, fontSize: 13 }}>
|
||||
<option value="">— Sin asignar —</option>
|
||||
{PROGRAMAS.map((p) => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
<td style={{ padding: "10px 8px", textAlign: "center" }}>
|
||||
{(sel[f.conjunto] || selProg[f.conjunto]) && (
|
||||
<button onClick={() => borrar(f.conjunto)} title="Borrar asignación"
|
||||
style={{ border: "none", background: "transparent", color: "#dc2626",
|
||||
cursor: "pointer", fontSize: 16, fontWeight: 700, lineHeight: 1 }}>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{vis.length === 0 && (
|
||||
<tr><td colSpan={4} style={{ padding: 30, textAlign: "center", color: "#94a3b8", fontSize: 13 }}>
|
||||
No hay conjuntos sin sede.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center",
|
||||
padding: "14px 22px", borderTop: "1px solid #e2e8f0" }}>
|
||||
<span style={{ fontSize: 13, color: msg.startsWith("✓") ? "#047857" : (msg ? "#b91c1c" : "#94a3b8") }}>
|
||||
{msg || `${vis.length} conjuntos sin sede`}
|
||||
</span>
|
||||
<button onClick={guardar} disabled={guardando}
|
||||
style={{ padding: "9px 22px", border: "none", background: "#1e3a5f", color: "#fff",
|
||||
borderRadius: 9, fontSize: 14, fontWeight: 700, cursor: guardando ? "wait" : "pointer" }}>
|
||||
{guardando ? "Guardando..." : "Guardar"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Apartado "Programas": lista de programas con contar=NO (ocultos), con switch para reactivar.
|
||||
function ProgramasOcultos() {
|
||||
const [filas, setFilas] = useState([]);
|
||||
const [cargando, setCargando] = useState(true);
|
||||
const [enc, setEnc] = useState({}); // num_indice -> true si se va a encender
|
||||
const [guardando, setGuardando] = useState(false);
|
||||
const [msg, setMsg] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
api.programasOcultos()
|
||||
.then((r) => { setFilas(r.filas || []); setCargando(false); })
|
||||
.catch(() => setCargando(false));
|
||||
}, []);
|
||||
|
||||
async function guardar() {
|
||||
const nis = Object.entries(enc).filter(([, v]) => v).map(([ni]) => ni);
|
||||
if (nis.length === 0) { setMsg("No hay programas para activar."); return; }
|
||||
setGuardando(true); setMsg("");
|
||||
try {
|
||||
await api.encenderProgramas(nis);
|
||||
setMsg("✓ Activados. Ya aparecen en la matriz.");
|
||||
const r = await api.programasOcultos();
|
||||
setFilas(r.filas || []); setEnc({});
|
||||
window.dispatchEvent(new Event("datos-actualizar")); // avisa a TODOS los apartados
|
||||
} catch (e) { setMsg("Error: " + e.message); }
|
||||
finally { setGuardando(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ padding: "16px 22px 6px" }}>
|
||||
<div style={{ fontSize: 13, color: "#64748b", lineHeight: 1.5 }}>
|
||||
Aquí están los programas ocultos del <b>Detalle por Curso</b>. Enciende el interruptor
|
||||
y guarda para que vuelvan a aparecer en la tabla del dashboard.
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "8px 22px" }}>
|
||||
{cargando ? (
|
||||
<div style={{ padding: 40, textAlign: "center", color: "#94a3b8" }}>Cargando...</div>
|
||||
) : filas.length === 0 ? (
|
||||
<div style={{ padding: 30, textAlign: "center", color: "#94a3b8", fontSize: 13 }}>
|
||||
No hay programas ocultos.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{filas.map((f) => {
|
||||
const on = !!enc[f.num_indice];
|
||||
return (
|
||||
<div key={f.num_indice} style={{ display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
padding: "12px 14px", border: "1px solid #e2e8f0", borderRadius: 10, background: "#fff" }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 600, color: "#0f172a" }}>{f.dsc_det_programa}</div>
|
||||
<div style={{ fontSize: 12, color: "#94a3b8" }}>
|
||||
Índice {f.num_indice}{f.fch_inicio ? ` · ${f.fch_inicio}` : ""}{f.pauta ? ` · Pauta ${f.pauta}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div onClick={() => setEnc((p) => ({ ...p, [f.num_indice]: !p[f.num_indice] }))}
|
||||
style={{ width: 46, height: 26, borderRadius: 999, cursor: "pointer", flexShrink: 0,
|
||||
background: on ? "#2563eb" : "#cbd5e1", position: "relative", transition: "background .15s" }}>
|
||||
<div style={{ width: 20, height: 20, borderRadius: "50%", background: "#fff",
|
||||
position: "absolute", top: 3, left: on ? 23 : 3, transition: "left .15s",
|
||||
boxShadow: "0 1px 3px rgba(0,0,0,0.3)" }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center",
|
||||
padding: "14px 22px", borderTop: "1px solid #e2e8f0" }}>
|
||||
<span style={{ fontSize: 13, color: msg.startsWith("✓") ? "#047857" : "#b91c1c" }}>{msg}</span>
|
||||
<button onClick={guardar} disabled={guardando}
|
||||
style={{ padding: "9px 22px", border: "none", background: "#1e3a5f", color: "#fff",
|
||||
borderRadius: 9, fontSize: 14, fontWeight: 700, cursor: guardando ? "wait" : "pointer" }}>
|
||||
{guardando ? "Guardando..." : "Guardar"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Mini-apartado LEYENDA: tabla de campanias + Agregar (popup) + eliminar (visual) ──
|
||||
function LeyendaCampanias() {
|
||||
const CARGOS = ["TEAC", "TERC", "VRF", "CO2", "Seminarios", "Diseño de Chillers", "Diplomado REF", "CAD", "Amoniaco", "Cámaras", "Diseño de Sistemas", "Metrado y Costeo"];
|
||||
const SEDES = ["Lima", "Piura", "Arequipa", "Trujillo"];
|
||||
const ORIGENES = ["Pauta_wsp", "Web_Whatsapp"];
|
||||
const DIAS = ["Domingo", "Sábado", "Semipresencial", "-"];
|
||||
|
||||
const [filas, setFilas] = useState([]);
|
||||
const [cargando, setCargando] = useState(true);
|
||||
const [busca, setBusca] = useState("");
|
||||
const [popup, setPopup] = useState(false);
|
||||
const [editandoId, setEditandoId] = useState(null); // id de la fila en edicion, o null = agregar
|
||||
const [confirmar, setConfirmar] = useState(null); // fila a eliminar, o null
|
||||
const [choques, setChoques] = useState(null); // [filas que chocan] o null (popup de aviso)
|
||||
const [guardando, setGuardando] = useState(false);
|
||||
const [msg, setMsg] = useState("");
|
||||
const nuevaVacia = { frase_busqueda: "", cargo: "TEAC", codigo: "", sede: "Lima", dia: "-", origen: "Pauta_wsp", fecha_inicio: "", fecha_fin: "" };
|
||||
const [nueva, setNueva] = useState(nuevaVacia);
|
||||
|
||||
function recargar() {
|
||||
setCargando(true);
|
||||
api.campanias().then((r) => { setFilas(r.filas || []); setCargando(false); })
|
||||
.catch(() => setCargando(false));
|
||||
}
|
||||
useEffect(() => { recargar(); }, []);
|
||||
|
||||
const vis = filas.filter((f) =>
|
||||
!busca || ((f.frase_busqueda || "") + (f.cargo || "") + (f.codigo || "") + (f.sede || "")).toLowerCase().includes(busca.toLowerCase()));
|
||||
|
||||
// Extrae los emojis de un texto (pictográficos). Devuelve un Set de emojis.
|
||||
function emojisDe(txt) {
|
||||
const s = new Set();
|
||||
for (const ch of String(txt || "")) {
|
||||
const cp = ch.codePointAt(0);
|
||||
// rangos de emojis/pictogramas comunes
|
||||
if ((cp >= 0x1F000 && cp <= 0x1FAFF) || (cp >= 0x2600 && cp <= 0x27BF) ||
|
||||
(cp >= 0x2B00 && cp <= 0x2BFF) || cp === 0x2764 || (cp >= 0x2190 && cp <= 0x21FF)) {
|
||||
s.add(ch);
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
// ¿los rangos [i1,f1] y [i2,f2] se solapan? (fechas en texto YYYY-MM-DD)
|
||||
function fechasSolapan(i1, f1, i2, f2) {
|
||||
if (!i1 || !f1 || !i2 || !f2) return false;
|
||||
return i1 <= f2 && i2 <= f1;
|
||||
}
|
||||
// Devuelve las campañas existentes que chocan con "nueva":
|
||||
// mismo emoji compartido Y fechas solapadas.
|
||||
function detectarChoques() {
|
||||
const emN = emojisDe(nueva.frase_busqueda);
|
||||
if (emN.size === 0) return [];
|
||||
const iN = (nueva.fecha_inicio || "").slice(0, 10);
|
||||
const fN = (nueva.fecha_fin || "").slice(0, 10);
|
||||
return filas.filter((f) => {
|
||||
if (editandoId != null && f.id === editandoId) return false; // no chocar contra si misma
|
||||
const emE = emojisDe(f.frase_busqueda);
|
||||
const comparte = [...emN].some((e) => emE.has(e)); // ¿comparten algún emoji?
|
||||
if (!comparte) return false;
|
||||
return fechasSolapan(iN, fN, (f.fecha_inicio || "").slice(0, 10), (f.fecha_fin || "").slice(0, 10));
|
||||
});
|
||||
}
|
||||
|
||||
function abrirAgregar() {
|
||||
setEditandoId(null); setNueva(nuevaVacia); setMsg(""); setPopup(true);
|
||||
}
|
||||
function abrirEditar(f) {
|
||||
setEditandoId(f.id);
|
||||
setNueva({
|
||||
frase_busqueda: f.frase_busqueda || "", cargo: f.cargo || "TEAC",
|
||||
codigo: f.codigo || "", sede: f.sede || "Lima", dia: f.dia || "-",
|
||||
origen: f.origen || "Pauta_wsp",
|
||||
fecha_inicio: (f.fecha_inicio || "").slice(0, 10),
|
||||
fecha_fin: (f.fecha_fin || "").slice(0, 10),
|
||||
});
|
||||
setMsg(""); setPopup(true);
|
||||
}
|
||||
|
||||
async function agregar() {
|
||||
if (!nueva.frase_busqueda.trim()) { setMsg("Escribe la frase de búsqueda."); return; }
|
||||
// Aviso de choque: mismo emoji + fechas solapadas. Si las fechas NO chocan, no avisa.
|
||||
const ch = detectarChoques();
|
||||
if (ch.length > 0) { setChoques(ch); return; }
|
||||
await guardarCampania();
|
||||
}
|
||||
|
||||
async function guardarCampania() {
|
||||
setChoques(null);
|
||||
setGuardando(true); setMsg("");
|
||||
try {
|
||||
if (editandoId != null) await api.editarCampania(editandoId, nueva);
|
||||
else await api.agregarCampania(nueva);
|
||||
setPopup(false); setNueva(nuevaVacia); setEditandoId(null);
|
||||
recargar();
|
||||
window.dispatchEvent(new Event("datos-actualizar"));
|
||||
} catch (e) { setMsg("Error al guardar: " + e.message); }
|
||||
finally { setGuardando(false); }
|
||||
}
|
||||
async function borrarConfirmado() {
|
||||
const f = confirmar;
|
||||
setConfirmar(null);
|
||||
try {
|
||||
await api.borrarCampania(f.id);
|
||||
recargar();
|
||||
window.dispatchEvent(new Event("datos-actualizar"));
|
||||
} catch (e) { setMsg("Error al borrar: " + e.message); }
|
||||
}
|
||||
|
||||
const inp = { width: "100%", height: 38, border: "1px solid #e2e8f0", borderRadius: 8,
|
||||
padding: "0 11px", fontSize: 13, boxSizing: "border-box" };
|
||||
const lab = { fontSize: 12.5, fontWeight: 600, color: "#475569", display: "block", marginBottom: 5 };
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ padding: "14px 22px 8px", display: "flex", gap: 10 }}>
|
||||
<input value={busca} onChange={(e) => setBusca(e.target.value)}
|
||||
placeholder="Buscar frase, cargo, código o sede..."
|
||||
style={{ flex: 1, padding: "9px 12px", border: "1px solid #cbd5e1", borderRadius: 9, fontSize: 13 }} />
|
||||
<button onClick={abrirAgregar}
|
||||
style={{ padding: "0 16px", background: "#1e3a8a", color: "#fff", border: "none",
|
||||
borderRadius: 9, fontSize: 13, fontWeight: 700, cursor: "pointer", whiteSpace: "nowrap" }}>
|
||||
+ Agregar leyenda
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: "hidden", padding: "0 22px 12px" }}>
|
||||
<div style={{ height: "100%", border: "1px solid #e2e8f0", borderRadius: 12, overflow: "auto" }}>
|
||||
<table style={{ minWidth: 920, width: "100%", borderCollapse: "separate", borderSpacing: 0, fontSize: 12.5 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
{["Frase de búsqueda", "Cargo", "Código", "Sede", "Día", "Origen", "Inicio", "Fin", ""].map((h, i, arr) => (
|
||||
<th key={i} style={{ position: "sticky", top: 0, zIndex: 20, background: "#1e3a5f", color: "#fff",
|
||||
padding: "10px 12px", fontWeight: 600, textAlign: "center", whiteSpace: "nowrap",
|
||||
borderRight: i < arr.length - 1 ? "1px solid rgba(255,255,255,0.12)" : "none" }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cargando && (
|
||||
<tr><td colSpan={9} style={{ padding: 40, textAlign: "center", color: "#94a3b8" }}>Cargando...</td></tr>
|
||||
)}
|
||||
{!cargando && vis.length === 0 && (
|
||||
<tr><td colSpan={9} style={{ padding: 40, textAlign: "center", color: "#94a3b8" }}>Sin leyendas.</td></tr>
|
||||
)}
|
||||
{!cargando && vis.map((f) => (
|
||||
<tr key={f.id}>
|
||||
<td style={{ padding: "9px 12px", minWidth: 220, textAlign: "left", borderRight: "1px solid #eef2f7" }}>{f.frase_busqueda}</td>
|
||||
<td style={{ padding: "9px 12px", whiteSpace: "nowrap", textAlign: "center", borderRight: "1px solid #eef2f7" }}>{f.cargo}</td>
|
||||
<td style={{ padding: "9px 12px", fontWeight: 600, whiteSpace: "nowrap", textAlign: "center", borderRight: "1px solid #eef2f7" }}>{f.codigo}</td>
|
||||
<td style={{ padding: "9px 12px", whiteSpace: "nowrap", textAlign: "center", borderRight: "1px solid #eef2f7" }}>{f.sede}</td>
|
||||
<td style={{ padding: "9px 12px", whiteSpace: "nowrap", textAlign: "center", borderRight: "1px solid #eef2f7" }}>{f.dia}</td>
|
||||
<td style={{ padding: "9px 12px", whiteSpace: "nowrap", textAlign: "center", borderRight: "1px solid #eef2f7" }}>{f.origen}</td>
|
||||
<td style={{ padding: "9px 12px", whiteSpace: "nowrap", textAlign: "center", borderRight: "1px solid #eef2f7" }}>{f.fecha_inicio}</td>
|
||||
<td style={{ padding: "9px 12px", whiteSpace: "nowrap", textAlign: "center", borderRight: "1px solid #eef2f7" }}>{f.fecha_fin}</td>
|
||||
<td style={{ padding: "9px 12px", textAlign: "center", whiteSpace: "nowrap" }}>
|
||||
<div style={{ display: "inline-flex", gap: 6, alignItems: "center" }}>
|
||||
<button onClick={() => abrirEditar(f)} title="Editar leyenda"
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = "#2563eb"; e.currentTarget.style.color = "#fff"; e.currentTarget.style.borderColor = "#2563eb"; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = "#eff6ff"; e.currentTarget.style.color = "#2563eb"; e.currentTarget.style.borderColor = "#bfdbfe"; }}
|
||||
style={{ width: 28, height: 28, borderRadius: 8, background: "#eff6ff",
|
||||
border: "1px solid #bfdbfe", color: "#2563eb", cursor: "pointer",
|
||||
fontSize: 14, lineHeight: 1, display: "inline-flex",
|
||||
alignItems: "center", justifyContent: "center",
|
||||
transition: "all 0.15s ease" }}>✎</button>
|
||||
<button onClick={() => setConfirmar(f)} title="Eliminar leyenda"
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = "#ef4444"; e.currentTarget.style.color = "#fff"; e.currentTarget.style.borderColor = "#ef4444"; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = "#fef2f2"; e.currentTarget.style.color = "#ef4444"; e.currentTarget.style.borderColor = "#fecaca"; }}
|
||||
style={{ width: 28, height: 28, borderRadius: 8, background: "#fef2f2",
|
||||
border: "1px solid #fecaca", color: "#ef4444", cursor: "pointer",
|
||||
fontSize: 16, fontWeight: 700, lineHeight: 1, display: "inline-flex",
|
||||
alignItems: "center", justifyContent: "center",
|
||||
transition: "all 0.15s ease" }}>×</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Popup Agregar leyenda */}
|
||||
{popup && (
|
||||
<div onClick={() => setPopup(false)}
|
||||
style={{ position: "fixed", inset: 0, background: "rgba(15,23,42,0.45)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1300 }}>
|
||||
<div onClick={(e) => e.stopPropagation()}
|
||||
style={{ background: "#fff", borderRadius: 14, width: 520, maxWidth: "94vw",
|
||||
boxShadow: "0 20px 50px rgba(0,0,0,.25)", overflow: "hidden" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center",
|
||||
padding: "16px 20px", borderBottom: "1px solid #eef2f7" }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: "#0f172a" }}>{editandoId != null ? "Editar leyenda" : "Agregar leyenda"}</div>
|
||||
<button onClick={() => setPopup(false)}
|
||||
style={{ background: "transparent", border: "none", fontSize: 20, color: "#94a3b8", cursor: "pointer" }}>×</button>
|
||||
</div>
|
||||
<div style={{ padding: "18px 20px", display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
<div>
|
||||
<label style={lab}>Frase de búsqueda</label>
|
||||
<input style={inp} value={nueva.frase_busqueda} placeholder="Ej. 🖥️ Estoy interesado en sus Seminarios"
|
||||
onChange={(e) => setNueva({ ...nueva, frase_busqueda: e.target.value })} />
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
|
||||
<div>
|
||||
<label style={lab}>Cargo / programa</label>
|
||||
<select style={{ ...inp, padding: "0 8px", background: "#fff" }} value={nueva.cargo}
|
||||
onChange={(e) => setNueva({ ...nueva, cargo: e.target.value })}>
|
||||
{CARGOS.map((c) => <option key={c}>{c}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={lab}>Código</label>
|
||||
<input style={inp} value={nueva.codigo} placeholder="Ej. 74a"
|
||||
onChange={(e) => setNueva({ ...nueva, codigo: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
|
||||
<div>
|
||||
<label style={lab}>Sede</label>
|
||||
<select style={{ ...inp, padding: "0 8px", background: "#fff" }} value={nueva.sede}
|
||||
onChange={(e) => setNueva({ ...nueva, sede: e.target.value })}>
|
||||
{SEDES.map((s) => <option key={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={lab}>Origen</label>
|
||||
<select style={{ ...inp, padding: "0 8px", background: "#fff" }} value={nueva.origen}
|
||||
onChange={(e) => setNueva({ ...nueva, origen: e.target.value })}>
|
||||
{ORIGENES.map((o) => <option key={o}>{o}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={lab}>Día / frecuencia</label>
|
||||
<select style={{ ...inp, padding: "0 8px", background: "#fff" }} value={nueva.dia}
|
||||
onChange={(e) => setNueva({ ...nueva, dia: e.target.value })}>
|
||||
{DIAS.map((d) => <option key={d}>{d}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
|
||||
<div>
|
||||
<label style={lab}>Fecha inicio</label>
|
||||
<input type="date" style={inp} value={nueva.fecha_inicio}
|
||||
onChange={(e) => setNueva({ ...nueva, fecha_inicio: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={lab}>Fecha fin</label>
|
||||
<input type="date" style={inp} value={nueva.fecha_fin}
|
||||
onChange={(e) => setNueva({ ...nueva, fecha_fin: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: 10, padding: "14px 20px",
|
||||
borderTop: "1px solid #eef2f7", background: "#f8fafc" }}>
|
||||
<button onClick={() => setPopup(false)}
|
||||
style={{ height: 38, padding: "0 16px", border: "1px solid #cbd5e1", background: "#fff",
|
||||
borderRadius: 8, fontSize: 13, fontWeight: 600, color: "#475569", cursor: "pointer" }}>Cancelar</button>
|
||||
<button onClick={agregar} disabled={guardando}
|
||||
style={{ height: 38, padding: "0 18px", border: "none", background: "#1e3a8a",
|
||||
borderRadius: 8, fontSize: 13, fontWeight: 700, color: "#fff",
|
||||
cursor: guardando ? "default" : "pointer", opacity: guardando ? 0.6 : 1 }}>
|
||||
{guardando ? "Guardando..." : (editandoId != null ? "Guardar cambios" : "Guardar leyenda")}
|
||||
</button>
|
||||
</div>
|
||||
{msg && <div style={{ padding: "0 20px 14px", fontSize: 12.5,
|
||||
color: msg.startsWith("Error") ? "#ef4444" : "#0f766e" }}>{msg}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Popup de confirmación de eliminación */}
|
||||
{confirmar !== null && (
|
||||
<div onClick={() => setConfirmar(null)}
|
||||
style={{ position: "fixed", inset: 0, background: "rgba(15,23,42,0.45)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1400 }}>
|
||||
<div onClick={(e) => e.stopPropagation()}
|
||||
style={{ background: "#fff", borderRadius: 14, width: 400, maxWidth: "92vw",
|
||||
boxShadow: "0 20px 50px rgba(0,0,0,.25)", overflow: "hidden", textAlign: "center" }}>
|
||||
<div style={{ padding: "26px 24px 8px" }}>
|
||||
<div style={{ width: 52, height: 52, borderRadius: "50%", background: "#fef2f2",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
margin: "0 auto 14px", fontSize: 26, color: "#ef4444" }}>🗑️</div>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: "#0f172a", marginBottom: 6 }}>
|
||||
¿Eliminar esta leyenda?
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#64748b", lineHeight: 1.5 }}>
|
||||
{confirmar && (
|
||||
<>Se eliminará la pauta <b style={{ color: "#0f172a" }}>{confirmar.codigo}</b>
|
||||
{" "}({confirmar.cargo} — {confirmar.sede}). Esta acción no se puede deshacer.</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 10, padding: "18px 24px 22px" }}>
|
||||
<button onClick={() => setConfirmar(null)}
|
||||
style={{ flex: 1, height: 40, border: "1px solid #cbd5e1", background: "#fff",
|
||||
borderRadius: 9, fontSize: 13.5, fontWeight: 600, color: "#475569", cursor: "pointer" }}>Cancelar</button>
|
||||
<button onClick={borrarConfirmado}
|
||||
style={{ flex: 1, height: 40, border: "none", background: "#ef4444",
|
||||
borderRadius: 9, fontSize: 13.5, fontWeight: 700, color: "#fff", cursor: "pointer" }}>Sí, eliminar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Popup de ADVERTENCIA de choque: mismo emoji + fechas solapadas */}
|
||||
{choques !== null && (
|
||||
<div onClick={() => setChoques(null)}
|
||||
style={{ position: "fixed", inset: 0, background: "rgba(15,23,42,0.45)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1500 }}>
|
||||
<div onClick={(e) => e.stopPropagation()}
|
||||
style={{ background: "#fff", borderRadius: 14, width: 460, maxWidth: "94vw",
|
||||
boxShadow: "0 20px 50px rgba(0,0,0,.25)", overflow: "hidden" }}>
|
||||
<div style={{ padding: "24px 24px 6px", textAlign: "center" }}>
|
||||
<div style={{ width: 52, height: 52, borderRadius: "50%", background: "#fff7ed",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
margin: "0 auto 14px", fontSize: 26 }}>⚠️</div>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: "#0f172a", marginBottom: 6 }}>
|
||||
Esta leyenda podría duplicar leads
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#64748b", lineHeight: 1.5 }}>
|
||||
La frase que vas a crear comparte emoji y fechas con {choques.length === 1 ? "esta pauta existente" : `estas ${choques.length} pautas existentes`}.
|
||||
Un mismo mensaje podría contarse dos veces.
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: "12px 20px", display: "flex", flexDirection: "column", gap: 8, maxHeight: 240, overflowY: "auto" }}>
|
||||
{choques.map((c) => (
|
||||
<div key={c.id} style={{ border: "1px solid #fed7aa", background: "#fffbeb",
|
||||
borderRadius: 10, padding: "10px 12px" }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: "#0f172a", marginBottom: 3 }}>
|
||||
Pauta {c.codigo} <span style={{ fontWeight: 500, color: "#92400e" }}>({c.cargo} — {c.sede})</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12.5, color: "#475569", marginBottom: 4 }}>“{c.frase_busqueda}”</div>
|
||||
<div style={{ fontSize: 12, color: "#b45309", fontWeight: 600 }}>
|
||||
Vigente: {c.fecha_inicio} → {c.fecha_fin}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 10, padding: "12px 24px 20px" }}>
|
||||
<button onClick={() => setChoques(null)}
|
||||
style={{ flex: 1, height: 40, border: "1px solid #cbd5e1", background: "#fff",
|
||||
borderRadius: 9, fontSize: 13.5, fontWeight: 600, color: "#475569", cursor: "pointer" }}>Cancelar</button>
|
||||
<button onClick={guardarCampania} disabled={guardando}
|
||||
style={{ flex: 1, height: 40, border: "none", background: "#ea580c",
|
||||
borderRadius: 9, fontSize: 13.5, fontWeight: 700, color: "#fff",
|
||||
cursor: guardando ? "default" : "pointer", opacity: guardando ? 0.6 : 1 }}>
|
||||
{guardando ? "Guardando..." : "Crear de todos modos"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ConfigModal({ onClose }) {
|
||||
const [seccion, setSeccion] = useState("leyenda");
|
||||
const [filas, setFilas] = useState([]);
|
||||
const [cargando, setCargando] = useState(true);
|
||||
const [guardando, setGuardando] = useState(false);
|
||||
const [msg, setMsg] = useState("");
|
||||
const [busca, setBusca] = useState("");
|
||||
const [edit, setEdit] = useState({}); // conjunto -> pauta editada
|
||||
const [editProg, setEditProg] = useState({}); // conjunto -> {num_indice, label} programa elegido
|
||||
const [progOpc, setProgOpc] = useState([]); // opciones del desplegable de Programa
|
||||
|
||||
useEffect(() => {
|
||||
api.leyendaAnuncios()
|
||||
.then((r) => { setFilas(r.filas || []); setCargando(false); })
|
||||
.catch(() => setCargando(false));
|
||||
api.programasDisponibles()
|
||||
.then((r) => setProgOpc(r.programas || []))
|
||||
.catch(() => setProgOpc([]));
|
||||
}, []);
|
||||
|
||||
const setPauta = (conjunto, val) => setEdit((p) => ({ ...p, [conjunto]: val }));
|
||||
const valorPauta = (f) => (edit[f.conjunto] !== undefined ? edit[f.conjunto] : (f.pauta || ""));
|
||||
// Programa mostrado: el elegido si hay, si no el que ya trae la fila
|
||||
const valorPrograma = (f) => (editProg[f.conjunto] !== undefined ? editProg[f.conjunto].label : (f.programa || ""));
|
||||
|
||||
async function guardar() {
|
||||
// Un cambio por conjunto que tenga pauta editada o programa elegido
|
||||
const conjuntos = new Set([...Object.keys(edit), ...Object.keys(editProg)]);
|
||||
const cambios = [];
|
||||
for (const conjunto of conjuntos) {
|
||||
const f = filas.find((x) => x.conjunto === conjunto) || {};
|
||||
const pauta = (edit[conjunto] !== undefined ? edit[conjunto] : (f.pauta || "")).trim();
|
||||
const prog = editProg[conjunto]; // {num_indice, label} o undefined
|
||||
cambios.push({ conjunto, pauta, num_indice: prog ? prog.num_indice : null });
|
||||
}
|
||||
if (cambios.length === 0) { setMsg("No hay cambios."); return; }
|
||||
setGuardando(true); setMsg("");
|
||||
try {
|
||||
await api.guardarLeyenda(cambios);
|
||||
setMsg("✓ Guardado correctamente.");
|
||||
const r = await api.leyendaAnuncios();
|
||||
setFilas(r.filas || []); setEdit({}); setEditProg({});
|
||||
window.dispatchEvent(new Event("datos-actualizar")); // avisa a TODOS los apartados
|
||||
} catch (e) { setMsg("Error: " + e.message); }
|
||||
finally { setGuardando(false); }
|
||||
}
|
||||
|
||||
// Orden: 1) pauta Y programa, 2) pauta sin programa, 3) programa sin pauta, 4) nada
|
||||
const _rango = (f) => {
|
||||
const p = valorPauta(f).trim() !== "";
|
||||
const g = (f.programa || "").trim() !== "";
|
||||
if (p && g) return 0;
|
||||
if (p && !g) return 1;
|
||||
if (!p && g) return 2;
|
||||
return 3;
|
||||
};
|
||||
const filtradas = filas
|
||||
.filter((f) =>
|
||||
!busca || f.conjunto.toLowerCase().includes(busca.toLowerCase())
|
||||
|| String(f.pauta).includes(busca)
|
||||
|| (f.programa || "").toLowerCase().includes(busca.toLowerCase()))
|
||||
.sort((a, b) => (_rango(a) - _rango(b)) || a.conjunto.localeCompare(b.conjunto));
|
||||
|
||||
return (
|
||||
<div onClick={onClose}
|
||||
style={{ position: "fixed", inset: 0, background: "rgba(15,23,42,0.55)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1200 }}>
|
||||
<div onClick={(e) => e.stopPropagation()}
|
||||
style={{ background: "#fff", borderRadius: 16, width: 1320, maxWidth: "96vw",
|
||||
height: 680, maxHeight: "92vh", display: "flex", overflow: "hidden",
|
||||
boxShadow: "0 24px 70px rgba(0,0,0,0.4)",
|
||||
fontFamily: "system-ui, -apple-system, 'Segoe UI', sans-serif" }}>
|
||||
{/* Sidebar del modal */}
|
||||
<div style={{ width: 240, flexShrink: 0, background: "#f8fafc", borderRight: "1px solid #e2e8f0", padding: "18px 12px" }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: "#94a3b8", textTransform: "uppercase",
|
||||
padding: "0 10px 10px" }}>Configuración</div>
|
||||
<button onClick={() => setSeccion("leyenda")}
|
||||
style={{ width: "100%", textAlign: "left", padding: "10px 12px", borderRadius: 8,
|
||||
border: "none", cursor: "pointer", fontSize: 14, fontWeight: 600, marginBottom: 4,
|
||||
whiteSpace: "nowrap",
|
||||
background: seccion === "leyenda" ? "#e2e8f0" : "transparent", color: "#1e293b" }}>
|
||||
Leyenda de Anuncios
|
||||
</button>
|
||||
<button onClick={() => setSeccion("programas")}
|
||||
style={{ width: "100%", textAlign: "left", padding: "10px 12px", borderRadius: 8,
|
||||
border: "none", cursor: "pointer", fontSize: 14, fontWeight: 600, marginBottom: 4,
|
||||
whiteSpace: "nowrap",
|
||||
background: seccion === "programas" ? "#e2e8f0" : "transparent", color: "#1e293b" }}>
|
||||
Programas
|
||||
</button>
|
||||
<button onClick={() => setSeccion("sedes")}
|
||||
style={{ width: "100%", textAlign: "left", padding: "10px 12px", borderRadius: 8,
|
||||
border: "none", cursor: "pointer", fontSize: 14, fontWeight: 600, marginBottom: 4,
|
||||
whiteSpace: "nowrap",
|
||||
background: seccion === "sedes" ? "#e2e8f0" : "transparent", color: "#1e293b" }}>
|
||||
Sedes de Anuncios
|
||||
</button>
|
||||
<button onClick={() => setSeccion("leyendas")}
|
||||
style={{ width: "100%", textAlign: "left", padding: "10px 12px", borderRadius: 8,
|
||||
border: "none", cursor: "pointer", fontSize: 14, fontWeight: 600, whiteSpace: "nowrap",
|
||||
background: seccion === "leyendas" ? "#e2e8f0" : "transparent", color: "#1e293b" }}>
|
||||
Leyenda
|
||||
</button>
|
||||
</div>
|
||||
{/* Contenido */}
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center",
|
||||
padding: "16px 22px", borderBottom: "1px solid #e2e8f0" }}>
|
||||
<div style={{ fontSize: 17, fontWeight: 700, color: "#0f172a" }}>
|
||||
{seccion === "leyenda" ? "Leyenda de Anuncios"
|
||||
: seccion === "programas" ? "Programas"
|
||||
: seccion === "leyendas" ? "Leyenda"
|
||||
: "Sedes de Anuncios"}
|
||||
</div>
|
||||
<button onClick={onClose}
|
||||
style={{ background: "transparent", border: "none", fontSize: 24, cursor: "pointer", color: "#64748b" }}>×</button>
|
||||
</div>
|
||||
|
||||
{seccion === "leyendas" ? (
|
||||
<LeyendaCampanias />
|
||||
) : seccion === "sedes" ? (
|
||||
<SedesAnuncios />
|
||||
) : seccion === "programas" ? (
|
||||
<ProgramasOcultos />
|
||||
) : (
|
||||
<>
|
||||
<div style={{ padding: "14px 22px 8px" }}>
|
||||
<input value={busca} onChange={(e) => setBusca(e.target.value)}
|
||||
placeholder="Buscar conjunto, pauta o programa..."
|
||||
style={{ width: "100%", padding: "9px 12px", border: "1px solid #cbd5e1",
|
||||
borderRadius: 9, fontSize: 13 }} />
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: "hidden", padding: "0 22px 4px" }}>
|
||||
{cargando ? (
|
||||
<div style={{ padding: 40, textAlign: "center", color: "#94a3b8" }}>Cargando...</div>
|
||||
) : (
|
||||
<div style={{ height: "100%", border: "1px solid #e2e8f0", borderRadius: 12,
|
||||
overflow: "auto" }}>
|
||||
<table style={{ width: "100%", borderCollapse: "separate", borderSpacing: 0,
|
||||
tableLayout: "fixed" }}>
|
||||
<colgroup>
|
||||
<col style={{ width: "38%" }} />
|
||||
<col style={{ width: 100 }} />
|
||||
<col style={{ width: "auto" }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ position: "sticky", top: 0, zIndex: 20, background: "#1e3a5f", color: "#fff",
|
||||
padding: "11px 14px", fontSize: 13, fontWeight: 600, textAlign: "left",
|
||||
borderTopLeftRadius: 12 }}>Conjunto de Anuncios</th>
|
||||
<th style={{ position: "sticky", top: 0, zIndex: 20, background: "#1e3a5f", color: "#fff",
|
||||
padding: "11px 14px", fontSize: 13, fontWeight: 600, width: 110, textAlign: "left" }}>Pauta</th>
|
||||
<th style={{ position: "sticky", top: 0, zIndex: 20, background: "#1e3a5f", color: "#fff",
|
||||
padding: "11px 14px", fontSize: 13, fontWeight: 600, textAlign: "left", width: 340,
|
||||
borderTopRightRadius: 12 }}>Programa</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtradas.map((f) => (
|
||||
<tr key={f.conjunto}>
|
||||
<td style={{ padding: "8px 14px", fontSize: 13, color: "#334155", textAlign: "left",
|
||||
borderRight: "1px solid #eef2f7", wordBreak: "break-word" }}>{f.conjunto}</td>
|
||||
<td style={{ padding: "8px 14px", borderRight: "1px solid #eef2f7", textAlign: "center" }}>
|
||||
<input value={valorPauta(f)} onChange={(e) => setPauta(f.conjunto, e.target.value)}
|
||||
style={{ width: 90, padding: "6px 8px", border: "1px solid #cbd5e1",
|
||||
borderRadius: 6, fontSize: 13, textAlign: "left", fontFamily: "inherit",
|
||||
background: edit[f.conjunto] !== undefined ? "#fffbeb" : "#fff" }} />
|
||||
</td>
|
||||
<td style={{ padding: "8px 14px", maxWidth: 0, position: "relative" }}>
|
||||
<ProgramaSelect valor={valorPrograma(f)} opciones={progOpc}
|
||||
onSelect={(o) => setEditProg((p) => ({ ...p, [f.conjunto]: o }))} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center",
|
||||
padding: "14px 22px", borderTop: "1px solid #e2e8f0" }}>
|
||||
<span style={{ fontSize: 13, color: msg.startsWith("✓") ? "#047857" : "#b91c1c" }}>{msg}</span>
|
||||
<button onClick={guardar} disabled={guardando}
|
||||
style={{ padding: "9px 22px", border: "none", background: "#1e3a5f", color: "#fff",
|
||||
borderRadius: 9, fontSize: 14, fontWeight: 700, cursor: guardando ? "wait" : "pointer" }}>
|
||||
{guardando ? "Guardando..." : "Guardar"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
frontend/src/components/ErrorBoundary.jsx
Normal file
41
frontend/src/components/ErrorBoundary.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Component } from "react";
|
||||
|
||||
// Captura errores de render de sus hijos y muestra un mensaje en vez de
|
||||
// dejar la pantalla en blanco. Botón para reintentar (re-monta el contenido).
|
||||
export default class ErrorBoundary extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { error: null };
|
||||
}
|
||||
static getDerivedStateFromError(error) {
|
||||
return { error };
|
||||
}
|
||||
componentDidCatch(error, info) {
|
||||
// Log para diagnóstico en consola del navegador
|
||||
console.error("[ErrorBoundary]", error, info);
|
||||
}
|
||||
reintentar = () => this.setState({ error: null });
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<div style={{ padding: 40, display: "flex", flexDirection: "column",
|
||||
alignItems: "center", justifyContent: "center", height: "100%",
|
||||
textAlign: "center", color: "#475569" }}>
|
||||
<div style={{ fontSize: 40, marginBottom: 12 }}>⚠️</div>
|
||||
<div style={{ fontSize: 17, fontWeight: 700, color: "#0f172a", marginBottom: 6 }}>
|
||||
Ocurrió un problema al mostrar esta sección
|
||||
</div>
|
||||
<div style={{ fontSize: 13.5, marginBottom: 18, maxWidth: 420, lineHeight: 1.5 }}>
|
||||
El resto del dashboard sigue funcionando. Puedes reintentar o cambiar de apartado.
|
||||
</div>
|
||||
<button onClick={this.reintentar}
|
||||
style={{ height: 40, padding: "0 20px", border: "none", background: "#2563eb",
|
||||
borderRadius: 9, fontSize: 14, fontWeight: 700, color: "#fff", cursor: "pointer" }}>
|
||||
Reintentar
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
// src/components/IndicadorActualizacion.jsx
|
||||
// Indicador (arriba-derecha) con la hora de la última actualización + botón de
|
||||
// recarga. Al pulsar: re-lee las bases para la vista actual y recachea el resto.
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { api } from "../lib/api";
|
||||
import { getVistaActual, emitirRefresco } from "../lib/vistaActual";
|
||||
|
||||
function formatearHora(iso) {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString("es-PE", {
|
||||
day: "2-digit", month: "2-digit",
|
||||
hour: "2-digit", minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return "—";
|
||||
}
|
||||
}
|
||||
|
||||
export default function IndicadorActualizacion() {
|
||||
const [hora, setHora] = useState(null);
|
||||
const [cargando, setCargando] = useState(false);
|
||||
|
||||
const consultar = useCallback(async () => {
|
||||
try {
|
||||
const r = await api.ultimaActualizacion();
|
||||
setHora(r?.hora || null);
|
||||
} catch { /* silencioso */ }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
consultar();
|
||||
const id = setInterval(consultar, 60000);
|
||||
return () => clearInterval(id);
|
||||
}, [consultar]);
|
||||
|
||||
async function recargar() {
|
||||
if (cargando) return;
|
||||
setCargando(true);
|
||||
try {
|
||||
const r = await api.cacheRefreshManual(getVistaActual());
|
||||
setHora(r?.hora || null);
|
||||
emitirRefresco();
|
||||
} catch { /* silencioso */ }
|
||||
finally { setCargando(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@keyframes spin-actualiza { to { transform: rotate(360deg); } }
|
||||
.ind-actualiza-btn { transition: color .15s, transform .15s; }
|
||||
.ind-actualiza-btn:hover:not(:disabled) { color:#7dd3fc; transform: scale(1.15); }
|
||||
.ind-actualiza-spin { animation: spin-actualiza .9s linear infinite; }
|
||||
`}</style>
|
||||
<div
|
||||
style={{
|
||||
position: "fixed", right: 16, top: 14, zIndex: 60,
|
||||
display: "flex", alignItems: "center", gap: 8,
|
||||
background: "rgba(15,23,42,0.9)",
|
||||
color: cargando ? "#7dd3fc" : "#cbd5e1",
|
||||
border: "1px solid " + (cargando ? "#0ea5e9" : "#334155"),
|
||||
borderRadius: 20, padding: "6px 12px",
|
||||
fontSize: 12, lineHeight: 1, fontWeight: 500,
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.28)",
|
||||
backdropFilter: "blur(4px)",
|
||||
userSelect: "none",
|
||||
}}
|
||||
title="Última actualización de las bases de datos"
|
||||
>
|
||||
<span style={{ opacity: cargando ? 1 : 0.85 }}>
|
||||
{cargando ? "Actualizando…" : formatearHora(hora)}
|
||||
</span>
|
||||
<button
|
||||
onClick={recargar}
|
||||
disabled={cargando}
|
||||
className={"ind-actualiza-btn" + (cargando ? " ind-actualiza-spin" : "")}
|
||||
title={cargando ? "Actualizando datos…" : "Actualizar ahora"}
|
||||
style={{
|
||||
background: "transparent", border: "none",
|
||||
cursor: cargando ? "default" : "pointer",
|
||||
color: "#38bdf8", fontSize: 15, padding: 0, lineHeight: 1,
|
||||
display: "flex", alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2.4"
|
||||
strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
|
||||
<path d="M21 3v6h-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
// src/components/Modal.jsx
|
||||
export default function Modal({ title, onClose, children, width = 900 }) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: "fixed", inset: 0, background: "rgba(15,23,42,0.55)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
zIndex: 1000, padding: 20,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: "#fff", borderRadius: 16, width: "100%", maxWidth: width,
|
||||
maxHeight: "90vh", overflow: "auto", boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: "flex", justifyContent: "space-between", alignItems: "center",
|
||||
padding: "16px 22px", borderBottom: "1px solid #e2e8f0", position: "sticky",
|
||||
top: 0, background: "#fff", zIndex: 2,
|
||||
}}>
|
||||
<h3 style={{ fontSize: 16, fontWeight: 700, color: "#1e40af" }}>{title}</h3>
|
||||
<button onClick={onClose} style={{
|
||||
border: "none", background: "#f1f5f9", borderRadius: 8, width: 32, height: 32,
|
||||
cursor: "pointer", fontSize: 18, color: "#475569",
|
||||
}}>×</button>
|
||||
</div>
|
||||
<div style={{ padding: 22 }}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,48 @@
|
||||
// src/components/Sidebar.jsx
|
||||
import { useAuth } from "../lib/auth";
|
||||
import { useState, useEffect } from "react";
|
||||
import { api } from "../lib/api";
|
||||
|
||||
const MENU = [
|
||||
{ id: "ocupabilidad", ico: "📊", label: "Ocupabilidad" },
|
||||
{ id: "ventas", ico: "💰", label: "Ventas" },
|
||||
{ id: "comisiones", ico: "🧮", label: "Comisiones" },
|
||||
{ id: "cobranza", ico: "📋", label: "Cobranza" },
|
||||
{ id: "rentabilidad", ico: "📈", label: "Rentabilidad" },
|
||||
{ id: "asesores", ico: "👥", label: "Asesores" },
|
||||
{ id: "usuarios", ico: "🔐", label: "Usuarios" },
|
||||
{ id: "leads", ico: "🎯", label: "Leads" },
|
||||
{ id: "otros", ico: "📊", label: "Otros General" },
|
||||
{ id: "vendedores", ico: "🧑💼", label: "Vendedores" },
|
||||
{ id: "roas", ico: "📈", label: "ROAS" },
|
||||
];
|
||||
|
||||
export default function Sidebar({ active, onChange }) {
|
||||
const { perfil, permisos, logout } = useAuth();
|
||||
const visibles = MENU.filter((m) => permisos.includes(m.id));
|
||||
export default function Sidebar({ active, onChange, onConfig }) {
|
||||
const [menuUser, setMenuUser] = useState(false);
|
||||
const [ultima, setUltima] = useState("");
|
||||
const [actualizando, setActualizando] = useState(false);
|
||||
|
||||
const cargarHora = () => api.ultimaActualizacion()
|
||||
.then((r) => setUltima(r.hora || "")).catch(() => {});
|
||||
|
||||
useEffect(() => {
|
||||
cargarHora();
|
||||
const id = setInterval(cargarHora, 60 * 1000); // refresca la hora cada minuto
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// Actualiza TODO manualmente (como si pasaran los 15 min, pero al instante).
|
||||
async function actualizarTodo() {
|
||||
if (actualizando) return;
|
||||
setActualizando(true);
|
||||
try {
|
||||
const r = await api.refrescarTodo();
|
||||
if (r && r.hora) setUltima(r.hora); // hora del refresco manual recien hecho
|
||||
else await cargarHora();
|
||||
window.dispatchEvent(new Event("datos-actualizar")); // recarga el apartado abierto
|
||||
} catch (e) {
|
||||
// silencioso: no romper la UI si falla
|
||||
} finally {
|
||||
setActualizando(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-logo">Escuela <span>Refrigeración</span></div>
|
||||
|
||||
<nav className="nav" style={{ flex: 1 }}>
|
||||
{visibles.map((m) => (
|
||||
{MENU.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
className={`nav-item ${active === m.id ? "active" : ""}`}
|
||||
@@ -31,22 +53,59 @@ export default function Sidebar({ active, onChange }) {
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div style={{ padding: 14, borderTop: "1px solid rgba(255,255,255,0.08)" }}>
|
||||
<div style={{ fontSize: 12, color: "#94a3b8", marginBottom: 2 }}>
|
||||
{perfil?.nombre || "Usuario"}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "#60a5fa", fontWeight: 600, marginBottom: 10 }}>
|
||||
{perfil?.rol || ""}
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
style={{
|
||||
width: "100%", padding: "8px", border: "1px solid rgba(255,255,255,0.15)",
|
||||
borderRadius: 8, background: "transparent", color: "#cbd5e1", fontSize: 12,
|
||||
cursor: "pointer", fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
🚪 Cerrar sesión
|
||||
{/* Botón de actualizar manual + hora de última actualización */}
|
||||
<div style={{ padding: "0 12px 8px" }}>
|
||||
<button onClick={actualizarTodo} disabled={actualizando}
|
||||
title="Actualizar todos los datos ahora (sin esperar los 15 min)"
|
||||
style={{ width: "100%", display: "flex", alignItems: "center", justifyContent: "center",
|
||||
gap: 8, padding: "9px 12px", background: actualizando ? "#1e293b" : "#2563eb",
|
||||
border: "none", borderRadius: 9, color: "#fff", fontSize: 12.5, fontWeight: 600,
|
||||
cursor: actualizando ? "default" : "pointer", opacity: actualizando ? 0.75 : 1,
|
||||
transition: "all .15s ease" }}>
|
||||
<span style={{ display: "inline-block",
|
||||
animation: actualizando ? "spin 1s linear infinite" : "none" }}>🔄</span>
|
||||
{actualizando ? "Actualizando..." : "Actualizar ahora"}
|
||||
</button>
|
||||
{actualizando ? (
|
||||
<div style={{ marginTop: 6, fontSize: 10, color: "#93c5fd", textAlign: "center" }}>
|
||||
Actualizando en segundo plano...
|
||||
</div>
|
||||
) : ultima ? (
|
||||
<div style={{ marginTop: 6, fontSize: 10, color: "#64748b", textAlign: "center" }}>
|
||||
Actualizado: {ultima}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={{ position: "relative", borderTop: "1px solid rgba(255,255,255,0.08)", padding: 12 }}>
|
||||
{menuUser && (
|
||||
<div style={{ position: "absolute", bottom: 64, left: 12, right: 12, background: "#1e293b",
|
||||
border: "1px solid rgba(255,255,255,0.12)", borderRadius: 10, overflow: "hidden",
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,0.4)" }}>
|
||||
<button onClick={() => { setMenuUser(false); onConfig && onConfig(); }}
|
||||
style={{ width: "100%", textAlign: "left", padding: "11px 14px", background: "transparent",
|
||||
border: "none", color: "#e2e8f0", cursor: "pointer", fontSize: 13,
|
||||
display: "flex", alignItems: "center", gap: 9 }}>
|
||||
⚙️ Configuración
|
||||
</button>
|
||||
<button onClick={() => setMenuUser(false)}
|
||||
style={{ width: "100%", textAlign: "left", padding: "11px 14px", background: "transparent",
|
||||
border: "none", borderTop: "1px solid rgba(255,255,255,0.08)", color: "#f87171",
|
||||
cursor: "pointer", fontSize: 13, display: "flex", alignItems: "center", gap: 9 }}>
|
||||
⎋ Cerrar sesión
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button onClick={() => setMenuUser((v) => !v)}
|
||||
style={{ width: "100%", display: "flex", alignItems: "center", gap: 10, background: "transparent",
|
||||
border: "none", cursor: "pointer", padding: "6px 4px", color: "#e2e8f0" }}>
|
||||
<div style={{ width: 34, height: 34, borderRadius: "50%", background: "#2563eb", color: "#fff",
|
||||
display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 700,
|
||||
fontSize: 13, flexShrink: 0 }}>AS</div>
|
||||
<div style={{ flex: 1, textAlign: "left", lineHeight: 1.2 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>Aron</div>
|
||||
<div style={{ fontSize: 11, color: "#94a3b8" }}>RP ERP</div>
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: "#94a3b8" }}>{menuUser ? "▲" : "▼"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// src/components/UI.jsx
|
||||
export function Loader({ text = "Cargando..." }) {
|
||||
return (
|
||||
<div className="loader-wrap">
|
||||
@@ -12,34 +11,15 @@ export function ErrorBox({ msg }) {
|
||||
return <div className="error-box">⚠️ {msg}</div>;
|
||||
}
|
||||
|
||||
export function colorSemaforo(pct) {
|
||||
if (pct >= 80) return { bg: "#d1fae5", fg: "#065f46", fill: "#10b981" };
|
||||
if (pct >= 60) return { bg: "#fef9c3", fg: "#854d0e", fill: "#f59e0b" };
|
||||
return { bg: "#fee2e2", fg: "#991b1b", fill: "#ef4444" };
|
||||
}
|
||||
|
||||
export function ProgressBar({ pct }) {
|
||||
const c = colorSemaforo(pct);
|
||||
const w = Math.min(Math.max(pct, 0), 100);
|
||||
return (
|
||||
<div className="pbar">
|
||||
<span className="pct" style={{ color: c.fg }}>{pct.toFixed(1)}%</span>
|
||||
<div className="track">
|
||||
<div className="fill" style={{ width: `${w}%`, background: c.fill }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Filters({ children }) {
|
||||
return <div className="filters">{children}</div>;
|
||||
}
|
||||
|
||||
export function Select({ label, value, options, onChange, width }) {
|
||||
export function Select({ label, value, options, onChange }) {
|
||||
return (
|
||||
<div className="filter-group" style={width ? { width } : undefined}>
|
||||
<div className="filter-group">
|
||||
{label && <label>{label}</label>}
|
||||
<select value={value} onChange={(e) => onChange(e.target.value)} style={width ? { width: "100%" } : undefined}>
|
||||
<select value={value} onChange={(e) => onChange(e.target.value)}>
|
||||
{options.map((o) => {
|
||||
const val = typeof o === "object" ? o.value : o;
|
||||
const txt = typeof o === "object" ? o.label : o;
|
||||
|
||||
@@ -1,86 +1,150 @@
|
||||
// src/lib/api.js
|
||||
// Cliente para el backend FastAPI. Cambia BASE_URL si el backend corre en otra IP.
|
||||
const BASE_URL = "http://localhost:8000";
|
||||
// Cliente del backend FastAPI de Leads.
|
||||
// 1) Si existe VITE_API_URL (archivo .env del frontend), se usa esa.
|
||||
// 2) Si no, se decide por el host desde el que se abrió la página:
|
||||
// localhost -> backend local http://localhost:8001
|
||||
// cualquier otro -> API publicada del servidor
|
||||
// Así funciona en desarrollo y en el servidor sin necesitar ningún .env.
|
||||
const API_LOCAL = "http://localhost:8001";
|
||||
const API_SERVIDOR = "https://dashboardapi.escueladerefrigeracion.edu.pe";
|
||||
|
||||
async function get(path, params = {}) {
|
||||
function _baseUrlPorDefecto() {
|
||||
if (typeof window === "undefined") return API_LOCAL;
|
||||
const h = window.location.hostname;
|
||||
if (h === "localhost" || h === "127.0.0.1" || h === "::1") {
|
||||
return API_LOCAL;
|
||||
}
|
||||
const proto = window.location.protocol || "http:";
|
||||
return `${proto}//dashboardapi.escueladerefrigeracion.edu.pe`;
|
||||
}
|
||||
|
||||
const BASE_URL = (
|
||||
import.meta.env.VITE_API_URL || _baseUrlPorDefecto()
|
||||
).replace(/\/+$/, "");
|
||||
|
||||
// ── Cache en memoria (frontend) con TTL 15 min y "stale-while-revalidate" ──
|
||||
// Guarda la respuesta de cada GET (por path+params). Al volver a pedir:
|
||||
// - si hay dato vigente (< 15 min) -> lo devuelve al instante, SIN fetch.
|
||||
// - si venció -> devuelve el viejo al instante Y refresca en 2º plano.
|
||||
// Así, al cambiar de apartado y volver, NO se recarga; y a los 15 min se
|
||||
// actualiza solo sin mostrar loader. Los POST invalidan el cache.
|
||||
const TTL = 15 * 60 * 1000; // 15 minutos
|
||||
const _cache = new Map(); // key -> { ts, data }
|
||||
const _enVuelo = new Map(); // key -> Promise (evita fetch duplicado)
|
||||
|
||||
function _key(path, params) {
|
||||
return path + "?" + new URLSearchParams(params).toString();
|
||||
}
|
||||
|
||||
async function _fetch(path, params) {
|
||||
const qs = new URLSearchParams(params).toString();
|
||||
const url = `${BASE_URL}${path}${qs ? "?" + qs : ""}`;
|
||||
const r = await fetch(url);
|
||||
const r = await fetch(`${BASE_URL}${path}${qs ? "?" + qs : ""}`);
|
||||
if (!r.ok) throw new Error(`Error ${r.status} en ${path}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
async function postJson(path, body) {
|
||||
// GET con cache. Devuelve SIEMPRE una promesa que resuelve rápido si hay cache.
|
||||
function get(path, params = {}) {
|
||||
const key = _key(path, params);
|
||||
const hit = _cache.get(key);
|
||||
const now = Date.now();
|
||||
|
||||
if (hit) {
|
||||
// Hay algo cacheado -> devolverlo YA.
|
||||
if (now - hit.ts >= TTL && !_enVuelo.has(key)) {
|
||||
// Venció: refrescar en 2º plano (no bloquea, no muestra loader).
|
||||
const p = _fetch(path, params)
|
||||
.then((data) => { _cache.set(key, { ts: Date.now(), data }); return data; })
|
||||
.finally(() => _enVuelo.delete(key));
|
||||
_enVuelo.set(key, p);
|
||||
}
|
||||
return Promise.resolve(hit.data);
|
||||
}
|
||||
|
||||
// No hay cache: si ya hay una petición en vuelo para esta key, reusarla.
|
||||
if (_enVuelo.has(key)) return _enVuelo.get(key);
|
||||
|
||||
const p = _fetch(path, params)
|
||||
.then((data) => { _cache.set(key, { ts: Date.now(), data }); return data; })
|
||||
.finally(() => _enVuelo.delete(key));
|
||||
_enVuelo.set(key, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
// Invalida entradas del cache cuyo path empiece con alguno de los prefijos dados.
|
||||
function invalidar(...prefijos) {
|
||||
for (const k of _cache.keys()) {
|
||||
if (prefijos.some((p) => k.startsWith(p))) _cache.delete(k);
|
||||
}
|
||||
}
|
||||
|
||||
// POST helper: hace el POST y luego invalida los caches afectados.
|
||||
async function post(path, body, invalidarPrefijos = []) {
|
||||
const r = await fetch(`${BASE_URL}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!r.ok) throw new Error(`Error ${r.status} en ${path}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
async function post(path, params = {}) {
|
||||
const qs = new URLSearchParams(params).toString();
|
||||
const url = `${BASE_URL}${path}${qs ? "?" + qs : ""}`;
|
||||
const r = await fetch(url, { method: "POST" });
|
||||
if (!r.ok) throw new Error(`Error ${r.status} en ${path}`);
|
||||
return r.json();
|
||||
if (!r.ok) throw new Error(`Error al guardar en ${path}`);
|
||||
const data = await r.json();
|
||||
if (invalidarPrefijos.length) invalidar(...invalidarPrefijos);
|
||||
return data;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
periodoActual: () => get("/api/periodo-actual"),
|
||||
leadsFiltros: () => get("/api/leads/filtros"),
|
||||
leads: (ano = "TODOS", mes = "TODOS", dia = "TODOS", programa = "TODOS", sede = "TODOS") =>
|
||||
get("/api/leads", { ano, mes, dia, programa, sede }),
|
||||
otrosGeneral: (ano = "TODOS", mes = "TODOS", dia = "TODOS") =>
|
||||
get("/api/otros-general", { ano, mes, dia }),
|
||||
vendedores: (ano = "TODOS", mes = "TODOS", dia = "TODOS") =>
|
||||
get("/api/vendedores", { ano, mes, dia }),
|
||||
roas: (ano = "TODOS", mes = "TODOS", dia = "TODOS") =>
|
||||
get("/api/roas", { ano, mes, dia }),
|
||||
conjuntosSinSede: () => get("/api/conjuntos-sin-sede"),
|
||||
guardarConjuntoSede: (cambios) =>
|
||||
post("/api/conjuntos-sin-sede/guardar", { cambios }, ["/api/conjuntos-sin-sede", "/api/roas"]),
|
||||
borrarConjuntoSede: (conjunto) =>
|
||||
post("/api/conjuntos-sin-sede/borrar", { conjunto }, ["/api/conjuntos-sin-sede", "/api/roas"]),
|
||||
ultimaActualizacion: () => get("/api/ultima-actualizacion"),
|
||||
cacheRefresh: () => post("/api/cache/refresh"),
|
||||
cacheRefreshManual: (clave) => post("/api/cache/refresh-manual", clave ? { clave } : {}),
|
||||
precargaModulo: (modulo, ano) => post("/api/precarga/modulo", { modulo, ano }),
|
||||
precargaEstado: (modulo) => get("/api/precarga/estado", { modulo }),
|
||||
ocupabilidad: (ano, mes, sede = "TODOS", programa = "TODOS") =>
|
||||
get("/api/ocupabilidad", { ano, mes, sede, programa }),
|
||||
ocupabilidadAlumnos: (num_indice) =>
|
||||
get("/api/ocupabilidad/alumnos", { num_indice }),
|
||||
ventas: (ano, mes, sede = "TODOS", programa = "TODOS", con_overrides = false) => get("/api/ventas", { ano, mes, sede, programa, con_overrides }),
|
||||
ventasDetalle: (vendedor, ano, mes, tipo, sede = "TODOS", programa = "TODOS") =>
|
||||
get("/api/ventas/detalle", { vendedor, ano, mes, tipo, sede, programa }),
|
||||
cobranza: (ano, mes, sectorista = "TODOS", agrupacion = "SEDE", sede_filtro = "TODOS", prog_filtro = "TODOS", venc_hasta = "TODOS") =>
|
||||
get("/api/cobranza", { ano, mes, sectorista, agrupacion, sede_filtro, prog_filtro, venc_hasta }),
|
||||
cobranzaDetalle: (grupo, ano, mes, sectorista = "TODOS", agrupacion = "PROGRAMA", prog_filtro = "TODOS", sede_filtro = "TODOS", venc_hasta = "TODOS") =>
|
||||
get("/api/cobranza/detalle", { grupo, ano, mes, sectorista, agrupacion, prog_filtro, sede_filtro, venc_hasta }),
|
||||
alumnoEstado: (matricula) => get("/api/alumno/estado", { matricula }),
|
||||
alumnosPorRetirar: () => get("/api/alumnos/por-retirar"),
|
||||
alumnoEstadoGuardar: (matricula, estado, observaciones, usuario) =>
|
||||
postJson("/api/alumno/estado/guardar", { matricula, estado, observaciones, usuario }),
|
||||
rentabilidad: (ano, mes, sede = "TODOS", programa = "TODOS") =>
|
||||
get("/api/rentabilidad", { ano, mes, sede, programa }),
|
||||
rentabilidadDetalle: (programa, ano, mes) =>
|
||||
get("/api/rentabilidad/detalle", { programa, ano, mes }),
|
||||
rentabilidadCostos: (programa, ano, mes) =>
|
||||
get("/api/rentabilidad/costos", { programa, ano, mes }),
|
||||
saldoPendiente: (tipo_cuota) => get("/api/saldo-pendiente", { tipo_cuota }),
|
||||
asesores: () => get("/api/asesores"),
|
||||
asesorEstado: (agent_id, online) =>
|
||||
post("/api/asesores/estado", { agent_id, online }),
|
||||
guardarCostos: (num_indice, inicial, actual) =>
|
||||
postJson("/api/rentabilidad/costos/guardar", { num_indice, inicial, actual }),
|
||||
clasificarSede: (programas) =>
|
||||
postJson("/api/cobranza/clasificar-sede", { programas }),
|
||||
clasificarFull: (programas) =>
|
||||
postJson("/api/cobranza/clasificar-full", { programas }),
|
||||
cobranzaDetalleTodos: (ano, mes, sectorista = "TODOS") =>
|
||||
get("/api/cobranza/detalle-todos", { ano, mes, sectorista }),
|
||||
usuariosListar: () => get("/api/usuarios"),
|
||||
usuariosCrear: (email, password, nombre, rol) =>
|
||||
postJson("/api/usuarios/crear", { email, password, nombre, rol }),
|
||||
usuariosActualizarRol: (id, rol) => postJson("/api/usuarios/rol", { id, rol }),
|
||||
usuariosEliminar: (id) => postJson("/api/usuarios/eliminar", { id }),
|
||||
vendedoresManualesListar: (ano, mes) => get("/api/comisiones/vendedores", { ano, mes }),
|
||||
vendedoresManualesCrear: (datos) => postJson("/api/comisiones/vendedores/crear", datos),
|
||||
vendedoresManualesEliminar: (id) => postJson("/api/comisiones/vendedores/eliminar", { id }),
|
||||
vendedoresManualesActualizar: (datos) => postJson("/api/comisiones/vendedores/actualizar", datos),
|
||||
vendedoresManualesLote: (datos) => postJson("/api/comisiones/vendedores/lote", datos),
|
||||
comisionesOverrideGuardar: (registros) => postJson("/api/comisiones/override/guardar", { registros }),
|
||||
comisionesOverrideRestaurar: (num_matricula) => postJson("/api/comisiones/override/restaurar", { num_matricula }),
|
||||
comisionesDetalleTodos: (ano, mes) => get("/api/comisiones/detalle-todos", { ano, mes }),
|
||||
comisionesConfig: (ano, mes) => get("/api/comisiones/config", { ano, mes }),
|
||||
comisionesConfigGuardar: (datos) => postJson("/api/comisiones/config/guardar", datos),
|
||||
// Actualiza TODO manualmente (como si pasaran los 15 min), pero EN SEGUNDO
|
||||
// PLANO: NO se toca el cache del cliente mientras el backend trabaja, así el
|
||||
// usuario sigue viendo los datos viejos sin pantallas de carga durante los
|
||||
// ~29s. Recien cuando el backend responde OK se limpia el cache del cliente,
|
||||
// para que a partir de ahi todo muestre los datos nuevos de inmediato.
|
||||
refrescarTodo: async () => {
|
||||
const r = await fetch(`${BASE_URL}/api/cache/refresh`, { method: "POST" });
|
||||
if (!r.ok) throw new Error("Error al actualizar");
|
||||
await r.json(); // espera a que el backend TERMINE de rehacer todo
|
||||
_cache.clear(); // solo AHORA se invalida el cache del cliente
|
||||
// Trae la hora de actualizacion FRESCA (sin cache) y la deja cacheada, para
|
||||
// que el sidebar muestre la hora del refresco manual recien hecho.
|
||||
const hr = await _fetch("/api/ultima-actualizacion", {});
|
||||
_cache.set(_key("/api/ultima-actualizacion", {}), { ts: Date.now(), data: hr });
|
||||
return hr; // { hora: "..." }
|
||||
},
|
||||
conjuntosSinPauta: () => get("/api/conjuntos-sin-pauta"),
|
||||
usoPauta: (pauta, excluir) => get("/api/pauta/uso", { pauta, excluir: excluir || "" }),
|
||||
leyendaAnuncios: () => get("/api/leyenda-anuncios"),
|
||||
programasDisponibles: () => get("/api/programas-disponibles"),
|
||||
programasOcultos: () => get("/api/programas-ocultos"),
|
||||
encenderProgramas: (num_indices) =>
|
||||
post("/api/programas-ocultos/encender", { num_indices },
|
||||
["/api/programas-ocultos", "/api/programas-disponibles", "/api/leads"]),
|
||||
guardarLeyenda: (cambios) =>
|
||||
post("/api/leyenda-anuncios/guardar", { cambios }, ["/api/leyenda-anuncios", "/api/roas"]),
|
||||
guardarPauta: (num_indice, pauta, conjunto, contar) =>
|
||||
post("/api/curso/guardar-pauta", { num_indice, pauta, conjunto, contar },
|
||||
["/api/leads", "/api/conjuntos-sin-pauta", "/api/roas"]),
|
||||
alertas: () => get("/api/leads/alertas"),
|
||||
// Leyenda (campanias de Supabase). Al agregar/borrar, invalida TODOS los
|
||||
// apartados que dependen de las leyendas para que el cambio se vea al instante.
|
||||
campanias: () => get("/api/campanias"),
|
||||
agregarCampania: (datos) =>
|
||||
post("/api/campanias/agregar", datos,
|
||||
["/api/campanias", "/api/leads", "/api/vendedores", "/api/roas", "/api/otros-general"]),
|
||||
editarCampania: (id, datos) =>
|
||||
post("/api/campanias/editar", { id, ...datos },
|
||||
["/api/campanias", "/api/leads", "/api/vendedores", "/api/roas", "/api/otros-general"]),
|
||||
borrarCampania: (id) =>
|
||||
post("/api/campanias/borrar", { id },
|
||||
["/api/campanias", "/api/leads", "/api/vendedores", "/api/roas", "/api/otros-general"]),
|
||||
};
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
// src/lib/auth.jsx
|
||||
import { createContext, useContext, useEffect, useState, useCallback } from "react";
|
||||
import { supabase } from "./supabase";
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [session, setSession] = useState(null);
|
||||
const [perfil, setPerfil] = useState(null);
|
||||
const [permisos, setPermisos] = useState([]);
|
||||
const [cargando, setCargando] = useState(true);
|
||||
|
||||
const cargarPerfil = useCallback(async (userId) => {
|
||||
try {
|
||||
const { data: perf, error: e1 } = await supabase
|
||||
.from("perfiles")
|
||||
.select("nombre, rol, activo")
|
||||
.eq("id", userId)
|
||||
.single();
|
||||
if (e1 || !perf || perf.activo === false) {
|
||||
setPerfil(null);
|
||||
setPermisos([]);
|
||||
return false;
|
||||
}
|
||||
const { data: perms } = await supabase
|
||||
.from("permisos")
|
||||
.select("modulo, puede_ver")
|
||||
.eq("rol", perf.rol);
|
||||
const modulos = (perms || [])
|
||||
.filter((p) => p.puede_ver)
|
||||
.map((p) => p.modulo);
|
||||
setPerfil({ nombre: perf.nombre, rol: perf.rol });
|
||||
setPermisos(modulos);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("[auth] cargarPerfil falló:", e);
|
||||
setPerfil(null);
|
||||
setPermisos([]);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let activo = true;
|
||||
let resuelto = false;
|
||||
|
||||
const terminar = () => {
|
||||
if (activo) { setCargando(false); resuelto = true; sessionStorage.removeItem("auth_reload"); }
|
||||
};
|
||||
|
||||
// Fuente principal: onAuthStateChange dispara INITIAL_SESSION al arrancar
|
||||
// con la sesión persistida (renueva token solo). Maneja login/logout en vivo.
|
||||
const { data: sub } = supabase.auth.onAuthStateChange(async (_evt, s) => {
|
||||
try {
|
||||
setSession(s);
|
||||
if (s?.user) await cargarPerfil(s.user.id);
|
||||
else { setPerfil(null); setPermisos([]); }
|
||||
} catch (e) {
|
||||
console.error("[auth] onAuthStateChange falló:", e);
|
||||
} finally {
|
||||
terminar();
|
||||
}
|
||||
});
|
||||
|
||||
// Respaldo: si el listener no disparó en 6s, intenta getSession una vez.
|
||||
// Si trae sesión la usa; si no, NO fuerza login (deja que el listener resuelva).
|
||||
const tRespaldo = setTimeout(async () => {
|
||||
if (resuelto || !activo) return;
|
||||
try {
|
||||
const { data } = await supabase.auth.getSession();
|
||||
const s = data?.session || null;
|
||||
if (s?.user && activo) {
|
||||
setSession(s);
|
||||
await cargarPerfil(s.user.id);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[auth] getSession (respaldo) falló:", e);
|
||||
} finally {
|
||||
terminar();
|
||||
}
|
||||
}, 6000);
|
||||
|
||||
// Si a los 8s sigue sin resolver, recarga la página UNA vez (F5 automático)
|
||||
// en vez de mostrar el login pegado. sessionStorage evita bucle.
|
||||
const tSeguridad = setTimeout(() => {
|
||||
if (!activo || resuelto) return;
|
||||
if (!sessionStorage.getItem("auth_reload")) {
|
||||
sessionStorage.setItem("auth_reload", "1");
|
||||
window.location.reload();
|
||||
} else {
|
||||
setCargando(false);
|
||||
}
|
||||
}, 8000);
|
||||
|
||||
return () => {
|
||||
activo = false;
|
||||
clearTimeout(tRespaldo);
|
||||
clearTimeout(tSeguridad);
|
||||
sub?.subscription?.unsubscribe();
|
||||
};
|
||||
}, [cargarPerfil]);
|
||||
|
||||
const login = async (email, password) => {
|
||||
const { data, error } = await supabase.auth.signInWithPassword({ email, password });
|
||||
if (error) return { ok: false, msg: traducirError(error.message) };
|
||||
const ok = await cargarPerfil(data.user.id);
|
||||
if (!ok) {
|
||||
await supabase.auth.signOut();
|
||||
return { ok: false, msg: "Tu usuario no tiene un perfil asignado. Contacta al administrador." };
|
||||
}
|
||||
return { ok: true };
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
await supabase.auth.signOut();
|
||||
setPerfil(null);
|
||||
setPermisos([]);
|
||||
};
|
||||
|
||||
const value = {
|
||||
session,
|
||||
perfil,
|
||||
permisos,
|
||||
cargando,
|
||||
login,
|
||||
logout,
|
||||
autenticado: !!session && !!perfil,
|
||||
puedeVer: (modulo) => permisos.includes(modulo),
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error("useAuth debe usarse dentro de AuthProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function traducirError(msg) {
|
||||
if (/invalid login credentials/i.test(msg)) return "Correo o contraseña incorrectos.";
|
||||
if (/email not confirmed/i.test(msg)) return "El correo no está confirmado.";
|
||||
return msg || "Error al iniciar sesión.";
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
// src/lib/supabase.js
|
||||
// Cliente de Supabase para autenticación (login).
|
||||
// IMPORTANTE: aquí va la clave PÚBLICA (anon), nunca la secreta (service_role).
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || "";
|
||||
const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY || "";
|
||||
|
||||
export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
||||
auth: {
|
||||
persistSession: true,
|
||||
autoRefreshToken: true,
|
||||
detectSessionInUrl: true,
|
||||
},
|
||||
});
|
||||
@@ -1,11 +1,10 @@
|
||||
// src/lib/useColumnasAjustables.jsx
|
||||
// Hook reutilizable para columnas redimensionables (estilo Excel) en cualquier tabla.
|
||||
// Hook para columnas redimensionables (arrastrar el borde, estilo Excel).
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export function useColumnasAjustables(anchosIniciales) {
|
||||
const [anchos, setAnchos] = useState(anchosIniciales);
|
||||
|
||||
// Si cambia el número de columnas (ej. Cobranza Sede↔Programa), reiniciar anchos
|
||||
useEffect(() => {
|
||||
if (anchos.length !== anchosIniciales.length) {
|
||||
setAnchos(anchosIniciales);
|
||||
@@ -29,7 +28,6 @@ export function useColumnasAjustables(anchosIniciales) {
|
||||
document.addEventListener("mouseup", onUp);
|
||||
}
|
||||
|
||||
// Devuelve los elementos <col> para el <colgroup> de la tabla
|
||||
function ColGroup() {
|
||||
return (
|
||||
<colgroup>
|
||||
@@ -38,7 +36,6 @@ export function useColumnasAjustables(anchosIniciales) {
|
||||
);
|
||||
}
|
||||
|
||||
// Devuelve el divisor arrastrable para poner dentro de cada <th>
|
||||
function Resizer({ index }) {
|
||||
return (
|
||||
<span
|
||||
@@ -51,10 +48,6 @@ export function useColumnasAjustables(anchosIniciales) {
|
||||
}
|
||||
|
||||
const anchoTotal = anchos.reduce((a, b) => a + b, 0);
|
||||
|
||||
// width:100% → llena el contenedor desde el inicio (sin huecos a la derecha).
|
||||
// minWidth:anchoTotal → si las columnas crecen más que el contenedor, aparece scroll.
|
||||
// tableLayout:fixed + colgroup → encabezados y celdas alineados.
|
||||
const tableProps = {
|
||||
style: { tableLayout: "fixed", width: "100%", minWidth: anchoTotal },
|
||||
};
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
// src/lib/vistaActual.js
|
||||
// Registro de la "vista actual" (clave de caché del backend) + bus de eventos
|
||||
// para que la página visible se recargue tras el refresco manual.
|
||||
let _clave = null;
|
||||
|
||||
export function setVistaActual(clave) {
|
||||
_clave = clave || null;
|
||||
}
|
||||
|
||||
export function getVistaActual() {
|
||||
return _clave;
|
||||
}
|
||||
|
||||
const EVENTO = "datos-refrescados";
|
||||
|
||||
export function emitirRefresco() {
|
||||
window.dispatchEvent(new Event(EVENTO));
|
||||
}
|
||||
|
||||
export function onRefrescar(cb) {
|
||||
window.addEventListener(EVENTO, cb);
|
||||
return () => window.removeEventListener(EVENTO, cb);
|
||||
}
|
||||
@@ -1,13 +1,10 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import { AuthProvider } from "./lib/auth";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
// src/pages/Asesores.jsx
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { api } from "../lib/api";
|
||||
import { Loader, ErrorBox } from "../components/UI";
|
||||
|
||||
const ESTADO_INFO = {
|
||||
online: { txt: "En Línea", color: "#10b981", bg: "#d1fae5", fg: "#065f46" },
|
||||
busy: { txt: "Ocupado", color: "#f59e0b", bg: "#fef9c3", fg: "#854d0e" },
|
||||
offline:{ txt: "Fuera de Línea", color: "#94a3b8", bg: "#f1f5f9", fg: "#475569" },
|
||||
};
|
||||
|
||||
export default function Asesores() {
|
||||
const [agentes, setAgentes] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [cambiando, setCambiando] = useState(null);
|
||||
|
||||
const cargar = useCallback(() => {
|
||||
setLoading(true); setError(null);
|
||||
api.asesores()
|
||||
.then((res)=>{ setAgentes(res.agentes||[]); setLoading(false); })
|
||||
.catch((e)=>{ setError(e.message); setLoading(false); });
|
||||
}, []);
|
||||
|
||||
useEffect(() => { cargar(); }, [cargar]);
|
||||
|
||||
async function toggle(ag) {
|
||||
const online = ag.availability_status !== "online";
|
||||
setCambiando(ag.id);
|
||||
try {
|
||||
await api.asesorEstado(ag.id, online);
|
||||
setAgentes((prev)=>prev.map((a)=>a.id===ag.id ? {...a, availability_status: online?"online":"offline"} : a));
|
||||
} catch (e) { /* noop */ }
|
||||
setCambiando(null);
|
||||
}
|
||||
|
||||
async function todos(online) {
|
||||
setCambiando("all");
|
||||
for (const ag of agentes) {
|
||||
try { await api.asesorEstado(ag.id, online); } catch (e) {}
|
||||
}
|
||||
setAgentes((prev)=>prev.map((a)=>({...a, availability_status: online?"online":"offline"})));
|
||||
setCambiando(null);
|
||||
}
|
||||
|
||||
const total = agentes.length;
|
||||
const enLinea = agentes.filter((a)=>a.availability_status==="online").length;
|
||||
const ocupados = agentes.filter((a)=>a.availability_status==="busy").length;
|
||||
const offline = total - enLinea - ocupados;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">👥 Asesores</h1>
|
||||
|
||||
{loading ? <Loader text="Cargando asesores de Chatwoot..." /> :
|
||||
error ? <ErrorBox msg={error} /> :
|
||||
<>
|
||||
<div className="kpis" style={{gridTemplateColumns:"repeat(4,1fr)"}}>
|
||||
<div className="kpi"><div className="ico">👥</div><div className="label">Total Asesores</div><div className="value">{total}</div></div>
|
||||
<div className="kpi" style={{background:"#d1fae5"}}><div className="ico">🟢</div><div className="label">En Línea</div><div className="value" style={{color:"#065f46"}}>{enLinea}</div></div>
|
||||
<div className="kpi" style={{background:"#fef9c3"}}><div className="ico">🟡</div><div className="label">Ocupados</div><div className="value" style={{color:"#854d0e"}}>{ocupados}</div></div>
|
||||
<div className="kpi" style={{background:"#fee2e2"}}><div className="ico">🔴</div><div className="label">Fuera de Línea</div><div className="value" style={{color:"#991b1b"}}>{offline}</div></div>
|
||||
</div>
|
||||
|
||||
<div style={{display:"flex",gap:10,marginBottom:16}}>
|
||||
<button className="btn btn-primary" disabled={cambiando==="all"} onClick={()=>todos(true)}>🟢 Encender todos</button>
|
||||
<button className="btn btn-ghost" disabled={cambiando==="all"} onClick={()=>todos(false)}>🔴 Apagar todos</button>
|
||||
<button className="btn btn-ghost" title="Refrescar" style={{fontSize:18}} onClick={cargar}>🔄</button>
|
||||
</div>
|
||||
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>ASESOR</th><th>EMAIL</th><th>ROL</th><th>ESTADO</th><th>ACCIÓN</th></tr></thead>
|
||||
<tbody>
|
||||
{agentes.map((ag)=>{
|
||||
const est = ESTADO_INFO[ag.availability_status] || ESTADO_INFO.offline;
|
||||
const isOnline = ag.availability_status==="online";
|
||||
return (
|
||||
<tr key={ag.id}>
|
||||
<td className="col-name">{ag.name || ag.available_name || "—"}</td>
|
||||
<td>{ag.email || "—"}</td>
|
||||
<td>{ag.role || "agent"}</td>
|
||||
<td>
|
||||
<span style={{background:est.bg,color:est.fg,padding:"3px 10px",borderRadius:20,
|
||||
fontSize:11,fontWeight:700}}>● {est.txt}</span>
|
||||
</td>
|
||||
<td>
|
||||
<button className="btn" disabled={cambiando===ag.id}
|
||||
style={{padding:"5px 12px",fontSize:11,
|
||||
background:isOnline?"#fee2e2":"#d1fae5",
|
||||
color:isOnline?"#991b1b":"#065f46"}}
|
||||
onClick={()=>toggle(ag)}>
|
||||
{cambiando===ag.id ? "..." : (isOnline?"Apagar":"Encender")}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
764
frontend/src/pages/Leads.jsx
Normal file
764
frontend/src/pages/Leads.jsx
Normal file
@@ -0,0 +1,764 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
// src/pages/Login.jsx
|
||||
import { useState } from "react";
|
||||
import { useAuth } from "../lib/auth";
|
||||
|
||||
export default function Login() {
|
||||
const { login } = useAuth();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState(null);
|
||||
const [cargando, setCargando] = useState(false);
|
||||
|
||||
async function onSubmit(e) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setCargando(true);
|
||||
const res = await login(email.trim(), password);
|
||||
if (!res.ok) setError(res.msg);
|
||||
setCargando(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center",
|
||||
background: "linear-gradient(135deg, #0f172a 0%, #1e3a5f 100%)", padding: 20,
|
||||
}}>
|
||||
<div style={{
|
||||
background: "#fff", borderRadius: 16, padding: "40px 36px", width: "100%", maxWidth: 400,
|
||||
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
|
||||
}}>
|
||||
<div style={{ textAlign: "center", marginBottom: 28 }}>
|
||||
<div style={{ fontSize: 38, marginBottom: 8 }}>❄️</div>
|
||||
<h1 style={{ fontSize: 20, fontWeight: 800, color: "#0f172a" }}>Escuela de Refrigeración</h1>
|
||||
<p style={{ fontSize: 13, color: "#64748b", marginTop: 4 }}>Dashboard — Inicia sesión</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSubmit}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, color: "#475569", display: "block", marginBottom: 6 }}>
|
||||
Correo
|
||||
</label>
|
||||
<input
|
||||
type="email" value={email} onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="usuario@escuela.com" required autoFocus
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, color: "#475569", display: "block", marginBottom: 6 }}>
|
||||
Contraseña
|
||||
</label>
|
||||
<input
|
||||
type="password" value={password} onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••" required
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
background: "#fee2e2", border: "1px solid #fca5a5", color: "#991b1b",
|
||||
padding: "10px 12px", borderRadius: 8, fontSize: 13, marginBottom: 16,
|
||||
}}>⚠️ {error}</div>
|
||||
)}
|
||||
|
||||
<button type="submit" disabled={cargando} style={{
|
||||
width: "100%", padding: "12px", border: "none", borderRadius: 10,
|
||||
background: cargando ? "#94a3b8" : "#2563eb", color: "#fff", fontSize: 15, fontWeight: 600,
|
||||
cursor: cargando ? "default" : "pointer",
|
||||
}}>
|
||||
{cargando ? "Ingresando..." : "Iniciar sesión"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const inputStyle = {
|
||||
width: "100%", padding: "11px 14px", border: "1px solid #cbd5e1", borderRadius: 10,
|
||||
fontSize: 14, outline: "none", boxSizing: "border-box",
|
||||
};
|
||||
@@ -1,307 +0,0 @@
|
||||
// src/pages/Ocupabilidad.jsx
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { api } from "../lib/api";
|
||||
import { Loader, ErrorBox, ProgressBar, Filters, Select } from "../components/UI";
|
||||
import { useColumnasAjustables } from "../lib/useColumnasAjustables";
|
||||
import Modal from "../components/Modal";
|
||||
import { setVistaActual, onRefrescar } from "../lib/vistaActual";
|
||||
|
||||
const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
|
||||
const ANOS = [2024, 2025, 2026];
|
||||
const SEDES = ["TODOS", "LIMA", "AREQUIPA", "PIURA", "TRUJILLO"];
|
||||
const PROGRAMAS = ["TODOS", "SEMINARIOS", "OTROS", "TEAC", "TERC"];
|
||||
|
||||
const COLS = ["PROGRAMA","FECHA INICIO","DÍAS PARA INICIO","TOTAL INSCRITOS","RETIRADOS","INSCRITOS EN CURSO","META INSCRITOS","AVANCE INSCRITOS","INSCRITOS MES","INSCRITOS P.C","INSCRITOS REFRIPERU","INSCRITOS CONTINUIDAD","OPC."];
|
||||
function toNum(v) { const n = parseFloat(String(v).replace("%","").trim()); return isNaN(n) ? 0 : n; }
|
||||
|
||||
export default function Ocupabilidad() {
|
||||
const hoy = new Date();
|
||||
const [ano, setAno] = useState(hoy.getFullYear());
|
||||
const [mes, setMes] = useState(hoy.getMonth() + 1);
|
||||
const [sede, setSede] = useState("TODOS");
|
||||
const [programa, setPrograma] = useState("TODOS");
|
||||
const [mostrarRepro, setMostrarRepro] = useState(true);
|
||||
const [datos, setDatos] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [modalCurso, setModalCurso] = useState(null);
|
||||
const cols = useColumnasAjustables([280, 110, 120, 110, 100, 120, 110, 120, 100, 100, 130, 140, 80]);
|
||||
|
||||
useEffect(() => {
|
||||
let activo = true;
|
||||
setVistaActual(`ocupabilidad:${ano}|${mes}|${sede}|${programa}`);
|
||||
const cargar = () => {
|
||||
setLoading(true); setError(null);
|
||||
api.ocupabilidad(ano, mes, sede, programa)
|
||||
.then((res) => { if (activo) { setDatos(res.datos || []); setLoading(false); } })
|
||||
.catch((e) => { if (activo) { setError(e.message); setLoading(false); } });
|
||||
};
|
||||
cargar();
|
||||
const off = onRefrescar(cargar);
|
||||
return () => { activo = false; off(); };
|
||||
}, [ano, mes, sede, programa]);
|
||||
|
||||
// Toggle "Mostrar Reprogramados": si está apagado, ocultar filas de inicio
|
||||
const datosVisibles = useMemo(() => {
|
||||
if (mostrarRepro) return datos;
|
||||
return datos.filter((d) => String(d.dias_para_inicio).trim().toUpperCase() !== "REPROGRAMADO");
|
||||
}, [datos, mostrarRepro]);
|
||||
|
||||
const kpis = useMemo(() => {
|
||||
if (!datosVisibles.length) return null;
|
||||
const esRepro = (d) => String(d.dias_para_inicio).trim().toUpperCase() === "REPROGRAMADO";
|
||||
// Las filas REPROGRAMADO (inicios) no entran en las métricas (igual que escritorio)
|
||||
const validos = datosVisibles.filter((d) => !esRepro(d));
|
||||
const insc = validos.reduce((s, d) => s + (parseInt(d.Inscritos_Activos) || 0), 0);
|
||||
const totalInsc = validos.reduce((s, d) => s + (parseInt(d.Inscritos_Totales) || 0), 0);
|
||||
const retirados = validos.reduce((s, d) => s + (parseInt(d.Retirados ?? d.Inscritos_Retirados) || 0), 0);
|
||||
const meta = validos.reduce((s, d) => s + (parseInt(d.Meta_Curso) || 0), 0);
|
||||
return {
|
||||
cursos: validos.length, // solo programados (sin reprogramados)
|
||||
reprogramados: datosVisibles.filter(esRepro).length, // inicios rojos (REPROGRAMADO)
|
||||
inscritos: totalInsc, // columna TOTAL INSCRITOS
|
||||
meta,
|
||||
avance: meta > 0 ? (totalInsc / meta * 100) : 0,
|
||||
desercion: totalInsc > 0 ? (retirados / totalInsc * 100) : 0,
|
||||
sobreMeta: validos.filter((d) => (parseInt(d.Inscritos_Totales)||0) >= (parseInt(d.Meta_Curso)||1)).length,
|
||||
pc: validos.reduce((s, d) => s + (parseInt(d.Inscritos_PC) || 0), 0),
|
||||
};
|
||||
}, [datosVisibles]);
|
||||
|
||||
const totales = useMemo(() => {
|
||||
const t = { mes:0, total:0, pc:0, retirados:0, activos:0, refri:0, cont:0, meta:0 };
|
||||
datosVisibles.forEach((d) => {
|
||||
t.mes += parseInt(d.Inscritos_Mes)||0;
|
||||
t.total += parseInt(d.Inscritos_Totales)||0;
|
||||
t.pc += parseInt(d.Inscritos_PC)||0;
|
||||
t.retirados += parseInt(d.Retirados ?? d.Inscritos_Retirados)||0;
|
||||
t.activos += parseInt(d.Inscritos_Activos)||0;
|
||||
t.refri += parseInt(d.Descuento)||0;
|
||||
t.cont += parseInt(d.Inscritos_Continuidad)||0;
|
||||
t.meta += parseInt(d.Meta_Curso)||0;
|
||||
});
|
||||
return t;
|
||||
}, [datosVisibles]);
|
||||
|
||||
const KPI_CARDS = kpis ? [
|
||||
["📚","Cursos Activos", kpis.cursos, "programas este mes"],
|
||||
["🔁","Cursos Reprogramados", kpis.reprogramados, "inicios reprogramados"],
|
||||
["👥","Total Inscritos", kpis.inscritos, "alumnos en curso"],
|
||||
["🎯","Meta Total", kpis.meta, "inscritos objetivo"],
|
||||
["📈","Avance Inscritos", `${kpis.avance.toFixed(1)}%`, "total inscritos / meta"],
|
||||
["📉","Deserción", `${kpis.desercion.toFixed(1)}%`, "retirados / total inscritos"],
|
||||
] : [];
|
||||
|
||||
async function exportarExcel() {
|
||||
if (!datosVisibles.length) return;
|
||||
// Cargar SheetJS desde CDN solo cuando se necesita
|
||||
const XLSX = await import("xlsx");
|
||||
const filas = datosVisibles.map((d) => ({
|
||||
"PROGRAMA": d.programa_frecuencia || d.dsc_programa || "",
|
||||
"FECHA INICIO": d.fch_inicio || "",
|
||||
"DÍAS PARA INICIO": d.dias_para_inicio ?? "",
|
||||
"TOTAL INSCRITOS": parseInt(d.Inscritos_Totales)||0,
|
||||
"RETIRADOS": parseInt(d.Retirados ?? d.Inscritos_Retirados)||0,
|
||||
"INSCRITOS EN CURSO": parseInt(d.Inscritos_Activos)||0,
|
||||
"META INSCRITOS": parseInt(d.Meta_Curso)||0,
|
||||
"AVANCE INSCRITOS": d.Avance_Inscritos || "",
|
||||
"INSCRITOS MES": parseInt(d.Inscritos_Mes)||0,
|
||||
"INSCRITOS P.C": parseInt(d.Inscritos_PC)||0,
|
||||
"INSCRITOS REFRIPERU": parseInt(d.Descuento)||0,
|
||||
"INSCRITOS CONTINUIDAD": parseInt(d.Inscritos_Continuidad)||0,
|
||||
}));
|
||||
const ws = XLSX.utils.json_to_sheet(filas);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, "Ocupabilidad");
|
||||
XLSX.writeFile(wb, `cursos_${ano}_${mes}.xlsx`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">📊 Ocupabilidad</h1>
|
||||
|
||||
<Filters>
|
||||
<Select label="Año" value={ano} options={ANOS} onChange={(v) => setAno(+v)} />
|
||||
<Select label="Mes" value={mes} options={MESES.map((m,i)=>({value:i+1,label:m}))} onChange={(v) => setMes(+v)} />
|
||||
<Select label="Sede" value={sede} options={SEDES} onChange={setSede} />
|
||||
<Select label="Programa" value={programa} options={PROGRAMAS} onChange={setPrograma} />
|
||||
<div className="filter-group">
|
||||
<label>Reprogramados</label>
|
||||
<label style={{display:"flex",alignItems:"center",gap:6,height:38,cursor:"pointer",fontSize:13}}>
|
||||
<input type="checkbox" checked={mostrarRepro} onChange={(e)=>setMostrarRepro(e.target.checked)} />
|
||||
Mostrar
|
||||
</label>
|
||||
</div>
|
||||
<button className="btn btn-primary" style={{height:38,marginLeft:"auto"}} onClick={exportarExcel} disabled={!datosVisibles.length}>
|
||||
⬇️ Exportar Excel
|
||||
</button>
|
||||
</Filters>
|
||||
|
||||
{loading ? <Loader text="Calculando ocupabilidad..." /> :
|
||||
error ? <ErrorBox msg={error} /> :
|
||||
<>
|
||||
<div className="kpis">
|
||||
{KPI_CARDS.map(([ico,label,val,sub]) => (
|
||||
<div className="kpi" key={label}>
|
||||
<div className="ico">{ico}</div>
|
||||
<div className="label" style={{fontSize:15}}>{label}</div>
|
||||
<div className="value">{val}</div>
|
||||
<div className="sub">{sub}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="table-wrap" style={{overflowX:"auto"}}>
|
||||
<table {...cols.tableProps}>
|
||||
<cols.ColGroup />
|
||||
<thead><tr>{COLS.map((c, i) => <th key={c} style={{position:"relative"}}>{c}<cols.Resizer index={i} /></th>)}</tr></thead>
|
||||
<tbody>
|
||||
{datosVisibles.map((d, i) => {
|
||||
const prog = d.programa_frecuencia || d.dsc_programa || "";
|
||||
const dias = parseInt(d.dias_para_inicio);
|
||||
const pct = toNum(d.Avance_Inscritos);
|
||||
// Inicio reprogramado (existe en Supabase pero no en SQL este mes) → fila roja
|
||||
const esInicio = String(d.dias_para_inicio).trim().toUpperCase() === "REPROGRAMADO";
|
||||
const cel = (v) => esInicio ? "-" : v;
|
||||
return (
|
||||
<tr key={i} className={esInicio ? "row-inicio" : ""}>
|
||||
<td className="col-name" style={{minWidth:300,maxWidth:480,whiteSpace:"nowrap",overflow:"hidden",textOverflow:"ellipsis"}}>{prog}</td>
|
||||
<td>{d.fch_inicio || ""}</td>
|
||||
<td style={!esInicio && dias < 0 ? {color:"#dc2626",fontWeight:700} : {}}>{d.dias_para_inicio ?? ""}</td>
|
||||
<td>{cel(parseInt(d.Inscritos_Totales)||0)}</td>
|
||||
<td>{cel(parseInt(d.Retirados ?? d.Inscritos_Retirados)||0)}</td>
|
||||
<td>{cel(parseInt(d.Inscritos_Activos)||0)}</td>
|
||||
<td>{cel(parseInt(d.Meta_Curso)||0)}</td>
|
||||
<td>{esInicio ? "-" : <ProgressBar pct={pct} />}</td>
|
||||
<td>{cel(parseInt(d.Inscritos_Mes)||0)}</td>
|
||||
<td>{cel(parseInt(d.Inscritos_PC)||0)}</td>
|
||||
<td>{cel(parseInt(d.Descuento)||0)}</td>
|
||||
<td>{cel(parseInt(d.Inscritos_Continuidad)||0)}</td>
|
||||
<td>
|
||||
{esInicio ? "-" : (
|
||||
<button className="btn btn-ghost" style={{padding:"4px 10px",fontSize:11}}
|
||||
onClick={() => setModalCurso({ num_indice: d.num_indice, programa: prog })}>
|
||||
👁️ Ver
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{datosVisibles.length > 0 && (
|
||||
<tr className="total-row">
|
||||
<td className="col-name">TOTAL GENERAL</td>
|
||||
<td>—</td><td>—</td>
|
||||
<td>{totales.total}</td>
|
||||
<td>{totales.retirados}</td>
|
||||
<td>{totales.activos}</td>
|
||||
<td>{totales.meta}</td>
|
||||
<td><ProgressBar pct={totales.meta>0 ? totales.total/totales.meta*100 : 0} /></td>
|
||||
<td>{totales.mes}</td><td>{totales.pc}</td><td>{totales.refri}</td><td>{totales.cont}</td>
|
||||
<td>—</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>}
|
||||
|
||||
{modalCurso && (
|
||||
<ModalAlumnosCurso
|
||||
numIndice={modalCurso.num_indice}
|
||||
programa={modalCurso.programa}
|
||||
onClose={() => setModalCurso(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModalAlumnosCurso({ numIndice, programa, onClose }) {
|
||||
const [alumnos, setAlumnos] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let activo = true;
|
||||
setLoading(true);
|
||||
api.ocupabilidadAlumnos(numIndice)
|
||||
.then((res) => { if (activo) { setAlumnos(res.alumnos || []); setLoading(false); } })
|
||||
.catch(() => { if (activo) { setAlumnos([]); setLoading(false); } });
|
||||
return () => { activo = false; };
|
||||
}, [numIndice]);
|
||||
|
||||
const colorEstado = (e) => {
|
||||
if (e === "ALU") return { bg:"#ecfdf5", fg:"#047857", bd:"#a7f3d0" };
|
||||
if (e === "PRE") return { bg:"#eff6ff", fg:"#1e40af", bd:"#bfdbfe" };
|
||||
if (e === "RET") return { bg:"#fef2f2", fg:"#b91c1c", bd:"#fecaca" };
|
||||
return { bg:"#f1f5f9", fg:"#475569", bd:"#e2e8f0" };
|
||||
};
|
||||
|
||||
const conteo = useMemo(() => {
|
||||
const c = {};
|
||||
alumnos.forEach((a) => { c[a.estado] = (c[a.estado] || 0) + 1; });
|
||||
return c;
|
||||
}, [alumnos]);
|
||||
|
||||
return (
|
||||
<Modal title={`👥 Alumnos Inscritos — ${programa}`} onClose={onClose} width={1050}>
|
||||
{loading ? <Loader text="Cargando alumnos..." /> :
|
||||
alumnos.length === 0 ? <div style={{color:"#94a3b8",padding:20,textAlign:"center"}}>Sin alumnos.</div> :
|
||||
<>
|
||||
<div style={{display:"flex",gap:10,marginBottom:14,flexWrap:"wrap"}}>
|
||||
<span style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px",
|
||||
borderRadius:999,background:"#eff6ff",color:"#1e40af",fontSize:13,fontWeight:700,
|
||||
border:"1px solid #bfdbfe"}}>
|
||||
👥 Total: {alumnos.length} alumno{alumnos.length!==1?"s":""}
|
||||
</span>
|
||||
{["ALU","PRE","RET"].map((e)=> conteo[e] ? (
|
||||
<span key={e} style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px",
|
||||
borderRadius:999,background:colorEstado(e).bg,color:colorEstado(e).fg,fontSize:13,fontWeight:700,
|
||||
border:`1px solid ${colorEstado(e).bd}`}}>
|
||||
{e}: {conteo[e]}
|
||||
</span>
|
||||
) : null)}
|
||||
</div>
|
||||
<div className="table-wrap" style={{overflowX:"auto"}}>
|
||||
<table className="cob-table" style={{minWidth:900}}>
|
||||
<thead><tr>
|
||||
<th style={{textAlign:"left"}}>ALUMNO</th>
|
||||
<th>ESTADO</th>
|
||||
<th style={{textAlign:"left"}}>CURSO ANTERIOR (TEAC/TERC)</th>
|
||||
<th>ESTADO ANTERIOR</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{alumnos.map((a, i) => {
|
||||
const c = colorEstado(a.estado);
|
||||
const tieneAnt = a.curso_anterior && a.curso_anterior !== "-";
|
||||
const cAnt = colorEstado(a.estado_anterior);
|
||||
return (
|
||||
<tr key={i}>
|
||||
<td className="col-name" style={{textAlign:"left"}}>{a.alumno}</td>
|
||||
<td style={{textAlign:"center"}}>
|
||||
<span style={{display:"inline-block",padding:"2px 10px",borderRadius:999,
|
||||
background:c.bg,color:c.fg,fontWeight:700,fontSize:12,border:`1px solid ${c.bd}`}}>
|
||||
{a.estado}
|
||||
</span>
|
||||
</td>
|
||||
<td className="col-name" style={{textAlign:"left",color:tieneAnt?undefined:"#cbd5e1"}}>
|
||||
{a.curso_anterior || "-"}
|
||||
</td>
|
||||
<td style={{textAlign:"center"}}>
|
||||
{tieneAnt ? (
|
||||
<span style={{display:"inline-block",padding:"2px 10px",borderRadius:999,
|
||||
background:cAnt.bg,color:cAnt.fg,fontWeight:700,fontSize:12,border:`1px solid ${cAnt.bd}`}}>
|
||||
{a.estado_anterior}
|
||||
</span>
|
||||
) : <span style={{color:"#cbd5e1"}}>-</span>}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
// src/pages/Rentabilidad.jsx
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { api } from "../lib/api";
|
||||
import { Loader, ErrorBox, Filters, Select } from "../components/UI";
|
||||
import Modal from "../components/Modal";
|
||||
import { useColumnasAjustables } from "../lib/useColumnasAjustables";
|
||||
import { setVistaActual, onRefrescar } from "../lib/vistaActual";
|
||||
|
||||
const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
|
||||
const ANOS = [2024, 2025, 2026];
|
||||
const SEDES = ["TODOS","LIMA","AREQUIPA","PIURA","TRUJILLO"];
|
||||
const PROGRAMAS = ["TODOS","SEMINARIOS","OTROS","TEAC","TERC"];
|
||||
|
||||
const COLS = ["PROGRAMA","FECHA INICIO","TOTAL","RETIRADOS","EN CURSO","CUOTA PROM.","PROM. DESC. E.","V. VENTA INICIAL","V. COSTO INICIAL","M.B. INICIAL %","V. VENTA ACTUAL","V. COSTO ACTUAL","M.B. ACTUAL %","OPC."];
|
||||
// índices: inicial = 7,8,9 | actual = 10,11,12
|
||||
const BG_INI = "#eef4ff", BG_ACT = "#edfff6";
|
||||
|
||||
function toNum(v){ const n=parseFloat(String(v).replace("S/","").replace(/,/g,"").replace("%","").trim()); return isNaN(n)?0:n; }
|
||||
|
||||
export default function Rentabilidad() {
|
||||
const hoy = new Date();
|
||||
const [ano, setAno] = useState(hoy.getFullYear());
|
||||
const [mes, setMes] = useState(hoy.getMonth() + 1);
|
||||
const [sede, setSede] = useState("TODOS");
|
||||
const [programa, setPrograma] = useState("TODOS");
|
||||
const [mostrarInscritos, setMostrarInscritos] = useState(true); // TOTAL, RETIRADOS, EN CURSO
|
||||
const [filas, setFilas] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [modalVer, setModalVer] = useState(null);
|
||||
// Índices de columnas TOTAL(2), RETIRADOS(3), EN CURSO(4) — se ocultan si el check está apagado
|
||||
const IDX_INSCRITOS = [2, 3, 4];
|
||||
const ocultar = (i) => !mostrarInscritos && IDX_INSCRITOS.includes(i);
|
||||
const ANCHOS_BASE = [260, 110, 80, 90, 90, 110, 120, 120, 120, 110, 120, 120, 110, 80];
|
||||
const anchosVisibles = ANCHOS_BASE.filter((_, i) => !ocultar(i));
|
||||
const cols = useColumnasAjustables(anchosVisibles);
|
||||
const [modalCostos, setModalCostos] = useState(null);
|
||||
|
||||
function cargar() {
|
||||
setLoading(true); setError(null);
|
||||
api.rentabilidad(ano, mes, sede, programa)
|
||||
.then((res) => { setFilas(res.filas || []); setLoading(false); })
|
||||
.catch((e) => { setError(e.message); setLoading(false); });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let activo = true;
|
||||
setVistaActual(`rentabilidad:${ano}|${mes}|${sede}|${programa}`);
|
||||
const cargarSeguro = () => {
|
||||
setLoading(true); setError(null);
|
||||
api.rentabilidad(ano, mes, sede, programa)
|
||||
.then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } })
|
||||
.catch((e) => { if (activo) { setError(e.message); setLoading(false); } });
|
||||
};
|
||||
cargarSeguro();
|
||||
const off = onRefrescar(cargarSeguro);
|
||||
return () => { activo = false; off(); };
|
||||
}, [ano, mes, sede, programa]);
|
||||
|
||||
const datos = useMemo(() => filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL GENERAL"), [filas]);
|
||||
const total = useMemo(() => filas.find((f)=>String(f[0]).toUpperCase()==="TOTAL GENERAL"), [filas]);
|
||||
|
||||
const kpis = useMemo(() => {
|
||||
if (!total) return null;
|
||||
return {
|
||||
programas: datos.length,
|
||||
enCurso: total[4],
|
||||
ventaAct: total[10],
|
||||
costoAct: total[11],
|
||||
mbAct: total[12],
|
||||
};
|
||||
}, [datos, total]);
|
||||
|
||||
function bgCol(ci){ if([7,8,9].includes(ci))return BG_INI; if([10,11,12].includes(ci))return BG_ACT; return ""; }
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">📈 Rentabilidad</h1>
|
||||
|
||||
<Filters>
|
||||
<Select label="Año" value={ano} options={ANOS} onChange={(v)=>setAno(+v)} />
|
||||
<Select label="Mes" value={mes} options={MESES.map((m,i)=>({value:i+1,label:m}))} onChange={(v)=>setMes(+v)} />
|
||||
<Select label="Sede" value={sede} options={SEDES} onChange={setSede} />
|
||||
<Select label="Programa" value={programa} options={PROGRAMAS} onChange={setPrograma} />
|
||||
<div className="filter-group" style={{marginLeft:"auto"}}>
|
||||
<label>Inscritos</label>
|
||||
<label style={{display:"flex",alignItems:"center",gap:6,height:38,cursor:"pointer",fontSize:13}}>
|
||||
<input type="checkbox" checked={mostrarInscritos} onChange={(e)=>setMostrarInscritos(e.target.checked)} />
|
||||
Total / Retirados / En curso
|
||||
</label>
|
||||
</div>
|
||||
</Filters>
|
||||
|
||||
{loading ? <Loader text="Calculando rentabilidad..." /> :
|
||||
error ? <ErrorBox msg={error} /> :
|
||||
<>
|
||||
{kpis && (
|
||||
<div className="kpis" style={{gridTemplateColumns:"repeat(5,1fr)"}}>
|
||||
{[["📚","Programas",kpis.programas,"este mes"],
|
||||
["🎓","En Curso",kpis.enCurso,"alumnos activos"],
|
||||
["💵","Venta Actual",kpis.ventaAct,"ingreso total"],
|
||||
["💸","Costo Actual",kpis.costoAct,"gasto total"],
|
||||
["📊","M.B. Actual",kpis.mbAct,"margen bruto"]].map(([ico,label,val,sub])=>(
|
||||
<div className="kpi" key={label}>
|
||||
<div className="ico">{ico}</div><div className="label">{label}</div>
|
||||
<div className="value" style={{fontSize:18}}>{val}</div><div className="sub">{sub}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="table-wrap" style={{marginTop:16, overflowX:"auto"}}>
|
||||
<table {...cols.tableProps}>
|
||||
<cols.ColGroup />
|
||||
<thead><tr>{COLS.filter((_,i)=>!ocultar(i)).map((c, vi)=><th key={c} style={{position:"relative"}}>{c}<cols.Resizer index={vi} /></th>)}</tr></thead>
|
||||
<tbody>
|
||||
{datos.map((f, ri) => (
|
||||
<tr key={ri}>
|
||||
{f.slice(0,13).map((v,ci)=>{
|
||||
if (ocultar(ci)) return null;
|
||||
const isPct = ci===9||ci===12;
|
||||
return (
|
||||
<td key={ci} className={ci===0?"col-name":""}
|
||||
style={{background:bgCol(ci)||undefined,
|
||||
textAlign: ci===0?"left":"center",
|
||||
fontWeight:isPct?700:undefined,
|
||||
minWidth:ci===0?260:undefined}}>{v}</td>
|
||||
);
|
||||
})}
|
||||
<td style={{whiteSpace:"nowrap"}}>
|
||||
<button className="btn btn-ghost" style={{padding:"3px 8px",fontSize:11,marginRight:4}}
|
||||
onClick={()=>setModalVer({programa:f[0]})}>👁️</button>
|
||||
<button className="btn btn-ghost" style={{padding:"3px 8px",fontSize:11}}
|
||||
onClick={()=>setModalCostos({programa:f[0]})}>✏️</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{total && (
|
||||
<tr className="total-row">
|
||||
{total.slice(0,13).map((v,ci)=>(
|
||||
ocultar(ci) ? null :
|
||||
<td key={ci} className={ci===0?"col-name":""}
|
||||
style={{textAlign:ci===0?"left":"center"}}>{v}</td>
|
||||
))}
|
||||
<td>—</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>}
|
||||
|
||||
{modalVer && <ModalVerDetalle programa={modalVer.programa} ano={ano} mes={mes} onClose={()=>setModalVer(null)} />}
|
||||
{modalCostos && <ModalCostos programa={modalCostos.programa} ano={ano} mes={mes} onClose={()=>setModalCostos(null)} onGuardado={cargar} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModalVerDetalle({ programa, ano, mes, onClose }) {
|
||||
const [filas, setFilas] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
let activo = true; setLoading(true);
|
||||
api.rentabilidadDetalle(programa, ano, mes)
|
||||
.then((res)=>{ if(activo){setFilas(res.filas||[]);setLoading(false);} })
|
||||
.catch(()=>{ if(activo){setFilas([]);setLoading(false);} });
|
||||
return ()=>{activo=false;};
|
||||
}, [programa, ano, mes]);
|
||||
|
||||
const COLS_DET = ["VENDEDOR","ALUMNO","F. MATRÍCULA","F. CANCELACIÓN","TIPO","VALOR CUOTA","PROM. DESC. E.","VALOR VENTA"];
|
||||
|
||||
return (
|
||||
<Modal title={`👁️ Detalle — ${programa}`} onClose={onClose} width={1000}>
|
||||
{loading ? <Loader text="Cargando detalle..." /> :
|
||||
filas.length===0 ? <div style={{color:"#94a3b8",padding:20,textAlign:"center"}}>Sin alumnos.</div> :
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead><tr>{COLS_DET.map((c)=><th key={c}>{c}</th>)}</tr></thead>
|
||||
<tbody>
|
||||
{filas.map((f,i)=>(
|
||||
<tr key={i}>
|
||||
{f.map((v,j)=>(
|
||||
<td key={j} className={j===1?"col-name":""} style={{textAlign:j>=5?"right":(j===1?"left":"center")}}>{v}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ModalCostos({ programa, ano, mes, onClose, onGuardado }) {
|
||||
const [costos, setCostos] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [guardando, setGuardando] = useState(false);
|
||||
const [msg, setMsg] = useState(null);
|
||||
// [keyGuardar, etiqueta, prefijoBackend]
|
||||
const CAMPOS = [["epp","COSTO EPP","epp"],["certificado","COSTO CERTIFICADO","cert"],["docente","COSTO DOCENTE","doc"],["marketing","COSTO MARKETING","mkt"],["consumibles","COSTO CONSUMIBLES","cons"]];
|
||||
|
||||
const [ini, setIni] = useState({});
|
||||
const [act, setAct] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
let activo = true; setLoading(true);
|
||||
api.rentabilidadCostos(programa, ano, mes)
|
||||
.then((res)=>{
|
||||
if(!activo) return;
|
||||
const c = res.costos || {};
|
||||
setCostos(c);
|
||||
const gi={}, ga={};
|
||||
CAMPOS.forEach(([k,,bp])=>{ gi[k]=+(c[`${bp}_inicial`]||0); ga[k]=+(c[`${bp}_actual`]||0); });
|
||||
setIni(gi); setAct(ga); setLoading(false);
|
||||
})
|
||||
.catch(()=>{ if(activo){setCostos(null);setLoading(false);} });
|
||||
return ()=>{activo=false;};
|
||||
}, [programa, ano, mes]);
|
||||
|
||||
const totIni = Object.values(ini).reduce((a,b)=>a+(+b||0),0);
|
||||
const totAct = Object.values(act).reduce((a,b)=>a+(+b||0),0);
|
||||
|
||||
async function guardar() {
|
||||
setGuardando(true); setMsg(null);
|
||||
try {
|
||||
await api.guardarCostos(costos.num_indice, ini, act);
|
||||
if (onGuardado) onGuardado(); // recarga la tabla principal
|
||||
onClose(); // cierra el modal
|
||||
} catch (e) {
|
||||
setMsg({ ok:false, txt:"❌ Error al guardar. Verifica Supabase." });
|
||||
setGuardando(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title={`✏️ Editar Costos — ${programa}`} onClose={onClose} width={760}>
|
||||
{loading ? <Loader text="Cargando costos..." /> :
|
||||
!costos ? <div style={{color:"#94a3b8",padding:20,textAlign:"center"}}>Sin datos de costos.</div> :
|
||||
<>
|
||||
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:16}}>
|
||||
<Columna titulo="INICIAL" color="#1e3a5f" campos={CAMPOS} valores={ini} setVal={setIni} total={totIni} />
|
||||
<Columna titulo="ACTUAL" color="#1a5f4a" campos={CAMPOS} valores={act} setVal={setAct} total={totAct} />
|
||||
</div>
|
||||
{msg && <div style={{marginTop:14,padding:10,borderRadius:8,fontSize:13,
|
||||
background:msg.ok?"#d1fae5":"#fee2e2",color:msg.ok?"#065f46":"#991b1b"}}>{msg.txt}</div>}
|
||||
<div style={{display:"flex",gap:10,marginTop:16}}>
|
||||
<button className="btn btn-primary" disabled={guardando} onClick={guardar}>
|
||||
{guardando ? "Guardando..." : "💾 Guardar"}
|
||||
</button>
|
||||
<button className="btn btn-ghost" onClick={onClose}>Cerrar</button>
|
||||
</div>
|
||||
</>}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function Columna({ titulo, color, campos, valores, setVal, total }) {
|
||||
return (
|
||||
<div>
|
||||
<div style={{background:color,color:"#fff",borderRadius:8,padding:"8px 12px",
|
||||
marginBottom:10,fontWeight:600,fontSize:12,textAlign:"center"}}>{titulo}</div>
|
||||
{campos.map(([k,label])=>(
|
||||
<div key={k} style={{marginBottom:8}}>
|
||||
<label style={{fontSize:11,fontWeight:600,color:"#64748b",display:"block",marginBottom:3}}>{label}</label>
|
||||
<input type="number" value={valores[k]??0} min="0" step="100"
|
||||
onChange={(e)=>setVal({...valores,[k]:+e.target.value})}
|
||||
style={{width:"100%",padding:"7px 10px",border:"1px solid #cbd5e1",borderRadius:8,fontSize:13}} />
|
||||
</div>
|
||||
))}
|
||||
<div style={{background:"#f1f5f9",borderRadius:8,padding:10,textAlign:"center",
|
||||
fontWeight:700,color,marginTop:8}}>
|
||||
TOTAL: S/ {total.toLocaleString("es-PE",{maximumFractionDigits:0})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
138
frontend/src/pages/Roas.jsx
Normal file
138
frontend/src/pages/Roas.jsx
Normal file
@@ -0,0 +1,138 @@
|
||||
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 Roas() {
|
||||
const hoy = new Date();
|
||||
const [ano, setAno] = useState(String(hoy.getFullYear()));
|
||||
const [mes, setMes] = useState(String(hoy.getMonth() + 1));
|
||||
|
||||
const [filtros, setFiltros] = useState({ anos: [2026] });
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [expand, setExpand] = useState({}); // sede -> abierto/cerrado
|
||||
const [expandP, setExpandP] = useState({}); // "sede|prog" -> abierto/cerrado
|
||||
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("roas-actualizar", onActualizar);
|
||||
window.addEventListener("datos-actualizar", onActualizar);
|
||||
return () => { clearInterval(id);
|
||||
window.removeEventListener("roas-actualizar", onActualizar);
|
||||
window.removeEventListener("datos-actualizar", onActualizar); };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let activo = true;
|
||||
if (!data) setLoading(true);
|
||||
setError(null);
|
||||
api.roas(ano, mes, "TODOS")
|
||||
.then((res) => { if (activo) { setData(res); setLoading(false); } })
|
||||
.catch((e) => { if (activo) { setError(e.message); setLoading(false); } });
|
||||
return () => { activo = false; };
|
||||
}, [ano, mes, 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 soles = (v) => "$" + Number(v || 0).toLocaleString("es-PE", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
const num = (v) => Number(v || 0).toLocaleString("es-PE");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">📈 ROAS</h1>
|
||||
|
||||
<Filters>
|
||||
<Select label="Año" value={ano} options={optAnos} onChange={setAno} />
|
||||
<Select label="Mes" value={mes} options={optMes} onChange={setMes} />
|
||||
</Filters>
|
||||
|
||||
{loading ? <Loader text="Cargando ROAS..." /> :
|
||||
error ? <ErrorBox msg={error} /> :
|
||||
!data ? <ErrorBox msg="Sin datos" /> :
|
||||
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||
<div className="card-title" style={{ padding: "14px 16px 6px" }}>Inversión por Sede</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: 160 }}>Sede</th>
|
||||
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Importe Gastado</th>
|
||||
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Resultados</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(data.filas || []).map((f) => {
|
||||
const subs = f.programas || [];
|
||||
const abierto = !!expand[f.sede];
|
||||
return (
|
||||
<Fragment key={f.sede}>
|
||||
<tr>
|
||||
<td className="col-name">
|
||||
{subs.length > 0 && (
|
||||
<button onClick={() => setExpand((e) => ({ ...e, [f.sede]: !e[f.sede] }))}
|
||||
style={{ border: "none", background: "transparent", cursor: "pointer",
|
||||
color: "#2563eb", fontWeight: 700, fontSize: 15, marginRight: 8, width: 16 }}>
|
||||
{abierto ? "−" : "+"}
|
||||
</button>
|
||||
)}
|
||||
{f.sede}
|
||||
</td>
|
||||
<td style={{ fontWeight: 600 }}>{soles(f.importe)}</td>
|
||||
<td>{num(f.resultados)}</td>
|
||||
</tr>
|
||||
{abierto && subs.map((p) => {
|
||||
const claveP = f.sede + "|" + p.programa;
|
||||
const abiertoP = !!expandP[claveP];
|
||||
const conjs = p.conjuntos || [];
|
||||
return (
|
||||
<Fragment key={claveP}>
|
||||
<tr style={{ background: "#f8fafc" }}>
|
||||
<td className="col-name" style={{ paddingLeft: 24, color: "#475569", fontSize: 13 }}>
|
||||
{conjs.length > 0 && (
|
||||
<button onClick={() => setExpandP((e) => ({ ...e, [claveP]: !e[claveP] }))}
|
||||
style={{ border: "none", background: "transparent", cursor: "pointer",
|
||||
color: "#2563eb", fontWeight: 700, fontSize: 14, marginRight: 8, width: 14 }}>
|
||||
{abiertoP ? "−" : "+"}
|
||||
</button>
|
||||
)}
|
||||
{p.programa}
|
||||
</td>
|
||||
<td style={{ color: "#475569" }}>{soles(p.importe)}</td>
|
||||
<td style={{ color: "#475569" }}>{num(p.resultados)}</td>
|
||||
</tr>
|
||||
{abiertoP && conjs.map((c) => (
|
||||
<tr key={claveP + "-" + c.conjunto} style={{ background: "#eef2f7" }}>
|
||||
<td className="col-name" title={c.conjunto} style={{ paddingLeft: 60, color: "#94a3b8", fontSize: 12.5,
|
||||
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", maxWidth: 280 }}>{c.conjunto}</td>
|
||||
<td style={{ color: "#94a3b8", fontSize: 12.5 }}>{soles(c.importe)}</td>
|
||||
<td style={{ color: "#94a3b8", fontSize: 12.5 }}>{num(c.resultados)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{data.total && (
|
||||
<tr className="total-row">
|
||||
<td className="col-name">Total</td>
|
||||
<td>{soles(data.total.importe)}</td>
|
||||
<td>{num(data.total.resultados)}</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
// src/pages/SaldoPendiente.jsx
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { api } from "../lib/api";
|
||||
import { Loader, ErrorBox, Filters, Select } from "../components/UI";
|
||||
|
||||
const CUOTAS = ["1° Cuota","2° Cuota","3° Cuota","4° Cuota","5° Cuota"];
|
||||
|
||||
function toNum(v){ const n=parseFloat(String(v).replace("S/","").replace(/,/g,"").trim()); return isNaN(n)?0:n; }
|
||||
function fmt(v){ const n=toNum(v); return "S/ " + n.toLocaleString("es-PE",{maximumFractionDigits:0}); }
|
||||
|
||||
export default function SaldoPendiente() {
|
||||
const [tipoCuota, setTipoCuota] = useState("1° Cuota");
|
||||
const [datos, setDatos] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [vendedor, setVendedor] = useState("TODOS");
|
||||
|
||||
useEffect(() => {
|
||||
let activo = true;
|
||||
setLoading(true); setError(null);
|
||||
api.saldoPendiente(tipoCuota)
|
||||
.then((res)=>{ if(activo){setDatos(res.datos||[]);setLoading(false);} })
|
||||
.catch((e)=>{ if(activo){setError(e.message);setLoading(false);} });
|
||||
return ()=>{activo=false;};
|
||||
}, [tipoCuota]);
|
||||
|
||||
const colSaldo = `SALDO ${tipoCuota.toUpperCase()}`;
|
||||
const vendedores = useMemo(() => {
|
||||
const set = new Set(datos.map((d)=>String(d.VENDEDOR||"").trim()).filter(Boolean));
|
||||
return ["TODOS", ...Array.from(set).sort()];
|
||||
}, [datos]);
|
||||
|
||||
const filtrados = useMemo(() => {
|
||||
let arr = datos.filter((d)=>toNum(d[colSaldo])>0.01);
|
||||
if (vendedor!=="TODOS") arr = arr.filter((d)=>String(d.VENDEDOR||"").trim().toUpperCase()===vendedor.toUpperCase());
|
||||
return arr;
|
||||
}, [datos, vendedor, colSaldo]);
|
||||
|
||||
const totalSaldo = useMemo(()=>filtrados.reduce((s,d)=>s+toNum(d[colSaldo]),0),[filtrados,colSaldo]);
|
||||
const totalMat = useMemo(()=>filtrados.reduce((s,d)=>s+toNum(d["SALDO MAT."]),0),[filtrados]);
|
||||
|
||||
const COLS = ["MATRÍCULA","VENDEDOR","ALUMNO","PROGRAMA","F. INICIO","F. MATR.","SALDO MAT.",colSaldo,"VENCIMIENTO","INV. NETA"];
|
||||
const KEYS = ["MATRICULA","VENDEDOR","ALUMNO","PROGRAMA","FECHA INICIO","FECHA MATR.","SALDO MAT.",colSaldo,"VENCIMIENTO","INV. NETA"];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">⏳ Saldo Pendiente</h1>
|
||||
|
||||
<Filters>
|
||||
<Select label="Tipo de cuota" value={tipoCuota} options={CUOTAS} onChange={setTipoCuota} />
|
||||
<Select label="Vendedor" value={vendedor} options={vendedores} onChange={setVendedor} />
|
||||
</Filters>
|
||||
|
||||
{loading ? <Loader text="Calculando saldos..." /> :
|
||||
error ? <ErrorBox msg={error} /> :
|
||||
<>
|
||||
<div className="kpis" style={{gridTemplateColumns:"repeat(3,1fr)"}}>
|
||||
<div className="kpi"><div className="ico">👥</div><div className="label">Alumnos con Saldo</div><div className="value">{filtrados.length}</div></div>
|
||||
<div className="kpi"><div className="ico">⏳</div><div className="label">Saldo {tipoCuota}</div><div className="value" style={{fontSize:20}}>{fmt(totalSaldo)}</div></div>
|
||||
<div className="kpi"><div className="ico">📋</div><div className="label">Saldo Matrícula</div><div className="value" style={{fontSize:20}}>{fmt(totalMat)}</div></div>
|
||||
</div>
|
||||
|
||||
<div className="table-wrap" style={{marginTop:16}}>
|
||||
<table style={{minWidth:1100}}>
|
||||
<thead><tr>{COLS.map((c)=><th key={c}>{c}</th>)}</tr></thead>
|
||||
<tbody>
|
||||
{filtrados.map((d,i)=>(
|
||||
<tr key={i}>
|
||||
{KEYS.map((k,j)=>{
|
||||
const isMonto = k.includes("SALDO")||k.includes("INV");
|
||||
return (
|
||||
<td key={j} className={k==="ALUMNO"||k==="PROGRAMA"?"col-name":""}
|
||||
style={{textAlign:isMonto?"right":(k==="ALUMNO"||k==="PROGRAMA"?"left":"center"),
|
||||
fontWeight:k===colSaldo?700:undefined,
|
||||
color:k===colSaldo?"#dc2626":undefined}}>
|
||||
{isMonto ? fmt(d[k]) : (d[k] ?? "")}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
{filtrados.length>0 && (
|
||||
<tr className="total-row">
|
||||
<td colSpan={6} style={{textAlign:"left"}}>TOTAL GENERAL ({filtrados.length})</td>
|
||||
<td style={{textAlign:"right"}}>{fmt(totalMat)}</td>
|
||||
<td style={{textAlign:"right"}}>{fmt(totalSaldo)}</td>
|
||||
<td>—</td><td>—</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
// src/pages/Usuarios.jsx
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { api } from "../lib/api";
|
||||
import { Loader, ErrorBox, Filters, Select } from "../components/UI";
|
||||
|
||||
const ROLES = ["ADMINISTRADOR", "VENTAS", "COBRANZA"];
|
||||
|
||||
export default function Usuarios() {
|
||||
const [usuarios, setUsuarios] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// form nuevo usuario
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [nombre, setNombre] = useState("");
|
||||
const [rol, setRol] = useState("COBRANZA");
|
||||
const [creando, setCreando] = useState(false);
|
||||
const [msg, setMsg] = useState(null);
|
||||
|
||||
const cargar = useCallback(() => {
|
||||
setLoading(true); setError(null);
|
||||
api.usuariosListar()
|
||||
.then((res) => { setUsuarios(res.usuarios || []); setLoading(false); })
|
||||
.catch((e) => { setError(e.message); setLoading(false); });
|
||||
}, []);
|
||||
|
||||
useEffect(() => { cargar(); }, [cargar]);
|
||||
|
||||
async function crear() {
|
||||
setMsg(null);
|
||||
if (!email.trim() || !password.trim()) { setMsg({ ok:false, txt:"Correo y contraseña son obligatorios." }); return; }
|
||||
setCreando(true);
|
||||
try {
|
||||
await api.usuariosCrear(email.trim(), password, nombre.trim(), rol);
|
||||
setMsg({ ok:true, txt:`Usuario ${email} creado como ${rol}.` });
|
||||
setEmail(""); setPassword(""); setNombre(""); setRol("COBRANZA");
|
||||
cargar();
|
||||
} catch (e) {
|
||||
setMsg({ ok:false, txt:"Error: " + e.message });
|
||||
}
|
||||
setCreando(false);
|
||||
}
|
||||
|
||||
async function cambiarRol(id, nuevoRol) {
|
||||
try { await api.usuariosActualizarRol(id, nuevoRol); cargar(); }
|
||||
catch (e) { alert("No se pudo cambiar el rol: " + e.message); }
|
||||
}
|
||||
|
||||
async function eliminar(id, correo) {
|
||||
if (!confirm(`¿Eliminar al usuario ${correo}? Esta acción no se puede deshacer.`)) return;
|
||||
try { await api.usuariosEliminar(id); cargar(); }
|
||||
catch (e) { alert("No se pudo eliminar: " + e.message); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">🔐 Usuarios</h1>
|
||||
|
||||
{/* Crear nuevo usuario */}
|
||||
<div style={{ background:"#fff", border:"1px solid #e2e8f0", borderRadius:12, padding:18, marginBottom:20 }}>
|
||||
<h3 style={{ fontSize:15, fontWeight:700, color:"#0f172a", marginBottom:14 }}>➕ Crear nuevo usuario</h3>
|
||||
<div style={{ display:"flex", gap:12, flexWrap:"wrap", alignItems:"flex-end" }}>
|
||||
<Campo label="Correo">
|
||||
<input value={email} onChange={(e)=>setEmail(e.target.value)} placeholder="usuario@escuela.com" style={inp} />
|
||||
</Campo>
|
||||
<Campo label="Contraseña">
|
||||
<input type="text" value={password} onChange={(e)=>setPassword(e.target.value)} placeholder="mín. 6 caracteres" style={inp} />
|
||||
</Campo>
|
||||
<Campo label="Nombre">
|
||||
<input value={nombre} onChange={(e)=>setNombre(e.target.value)} placeholder="Nombre completo" style={inp} />
|
||||
</Campo>
|
||||
<Campo label="Rol">
|
||||
<Select value={rol} options={ROLES} onChange={setRol} />
|
||||
</Campo>
|
||||
<button className="btn btn-primary" style={{height:38}} disabled={creando} onClick={crear}>
|
||||
{creando ? "Creando..." : "Crear usuario"}
|
||||
</button>
|
||||
</div>
|
||||
{msg && (
|
||||
<div style={{ marginTop:12, padding:10, borderRadius:8, fontSize:13,
|
||||
background: msg.ok ? "#d1fae5" : "#fee2e2", color: msg.ok ? "#065f46" : "#991b1b" }}>
|
||||
{msg.ok ? "✅" : "⚠️"} {msg.txt}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Lista de usuarios */}
|
||||
{loading ? <Loader text="Cargando usuarios..." /> :
|
||||
error ? <ErrorBox msg={error} /> :
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>CORREO</th><th>NOMBRE</th><th>ROL</th><th>ACCIONES</th></tr></thead>
|
||||
<tbody>
|
||||
{usuarios.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td className="col-name">{u.email}</td>
|
||||
<td>{u.nombre || "—"}</td>
|
||||
<td>
|
||||
<select value={u.rol} onChange={(e)=>cambiarRol(u.id, e.target.value)}
|
||||
style={{ padding:"5px 8px", border:"1px solid #cbd5e1", borderRadius:6, fontSize:12 }}>
|
||||
{ROLES.map((r)=><option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<button className="btn" style={{ padding:"4px 10px", fontSize:11, background:"#fee2e2", color:"#991b1b" }}
|
||||
onClick={()=>eliminar(u.id, u.email)}>🗑️ Eliminar</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{usuarios.length === 0 && (
|
||||
<tr><td colSpan={4} style={{ padding:20, textAlign:"center", color:"#94a3b8" }}>Sin usuarios.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Campo({ label, children }) {
|
||||
return (
|
||||
<div className="filter-group">
|
||||
<label>{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const inp = { padding:"8px 12px", border:"1px solid #cbd5e1", borderRadius:8, fontSize:13, minWidth:180 };
|
||||
423
frontend/src/pages/Vendedores.jsx
Normal file
423
frontend/src/pages/Vendedores.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
// src/pages/Ventas.jsx
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { api } from "../lib/api";
|
||||
import { Loader, ErrorBox, Filters, Select } from "../components/UI";
|
||||
import Modal from "../components/Modal";
|
||||
import { useColumnasAjustables } from "../lib/useColumnasAjustables";
|
||||
import { setVistaActual, onRefrescar } from "../lib/vistaActual";
|
||||
|
||||
const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
|
||||
const ANOS = [2024, 2025, 2026];
|
||||
const SEDES = ["TODOS", "LIMA", "AREQUIPA", "PIURA", "TRUJILLO"];
|
||||
const PROGRAMAS = ["TODOS", "SEMINARIOS", "OTROS", "TEAC", "TERC"];
|
||||
const COLS = ["VENDEDOR","INSCRITOS","VENTA TOTAL","INSCRITOS P.C DEL MES","VENTA P.C DEL MES","INSCRITOS MES ANTERIOR","VENTA MES ANTERIOR","AVANCE P.C TOTAL","OPC."];
|
||||
const TIPOS = ["Venta Inscritos", "Venta P.C", "Venta Pendientes"];
|
||||
const HEADERS_DETALLE = ["VENDEDOR","ALUMNO","F. MATRÍCULA","F. CANCELACIÓN","TIPO","CUOTA PROM.","DESC. ESPECIAL","INV. NETA"];
|
||||
|
||||
export default function Ventas() {
|
||||
const hoy = new Date();
|
||||
const [ano, setAno] = useState(hoy.getFullYear());
|
||||
const [mes, setMes] = useState(hoy.getMonth() + 1);
|
||||
const [sede, setSede] = useState("TODOS");
|
||||
const [programa, setPrograma] = useState("TODOS");
|
||||
const [filas, setFilas] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [modal, setModal] = useState(null); // { vendedor }
|
||||
const cols = useColumnasAjustables([200, 100, 130, 120, 130, 120, 130, 140, 100]);
|
||||
|
||||
useEffect(() => {
|
||||
let activo = true;
|
||||
setVistaActual(`ventas:${ano}|${mes}|${sede}|${programa}|False`);
|
||||
const cargar = () => {
|
||||
setLoading(true); setError(null);
|
||||
api.ventas(ano, mes, sede, programa)
|
||||
.then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } })
|
||||
.catch((e) => { if (activo) { setError(e.message); setLoading(false); } });
|
||||
};
|
||||
cargar();
|
||||
const off = onRefrescar(cargar);
|
||||
return () => { activo = false; off(); };
|
||||
}, [ano, mes, sede, programa]);
|
||||
|
||||
const total = useMemo(() => filas.find((f) => f[0] === "TOTAL GENERAL"), [filas]);
|
||||
const datos = useMemo(() => filas.filter((f) => f[0] !== "TOTAL GENERAL"), [filas]);
|
||||
|
||||
const KPIS = total ? [
|
||||
["👤","Vendedores", datos.length, "activos este mes"],
|
||||
["🎓","Total Inscritos", total[1], "matriculados"],
|
||||
["💵","Venta Total", total[2], "inversión neta"],
|
||||
["✅","Venta P.C del Mes", total[4], "pagos completos"],
|
||||
["🚀","Avance P.C Total", total[7], "P.C + Pendientes"],
|
||||
] : [];
|
||||
|
||||
// Exportar saldos por cuota — Excel con formato (anchos, encabezado de color,
|
||||
// fechas reales, montos numéricos en soles y fila TOTAL GENERAL).
|
||||
async function exportarCuota(tipoCuota) {
|
||||
const toNum = (v) => { const n = parseFloat(String(v).replace("S/","").replace(/,/g,"").trim()); return isNaN(n)?0:n; };
|
||||
// Convierte "dd-mm-yyyy" o "dd/mm/yyyy" a Date; si no puede, devuelve el texto original
|
||||
const toDate = (v) => {
|
||||
if (!v) return "";
|
||||
const s = String(v).trim().replace(/\//g,"-");
|
||||
const p = s.split("-");
|
||||
if (p.length === 3) {
|
||||
let [d,m,y] = p;
|
||||
if (y.length === 4) return new Date(+y, +m-1, +d);
|
||||
}
|
||||
return String(v);
|
||||
};
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await api.saldoPendiente(tipoCuota);
|
||||
} catch (e) {
|
||||
alert("No se pudo obtener los datos de saldos: " + e.message);
|
||||
return;
|
||||
}
|
||||
const datosS = res.datos || [];
|
||||
const colSaldo = `SALDO ${tipoCuota.toUpperCase()}`;
|
||||
const filtrados = datosS.filter((d) => toNum(d[colSaldo]) > 0.01);
|
||||
if (filtrados.length === 0) { alert(`No hay saldos pendientes para ${tipoCuota}.`); return; }
|
||||
|
||||
const COLS = ["MATRÍCULA","VENDEDOR","ALUMNO","PROGRAMA","F. INICIO","F. MATR.","SALDO MAT.",colSaldo,"VENCIMIENTO","INV. NETA"];
|
||||
const KEYS = ["MATRICULA","VENDEDOR","ALUMNO","PROGRAMA","FECHA INICIO","FECHA MATR.","SALDO MAT.",colSaldo,"VENCIMIENTO","INV. NETA"];
|
||||
const COL_MONTO = ["SALDO MAT.", colSaldo, "INV. NETA"];
|
||||
const COL_FECHA = ["F. INICIO","F. MATR.","VENCIMIENTO"];
|
||||
|
||||
const XLSX = await import("xlsx-js-style");
|
||||
|
||||
// Construir matriz de celdas (AOA) con tipos correctos
|
||||
const headerStyle = {
|
||||
font: { bold: true, color: { rgb: "FFFFFF" }, sz: 11 },
|
||||
fill: { fgColor: { rgb: "1E40AF" } },
|
||||
alignment: { horizontal: "center", vertical: "center" },
|
||||
};
|
||||
const moneyFmt = '"S/" #,##0';
|
||||
const dateFmt = "dd/mm/yyyy";
|
||||
|
||||
const ws = {};
|
||||
const range = { s: { r: 0, c: 0 }, e: { r: filtrados.length + 1, c: COLS.length - 1 } };
|
||||
|
||||
// Encabezado
|
||||
COLS.forEach((h, c) => {
|
||||
const ref = XLSX.utils.encode_cell({ r: 0, c });
|
||||
ws[ref] = { v: h, t: "s", s: headerStyle };
|
||||
});
|
||||
|
||||
// Filas de datos
|
||||
filtrados.forEach((d, ri) => {
|
||||
KEYS.forEach((k, c) => {
|
||||
const ref = XLSX.utils.encode_cell({ r: ri + 1, c });
|
||||
const col = COLS[c];
|
||||
if (COL_MONTO.includes(col)) {
|
||||
ws[ref] = { v: toNum(d[k]), t: "n", z: moneyFmt };
|
||||
} else if (COL_FECHA.includes(col)) {
|
||||
const dt = toDate(d[k]);
|
||||
if (dt instanceof Date) ws[ref] = { v: dt, t: "d", z: dateFmt };
|
||||
else ws[ref] = { v: dt, t: "s" };
|
||||
} else {
|
||||
ws[ref] = { v: d[k] ?? "", t: "s" };
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Fila TOTAL GENERAL: promedio en SALDO MAT. y SALDO CUOTA, suma en INV. NETA
|
||||
const rTot = filtrados.length + 1;
|
||||
const sum = (k) => filtrados.reduce((s,d)=>s+toNum(d[k]),0);
|
||||
const prom = (k) => filtrados.length ? sum(k)/filtrados.length : 0;
|
||||
const totalStyle = { font: { bold: true }, fill: { fgColor: { rgb: "EFF6FF" } } };
|
||||
COLS.forEach((col, c) => {
|
||||
const ref = XLSX.utils.encode_cell({ r: rTot, c });
|
||||
if (c === 0) ws[ref] = { v: "TOTAL GENERAL", t: "s", s: totalStyle };
|
||||
else if (col === "SALDO MAT.") ws[ref] = { v: prom("SALDO MAT."), t: "n", z: moneyFmt, s: totalStyle };
|
||||
else if (col === colSaldo) ws[ref] = { v: prom(colSaldo), t: "n", z: moneyFmt, s: totalStyle };
|
||||
else if (col === "INV. NETA") ws[ref] = { v: sum("INV. NETA"), t: "n", z: moneyFmt, s: totalStyle };
|
||||
else ws[ref] = { v: "", t: "s", s: totalStyle };
|
||||
});
|
||||
|
||||
ws["!ref"] = XLSX.utils.encode_range(range);
|
||||
ws["!cols"] = [
|
||||
{ wch: 12 }, // MATRÍCULA
|
||||
{ wch: 22 }, // VENDEDOR
|
||||
{ wch: 28 }, // ALUMNO
|
||||
{ wch: 32 }, // PROGRAMA
|
||||
{ wch: 12 }, // F. INICIO
|
||||
{ wch: 12 }, // F. MATR.
|
||||
{ wch: 13 }, // SALDO MAT.
|
||||
{ wch: 15 }, // SALDO CUOTA
|
||||
{ wch: 13 }, // VENCIMIENTO
|
||||
{ wch: 13 }, // INV. NETA
|
||||
];
|
||||
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, "Saldos");
|
||||
const nombre = tipoCuota.replace("°","").replace(/\s/g,"_");
|
||||
XLSX.writeFile(wb, `saldos_${nombre}.xlsx`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">🛒 Ventas</h1>
|
||||
|
||||
<Filters>
|
||||
<Select label="Año" value={ano} options={ANOS} onChange={(v) => setAno(+v)} />
|
||||
<Select label="Mes" value={mes} options={MESES.map((m,i)=>({value:i+1,label:m}))} onChange={(v) => setMes(+v)} />
|
||||
<Select label="Sede" value={sede} options={SEDES} onChange={setSede} />
|
||||
<Select label="Programa" value={programa} options={PROGRAMAS} onChange={setPrograma} />
|
||||
<div style={{display:"flex",gap:8,alignItems:"flex-end",marginLeft:"auto"}}>
|
||||
<button className="btn btn-primary" style={{height:38}} onClick={() => exportarCuota("1° Cuota")}>
|
||||
⬇️ Exportar 1° Cuota
|
||||
</button>
|
||||
<button className="btn btn-primary" style={{height:38}} onClick={() => exportarCuota("2° Cuota")}>
|
||||
⬇️ Exportar 2° Cuota
|
||||
</button>
|
||||
</div>
|
||||
</Filters>
|
||||
|
||||
{loading ? <Loader text="Calculando ventas..." /> :
|
||||
error ? <ErrorBox msg={error} /> :
|
||||
<>
|
||||
<div className="kpis" style={{ gridTemplateColumns: "repeat(5,1fr)" }}>
|
||||
{KPIS.map(([ico,label,val,sub]) => (
|
||||
<div className="kpi" key={label}>
|
||||
<div className="ico">{ico}</div>
|
||||
<div className="label" style={{fontSize:15}}>{label}</div>
|
||||
<div className="value" style={{fontSize:20}}>{val}</div>
|
||||
<div className="sub">{sub}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="table-wrap" style={{overflowX:"auto"}}>
|
||||
<table {...cols.tableProps}>
|
||||
<cols.ColGroup />
|
||||
<thead><tr>{COLS.map((c, i) => <th key={c} style={{position:"relative"}}>{c}<cols.Resizer index={i} /></th>)}</tr></thead>
|
||||
<tbody>
|
||||
{datos.map((f, i) => (
|
||||
<tr key={i}>
|
||||
<td className="col-name">{f[0]}</td>
|
||||
<td>{f[1]}</td><td>{f[2]}</td>
|
||||
<td>{f[3]}</td><td>{f[4]}</td>
|
||||
<td>{f[5]}</td><td>{f[6]}</td>
|
||||
<td style={{fontWeight:600}}>{f[7]}</td>
|
||||
<td>
|
||||
<button className="btn btn-ghost" style={{padding:"4px 10px",fontSize:11}}
|
||||
onClick={() => setModal({ vendedor: f[0] })}>👁️ Ver</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{total && (
|
||||
<tr className="total-row">
|
||||
<td className="col-name">TOTAL GENERAL</td>
|
||||
<td>{total[1]}</td><td>{total[2]}</td>
|
||||
<td>{total[3]}</td><td>{total[4]}</td>
|
||||
<td>{total[5]}</td><td>{total[6]}</td>
|
||||
<td>{total[7]}</td><td>—</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>}
|
||||
|
||||
{modal && (
|
||||
<ModalDetalle vendedor={modal.vendedor} ano={ano} mes={mes} onClose={() => setModal(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModalDetalle({ vendedor, ano, mes, onClose }) {
|
||||
const [tipo, setTipo] = useState(TIPOS[0]);
|
||||
const [filas, setFilas] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let activo = true;
|
||||
setLoading(true);
|
||||
api.ventasDetalle(vendedor, ano, mes, tipo)
|
||||
.then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } })
|
||||
.catch(() => { if (activo) { setFilas([]); setLoading(false); } });
|
||||
return () => { activo = false; };
|
||||
}, [vendedor, ano, mes, tipo]);
|
||||
|
||||
// Columnas redimensionables (igual que las tablas principales)
|
||||
const colsDet = useColumnasAjustables([200, 260, 120, 130, 120, 120, 130, 120]);
|
||||
|
||||
return (
|
||||
<Modal title={`👤 ${vendedor}`} onClose={onClose} width={1500}>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<Select label="Tipo de venta" value={tipo} options={TIPOS} onChange={setTipo} />
|
||||
</div>
|
||||
{loading ? <Loader text="Cargando detalle..." /> :
|
||||
filas.length === 0 ? <div style={{color:"#94a3b8",padding:20,textAlign:"center"}}>Sin registros para esta categoría.</div> :
|
||||
<div className="table-wrap" style={{overflowX:"auto"}}>
|
||||
<table {...colsDet.tableProps}>
|
||||
<colsDet.ColGroup />
|
||||
<thead><tr>{HEADERS_DETALLE.map((h, i) => <th key={h} style={{position:"relative", whiteSpace:"nowrap"}}>{h}<colsDet.Resizer index={i} /></th>)}</tr></thead>
|
||||
<tbody>
|
||||
{filas.map((f, i) => (
|
||||
<tr key={i}>
|
||||
{f.slice(0, HEADERS_DETALLE.length).map((v, j) => (
|
||||
<td key={j} className={j === 1 ? "col-name" : ""}
|
||||
style={{textAlign:"center", whiteSpace:"nowrap", overflow:"hidden", textOverflow:"ellipsis"}}>{v}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,127 +1,74 @@
|
||||
/* src/styles.css */
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Segoe UI', -apple-system, Arial, sans-serif;
|
||||
background: #f1f5f9;
|
||||
color: #0f172a;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: 'Segoe UI', system-ui, sans-serif; background: #f1f5f9; color: #0f172a; }
|
||||
|
||||
/* ── Layout ── */
|
||||
.app { display: flex; min-height: 100vh; }
|
||||
.app { display: flex; height: 100vh; overflow: hidden; }
|
||||
|
||||
/* ── Sidebar ── */
|
||||
/* ── Sidebar fija ── */
|
||||
.sidebar {
|
||||
width: 240px; background: #0f172a; color: #e2e8f0;
|
||||
display: flex; flex-direction: column; padding: 0;
|
||||
position: fixed; height: 100vh; left: 0; top: 0;
|
||||
}
|
||||
.sidebar-logo {
|
||||
padding: 22px 20px; font-size: 16px; font-weight: 700; color: #fff;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08); letter-spacing: 0.3px;
|
||||
display: flex; flex-direction: column; flex-shrink: 0;
|
||||
height: 100vh; position: sticky; top: 0;
|
||||
}
|
||||
.sidebar-logo { padding: 22px 18px; font-size: 18px; font-weight: 700; border-bottom: 1px solid rgba(255,255,255,0.08); }
|
||||
.sidebar-logo span { color: #60a5fa; }
|
||||
.nav { padding: 12px 10px; flex: 1; }
|
||||
.nav { padding: 12px 8px; }
|
||||
.nav-item {
|
||||
display: flex; align-items: center; gap: 11px; padding: 11px 14px;
|
||||
border-radius: 10px; cursor: pointer; font-size: 13.5px; font-weight: 500;
|
||||
color: #cbd5e1; margin-bottom: 3px; transition: all 0.15s; border: none;
|
||||
background: transparent; width: 100%; text-align: left;
|
||||
width: 100%; text-align: left; padding: 11px 14px; margin-bottom: 4px;
|
||||
background: transparent; border: none; color: #cbd5e1; border-radius: 8px;
|
||||
cursor: pointer; font-size: 14px; display: flex; align-items: center; gap: 10px;
|
||||
}
|
||||
.nav-item:hover { background: rgba(255,255,255,0.06); color: #fff; }
|
||||
.nav-item.active { background: #2563eb; color: #fff; }
|
||||
.nav-item .ico { font-size: 16px; }
|
||||
.nav-item:hover { background: rgba(255,255,255,0.06); }
|
||||
.nav-item.active { background: #2563eb; color: #fff; font-weight: 600; }
|
||||
|
||||
/* ── Main content ── */
|
||||
.main { margin-left: 240px; flex: 1; padding: 22px 28px; min-width: 0; overflow-x: hidden; }
|
||||
.page-title { font-size: 22px; font-weight: 700; color: #0f172a; margin-bottom: 14px; }
|
||||
.main { flex: 1; padding: 24px 28px; overflow-y: auto; height: 100vh; }
|
||||
.page-title { font-size: 24px; font-weight: 700; margin-bottom: 18px; color: #0f172a; }
|
||||
|
||||
/* ── Filters bar ── */
|
||||
.filters { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 18px; align-items: flex-end; }
|
||||
/* ── Filtros ── */
|
||||
.filters { display: flex; gap: 14px; flex-wrap: wrap; align-items: flex-end; margin-bottom: 20px; }
|
||||
.filter-group { display: flex; flex-direction: column; gap: 4px; }
|
||||
.filter-group label {
|
||||
font-size: 11px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.4px;
|
||||
}
|
||||
.filter-group label { font-size: 11px; font-weight: 700; color: #64748b; text-transform: uppercase; letter-spacing: .3px; }
|
||||
.filter-group select {
|
||||
padding: 8px 12px; border: 1px solid #cbd5e1; border-radius: 8px;
|
||||
font-size: 13px; background: #fff; color: #0f172a; cursor: pointer; min-width: 130px;
|
||||
padding: 8px 12px; border: 1px solid #cbd5e1; border-radius: 8px; font-size: 14px;
|
||||
background: #fff; min-width: 130px; cursor: pointer;
|
||||
}
|
||||
.btn {
|
||||
padding: 9px 16px; border: none; border-radius: 8px; font-size: 13px; font-weight: 600;
|
||||
cursor: pointer; transition: all 0.15s;
|
||||
}
|
||||
.btn-primary { background: #2563eb; color: #fff; }
|
||||
.btn-primary:hover { background: #1d4ed8; }
|
||||
.btn-ghost { background: #f1f5f9; color: #475569; border: 1px solid #cbd5e1; }
|
||||
.btn-ghost:hover { background: #e2e8f0; }
|
||||
|
||||
/* ── KPI cards ── */
|
||||
.kpis { display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px; margin-bottom: 18px; }
|
||||
.kpi {
|
||||
background: #fff; border-radius: 12px; padding: 16px; border: 1px solid #e2e8f0;
|
||||
}
|
||||
.kpi .ico { font-size: 18px; margin-bottom: 4px; }
|
||||
.kpi .label { font-size: 10px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.6px; }
|
||||
.kpi .value { font-size: 24px; font-weight: 700; color: #0f172a; line-height: 1.3; }
|
||||
.kpi .sub { font-size: 11px; color: #94a3b8; margin-top: 2px; }
|
||||
/* ── KPIs ── */
|
||||
.kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 18px; }
|
||||
.kpi { background: #fff; border: 1px solid #e2e8f0; border-radius: 14px; padding: 16px; }
|
||||
.kpi .ico { font-size: 22px; margin-bottom: 6px; }
|
||||
.kpi .label { font-size: 13px; font-weight: 600; color: #475569; }
|
||||
.kpi .value { font-size: 26px; font-weight: 800; color: #0f172a; margin: 4px 0; }
|
||||
.kpi .sub { font-size: 11px; color: #94a3b8; }
|
||||
|
||||
/* ── Table ── */
|
||||
.table-wrap {
|
||||
background: #fff; border-radius: 12px; border: 1px solid #e2e8f0;
|
||||
overflow: hidden; overflow-x: auto; max-width: 100%;
|
||||
}
|
||||
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
thead th {
|
||||
background: #334155; color: #f1f5f9; padding: 11px 10px; font-size: 11px;
|
||||
font-weight: 700; text-transform: uppercase; letter-spacing: 0.4px;
|
||||
white-space: nowrap; text-align: center; position: sticky; top: 0;
|
||||
border-right: 1px solid #475569;
|
||||
}
|
||||
thead th:last-child { border-right: none; }
|
||||
tbody td { padding: 9px 8px; text-align: center; font-size: 12px; color: #374151; border-bottom: 1px solid #eef2f7; border-right: 1px solid #f1f5f9; }
|
||||
tbody td:last-child { border-right: none; }
|
||||
tbody tr:nth-child(even) { background: #f8fafc; }
|
||||
tbody tr:hover td { background: #eff6ff; }
|
||||
tr.total-row td { background: #eff6ff !important; font-weight: 700; color: #1e40af; }
|
||||
.col-name { text-align: left !important; font-weight: 500; color: #111827; }
|
||||
/* tarjeta compuesta (varios sub-kpis) */
|
||||
.kpi-multi { background: #fff; border: 1px solid #e2e8f0; border-radius: 14px; padding: 14px 16px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
|
||||
.kpi-row { display: flex; align-items: center; gap: 12px; padding: 9px 0; }
|
||||
.kpi-row + .kpi-row { border-top: 1px solid #f1f5f9; }
|
||||
.kpi-row .ico { font-size: 24px; width: 34px; text-align: center; flex-shrink: 0; }
|
||||
.kpi-row .txt { line-height: 1.25; }
|
||||
.kpi-row .txt .label { font-size: 12px; font-weight: 600; color: #64748b; }
|
||||
.kpi-row .txt .value { font-size: 21px; font-weight: 800; color: #0f172a; }
|
||||
|
||||
/* ── Progress bar ── */
|
||||
.pbar { display: flex; align-items: center; gap: 6px; }
|
||||
.pbar .pct { font-size: 11px; font-weight: 700; min-width: 42px; text-align: right; }
|
||||
.pbar .track { flex: 1; background: #e2e8f0; border-radius: 5px; height: 6px; overflow: hidden; min-width: 50px; }
|
||||
.pbar .fill { height: 100%; border-radius: 5px; }
|
||||
/* ── Tablas ── */
|
||||
.table-wrap { background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; overflow: auto; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { background: #1e3a5f; color: #f1f5f9; padding: 10px 12px; font-size: 12px; font-weight: 700; text-align: center; white-space: nowrap; border-right: 1px solid rgba(255,255,255,0.12); }
|
||||
td { padding: 9px 12px; font-size: 13px; border-right: 1px solid #eef2f7; text-align: center; }
|
||||
th:last-child, td:last-child { border-right: none; }
|
||||
.total-row td { background: #eff6ff; font-weight: 700; }
|
||||
.col-name { text-align: left !important; }
|
||||
|
||||
/* ── Loader ── */
|
||||
.loader-wrap { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 60px; }
|
||||
.spinner {
|
||||
width: 44px; height: 44px; border: 4px solid #e2e8f0; border-top-color: #2563eb;
|
||||
border-radius: 50%; animation: spin 0.8s linear infinite;
|
||||
}
|
||||
/* ── Loader / error ── */
|
||||
.loader-wrap { display: flex; flex-direction: column; align-items: center; padding: 50px; color: #64748b; }
|
||||
.spinner { width: 36px; height: 36px; border: 4px solid #e2e8f0; border-top-color: #2563eb; border-radius: 50%; animation: spin 1s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.loader-text { margin-top: 14px; font-size: 13px; color: #64748b; }
|
||||
.loader-text { margin-top: 12px; font-size: 14px; }
|
||||
.error-box { background: #fee2e2; color: #991b1b; padding: 14px 18px; border-radius: 10px; font-size: 14px; }
|
||||
|
||||
/* ── Error ── */
|
||||
.error-box {
|
||||
background: #fee2e2; border: 1px solid #fca5a5; color: #991b1b;
|
||||
padding: 16px; border-radius: 10px; font-size: 13px;
|
||||
}
|
||||
.card { background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; padding: 16px; }
|
||||
.card-title { font-size: 14px; font-weight: 700; color: #1e293b; margin-bottom: 12px; }
|
||||
|
||||
/* ── Cobranza: colores por cartera + hover por cartera ── */
|
||||
.cob-table td.cart-ant { background: #fef3c7; }
|
||||
.cob-table td.cart-mes { background: #d1fae5; }
|
||||
.cob-table td.cart-tot { background: #ede9fe; }
|
||||
.cob-table tbody tr:hover td.cart-ant { background: #fde68a !important; }
|
||||
.cob-table tbody tr:hover td.cart-mes { background: #a7f3d0 !important; }
|
||||
.cob-table tbody tr:hover td.cart-tot { background: #ddd6fe !important; }
|
||||
/* Filas con deuda: rojo y su hover, prevalece sobre cartera */
|
||||
.cob-table tbody tr.row-deuda td { background: #fee2e2 !important; }
|
||||
.cob-table tbody tr.row-deuda:hover td { background: #fecaca !important; }
|
||||
/* Hover normal de celdas sin cartera */
|
||||
.cob-table tbody tr:hover td:not(.cart-ant):not(.cart-mes):not(.cart-tot) { background: #f1f5f9; }
|
||||
|
||||
/* ── Ocupabilidad: fila de Inicio reprogramado (desde Supabase) en rojo ── */
|
||||
tbody tr.row-inicio td { background: #fecaca !important; color: #7f1d1d; font-weight: 600; }
|
||||
tbody tr.row-inicio:hover td { background: #fca5a5 !important; }
|
||||
|
||||
/* Zona de redimensionado de columnas (popups con columnas ajustables) */
|
||||
.col-resizer { transition: background 0.15s; }
|
||||
.col-resizer:hover { background: #93c5fd; }
|
||||
/* Matriz "Detalle por Curso": líneas verticales suaves entre columnas (solo cuerpo) */
|
||||
.matriz-grid tbody td { border-right: 1px solid #eef2f7; }
|
||||
.matriz-grid tbody td:last-child { border-right: none; }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: { port: 3000, host: true }
|
||||
})
|
||||
server: { port: 5174 },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user