Dashboard Leads - version limpia para servidor (.env y config de despliegue restaurados)
This commit is contained in:
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Dependencias del frontend (se regeneran con npm install en el servidor)
|
||||||
|
node_modules/
|
||||||
|
frontend/node_modules/
|
||||||
|
|
||||||
|
# Build del frontend
|
||||||
|
dist/
|
||||||
|
frontend/dist/
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# Logs y temporales
|
||||||
|
*.log
|
||||||
|
_ELIMINAR/
|
||||||
|
|
||||||
|
# Scripts auxiliares de esta migracion (no van al repo)
|
||||||
|
SUBIR_A_GIT.bat
|
||||||
|
LIMPIAR_PARA_SERVIDOR.bat
|
||||||
|
APLICAR_ENV.bat
|
||||||
|
_ENV_NUEVOS/
|
||||||
|
|
||||||
|
# NOTA: backend/.env SI se versiona a proposito (repo privado interno).
|
||||||
|
# Si algun dia el repo deja de ser privado, descomenta la linea siguiente:
|
||||||
|
#backend/.env
|
||||||
22
backend/.env
Normal file
22
backend/.env
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
PG_HOST=191.98.134.81
|
||||||
|
PG_DATABASE=chatwoot_production
|
||||||
|
PG_USER=postgres
|
||||||
|
PG_PASSWORD=2165$%sd3%DFG
|
||||||
|
PG_PORT=5432
|
||||||
|
SQL_SERVER=191.98.134.80
|
||||||
|
SQL_DATABASE=BDUS_CK000040_0001
|
||||||
|
SQL_USERNAME=ASEBASTIAN
|
||||||
|
SQL_PASSWORD=24MY36$z>&Uf
|
||||||
|
GITHUB_BASE=https://raw.githubusercontent.com/aron1798/prueba1/main/DASHBOARD_LEADS
|
||||||
|
SUPABASE_URL=https://ogzjtkxnfswpbmnbhnjd.supabase.co
|
||||||
|
SUPABASE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9nemp0a3huZnN3cGJtbmJobmpkIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc3NjYzNDk4MywiZXhwIjoyMDkyMjEwOTgzfQ.D7XB6GIs8UvI97lcMdf6Y6-8ON2ENhh0DOaiMro2f9Y
|
||||||
|
SUPABASE_TABLA_LEADS=datos_unificados
|
||||||
|
SUPABASE_PAUTA_URL=https://uztqscimtsihrzgybsyb.supabase.co
|
||||||
|
SUPABASE_PAUTA_KEY=sb_publishable_aJCh5J6UghfKmSz6Swy4iA_orAqeHb7
|
||||||
|
SUPABASE_TABLA_PAUTA=basebi_programacion
|
||||||
|
CARTERA_URL=https://uztqscimtsihrzgybsyb.supabase.co
|
||||||
|
CARTERA_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InV6dHFzY2ltdHNpaHJ6Z3lic3liIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc2MTMyMzQ0MCwiZXhwIjoyMDc2ODk5NDQwfQ.pvqzwJ7fBxggNka3oga7SdhiADIgIVKGEuxGbzHQj0E
|
||||||
|
SUPABASE_TABLA_CARTERA=cartera_junta
|
||||||
|
SUPABASE_TABLA_ALIAS=alias_normalizacion
|
||||||
|
SUPABASE_TABLA_CONJUNTO=conjunto_pauta
|
||||||
|
META_CSV_URL=https://docs.google.com/spreadsheets/d/e/2PACX-1vQa5BFEeF-p0-6-FuEXDXT4eb_ZK0fkYgLMxK_Ly3EJJPwQBsYDfNsSW09ppMNrH2LIiwVdAnC5q64C/pub?gid=1241390845&single=true&output=csv
|
||||||
48
backend/.env.example
Normal file
48
backend/.env.example
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# ============================================================
|
||||||
|
# DASHBOARD LEADS - Plantilla de variables de entorno (backend)
|
||||||
|
# Copiar como .env y rellenar los valores reales.
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# ── Base de Datos PostgreSQL (Chatwoot) ──
|
||||||
|
PG_HOST=191.98.134.81
|
||||||
|
PG_DATABASE=chatwoot_production
|
||||||
|
PG_USER=postgres
|
||||||
|
PG_PASSWORD=tu_password_postgres_aqui
|
||||||
|
PG_PORT=5432
|
||||||
|
|
||||||
|
# ── Base de Datos SQL Server (Académico) ──
|
||||||
|
SQL_SERVER=191.98.134.80
|
||||||
|
SQL_DATABASE=BDUS_CK000040_0001
|
||||||
|
SQL_USERNAME=tu_usuario_sql_aqui
|
||||||
|
SQL_PASSWORD=tu_password_sql_aqui
|
||||||
|
# Driver ODBC. Dejar vacío/comentado para autodetección:
|
||||||
|
# Windows -> SQL Server | Linux/Docker -> ODBC Driver 18 for SQL Server
|
||||||
|
#SQL_DRIVER=ODBC Driver 18 for SQL Server
|
||||||
|
|
||||||
|
# ── Repositorio GitHub (mapa de campañas, opcional) ──
|
||||||
|
GITHUB_BASE=https://raw.githubusercontent.com/aron1798/prueba1/main/DASHBOARD_LEADS
|
||||||
|
|
||||||
|
# ── Supabase (Leads) ──
|
||||||
|
SUPABASE_URL=https://ogzjtkxnfswpbmnbhnjd.supabase.co
|
||||||
|
SUPABASE_KEY=tu_supabase_service_role_key_aqui
|
||||||
|
SUPABASE_TABLA_LEADS=datos_unificados
|
||||||
|
|
||||||
|
# ── Supabase (Pauta) ──
|
||||||
|
SUPABASE_PAUTA_URL=https://uztqscimtsihrzgybsyb.supabase.co
|
||||||
|
SUPABASE_PAUTA_KEY=tu_supabase_pauta_key_aqui
|
||||||
|
SUPABASE_TABLA_PAUTA=basebi_programacion
|
||||||
|
SUPABASE_TABLA_PROGRAMA_PAUTAS=programa_pautas
|
||||||
|
|
||||||
|
# ── Supabase (Cartera / catálogos) ──
|
||||||
|
CARTERA_URL=https://uztqscimtsihrzgybsyb.supabase.co
|
||||||
|
CARTERA_KEY=tu_cartera_key_aqui
|
||||||
|
SUPABASE_TABLA_CARTERA=cartera_junta
|
||||||
|
SUPABASE_TABLA_ALIAS=alias_normalizacion
|
||||||
|
SUPABASE_TABLA_CONJUNTO=conjunto_pauta
|
||||||
|
SUPABASE_TABLA_CAMPANIAS=campanias
|
||||||
|
|
||||||
|
# ── URL Google Sheets Meta CSV ──
|
||||||
|
META_CSV_URL=https://docs.google.com/spreadsheets/d/e/.../pub?gid=1241390845&single=true&output=csv
|
||||||
|
|
||||||
|
# ── Orígenes CORS permitidos (separados por coma) ──
|
||||||
|
CORS_ORIGINS=https://dashboard-leads.escueladerefrigeracion.lat,http://dashboard-leads.escueladerefrigeracion.lat,http://localhost:5174,*
|
||||||
21
backend/Dockerfile
Normal file
21
backend/Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
FROM python:3.10-slim
|
||||||
|
|
||||||
|
# Instalar dependencias del sistema y el Driver Oficial de Microsoft ODBC 18 para SQL Server en Linux
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
curl gnupg2 ca-certificates unixodbc unixodbc-dev gcc g++ \
|
||||||
|
&& curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg \
|
||||||
|
&& curl -fsSL https://packages.microsoft.com/config/debian/12/prod.list > /etc/apt/sources.list.d/mssql-release.list \
|
||||||
|
&& apt-get update \
|
||||||
|
&& ACCEPT_EULA=Y apt-get install -y msodbcsql18 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 8001
|
||||||
|
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001"]
|
||||||
79
backend/cache_manager.py
Normal file
79
backend/cache_manager.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# backend/cache_manager.py
|
||||||
|
"""Caché global en memoria con refresco en segundo plano (igual que el dashboard de ventas)."""
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
from typing import Any, Callable, Dict, Tuple
|
||||||
|
|
||||||
|
_CACHE: Dict[str, Tuple[float, Any]] = {}
|
||||||
|
_LOCK = threading.Lock()
|
||||||
|
_DEFAULT_TTL = 900 # 15 min
|
||||||
|
|
||||||
|
|
||||||
|
def _make_key(prefix: str, args: tuple) -> str:
|
||||||
|
return prefix + ":" + "|".join(str(a) for a in args)
|
||||||
|
|
||||||
|
|
||||||
|
def cache_get_or_set(prefix: str, args: tuple, loader: Callable[[], Any], ttl: int = _DEFAULT_TTL) -> Any:
|
||||||
|
key = _make_key(prefix, args)
|
||||||
|
now = time.time()
|
||||||
|
with _LOCK:
|
||||||
|
hit = _CACHE.get(key)
|
||||||
|
if hit and (now - hit[0] < ttl):
|
||||||
|
return hit[1]
|
||||||
|
value = loader()
|
||||||
|
with _LOCK:
|
||||||
|
_CACHE[key] = (now, value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def cache_keys():
|
||||||
|
with _LOCK:
|
||||||
|
return list(_CACHE.keys())
|
||||||
|
|
||||||
|
|
||||||
|
def cache_refresh_existing(loader_for_key):
|
||||||
|
for key in cache_keys():
|
||||||
|
try:
|
||||||
|
nuevo = loader_for_key(key)
|
||||||
|
if nuevo is not None:
|
||||||
|
with _LOCK:
|
||||||
|
_CACHE[key] = (time.time(), nuevo)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[cache refresh] {key}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def cache_invalidate(prefix: str = None) -> None:
|
||||||
|
with _LOCK:
|
||||||
|
if prefix is None:
|
||||||
|
_CACHE.clear()
|
||||||
|
else:
|
||||||
|
for k in list(_CACHE.keys()):
|
||||||
|
if k.startswith(prefix + ":"):
|
||||||
|
del _CACHE[k]
|
||||||
|
|
||||||
|
|
||||||
|
def cache_stats() -> dict:
|
||||||
|
with _LOCK:
|
||||||
|
return {"entradas": len(_CACHE), "claves": list(_CACHE.keys())}
|
||||||
|
|
||||||
|
|
||||||
|
_background_started = False
|
||||||
|
|
||||||
|
|
||||||
|
def start_background_refresh(refresh_fn: Callable[[], None], interval: int = 900):
|
||||||
|
global _background_started
|
||||||
|
if _background_started:
|
||||||
|
return
|
||||||
|
_background_started = True
|
||||||
|
|
||||||
|
def _loop():
|
||||||
|
while True:
|
||||||
|
time.sleep(interval)
|
||||||
|
try:
|
||||||
|
refresh_fn()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[background refresh] error: {e}")
|
||||||
|
|
||||||
|
t = threading.Thread(target=_loop, daemon=True)
|
||||||
|
t.start()
|
||||||
|
print(f"[cache] refresco en segundo plano cada {interval}s iniciado")
|
||||||
1055
backend/data_manager_v2.py
Normal file
1055
backend/data_manager_v2.py
Normal file
File diff suppressed because it is too large
Load Diff
894
backend/leads_logic.py
Normal file
894
backend/leads_logic.py
Normal file
@@ -0,0 +1,894 @@
|
|||||||
|
# backend/leads_logic.py
|
||||||
|
"""
|
||||||
|
Lógica del módulo LEADS. Traduce las medidas DAX del PBI a Python.
|
||||||
|
Columnas calculadas replicadas:
|
||||||
|
- Cantidad_Veces -> 1ª aparición de un teléfono = lead único
|
||||||
|
- Ultima_Etiqueta / ESTADO/OBJECION -> limpieza de cached_label_list
|
||||||
|
- Tipo_Programa / Sede -> clasificación por dsc_programa
|
||||||
|
Medidas replicadas:
|
||||||
|
- Leads_Totales_Pauta_Unico, Leads_Procesados_Pauta_Unicos,
|
||||||
|
Leads_Procesados_Contactados_Unicos, % Procesados, % Contactados,
|
||||||
|
Total_Matriculas, Cant_inscritos_Mes, Ocupabilidad,
|
||||||
|
Cursos (Inicios / Suspendidos / Ya Iniciados), Matrículas por día,
|
||||||
|
tabla Estado/Objeción.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, date
|
||||||
|
|
||||||
|
# Etiquetas de sistema que se eliminan para hallar el estado/objeción real (igual que el DAX)
|
||||||
|
ETIQUETAS_SISTEMA = {
|
||||||
|
"atención_humana", "negociación", "supervisor", "grupo_arequipa",
|
||||||
|
"grupo_trujillo", "sin_respuesta",
|
||||||
|
}
|
||||||
|
META_POR_TIPO = {
|
||||||
|
"PROGRAMAS TEAC": 22, "PROGRAMAS TERC": 22,
|
||||||
|
"PROVINCIA TEAC": 18, "PROVINCIA TERC": 18,
|
||||||
|
"SEMINARIOS": 15,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers de fecha ────────────────────────────────────────────
|
||||||
|
def _to_date(v):
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
if isinstance(v, datetime):
|
||||||
|
return v.date()
|
||||||
|
if isinstance(v, date):
|
||||||
|
return v
|
||||||
|
s = str(v)[:10]
|
||||||
|
for fmt in ("%Y-%m-%d", "%d-%m-%Y", "%d/%m/%Y"):
|
||||||
|
try:
|
||||||
|
return datetime.strptime(s, fmt).date()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _en_periodo(d, ano, mes, dia):
|
||||||
|
"""True si la fecha cae en el filtro (ano/mes/dia; 'TODOS' = sin filtrar)."""
|
||||||
|
if d is None:
|
||||||
|
return False
|
||||||
|
if ano not in ("TODOS", None) and d.year != int(ano):
|
||||||
|
return False
|
||||||
|
if mes not in ("TODOS", None) and d.month != int(mes):
|
||||||
|
return False
|
||||||
|
if dia not in ("TODOS", None) and d.day != int(dia):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ── Clasificadores (igual que columnas calculadas DAX) ──────────
|
||||||
|
def clasificar_tipo_programa(dsc_programa):
|
||||||
|
up = str(dsc_programa or "").upper()
|
||||||
|
otros = ["CERTIFICACIÓN", "CERTIFICACION", "CURSO A MEDIDA", "MASTERCLASS",
|
||||||
|
"TALLER DE REFRIGERACIÓN DOM", "GESTIÓN DE VENTA", "GESTION DE VENTA"]
|
||||||
|
for o in otros:
|
||||||
|
if o.upper() in up:
|
||||||
|
return "OTROS"
|
||||||
|
return None # el tipo PROGRAMAS/PROVINCIA se arma con sede+programa abajo
|
||||||
|
|
||||||
|
|
||||||
|
def clasificar_sede(dsc_programa):
|
||||||
|
up = str(dsc_programa or "").upper()
|
||||||
|
if "AREQUIPA" in up: return "AREQUIPA"
|
||||||
|
if "TRUJILLO" in up: return "TRUJILLO"
|
||||||
|
if "PIURA" in up: return "PIURA"
|
||||||
|
return "LIMA"
|
||||||
|
|
||||||
|
|
||||||
|
def sede_por_codigo_map():
|
||||||
|
"""{codigo: SEDE} tomado del mapa de campañas (la sede que le corresponde a
|
||||||
|
cada código, SIN reasignar por asesor). Usado por el filtro SEDE de leads,
|
||||||
|
para que coincida con 'sede del código' (no con sede_act)."""
|
||||||
|
from data_manager_v2 import CAMPANIAS
|
||||||
|
out = {}
|
||||||
|
for fila in CAMPANIAS:
|
||||||
|
cod = str(fila[2]).strip() # índice 2 = codigo
|
||||||
|
sede = str(fila[3]).strip().upper() # índice 3 = sede
|
||||||
|
if cod:
|
||||||
|
out[cod] = sede
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ── Grupo de PROGRAMA para el filtro: TEAC / TERC / SEMINARIOS / OTROS ──
|
||||||
|
def grupo_programa_curso(dsc_programa):
|
||||||
|
"""Reduce el tipo detallado a 4 grupos para el filtro PROGRAMA (cursos/matrículas).
|
||||||
|
TEAC = PROGRAMAS TEAC + PROVINCIA TEAC
|
||||||
|
TERC = PROGRAMAS TERC + PROVINCIA TERC
|
||||||
|
SEMINARIOS = SEMINARIOS
|
||||||
|
OTROS = OTROS + CARRERA"""
|
||||||
|
tp = tipo_programa_curso(dsc_programa)
|
||||||
|
if tp in ("PROGRAMAS TEAC", "PROVINCIA TEAC"):
|
||||||
|
return "TEAC"
|
||||||
|
if tp in ("PROGRAMAS TERC", "PROVINCIA TERC"):
|
||||||
|
return "TERC"
|
||||||
|
if tp == "SEMINARIOS":
|
||||||
|
return "SEMINARIOS"
|
||||||
|
return "OTROS" # OTROS + CARRERA
|
||||||
|
|
||||||
|
|
||||||
|
def grupo_programa_lead(cargo):
|
||||||
|
"""Grupo de programa para un LEAD, según su 'cargo' (del mapa de campañas).
|
||||||
|
TEAC → TEAC, TERC → TERC, resto (VRF/CO2/DIPLOMADO/etc.) → SEMINARIOS."""
|
||||||
|
c = str(cargo or "").strip().upper()
|
||||||
|
if c == "TEAC":
|
||||||
|
return "TEAC"
|
||||||
|
if c == "TERC":
|
||||||
|
return "TERC"
|
||||||
|
return "SEMINARIOS"
|
||||||
|
|
||||||
|
|
||||||
|
def grupo_tipo_cohorte(tp):
|
||||||
|
"""Reduce 'PROGRAMAS TEAC'/'PROVINCIA TEAC'/... a los 3 grupos de la tabla por Sede:
|
||||||
|
TEAC = PROGRAMAS/PROVINCIA TEAC, TERC = PROGRAMAS/PROVINCIA TERC, resto = SEMINARIOS."""
|
||||||
|
t = str(tp or "").upper()
|
||||||
|
if "TEAC" in t:
|
||||||
|
return "TEAC"
|
||||||
|
if "TERC" in t:
|
||||||
|
return "TERC"
|
||||||
|
return "SEMINARIOS"
|
||||||
|
|
||||||
|
|
||||||
|
# Réplica del DAX Sede_Act: la sede del lead se decide por el ASESOR;
|
||||||
|
# si el asesor no está en la lista, usa la sede de la campaña.
|
||||||
|
_SEDE_POR_ASESOR = {
|
||||||
|
"almendra peralta": "Arequipa",
|
||||||
|
"juan carlos aguilar": "Piura",
|
||||||
|
"diego lázaro": "Trujillo",
|
||||||
|
"diego lazaro": "Trujillo",
|
||||||
|
"verónica la rosa": "Lima",
|
||||||
|
"veronica la rosa": "Lima",
|
||||||
|
"dayana balabarca": "Lima",
|
||||||
|
"milagros vargas": "Lima",
|
||||||
|
"carmen montoya": "Lima",
|
||||||
|
"diana chávez": "Lima",
|
||||||
|
"diana chavez": "Lima",
|
||||||
|
"copito rivera": "Lima",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def sede_act(asesor, sede_campania):
|
||||||
|
return _SEDE_POR_ASESOR.get(str(asesor or "").strip().lower(), sede_campania)
|
||||||
|
|
||||||
|
|
||||||
|
def tipo_programa_cohorte(sede, programa_cat):
|
||||||
|
"""Igual al DAX 'Tipo Programa': combina sede + (TEAC/TERC) -> categoría."""
|
||||||
|
s = str(sede or "").upper()
|
||||||
|
p = str(programa_cat or "").upper()
|
||||||
|
if s == "LIMA" and p == "TEAC": return "PROGRAMAS TEAC"
|
||||||
|
if s == "LIMA" and p == "TERC": return "PROGRAMAS TERC"
|
||||||
|
if s in ("TRUJILLO", "PIURA", "AREQUIPA") and p == "TEAC": return "PROVINCIA TEAC"
|
||||||
|
if s in ("TRUJILLO", "PIURA", "AREQUIPA") and p == "TERC": return "PROVINCIA TERC"
|
||||||
|
return "SEMINARIOS"
|
||||||
|
|
||||||
|
|
||||||
|
def tipo_programa_curso(dsc_programa):
|
||||||
|
"""Réplica EXACTA del DAX Tipo_Programa (columna calculada de Fact_SQL_Base_Cursos).
|
||||||
|
Usa CONTAINSSTRING en el mismo ORDEN que el PBI (el orden importa)."""
|
||||||
|
p = str(dsc_programa or "")
|
||||||
|
def has(s): # CONTAINSSTRING es sensible a may/min en DAX; comparamos tal cual
|
||||||
|
return s in p
|
||||||
|
|
||||||
|
# 1) OTROS
|
||||||
|
if (has("CERTIFICACIÓN") or has("CURSO A MEDIDA") or has("MASTERCLASS")
|
||||||
|
or has("TALLER DE REFRIGERACIÓN DOM")
|
||||||
|
or has("GESTIÓN DE VENTA DE EQUIPOS DE AIRE ACONDICIONADO Y EQUIPOS DE REFRIGERACIÓN")):
|
||||||
|
return "OTROS"
|
||||||
|
if (has("CERTIFICACION FUNDAMENTOS DE CHILLER MODULAR INVERT- LG")
|
||||||
|
or has("PIURA - CERTIFICACIÓN MIDEA: TECNOLOGIA INVERTER")
|
||||||
|
or has("AREQUIPA - CERTIFICACIÓN MIDEA - TECNOLOGÍA INVERTER")
|
||||||
|
or has("CERTIFICACIÓN: OPERACIÓN Y MANTENIMIENTO DE REFRIGERADORES MIDEA")
|
||||||
|
or has("TRUJILLO - CERTIFICACIÓN MIDEA: TECNOLOGÍA INVERTER")
|
||||||
|
or has("CERTIFICACIÓN MIDEA: AIRE ACOND INVERTER (INTROD., FUNC., INST. Y MANT.)")):
|
||||||
|
return "OTROS"
|
||||||
|
# 2) CARRERA
|
||||||
|
if has("CARRERA TECNICA DE AIRE ACONDICIONADO Y REFRIGERACION"):
|
||||||
|
return "CARRERA"
|
||||||
|
# 3) PROVINCIA TEAC
|
||||||
|
if (has("TRUJILLO - TECNICO ESPECIALISTA EN AIRE ACONDICIONADO")
|
||||||
|
or has("PIURA - TECNICO ESPECIALISTA EN AIRE ACONDICIONADO")
|
||||||
|
or has("AREQUIPA - TECNICO ESPECIALISTA EN AIRE ACONDICIONADO")
|
||||||
|
or has("TRUJILLO - VIRTUAL TECNICO ESPECIALISTA EN AIRE ACONDICIONADO")):
|
||||||
|
return "PROVINCIA TEAC"
|
||||||
|
# 4) PROVINCIA TERC
|
||||||
|
if (has("AREQUIPA - TECNICO ESPECIALISTA EN REFRIGERACION DOMÉSTICA Y COMERCIAL")
|
||||||
|
or has("PIURA - TECNICO ESPECIALISTA EN REFRIGERACION DOMÉSTICA Y COMERCIAL")
|
||||||
|
or has("TRUJILLO - TECNICO ESPECIALISTA EN REFRIGERACION DOMÉSTICA Y COMERCIAL")):
|
||||||
|
return "PROVINCIA TERC"
|
||||||
|
# 5) SEMINARIOS
|
||||||
|
if (has("CO2") or has("CERTIF") or has("SEM:") or has("MASTERCLASS") or has("DISEÑO")
|
||||||
|
or has("DIPLOMADO") or has("SEM.") or has("DUCTOS") or has("SEMINARIO") or has("SEMINARIOS")):
|
||||||
|
return "SEMINARIOS"
|
||||||
|
# 6) PROGRAMAS TEAC (Lima)
|
||||||
|
if (has("MANTENIMIENTO EN AIRE ACONDICIONADO")
|
||||||
|
or has("TECNICO ESPECIALISTA EN AIRE ACONDICIONADO")
|
||||||
|
or has("TALLER - TECNICO ESPECIALISTA EN AIRE ACONDICIONADO")
|
||||||
|
or has("CONTINUIDAD-TECNICO ESPECIALISTA EN AA (4M)")
|
||||||
|
or has("VIRTUAL TECNICO ESPECIALISTA EN AIRE ACONDICIONADO")):
|
||||||
|
return "PROGRAMAS TEAC"
|
||||||
|
# 7) PROGRAMAS TERC (Lima)
|
||||||
|
if (has("INSTALACION EN REFRIGERACION COMERCIAL")
|
||||||
|
or has("MANTENIMIENTO EN REFRIGERACIÓN COMERCIAL")
|
||||||
|
or has("CONTINUIDAD-TECNICO ESPECIALISTA EN REFRIGERACIÓN COMERCIAL (4M)")
|
||||||
|
or has("TECNICO ESPECIALISTA EN REFRIGERACION COMERCIAL")):
|
||||||
|
return "PROGRAMAS TERC"
|
||||||
|
return "SEMINARIOS"
|
||||||
|
|
||||||
|
|
||||||
|
def meta_curso(dsc_programa):
|
||||||
|
"""Meta por curso según Tipo_Programa (igual que el DAX Meta_Curso):
|
||||||
|
PROGRAMAS TEAC/TERC=22, PROVINCIA TEAC/TERC=18, resto=15."""
|
||||||
|
tp = tipo_programa_curso(dsc_programa)
|
||||||
|
if tp in ("PROGRAMAS TEAC", "PROGRAMAS TERC"):
|
||||||
|
return 22
|
||||||
|
if tp in ("PROVINCIA TEAC", "PROVINCIA TERC"):
|
||||||
|
return 18
|
||||||
|
return 15
|
||||||
|
|
||||||
|
|
||||||
|
# ── ESTADO/OBJECION (réplica EXACTA del DAX: cadena de SUBSTITUTE) ──
|
||||||
|
# El DAX quita comas y espacios (pega todas las etiquetas), elimina las de
|
||||||
|
# sistema, y luego reduce combinaciones concatenadas a un estado final.
|
||||||
|
_SUST = [
|
||||||
|
("atención_humana", ""), ("negociación", ""), ("supervisor", ""),
|
||||||
|
("grupo_arequipa", ""), ("grupo_trujillo", ""), ("sin_respuesta", ""),
|
||||||
|
("interesadovendido", "vendido"), ("próximo_inicio", "proxima_fecha"),
|
||||||
|
("inicio", ""), ("aprobación", ""), ("negociacion", ""), ("no_acepta", ""),
|
||||||
|
("pendiente", ""),
|
||||||
|
("revisando_informaciónsólo_consulta", "sólo_consulta"),
|
||||||
|
("contacto_iniciadovendido", "vendido"),
|
||||||
|
("revisando_informacióninteresado", "interesado"),
|
||||||
|
("revisando_informaciónvendido", "vendido"),
|
||||||
|
("sólo_consultainteresado", "interesado"),
|
||||||
|
("revisando_informaciónno_califica", "no_califica"),
|
||||||
|
("interesadopor_pagar", "por_pagar"),
|
||||||
|
("revisando_informaciónprecio_elevado", "precio_elevado"),
|
||||||
|
("proxima_fechavendido", "vendido"),
|
||||||
|
("sólo_consultavendido", "vendido"),
|
||||||
|
("vendidointeresado", "vendido"),
|
||||||
|
("revisando_informaciónproxima_fecha", "proxima_fecha"),
|
||||||
|
("contacto_iniciadosólo_consulta", "sólo_consulta"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def estado_objecion(etiquetas):
|
||||||
|
"""Réplica del DAX ESTADO/OBJECION (versión de 28 sustituciones del PBI)."""
|
||||||
|
if etiquetas is None:
|
||||||
|
return "no trabajado"
|
||||||
|
s = str(etiquetas).replace(",", "").replace(" ", "")
|
||||||
|
for buscar, reemplazar in _SUST:
|
||||||
|
s = s.replace(buscar, reemplazar)
|
||||||
|
s = s.strip()
|
||||||
|
return s if s else "no trabajado"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Ultima_Etiqueta (réplica EXACTA del DAX, usada para "Total Contactados") ──
|
||||||
|
_ULTIMA_NO_TRABAJADO = {
|
||||||
|
"", "(en blanco)", "aprobación", "atencion humana", "atención humana",
|
||||||
|
"grupo arequipa", "grupo trujillo", "importacion masiva", "inicio",
|
||||||
|
"negociacion", "sin respuesta", "pendiente",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def ultima_etiqueta(etiquetas):
|
||||||
|
if etiquetas is None or str(etiquetas).strip() == "":
|
||||||
|
return "NO TRABAJADO"
|
||||||
|
partes = str(etiquetas).split(", ")
|
||||||
|
limpia = partes[-1].replace("_", " ").strip()
|
||||||
|
if limpia in _ULTIMA_NO_TRABAJADO:
|
||||||
|
return "NO TRABAJADO"
|
||||||
|
return limpia if limpia else "NO TRABAJADO"
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════
|
||||||
|
# PROCESAMIENTO DE LEADS (calcula Cantidad_Veces y agrupa)
|
||||||
|
# ════════════════════════════════════════════════════════════════
|
||||||
|
def procesar_leads(filas_chatwoot):
|
||||||
|
"""Marca el lead único (1ª vez de cada teléfono) y arma estructura limpia.
|
||||||
|
Ordenamos por fecha para que la 1ª aparición de cada teléfono sea la 'única'."""
|
||||||
|
# Ordenar por fecha ascendente (el query puede venir ordenado por id)
|
||||||
|
filas_chatwoot = sorted(filas_chatwoot, key=lambda f: (_to_date(f.get("fecha_creada")) or date.min))
|
||||||
|
# Teléfonos de prueba a excluir (igual que el PBI: 924374783)
|
||||||
|
EXCLUIR = {"924374783"}
|
||||||
|
vistos = set()
|
||||||
|
out = []
|
||||||
|
for f in filas_chatwoot:
|
||||||
|
tel = str(f.get("telefono") or "").strip()
|
||||||
|
if not tel or tel in EXCLUIR:
|
||||||
|
continue
|
||||||
|
es_unico = tel not in vistos
|
||||||
|
vistos.add(tel)
|
||||||
|
fecha = _to_date(f.get("fecha_creada"))
|
||||||
|
asesor = (f.get("asesor") or "").strip()
|
||||||
|
sede_campania = (f.get("sede") or "").strip() or "SIN SEDE"
|
||||||
|
sede = sede_act(asesor, sede_campania) # réplica DAX Sede_Act (reasigna por asesor)
|
||||||
|
cargo = (f.get("programa") or "").strip() # TEAC / TERC / VRF / etc. (del mapa campañas)
|
||||||
|
out.append({
|
||||||
|
"telefono": tel,
|
||||||
|
"asesor": asesor,
|
||||||
|
"fecha": fecha,
|
||||||
|
"estado": estado_objecion(f.get("etiquetas")),
|
||||||
|
"ultima": ultima_etiqueta(f.get("etiquetas")),
|
||||||
|
"sede": sede,
|
||||||
|
"cargo": cargo, # TEAC / TERC / VRF / CO2 / etc.
|
||||||
|
"tipo_programa": tipo_programa_cohorte(sede, cargo), # PROGRAMAS/PROVINCIA/SEMINARIOS
|
||||||
|
"codigo": (f.get("codigo") or "").strip() or "-", # código de campaña
|
||||||
|
"es_unico": es_unico,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════
|
||||||
|
# KPIs DEL MÓDULO LEADS
|
||||||
|
# ════════════════════════════════════════════════════════════════
|
||||||
|
def kpis_leads(leads, cursos, matriculas, ano, mes, dia):
|
||||||
|
# Leads en el periodo (solo únicos = Cantidad_Veces == 1)
|
||||||
|
leads_periodo = [l for l in leads if l["es_unico"] and _en_periodo(l["fecha"], ano, mes, dia)]
|
||||||
|
|
||||||
|
recibidos = len(leads_periodo)
|
||||||
|
procesados = sum(1 for l in leads_periodo if l["asesor"])
|
||||||
|
contactados = sum(1 for l in leads_periodo if l["asesor"] and l["ultima"] != "NO TRABAJADO")
|
||||||
|
|
||||||
|
pct_procesados = (procesados / recibidos) if recibidos > 0 else 0.0
|
||||||
|
pct_contactados = (contactados / procesados) if procesados > 0 else 0.0
|
||||||
|
|
||||||
|
# ── Cursos del periodo (por fecha de inicio) ──
|
||||||
|
cursos_periodo = [c for c in cursos if _en_periodo(_to_date(c.get("fch_inicio")), ano, mes, dia)]
|
||||||
|
cursos_programados = len(cursos_periodo)
|
||||||
|
suspendidos = sum(1 for c in cursos_periodo if str(c.get("cod_estado", "")).strip().upper() == "SUS")
|
||||||
|
hoy = date.today()
|
||||||
|
iniciados = sum(1 for c in cursos_periodo
|
||||||
|
if str(c.get("cod_estado", "")).strip().upper() != "SUS"
|
||||||
|
and (_to_date(c.get("fch_inicio")) or hoy) <= hoy)
|
||||||
|
|
||||||
|
# ── Total matriculados (= medida Matriculas_Totales del PBI) ──
|
||||||
|
# Cuenta matrículas ALU/PRE cuya FECHA DE MATRÍCULA cae en el mes,
|
||||||
|
# excluyendo 2 vendedoras. El Calendario del PBI va por fch_matricula.
|
||||||
|
VENDEDORES_EXCLUIDOS = {"CALDERON S. LISSA GENA", "CRUZ G. FIORELLA MELISSA"}
|
||||||
|
total_matriculados = sum(
|
||||||
|
1 for m in matriculas
|
||||||
|
if str(m.get("estado_matricula", "")).strip().upper() in ("ALU", "PRE")
|
||||||
|
and str(m.get("dsc_vendedor", "")).strip() not in VENDEDORES_EXCLUIDOS
|
||||||
|
and _en_periodo(_to_date(m.get("fch_matricula")), ano, mes, dia)
|
||||||
|
)
|
||||||
|
# Ocupabilidad (PBI): SUM(Inscritos) / SUM(Meta).
|
||||||
|
# Excluye cursos suspendidos (SUS) y num_indice excluidos manualmente.
|
||||||
|
# (Ver FUTUROS_CAMBIOS.md: mover esta lista a GitHub/Supabase)
|
||||||
|
CURSOS_EXCLUIDOS = {"1154", "1121"}
|
||||||
|
cursos_ocup = [c for c in cursos_periodo
|
||||||
|
if str(c.get("cod_estado", "")).strip().upper() != "SUS"
|
||||||
|
and str(c.get("num_indice", "")).strip() not in CURSOS_EXCLUIDOS]
|
||||||
|
sum_meta = sum(meta_curso(c.get("dsc_programa")) for c in cursos_ocup)
|
||||||
|
sum_inscritos_cursos = sum(int(c.get("Inscritos_Totales", 0) or 0) for c in cursos_ocup)
|
||||||
|
|
||||||
|
# ── Matrículas en los cursos del mes ──
|
||||||
|
# Matrículas ALU/PRE que: (1) se hicieron en el mes (fch_matricula) Y
|
||||||
|
# (2) son de un curso que INICIA en el mes (dsc_promocion = dsc_det_programa
|
||||||
|
# de los cursos del periodo). Réplica de Cant_inscritos_Mes del PBI.
|
||||||
|
promos_periodo = {str(c.get("dsc_det_programa", "")).strip()
|
||||||
|
for c in cursos_periodo if str(c.get("dsc_det_programa", "")).strip()}
|
||||||
|
mats_mes = [m for m in matriculas
|
||||||
|
if str(m.get("dsc_promocion", "")).strip() in promos_periodo
|
||||||
|
and str(m.get("estado_matricula", "")).strip().upper() in ("ALU", "PRE")
|
||||||
|
and _en_periodo(_to_date(m.get("fch_matricula")), ano, mes, dia)]
|
||||||
|
matriculas_mes = len({str(m.get("num_matricula")) for m in mats_mes})
|
||||||
|
|
||||||
|
ocupabilidad = (sum_inscritos_cursos / sum_meta) if sum_meta > 0 else 0.0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"leads_recibidos": recibidos,
|
||||||
|
"leads_procesados": procesados,
|
||||||
|
"leads_contactados": contactados,
|
||||||
|
"pct_procesados": round(pct_procesados * 100, 2),
|
||||||
|
"pct_contactados": round(pct_contactados * 100, 2),
|
||||||
|
"total_matriculados": total_matriculados,
|
||||||
|
"matriculas_mes": matriculas_mes,
|
||||||
|
"ocupabilidad": round(ocupabilidad * 100, 2),
|
||||||
|
"cursos_programados": cursos_programados,
|
||||||
|
"cursos_reprogramados": 0, # requiere SharePoint (fase 2)
|
||||||
|
"cursos_suspendidos": suspendidos,
|
||||||
|
"cursos_iniciados": iniciados,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Tabla Estado/Objeción (agrupa por Ultima_Etiqueta + desglose por asesor) ──
|
||||||
|
def tabla_estado_objecion(leads, ano, mes, dia):
|
||||||
|
leads_periodo = [l for l in leads if l["es_unico"]
|
||||||
|
and l["asesor"] and _en_periodo(l["fecha"], ano, mes, dia)]
|
||||||
|
agg = {} # estado -> total
|
||||||
|
por_asesor = {} # estado -> {asesor: cantidad}
|
||||||
|
telefonos = {} # (estado, asesor) -> [telefonos]
|
||||||
|
for l in leads_periodo:
|
||||||
|
est = l["ultima"] or "NO TRABAJADO"
|
||||||
|
ase = l["asesor"] or "SIN ASESOR"
|
||||||
|
agg[est] = agg.get(est, 0) + 1
|
||||||
|
por_asesor.setdefault(est, {})
|
||||||
|
por_asesor[est][ase] = por_asesor[est].get(ase, 0) + 1
|
||||||
|
telefonos.setdefault((est, ase), []).append(l["telefono"])
|
||||||
|
filas = []
|
||||||
|
for estado in sorted(agg.keys(), key=lambda x: -agg[x]): # mayor a menor por cantidad
|
||||||
|
asesores = sorted(por_asesor[estado].items(), key=lambda x: -x[1])
|
||||||
|
filas.append({
|
||||||
|
"estado": estado,
|
||||||
|
"cantidad": agg[estado],
|
||||||
|
"asesores": [
|
||||||
|
{"asesor": a, "cantidad": c,
|
||||||
|
"telefonos": sorted(telefonos.get((estado, a), []))}
|
||||||
|
for a, c in asesores
|
||||||
|
],
|
||||||
|
})
|
||||||
|
total = sum(agg.values())
|
||||||
|
return {"filas": filas, "total": total}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Matrículas por día (gráfico de línea) ──
|
||||||
|
# Cuenta TODAS las matrículas del día (misma base que Total Matriculados):
|
||||||
|
# estado ALU/PRE, excluyendo las 2 vendedoras, por día de fch_matricula.
|
||||||
|
def matriculas_por_dia(matriculas, cursos, ano, mes, dia):
|
||||||
|
VENDEDORES_EXCLUIDOS = {"CALDERON S. LISSA GENA", "CRUZ G. FIORELLA MELISSA"}
|
||||||
|
conteo = {} # dia -> total
|
||||||
|
por_tipo = {} # dia -> {tipo_programa: cantidad}
|
||||||
|
for m in matriculas:
|
||||||
|
if str(m.get("estado_matricula", "")).strip().upper() not in ("ALU", "PRE"):
|
||||||
|
continue
|
||||||
|
if str(m.get("dsc_vendedor", "")).strip() in VENDEDORES_EXCLUIDOS:
|
||||||
|
continue
|
||||||
|
d = _to_date(m.get("fch_matricula"))
|
||||||
|
if not _en_periodo(d, ano, mes, dia):
|
||||||
|
continue
|
||||||
|
conteo[d.day] = conteo.get(d.day, 0) + 1
|
||||||
|
tp = tipo_programa_curso(m.get("dsc_programa")) # TEAC/TERC/PROVINCIA.../SEMINARIOS/OTROS
|
||||||
|
por_tipo.setdefault(d.day, {})
|
||||||
|
por_tipo[d.day][tp] = por_tipo[d.day].get(tp, 0) + 1
|
||||||
|
|
||||||
|
def _detalle(day):
|
||||||
|
items = sorted(por_tipo.get(day, {}).items(), key=lambda x: -x[1])
|
||||||
|
return [{"tipo": t, "cantidad": c} for t, c in items]
|
||||||
|
|
||||||
|
import calendar
|
||||||
|
if mes not in ("TODOS", None) and ano not in ("TODOS", None):
|
||||||
|
ndias = calendar.monthrange(int(ano), int(mes))[1]
|
||||||
|
return [{"dia": d, "cantidad": conteo.get(d, 0), "detalle": _detalle(d)}
|
||||||
|
for d in range(1, ndias + 1)]
|
||||||
|
return [{"dia": d, "cantidad": conteo[d], "detalle": _detalle(d)}
|
||||||
|
for d in sorted(conteo.keys())]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Gráfico "Leads por Programa": serie diaria Totales vs Procesados ──
|
||||||
|
def leads_por_dia(leads, ano, mes, dia):
|
||||||
|
lp = [l for l in leads if l["es_unico"] and _en_periodo(l["fecha"], ano, mes, dia)]
|
||||||
|
tot = {} # dia -> totales
|
||||||
|
proc = {} # dia -> procesados (con asesor)
|
||||||
|
for l in lp:
|
||||||
|
d = l["fecha"].day
|
||||||
|
tot[d] = tot.get(d, 0) + 1
|
||||||
|
if l["asesor"]:
|
||||||
|
proc[d] = proc.get(d, 0) + 1
|
||||||
|
import calendar
|
||||||
|
if mes not in ("TODOS", None) and ano not in ("TODOS", None):
|
||||||
|
ndias = calendar.monthrange(int(ano), int(mes))[1]
|
||||||
|
dias = range(1, ndias + 1)
|
||||||
|
else:
|
||||||
|
dias = sorted(set(list(tot.keys()) + list(proc.keys())))
|
||||||
|
return [{"dia": d, "totales": tot.get(d, 0), "procesados": proc.get(d, 0)} for d in dias]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Tabla por Sede_Act -> Tipo_Programa con 4 medidas (cohorte de leads) ──
|
||||||
|
def tabla_pauta(leads, matriculas, ano, mes, dia, importe_por_pauta_periodo=None,
|
||||||
|
resultados_por_pauta_periodo=None, campanias_sede_cargo=None):
|
||||||
|
importe_por_pauta_periodo = importe_por_pauta_periodo or {}
|
||||||
|
resultados_por_pauta_periodo = resultados_por_pauta_periodo or {}
|
||||||
|
campanias_sede_cargo = campanias_sede_cargo or {}
|
||||||
|
# 1) Leads únicos del periodo con su teléfono, fecha, sede y tipo
|
||||||
|
lp = [l for l in leads if l["es_unico"] and _en_periodo(l["fecha"], ano, mes, dia)]
|
||||||
|
# mapa teléfono -> fecha del lead (para "matrícula posterior al lead")
|
||||||
|
fecha_lead = {}
|
||||||
|
for l in lp:
|
||||||
|
fecha_lead.setdefault(l["telefono"], l["fecha"])
|
||||||
|
|
||||||
|
# 2) Matrículas que cruzan por teléfono y son POSTERIORES a la fecha del lead
|
||||||
|
# (réplica de Matriculas_Cohorte_Lead e Inversion_x_Pauta_Cohorte)
|
||||||
|
mat_por_tel = {} # telefono -> {"mats": set(num_matricula), "inv": float}
|
||||||
|
for m in matriculas:
|
||||||
|
tel = str(m.get("dsc_telefono_1") or "").strip()
|
||||||
|
if tel not in fecha_lead:
|
||||||
|
continue
|
||||||
|
fm = _to_date(m.get("fch_matricula"))
|
||||||
|
fl = fecha_lead[tel]
|
||||||
|
if not fm or not fl or not (fm > fl):
|
||||||
|
continue
|
||||||
|
# INV_NETA_FINAL: si DOL, ×3.34
|
||||||
|
try: inv = float(m.get("INV_NETA", 0) or 0)
|
||||||
|
except: inv = 0.0
|
||||||
|
if str(m.get("cod_moneda", "")).strip().upper() == "DOL":
|
||||||
|
inv *= 3.34
|
||||||
|
e = mat_por_tel.setdefault(tel, {"mats": set(), "inv": 0.0})
|
||||||
|
e["mats"].add(str(m.get("num_matricula")))
|
||||||
|
e["inv"] += inv
|
||||||
|
|
||||||
|
# 3) Agrupar por Sede -> Tipo_Programa -> Código
|
||||||
|
def _nuevo():
|
||||||
|
return {"recibidos": 0, "procesados": 0, "mats": set(), "inv": 0.0}
|
||||||
|
grupos = {} # sede -> tipo -> codigo -> métricas
|
||||||
|
for l in lp:
|
||||||
|
sede = l["sede"]; tp = grupo_tipo_cohorte(l["tipo_programa"]); cod = l.get("codigo", "-"); tel = l["telefono"]
|
||||||
|
g = grupos.setdefault(sede, {}).setdefault(tp, {}).setdefault(cod, _nuevo())
|
||||||
|
g["recibidos"] += 1
|
||||||
|
if l["asesor"]:
|
||||||
|
g["procesados"] += 1
|
||||||
|
if tel in mat_por_tel:
|
||||||
|
g["mats"] |= mat_por_tel[tel]["mats"]
|
||||||
|
g["inv"] += mat_por_tel[tel]["inv"]
|
||||||
|
|
||||||
|
def _fila(d):
|
||||||
|
return {"recibidos": d["recibidos"], "procesados": d["procesados"],
|
||||||
|
"matriculas": len(d["mats"]), "inversion": round(d["inv"], 0)}
|
||||||
|
|
||||||
|
# IMPORTE y RESULTADOS por PAUTA -> se asignan a su (sede, tipo) de CAMPANIAS,
|
||||||
|
# una sola vez por pauta (no por sede del lead), para NO duplicar.
|
||||||
|
# imp_st[(sede,tipo)][cod] = importe ; res_st[(sede,tipo)][cod] = resultados
|
||||||
|
imp_st = {}
|
||||||
|
res_st = {}
|
||||||
|
for cod, imp_val in importe_por_pauta_periodo.items():
|
||||||
|
info = campanias_sede_cargo.get(str(cod).strip())
|
||||||
|
if not info:
|
||||||
|
continue
|
||||||
|
se = str(info.get("sede") or "").upper()
|
||||||
|
tp = grupo_tipo_cohorte(tipo_programa_cohorte(se, info.get("cargo")))
|
||||||
|
imp_st.setdefault((se, tp), {})[str(cod).strip()] = float(imp_val or 0.0)
|
||||||
|
for cod, res_val in resultados_por_pauta_periodo.items():
|
||||||
|
info = campanias_sede_cargo.get(str(cod).strip())
|
||||||
|
if not info:
|
||||||
|
continue
|
||||||
|
se = str(info.get("sede") or "").upper()
|
||||||
|
tp = grupo_tipo_cohorte(tipo_programa_cohorte(se, info.get("cargo")))
|
||||||
|
res_st.setdefault((se, tp), {})[str(cod).strip()] = int(res_val or 0)
|
||||||
|
|
||||||
|
filas = []
|
||||||
|
for sede in sorted(grupos.keys()):
|
||||||
|
subfilas = []
|
||||||
|
s_rec = s_proc = 0; s_mats = set(); s_inv = 0.0; s_imp = 0.0; s_res = 0
|
||||||
|
for tp in sorted(grupos[sede].keys()):
|
||||||
|
cods = grupos[sede][tp]
|
||||||
|
imp_map = imp_st.get((sede.upper(), tp), {}) # importe por pauta de esta sede/tipo
|
||||||
|
res_map = res_st.get((sede.upper(), tp), {})
|
||||||
|
# métricas del tipo (sumando sus códigos)
|
||||||
|
t_rec = t_proc = 0; t_mats = set(); t_inv = 0.0; t_imp = 0.0; t_res = 0
|
||||||
|
codigos = []
|
||||||
|
for cod in sorted(cods.keys()):
|
||||||
|
d = cods[cod]
|
||||||
|
imp_cod = float(imp_map.get(str(cod).strip(), 0.0))
|
||||||
|
res_cod = int(res_map.get(str(cod).strip(), 0))
|
||||||
|
codigos.append({"codigo": cod, **_fila(d), "importe": round(imp_cod, 2),
|
||||||
|
"resultados": res_cod})
|
||||||
|
t_rec += d["recibidos"]; t_proc += d["procesados"]
|
||||||
|
t_mats |= d["mats"]; t_inv += d["inv"]; t_imp += imp_cod; t_res += res_cod
|
||||||
|
subfilas.append({
|
||||||
|
"tipo": tp, "recibidos": t_rec, "procesados": t_proc,
|
||||||
|
"matriculas": len(t_mats), "inversion": round(t_inv, 0),
|
||||||
|
"importe": round(t_imp, 2), "resultados": t_res, "codigos": codigos,
|
||||||
|
})
|
||||||
|
s_rec += t_rec; s_proc += t_proc; s_mats |= t_mats; s_inv += t_inv; s_imp += t_imp; s_res += t_res
|
||||||
|
filas.append({
|
||||||
|
"sede": sede, "recibidos": s_rec, "procesados": s_proc,
|
||||||
|
"matriculas": len(s_mats), "inversion": round(s_inv, 0),
|
||||||
|
"importe": round(s_imp, 2), "resultados": s_res, "subfilas": subfilas,
|
||||||
|
})
|
||||||
|
# Totales
|
||||||
|
t_rec = sum(f["recibidos"] for f in filas)
|
||||||
|
t_proc = sum(f["procesados"] for f in filas)
|
||||||
|
t_inv = round(sum(f["inversion"] for f in filas), 0)
|
||||||
|
# Total de importe/resultados: suma de TODAS las pautas que estan en campanias
|
||||||
|
# (una vez cada una), aunque no tengan fila con leads. Asi el total no se pierde.
|
||||||
|
t_imp = round(sum(float(v or 0.0) for cod, v in importe_por_pauta_periodo.items()
|
||||||
|
if str(cod).strip() in campanias_sede_cargo), 2)
|
||||||
|
t_res = sum(int(v or 0) for cod, v in resultados_por_pauta_periodo.items()
|
||||||
|
if str(cod).strip() in campanias_sede_cargo)
|
||||||
|
# matrículas total: unión global
|
||||||
|
all_mats = set()
|
||||||
|
for tel, e in mat_por_tel.items():
|
||||||
|
all_mats |= e["mats"]
|
||||||
|
return {"filas": filas, "total": {"recibidos": t_rec, "procesados": t_proc,
|
||||||
|
"matriculas": len(all_mats), "inversion": t_inv,
|
||||||
|
"importe": t_imp, "resultados": t_res}}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Personalizado: replica la columna Fact_SQL_Base_Cursos[Personalizado] (M) ──
|
||||||
|
# Base = dsc_det_programa, limpiando saltos de línea, luego cadena de reemplazos.
|
||||||
|
_PERS_REEMPLAZOS = [
|
||||||
|
("TECNICO ESPECIALISTA EN AIRE ACONDICIONADO", "TEAC"),
|
||||||
|
("TECNICO ESPECIALISTA EN REFRIGERACION COMERCIAL", "TERC"),
|
||||||
|
("TECNICO ESPECIALISTA EN REFRIGERACION DOMÉSTICA Y COMERCIAL", "TERC"),
|
||||||
|
("CARRERA TECNICA DE AIRE ACONDICIONADO Y REFRIGERACION ", "CARRERA"),
|
||||||
|
("VIRTUAL SEMINARIO SUPERVISION DE OBRAS EN AIRE ACONDICIONADO", "SUPERVISION DE OBRAS"),
|
||||||
|
("SEMINARIO APLICACIÓN DE VARIADORES DE FRECUENCIA EN SISTEMAS HVAC", "VARIADORES DE FRECUENCIA"),
|
||||||
|
("VIRTUAL - SISTEMAS DE REFRIGERACION CON CO2 - FASE SUBCRITICA Y TRANSCRITICA", "REFRIGERACION CON CO2"),
|
||||||
|
("VIRTUAL SEMINARIO DISEÑO DE CAMARAS DE REFRIGERACION CON SISTEMAS CON FREON", "DISEÑO DE CAMARAS"),
|
||||||
|
("VIRTUAL SEM. DETERMINACIÓN DE CAPACIDAD DE EQUIPOS DE AIRE ACONDICIONADO- CARGAS TÉRMICAS -(A/A)", "CARGAS TERMICAS"),
|
||||||
|
("VIRTUAL SEMINARIO DIBUJO TECNICO Y DISEÑO ASISTIDO POR COMPUTADORA (CAD) PARA HVAC -(A/A)", "DIBUJO TECNICO (CAD)"),
|
||||||
|
("VIRTUAL SEMINARIO: SISTEMAS DE REFRIGERACIÓN INDUSTRIAL POR AMONIACO (NH3)", "AMONIACO (NH3)"),
|
||||||
|
("SEMINARIO VIRTUAL METRADO , COSTEO Y PRESUPUESTOS DE HVAC (AA)", "METRADO, COSTEO Y PRESUPUESTOS"),
|
||||||
|
("CARRERA TECNICA LIMA GESTION DE EMPRESAS", "GESTION DE EMPRESAS"),
|
||||||
|
("VIRTUAL SEMINARIO DISEÑO DE CHILLERS PARA PROCESOS INDUSTRIALES DE REFRIGERACIÓN", "DISEÑO DE CHILLERS"),
|
||||||
|
("VIRTUAL SEMINARIO PRACTICO VENTILACION DE SOTANO Y PREZURIZACION DE ESCALERA -(A/A)", "VENTILACION DE SOTANO"),
|
||||||
|
("SEMINARIO USO DE SOFTWARE EN CÁLCULOS DE AIRE ACONDICIONADO", "SOFTWARE EN CALCULOS DE A/A"),
|
||||||
|
("DISEÑO DE SISTEMAS DE AIRE ACONDICIONADO", "DISEÑO DE SISTEMAS DE A/A"),
|
||||||
|
("SEMINARIO INSTALACIÓN DE SISTEMAS DE A/A DE VOLUMEN VARIABLE VRV/VRF - SAB", "VOLUMEN VARIABLE VRF"),
|
||||||
|
# Seminarios VRF por sede y CO2 → nombre corto (la frecuencia se conserva al final)
|
||||||
|
("PIURA - SEMINARIO INSTALACIÓN DE SISTEMAS DE A/A DE VOLUMEN VARIABLE - VRF", "PIURA - VRF"),
|
||||||
|
("AREQUIPA - SEMINARIO INSTALACIÓN DE SISTEMAS DE A/A DE VOLUMEN VARIABLE - VRF", "AREQUIPA - VRF"),
|
||||||
|
("TRUJILLO - SEMINARIO INSTALACIÓN DE SISTEMAS DE A/A DE VOLUMEN VARIABLE - VRF", "TRUJILLO - VRF"),
|
||||||
|
("VIRTUAL - DISEÑO Y OPERACIÓN DE SISTEMAS DE REFRIGERACIÓN CON CO2 EN FASE SUBCRITICA Y TRANSCRITICA", "CO2"),
|
||||||
|
# Reemplazos sobre Nombre_Programa_Detalle (bloque ductos y otros)
|
||||||
|
("VIRTUAL - DIMENSIONAMIENTO DE", ""),
|
||||||
|
("METÁLICOS EN AIRE ACONDICIONADO Y VENTILACIÓN", ""),
|
||||||
|
("MASTERCLASS: PROGRAMACIÓN DE CONTROLADORES PARA REFRIGERACIÓN", "PROGRAMACION DE CONTROLADORES"),
|
||||||
|
("DIPLOMADO INTERNACIONAL DE AIRE ACONDICIONADO", "DIPLOMADO DE A/A"),
|
||||||
|
# Reemplazos finales (sedes/carreras/masterclass)
|
||||||
|
("AREQUIPA - CARRERA TÉCNICA DE AIRE ACONDICIONADO Y REFRIGERACIÓN - TAR", "AREQUIPA - CARRERA - TARDE"),
|
||||||
|
("PIURA - CARRERA TÉCNICA DE AIRE ACONDICIONADO Y REFRIGERACIÓN - TAR", "PIURA - CARRERA - TARDE"),
|
||||||
|
("TRUJILLO - CARRERA TÉCNICA DE AIRE ACONDICIONADO Y REFRIGERACIÓN - TAR", "TRUJILLO - CARRERA - TARDE"),
|
||||||
|
("AREQUIPA - CARRERA TÉCNICA DE AIRE ACONDICIONADO Y REFRIGERACIÓN - MAN", "AREQUIPA - CARRERA - MAÑANA"),
|
||||||
|
("PIURA - CARRERA TÉCNICA DE AIRE ACONDICIONADO Y REFRIGERACIÓN - MAN", "PIURA - CARRERA - MAÑANA"),
|
||||||
|
("TRUJILLO - CARRERA TÉCNICA DE AIRE ACONDICIONADO Y REFRIGERACIÓN - MAN", "TRUJILLO - CARRERA - MAÑANA"),
|
||||||
|
("GESTIÓN DE VENTA DE EQUIPOS DE AIRE ACONDICIONADO Y EQUIPOS DE REFRIGERACIÓN - NOC", "GESTIÓN DE VENTA - NOC"),
|
||||||
|
("MASTERCLASS SELECCIÓN E INSTALACIÓN DE TARJETAS ELECTRÓNICAS UNIVERSALES EN EQUIPOS DE A/A .INVERTER - MAN", "MASTERCLASS TARJETAS UNIVERSALES - MAN"),
|
||||||
|
("MASTERCLASS: SISTEMAS DE A/A CON VRF - SAB", "MASTERCLASS: A/A CON VRF - SAB"),
|
||||||
|
("GESTIÓN DE VENTA DE EQUIPOS DE AIRE ACONDICIONADO Y EQUIPOS DE REFRIGERACIÓN - TAR", "GESTIÓN DE VENTA - TAR"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def personalizado_curso(dsc_programa, cod_frecuencia=""):
|
||||||
|
"""Replica Fact_SQL_Base_Cursos[Personalizado] del PBI:
|
||||||
|
base = dsc_programa & ' - ' & cod_frecuencia (NO dsc_det_programa)."""
|
||||||
|
s = f"{str(dsc_programa or '')} - {str(cod_frecuencia or '')}"
|
||||||
|
# Text.Clean + reemplazar saltos de línea por espacio, luego Trim
|
||||||
|
s = s.replace("\r", " ").replace("\n", " ")
|
||||||
|
s = "".join(ch for ch in s if ch >= " " or ch == " ") # Text.Clean (quita control)
|
||||||
|
s = s.strip()
|
||||||
|
for buscar, reemplazo in _PERS_REEMPLAZOS:
|
||||||
|
s = s.replace(buscar, reemplazo)
|
||||||
|
# colapsar espacios múltiples que pudieran quedar de reemplazos vacíos
|
||||||
|
s = " ".join(s.split())
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
# ── Matriz por Curso (num_indice): Personalizado + métricas de leads por pauta ──
|
||||||
|
# NOTA: las métricas de leads cruzan por el CÓDIGO DE PAUTA del curso, que viene de
|
||||||
|
# SharePoint (no disponible aún). Por eso salen en 0 hasta conectar esa fuente.
|
||||||
|
# Ver FUTUROS_CAMBIOS.md punto 5. Lo que sí se calcula: Personalizado y Fecha Inicio.
|
||||||
|
def matriz_cursos(cursos, leads, ano, mes, dia, pauta_map=None,
|
||||||
|
campanias_map=None, cartera_all=None, cartera_asig=None,
|
||||||
|
mat_por_indice=None, cartera_tels=None, cartera_origen=None,
|
||||||
|
importe_por_pauta=None, importe_por_pauta_periodo=None,
|
||||||
|
cartera_canal=None, conjunto_por_pauta=None, pautas_por_indice=None):
|
||||||
|
pauta_map = pauta_map or {}
|
||||||
|
pautas_por_indice = pautas_por_indice or {} # {num_indice: [pautas]} (muchos-a-muchos)
|
||||||
|
campanias_map = campanias_map or {}
|
||||||
|
cartera_all = cartera_all or {}
|
||||||
|
cartera_asig = cartera_asig or {}
|
||||||
|
mat_por_indice = mat_por_indice or {}
|
||||||
|
cartera_tels = cartera_tels or set()
|
||||||
|
cartera_origen = cartera_origen or {}
|
||||||
|
importe_por_pauta = importe_por_pauta or {}
|
||||||
|
importe_por_pauta_periodo = importe_por_pauta_periodo or {}
|
||||||
|
conjunto_por_pauta = conjunto_por_pauta or {} # {pauta: [conjuntos de anuncios]}
|
||||||
|
# cartera_canal = {"PAUTA": {"all","asig","tels"}, "OTROS": {...}} para el desglose por canal
|
||||||
|
cartera_canal = cartera_canal or {}
|
||||||
|
|
||||||
|
CURSOS_EXCLUIDOS = {"1121", "1154"}
|
||||||
|
|
||||||
|
def _excluir(c):
|
||||||
|
ni = str(c.get("num_indice", ""))
|
||||||
|
if ni in CURSOS_EXCLUIDOS:
|
||||||
|
return True
|
||||||
|
info = pauta_map.get(ni)
|
||||||
|
if not info:
|
||||||
|
return False # sin info en Supabase → no se excluye
|
||||||
|
# Excluir SUSPENDIDO o contar = NO
|
||||||
|
return info.get("contar") == "NO" or info.get("estado") == "SUSPENDIDO"
|
||||||
|
|
||||||
|
cursos_periodo = [c for c in cursos
|
||||||
|
if _en_periodo(_to_date(c.get("fch_inicio")), ano, mes, dia)
|
||||||
|
and not _excluir(c)]
|
||||||
|
|
||||||
|
# Leads únicos agrupados por codigo (= Pauta). Históricos y del mes (filtro fecha).
|
||||||
|
leads_unicos = [l for l in leads if l.get("es_unico")]
|
||||||
|
hist_por_cod = {} # codigo → set teléfonos
|
||||||
|
hist_ases_cod = {} # codigo → set teléfonos con asesor
|
||||||
|
mes_por_cod = {} # codigo → set teléfonos (en periodo filtrado)
|
||||||
|
mes_ases_cod = {} # codigo → set teléfonos con asesor (en periodo)
|
||||||
|
for l in leads_unicos:
|
||||||
|
cod = l.get("codigo", "-")
|
||||||
|
tel = l["telefono"]
|
||||||
|
hist_por_cod.setdefault(cod, set()).add(tel)
|
||||||
|
if l.get("asesor"):
|
||||||
|
hist_ases_cod.setdefault(cod, set()).add(tel)
|
||||||
|
if _en_periodo(l["fecha"], ano, mes, dia):
|
||||||
|
mes_por_cod.setdefault(cod, set()).add(tel)
|
||||||
|
if l.get("asesor"):
|
||||||
|
mes_ases_cod.setdefault(cod, set()).add(tel)
|
||||||
|
|
||||||
|
# Construir filas con sus medidas
|
||||||
|
pre = []
|
||||||
|
for c in sorted(cursos_periodo, key=lambda x: (_to_date(x.get("fch_inicio")) or date.min)):
|
||||||
|
nombre = personalizado_curso(c.get("dsc_programa"), c.get("cod_frecuencia"))
|
||||||
|
fch = _to_date(c.get("fch_inicio"))
|
||||||
|
ni = str(c.get("num_indice", "")).strip()
|
||||||
|
# LISTA de pautas del num_indice (muchos-a-muchos). Si no hay en la tabla nueva,
|
||||||
|
# usa la de basebi (compatibilidad).
|
||||||
|
cods = pautas_por_indice.get(ni)
|
||||||
|
if not cods:
|
||||||
|
_info = pauta_map.get(ni)
|
||||||
|
cods = [_info.get("pauta")] if _info and _info.get("pauta") else []
|
||||||
|
cods = [str(x).strip() for x in cods if x]
|
||||||
|
# Leads = union de telefonos de TODAS las pautas (sin duplicar)
|
||||||
|
def _union(mapa):
|
||||||
|
s = set()
|
||||||
|
for cd in cods:
|
||||||
|
s |= mapa.get(cd, set())
|
||||||
|
return len(s)
|
||||||
|
nuevos = _union(hist_por_cod)
|
||||||
|
nuevos_a = _union(hist_ases_cod)
|
||||||
|
nuevos_m = _union(mes_por_cod)
|
||||||
|
nuevos_ma = _union(mes_ases_cod)
|
||||||
|
pre.append({
|
||||||
|
"num_indice": ni,
|
||||||
|
"personalizado": nombre,
|
||||||
|
"fch": fch,
|
||||||
|
"pauta": cods[0] if cods else None, # pauta principal (para mostrar)
|
||||||
|
"pautas": cods, # todas las pautas (para sumar)
|
||||||
|
"fecha_inicio": fch.strftime("%d/%m/%Y") if fch else "",
|
||||||
|
"leads_nuevos": nuevos,
|
||||||
|
"leads_nuevos_asesor": nuevos_a,
|
||||||
|
"leads_nuevos_mes": nuevos_m,
|
||||||
|
"leads_nuevos_mes_asesor": nuevos_ma,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Índice HISTÓRICO completo (TODOS los cursos, sin filtro de fecha) por
|
||||||
|
# personalizado → lista de (fecha, leads_nuevos). Para el acumulado.
|
||||||
|
hist_cursos = {}
|
||||||
|
for c in cursos:
|
||||||
|
if _excluir(c):
|
||||||
|
continue
|
||||||
|
nom = personalizado_curso(c.get("dsc_programa"), c.get("cod_frecuencia"))
|
||||||
|
f = _to_date(c.get("fch_inicio"))
|
||||||
|
info = pauta_map.get(str(c.get("num_indice", "")))
|
||||||
|
cd = info.get("pauta") if info else None
|
||||||
|
nv = len(hist_por_cod.get(cd, ())) if cd else 0
|
||||||
|
hist_cursos.setdefault(nom, []).append((f, nv))
|
||||||
|
|
||||||
|
# Leads_Acumulados_Historico: suma de leads_nuevos de TODOS los cursos del
|
||||||
|
# MISMO personalizado con fecha <= la del curso actual (histórico completo).
|
||||||
|
filas = []
|
||||||
|
for r in pre:
|
||||||
|
acum = sum(nv for (f, nv) in hist_cursos.get(r["personalizado"], [])
|
||||||
|
if f and r["fch"] and f <= r["fch"])
|
||||||
|
# CARTERA TOTAL: teléfonos únicos de cartera_junta con la misma SEDE+PROGRAMA
|
||||||
|
# del curso (según su pauta → campanias) cuya fecha_creada <= fch_inicio del curso.
|
||||||
|
# cartera_total = todos; cartera_total_asig = solo con asesor asignado.
|
||||||
|
cartera_total = 0
|
||||||
|
cartera_total_asig = 0
|
||||||
|
cod = r.get("pauta")
|
||||||
|
cods = r.get("pautas") or ([cod] if cod else [])
|
||||||
|
if cods and r["fch"]:
|
||||||
|
# Cartera = union de telefonos de la sede+programa de TODAS las pautas del num_indice
|
||||||
|
tels_all = {}; tels_asig = {}
|
||||||
|
for cd in cods:
|
||||||
|
sp = campanias_map.get(str(cd).upper())
|
||||||
|
if not sp:
|
||||||
|
continue
|
||||||
|
for tel, fx in cartera_all.get(sp, {}).items():
|
||||||
|
if fx and (tel not in tels_all or fx < tels_all[tel]):
|
||||||
|
tels_all[tel] = fx
|
||||||
|
for tel, fx in cartera_asig.get(sp, {}).items():
|
||||||
|
if fx and (tel not in tels_asig or fx < tels_asig[tel]):
|
||||||
|
tels_asig[tel] = fx
|
||||||
|
cartera_total = sum(1 for fx in tels_all.values() if fx <= r["fch"])
|
||||||
|
cartera_total_asig = sum(1 for fx in tels_asig.values() if fx <= r["fch"])
|
||||||
|
# MATRÍCULAS NO IDENTIFICADAS: matrículas de este curso (num_indice) cuyo teléfono
|
||||||
|
# (tel_1, o tel_2 si el 1 está vacío) NO aparece en cartera_junta. Sin filtro de fecha.
|
||||||
|
_ni = r["num_indice"]
|
||||||
|
_ni = _ni[:-2] if _ni.endswith(".0") else _ni
|
||||||
|
_mats = mat_por_indice.get(_ni, []) # lista de (telefono, fecha_matricula)
|
||||||
|
_tels_no_iden = [ph for (ph, fm) in _mats if ph not in cartera_tels]
|
||||||
|
matriculas_no_iden = len(_tels_no_iden)
|
||||||
|
# MATRICULAS LEADS NUEVOS: matrícula hecha dentro de 45 días DESPUÉS del origen
|
||||||
|
# del lead (registro es_origen=SI de ese teléfono en la cartera).
|
||||||
|
mat_leads_nuevos = 0
|
||||||
|
mat_leads_antiguos = 0
|
||||||
|
_tels_nuevos = []
|
||||||
|
_tels_antiguos = []
|
||||||
|
for (ph, fm) in _mats:
|
||||||
|
if not ph or not fm:
|
||||||
|
continue
|
||||||
|
forig = cartera_origen.get(ph)
|
||||||
|
if forig is None:
|
||||||
|
continue
|
||||||
|
dias = (fm - forig).days
|
||||||
|
if 0 <= dias <= 45:
|
||||||
|
mat_leads_nuevos += 1
|
||||||
|
_tels_nuevos.append(ph)
|
||||||
|
else:
|
||||||
|
# >45 días, o matrícula anterior al origen del lead (dias<0) → Antiguo
|
||||||
|
mat_leads_antiguos += 1
|
||||||
|
_tels_antiguos.append(ph)
|
||||||
|
# IMPORTE PAUTA: gasto de Meta Ads sumando TODAS las pautas del num_indice.
|
||||||
|
importe_pauta = round(sum(float(importe_por_pauta.get(str(cd), 0.0)) for cd in cods), 2)
|
||||||
|
# IMPORTE PAUTA EN EL MES: igual, pero solo del periodo filtrado.
|
||||||
|
importe_pauta_mes = round(sum(float(importe_por_pauta_periodo.get(str(cd), 0.0)) for cd in cods), 2)
|
||||||
|
|
||||||
|
# ── SUBFILAS por CANAL (PAUTA / OTROS) ── mismo criterio que la fila padre
|
||||||
|
# (SEDE+PROGRAMA vía campanias, misma fecha) + filtro de canal desde cartera.
|
||||||
|
def _subfila(clave):
|
||||||
|
cc = cartera_canal.get(clave, {})
|
||||||
|
c_all = cc.get("all", {}); c_asig = cc.get("asig", {}); c_tels = cc.get("tels", set())
|
||||||
|
ct = ct_asig = 0
|
||||||
|
l_rec = l_proc = 0
|
||||||
|
l_rec_mes = l_proc_mes = 0
|
||||||
|
if cod and r["fch"]:
|
||||||
|
sp = campanias_map.get(str(cod).upper())
|
||||||
|
if sp:
|
||||||
|
fa = c_all.get(sp, {})
|
||||||
|
fg = c_asig.get(sp, {})
|
||||||
|
# Cartera Total/Asig del canal: fecha_creada <= inicio del curso (igual que padre)
|
||||||
|
ct = sum(1 for fx in fa.values() if fx and fx <= r["fch"])
|
||||||
|
ct_asig = sum(1 for fx in fg.values() if fx and fx <= r["fch"])
|
||||||
|
# L. Recibidos/Procesados del canal = misma cartera del canal (mismo corte)
|
||||||
|
l_rec = ct
|
||||||
|
l_proc = ct_asig
|
||||||
|
# "del Mes" = fecha_creada en el periodo filtrado
|
||||||
|
l_rec_mes = sum(1 for fx in fa.values() if _en_periodo(fx, ano, mes, dia))
|
||||||
|
l_proc_mes = sum(1 for fx in fg.values() if _en_periodo(fx, ano, mes, dia))
|
||||||
|
# Matrículas del curso, clasificadas por canal:
|
||||||
|
# - NO identificadas (tel NO en cartera) -> todas a OTROS.
|
||||||
|
# - identificadas -> al canal de su telefono (c_tels), regla 45 dias.
|
||||||
|
mt_noiden = mt_nuevos = mt_antiguos = 0
|
||||||
|
for (ph, fm) in _mats:
|
||||||
|
if not ph:
|
||||||
|
continue
|
||||||
|
en_cartera = ph in cartera_tels
|
||||||
|
if not en_cartera:
|
||||||
|
# no identificada: solo cuenta en la subfila OTROS
|
||||||
|
if clave == "OTROS":
|
||||||
|
mt_noiden += 1
|
||||||
|
continue
|
||||||
|
# identificada: solo cuenta si su tel pertenece a ESTE canal
|
||||||
|
if ph not in c_tels:
|
||||||
|
continue
|
||||||
|
forig = cartera_origen.get(ph)
|
||||||
|
if fm and forig:
|
||||||
|
d = (fm - forig).days
|
||||||
|
if 0 <= d <= 45: mt_nuevos += 1
|
||||||
|
else: mt_antiguos += 1
|
||||||
|
return {"canal": clave, "cartera_total": ct, "cartera_total_asig": ct_asig,
|
||||||
|
"leads_nuevos": l_rec, "leads_nuevos_asesor": l_proc,
|
||||||
|
"leads_nuevos_mes": l_rec_mes, "leads_nuevos_mes_asesor": l_proc_mes,
|
||||||
|
"matriculas_no_iden": mt_noiden, "mat_leads_nuevos": mt_nuevos,
|
||||||
|
"mat_leads_antiguos": mt_antiguos}
|
||||||
|
subfilas = [_subfila("PAUTA"), _subfila("WEB"), _subfila("OTROS")] if cartera_canal else []
|
||||||
|
|
||||||
|
filas.append({
|
||||||
|
"num_indice": r["num_indice"],
|
||||||
|
"personalizado": r["personalizado"],
|
||||||
|
"fecha_inicio": r["fecha_inicio"],
|
||||||
|
"pauta": r.get("pauta"),
|
||||||
|
"importe_pauta": importe_pauta,
|
||||||
|
"importe_pauta_mes": importe_pauta_mes,
|
||||||
|
"cartera_total": cartera_total,
|
||||||
|
"cartera_total_asig": cartera_total_asig,
|
||||||
|
"matriculas_no_iden": matriculas_no_iden,
|
||||||
|
"matriculas_no_iden_tels": _tels_no_iden,
|
||||||
|
"mat_leads_nuevos": mat_leads_nuevos,
|
||||||
|
"mat_leads_antiguos": mat_leads_antiguos,
|
||||||
|
"mat_leads_nuevos_tels": _tels_nuevos,
|
||||||
|
"mat_leads_antiguos_tels": _tels_antiguos,
|
||||||
|
"leads_acumulados": acum,
|
||||||
|
"leads_nuevos": r["leads_nuevos"],
|
||||||
|
"leads_nuevos_asesor": r["leads_nuevos_asesor"],
|
||||||
|
"leads_nuevos_mes": r["leads_nuevos_mes"],
|
||||||
|
"leads_nuevos_mes_asesor": r["leads_nuevos_mes_asesor"],
|
||||||
|
"subfilas_canal": subfilas,
|
||||||
|
"conjuntos": [cj for cd in cods for cj in conjunto_por_pauta.get(str(cd).strip(), [])],
|
||||||
|
"contar": (pauta_map.get(str(r["num_indice"]).strip()) or {}).get("contar") or "SI",
|
||||||
|
})
|
||||||
|
return {"filas": filas}
|
||||||
289
backend/main.py
Normal file
289
backend/main.py
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
# backend/main.py
|
||||||
|
"""API REST del Dashboard de LEADS (FastAPI)."""
|
||||||
|
from fastapi import FastAPI, Query, HTTPException, Body
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from datetime import datetime
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Cargar variables de entorno desde .env
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
import services
|
||||||
|
from cache_manager import start_background_refresh, cache_stats
|
||||||
|
|
||||||
|
app = FastAPI(title="Dashboard Leads API", version="1.0")
|
||||||
|
|
||||||
|
|
||||||
|
# Orígenes CORS configurables (por defecto permite todo o el dominio configurado)
|
||||||
|
cors_env = os.getenv("CORS_ORIGINS", "*")
|
||||||
|
origins = [o.strip() for o in cors_env.split(",") if o.strip()] if cors_env != "*" else ["*"]
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=origins if origins else ["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def _startup():
|
||||||
|
# Precarga en SEGUNDO PLANO (hilo aparte) para no bloquear el arranque del server.
|
||||||
|
import threading
|
||||||
|
def _precarga_bg():
|
||||||
|
print("[startup] Precargando datos de Leads (2do plano)...")
|
||||||
|
try:
|
||||||
|
services.precargar_todo()
|
||||||
|
print("[startup] Precarga completa.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[startup] Precarga falló: {e}")
|
||||||
|
threading.Thread(target=_precarga_bg, daemon=True).start()
|
||||||
|
# Otros General en un hilo aparte (sus consultas son mas pesadas)
|
||||||
|
def _precarga_otros_bg():
|
||||||
|
try:
|
||||||
|
services.precargar_otros_general()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[startup] Precarga Otros falló: {e}")
|
||||||
|
threading.Thread(target=_precarga_otros_bg, daemon=True).start()
|
||||||
|
start_background_refresh(services.refrescar_todo, interval=900)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
def health():
|
||||||
|
return {"status": "ok", "hora": datetime.now().isoformat()}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/cache/stats")
|
||||||
|
def stats():
|
||||||
|
return cache_stats()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/cache/refresh")
|
||||||
|
def refresh():
|
||||||
|
services.refrescar_todo()
|
||||||
|
return {"status": "refrescado"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/leads/filtros")
|
||||||
|
def get_filtros():
|
||||||
|
try:
|
||||||
|
return services.opciones_filtros()
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/leads")
|
||||||
|
def get_leads(
|
||||||
|
ano: str = Query("TODOS"),
|
||||||
|
mes: str = Query("TODOS"),
|
||||||
|
dia: str = Query("TODOS"),
|
||||||
|
programa: str = Query("TODOS"),
|
||||||
|
sede: str = Query("TODOS"),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return services.leads_dashboard(ano, mes, dia, programa, sede)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/vendedores")
|
||||||
|
def get_vendedores(
|
||||||
|
ano: str = Query("TODOS"),
|
||||||
|
mes: str = Query("TODOS"),
|
||||||
|
dia: str = Query("TODOS"),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return services.vendedores_dashboard(ano, mes, dia)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/roas")
|
||||||
|
def get_roas(
|
||||||
|
ano: str = Query("TODOS"),
|
||||||
|
mes: str = Query("TODOS"),
|
||||||
|
dia: str = Query("TODOS"),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return services.roas_dashboard(ano, mes, dia)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/conjuntos-sin-sede")
|
||||||
|
def get_conjuntos_sin_sede():
|
||||||
|
try:
|
||||||
|
return services.conjuntos_sin_sede()
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/conjuntos-sin-sede/guardar")
|
||||||
|
def post_conjunto_sede(body: dict = Body(...)):
|
||||||
|
try:
|
||||||
|
cambios = body.get("cambios") or []
|
||||||
|
return services.guardar_conjunto_sede(cambios)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/conjuntos-sin-sede/borrar")
|
||||||
|
def post_borrar_conjunto_sede(body: dict = Body(...)):
|
||||||
|
try:
|
||||||
|
return services.borrar_conjunto_sede(body.get("conjunto") or "")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/otros-general")
|
||||||
|
def get_otros_general(
|
||||||
|
ano: str = Query("TODOS"),
|
||||||
|
mes: str = Query("TODOS"),
|
||||||
|
dia: str = Query("TODOS"),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return services.otros_general(ano, mes, dia)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/conjuntos-sin-pauta")
|
||||||
|
def get_conjuntos_sin_pauta():
|
||||||
|
try:
|
||||||
|
return {"conjuntos": services.conjuntos_sin_pauta()}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/programas-ocultos")
|
||||||
|
def get_programas_ocultos():
|
||||||
|
try:
|
||||||
|
return services.programas_ocultos()
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/programas-ocultos/encender")
|
||||||
|
def post_encender_programas(body: dict = Body(...)):
|
||||||
|
try:
|
||||||
|
nis = body.get("num_indices") or []
|
||||||
|
return services.encender_programas(nis)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/programas-disponibles")
|
||||||
|
def get_programas_disponibles():
|
||||||
|
try:
|
||||||
|
return services.programas_disponibles()
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/leyenda-anuncios")
|
||||||
|
def get_leyenda():
|
||||||
|
try:
|
||||||
|
return services.leyenda_anuncios()
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/leyenda-anuncios/guardar")
|
||||||
|
def post_leyenda(body: dict = Body(...)):
|
||||||
|
try:
|
||||||
|
cambios = body.get("cambios") or []
|
||||||
|
return services.guardar_leyenda(cambios)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ── LEYENDA (campanias de Supabase): listar / agregar / borrar ──
|
||||||
|
@app.get("/api/campanias")
|
||||||
|
def get_campanias():
|
||||||
|
try:
|
||||||
|
return {"filas": services.campanias_listar()}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/campanias/agregar")
|
||||||
|
def post_campania(body: dict = Body(...)):
|
||||||
|
try:
|
||||||
|
return services.campania_agregar(body or {})
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/campanias/editar")
|
||||||
|
def post_editar_campania(body: dict = Body(...)):
|
||||||
|
try:
|
||||||
|
cid = body.get("id")
|
||||||
|
return services.campania_editar(cid, body or {})
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/campanias/borrar")
|
||||||
|
def post_borrar_campania(body: dict = Body(...)):
|
||||||
|
try:
|
||||||
|
return services.campania_borrar(body.get("id"))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/pauta/uso")
|
||||||
|
def get_uso_pauta(pauta: str = Query(...), excluir: str = Query("")):
|
||||||
|
try:
|
||||||
|
return services.uso_de_pauta(pauta, excluir)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/curso/guardar-pauta")
|
||||||
|
def post_guardar_pauta(body: dict = Body(...)):
|
||||||
|
try:
|
||||||
|
ni = str(body.get("num_indice", "")).strip()
|
||||||
|
pa = str(body.get("pauta", "")).strip()
|
||||||
|
cj = body.get("conjunto") or None
|
||||||
|
contar = body.get("contar") # "SI" / "NO" / None
|
||||||
|
if not ni:
|
||||||
|
raise HTTPException(status_code=400, detail="num_indice es obligatorio")
|
||||||
|
return services.guardar_edicion_curso(ni, pa, cj, contar)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/ultima-actualizacion")
|
||||||
|
def get_ultima_actualizacion():
|
||||||
|
try:
|
||||||
|
return services.ultima_actualizacion()
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/leads/alertas")
|
||||||
|
def get_alertas():
|
||||||
|
"""Valores de programa/sede en cartera_junta que NO están en el diccionario."""
|
||||||
|
try:
|
||||||
|
return services.alertas()
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/debug/cursos")
|
||||||
|
def debug_cursos(ano: str = "2026", mes: str = "1"):
|
||||||
|
"""Diagnóstico: lista cursos del periodo con inscritos, tipo y meta."""
|
||||||
|
try:
|
||||||
|
return services.debug_cursos(ano, mes)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
uvicorn.run("main:app", host="0.0.0.0", port=8001, reload=False)
|
||||||
6
backend/requirements.txt
Normal file
6
backend/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn
|
||||||
|
python-dotenv
|
||||||
|
pyodbc
|
||||||
|
psycopg2-binary
|
||||||
|
requests
|
||||||
1768
backend/services.py
Normal file
1768
backend/services.py
Normal file
File diff suppressed because it is too large
Load Diff
4
frontend/.env.example
Normal file
4
frontend/.env.example
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# URL base del backend FastAPI que consume el frontend.
|
||||||
|
# Local: http://localhost:8001
|
||||||
|
# Despliegue: https://api-leads.escueladerefrigeracion.lat
|
||||||
|
VITE_API_URL=http://localhost:8001
|
||||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Dashboard Leads — Escuela Refrigeración</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2098
frontend/package-lock.json
generated
Normal file
2098
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
frontend/package.json
Normal file
20
frontend/package.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "dashboard-leads",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"recharts": "^2.12.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@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>
|
||||||
35
frontend/src/App.jsx
Normal file
35
frontend/src/App.jsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import Sidebar from "./components/Sidebar";
|
||||||
|
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 [pagina, setPagina] = useState("leads");
|
||||||
|
const [config, setConfig] = useState(false);
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
switch (pagina) {
|
||||||
|
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} 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
113
frontend/src/components/Sidebar.jsx
Normal file
113
frontend/src/components/Sidebar.jsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
|
||||||
|
const MENU = [
|
||||||
|
{ 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, 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 }}>
|
||||||
|
{MENU.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
className={`nav-item ${active === m.id ? "active" : ""}`}
|
||||||
|
onClick={() => onChange(m.id)}
|
||||||
|
>
|
||||||
|
<span className="ico">{m.ico}</span> {m.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
frontend/src/components/UI.jsx
Normal file
31
frontend/src/components/UI.jsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
export function Loader({ text = "Cargando..." }) {
|
||||||
|
return (
|
||||||
|
<div className="loader-wrap">
|
||||||
|
<div className="spinner" />
|
||||||
|
<div className="loader-text">{text}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorBox({ msg }) {
|
||||||
|
return <div className="error-box">⚠️ {msg}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Filters({ children }) {
|
||||||
|
return <div className="filters">{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Select({ label, value, options, onChange }) {
|
||||||
|
return (
|
||||||
|
<div className="filter-group">
|
||||||
|
{label && <label>{label}</label>}
|
||||||
|
<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;
|
||||||
|
return <option key={val} value={val}>{txt}</option>;
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
frontend/src/lib/api.js
Normal file
148
frontend/src/lib/api.js
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
// 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://api-leads.escueladerefrigeracion.lat";
|
||||||
|
|
||||||
|
function _baseUrlPorDefecto() {
|
||||||
|
if (typeof window === "undefined") return API_LOCAL;
|
||||||
|
const h = window.location.hostname;
|
||||||
|
return h === "localhost" || h === "127.0.0.1" || h === "::1"
|
||||||
|
? API_LOCAL
|
||||||
|
: API_SERVIDOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 r = await fetch(`${BASE_URL}${path}${qs ? "?" + qs : ""}`);
|
||||||
|
if (!r.ok) throw new Error(`Error ${r.status} en ${path}`);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
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 = {
|
||||||
|
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"),
|
||||||
|
// 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"]),
|
||||||
|
};
|
||||||
56
frontend/src/lib/useColumnasAjustables.jsx
Normal file
56
frontend/src/lib/useColumnasAjustables.jsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// src/lib/useColumnasAjustables.jsx
|
||||||
|
// Hook para columnas redimensionables (arrastrar el borde, estilo Excel).
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
export function useColumnasAjustables(anchosIniciales) {
|
||||||
|
const [anchos, setAnchos] = useState(anchosIniciales);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (anchos.length !== anchosIniciales.length) {
|
||||||
|
setAnchos(anchosIniciales);
|
||||||
|
}
|
||||||
|
}, [anchosIniciales.length]);
|
||||||
|
|
||||||
|
function iniciarResize(e, i) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const xInicial = e.clientX;
|
||||||
|
const anchoInicial = anchos[i];
|
||||||
|
function onMove(ev) {
|
||||||
|
const nuevo = Math.max(50, anchoInicial + (ev.clientX - xInicial));
|
||||||
|
setAnchos((prev) => { const c = [...prev]; c[i] = nuevo; return c; });
|
||||||
|
}
|
||||||
|
function onUp() {
|
||||||
|
document.removeEventListener("mousemove", onMove);
|
||||||
|
document.removeEventListener("mouseup", onUp);
|
||||||
|
}
|
||||||
|
document.addEventListener("mousemove", onMove);
|
||||||
|
document.addEventListener("mouseup", onUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ColGroup() {
|
||||||
|
return (
|
||||||
|
<colgroup>
|
||||||
|
{anchos.map((w, i) => <col key={i} style={{ width: w, minWidth: w }} />)}
|
||||||
|
</colgroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resizer({ index }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
onMouseDown={(e) => iniciarResize(e, index)}
|
||||||
|
className="col-resizer"
|
||||||
|
style={{ position: "absolute", top: 0, right: 0, width: 8, height: "100%",
|
||||||
|
cursor: "col-resize", userSelect: "none", zIndex: 5 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const anchoTotal = anchos.reduce((a, b) => a + b, 0);
|
||||||
|
const tableProps = {
|
||||||
|
style: { tableLayout: "fixed", width: "100%", minWidth: anchoTotal },
|
||||||
|
};
|
||||||
|
|
||||||
|
return { anchos, ColGroup, Resizer, anchoTotal, tableProps };
|
||||||
|
}
|
||||||
10
frontend/src/main.jsx
Normal file
10
frontend/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
frontend/src/styles.css
Normal file
74
frontend/src/styles.css
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: 'Segoe UI', system-ui, sans-serif; background: #f1f5f9; color: #0f172a; }
|
||||||
|
|
||||||
|
.app { display: flex; height: 100vh; overflow: hidden; }
|
||||||
|
|
||||||
|
/* ── Sidebar fija ── */
|
||||||
|
.sidebar {
|
||||||
|
width: 240px; background: #0f172a; color: #e2e8f0;
|
||||||
|
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 8px; }
|
||||||
|
.nav-item {
|
||||||
|
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); }
|
||||||
|
.nav-item.active { background: #2563eb; color: #fff; font-weight: 600; }
|
||||||
|
|
||||||
|
.main { flex: 1; padding: 24px 28px; overflow-y: auto; height: 100vh; }
|
||||||
|
.page-title { font-size: 24px; font-weight: 700; margin-bottom: 18px; color: #0f172a; }
|
||||||
|
|
||||||
|
/* ── 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: 700; color: #64748b; text-transform: uppercase; letter-spacing: .3px; }
|
||||||
|
.filter-group select {
|
||||||
|
padding: 8px 12px; border: 1px solid #cbd5e1; border-radius: 8px; font-size: 14px;
|
||||||
|
background: #fff; min-width: 130px; cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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; }
|
||||||
|
|
||||||
|
/* 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; }
|
||||||
|
|
||||||
|
/* ── 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 / 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: 12px; font-size: 14px; }
|
||||||
|
.error-box { background: #fee2e2; color: #991b1b; padding: 14px 18px; border-radius: 10px; font-size: 14px; }
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
/* 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; }
|
||||||
7
frontend/vite.config.js
Normal file
7
frontend/vite.config.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: { port: 5174 },
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user