Proyecto completo con configuracion de variables de entorno para EasyPanel
This commit is contained in:
32
.gitignore
vendored
Normal file
32
.gitignore
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
# --- Entorno / credenciales (NO subir) ---
|
||||
.env
|
||||
backend/.env
|
||||
frontend/.env
|
||||
*.env
|
||||
!.env.example
|
||||
!backend/.env.example
|
||||
!frontend/.env.example
|
||||
|
||||
# --- Python ---
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# --- Node / frontend ---
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
|
||||
# --- Sistema / editor ---
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# --- Datos temporales ---
|
||||
*.log
|
||||
173
FUTUROS_CAMBIOS.md
Normal file
173
FUTUROS_CAMBIOS.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# FUTUROS CAMBIOS — Dashboard LEADS
|
||||
|
||||
Lista de cosas pendientes que el usuario quiere hacer más adelante.
|
||||
Cuando el usuario pida "futuros cambios", recordarle TODO esto.
|
||||
|
||||
---
|
||||
|
||||
## 1. Mapa de campañas (frases de pauta) → mover a GitHub o Supabase
|
||||
- Hoy está incrustado en el código: `backend/data_manager_v2.py`, lista `CAMPANIAS`
|
||||
(las ~90 frases con emojis + programa/sede/código/rango de fechas).
|
||||
- Objetivo: sacarlo a un JSON en GitHub (o tabla en Supabase) para editarlo
|
||||
sin tocar el código (igual que se hace en el otro dashboard con las clasificaciones).
|
||||
|
||||
## 2. Leyenda de los códigos de pauta → a GitHub/Supabase
|
||||
- Los códigos de campaña (191, 193, 63a, etc.) y su significado.
|
||||
- Mismo objetivo: que sea editable fuera del código.
|
||||
|
||||
## 3. Excluir cursos (num_indice) puntuales del cálculo
|
||||
- Por ahora EXCLUIR del cálculo de Ocupabilidad/cursos: **num_indice 1154 y 1121**.
|
||||
- Motivo: el usuario indicó que no deben contar (ej. reprogramados a otro mes).
|
||||
- Idea a futuro: manejar esta lista de exclusión desde GitHub/Supabase, no en código.
|
||||
- (Relacionado: el PBI filtra cursos por FECHA_MOSTRAR_COL = fecha con reprogramación
|
||||
de SharePoint; aquí no tenemos esa columna todavía → Cursos Reprogramados quedó en fase 2.)
|
||||
|
||||
## 4. Reprogramación de cursos (SharePoint) — FASE 2 [DEPENDE DE LA FUENTE]
|
||||
La fecha ORIGINAL planificada de cada curso venía de SharePoint
|
||||
(BaseBI_Programacion: FECHA CALENDARIO vs FECHA REPROGRAMADO). Sin esa fuente,
|
||||
varios KPIs no se pueden calcular exacto. Pendiente: traerla de Google Sheets / Supabase / JSON
|
||||
y construir FECHA_MOSTRAR_COL, FECHA_CUADRO_BI y ESTADO_CUADRO_BI.
|
||||
|
||||
KPIs/cosas que dependen de esto (hoy aproximados o en 0):
|
||||
- **Cursos Reprogramados** → hoy SALE 0 (no hay fecha original para comparar).
|
||||
- **Cursos Programados** → hoy usa solo `fch_inicio` (el PBI usa planificada + reprogramada).
|
||||
Diferencia chica, se cuadrará en fase 2.
|
||||
- **Cursos Iniciados / Suspendidos** → hoy por `fch_inicio` y `cod_estado`; revisar con reprogramación.
|
||||
- La exclusión manual de cursos (1154, 1121) es justo por reprogramación: cuando llegue
|
||||
la fuente, esto debería resolverse solo (ya no haría falta la lista manual).
|
||||
|
||||
## 5. Matriz por Curso (num_indice) con métricas de Leads por Pauta — NUEVA
|
||||
El usuario quiere una MATRIZ debajo de la tabla por Sede, con:
|
||||
- FILA: Fact_SQL_Base_Cursos[Personalizado], separada por cada num_indice (cursos se repiten).
|
||||
- VALORES: Fecha_Inicio_Visual, Leads_Acumulados_Historico, Cant_Leads_Nuevos,
|
||||
Cant_Leads_Nuevos_Con_Asesor, Cant_Leads_Nuevo_x_Mes, Cant_Leads_Nuevos_x_Mes_Con_Asesor.
|
||||
|
||||
DEPENDE DE: el campo **Pauta** (código de campaña) de CADA curso, que en el PBI venía de
|
||||
SharePoint (BaseBI_Programacion → PAUTA_CODIGO). El cruce de TODAS esas medidas es por
|
||||
TREATAS(Cursos[Pauta], Leads[codigo]) — o sea por CÓDIGO, no por teléfono.
|
||||
|
||||
Lo que se necesita para construirla (traer a Google Sheets / Supabase / JSON):
|
||||
- Por cada num_indice (curso): su **código(s) de Pauta** asociado.
|
||||
- Idealmente también FECHA_CUADRO_BI (fecha real/reprogramada) — mismo paquete que el punto 4.
|
||||
|
||||
Medidas (ya tengo las fórmulas DAX exactas guardadas):
|
||||
- Cant_Leads_Nuevos = leads únicos (Cantidad_Veces=1) con codigo = Pauta del curso, histórico.
|
||||
- ..._Con_Asesor = + asesor no vacío.
|
||||
- ..._x_Mes = igual pero respeta filtro de fecha del mes.
|
||||
- ..._x_Mes_Con_Asesor = del mes + con asesor.
|
||||
- Leads_Acumulados_Historico = suma de Cant_Leads_Nuevos de cursos del mismo Personalizado
|
||||
con fecha <= la del curso actual.
|
||||
- Fecha_Inicio_Visual = FECHA_CUADRO_BI del curso.
|
||||
|
||||
ESTADO: ✅ La MATRIZ YA ESTÁ CONSTRUIDA en el dashboard (backend `leads_logic.matriz_cursos`,
|
||||
frontend "Detalle por Curso"). Hoy muestra Programa + Fecha Inicio reales; las 5 columnas de
|
||||
leads salen en 0 porque FALTA el código de Pauta por curso. Cuando llegue la fuente, solo se
|
||||
rellena el cruce y se activan las 5 medidas.
|
||||
|
||||
### Fuente SharePoint que hay que conectar (acordado)
|
||||
Archivo: Base_BI_2.xlsx
|
||||
Ruta: https://escuelarefrigeracion.sharepoint.com/sites/ASESORASCOMERCIALES/
|
||||
Documentos compartidos/2. BASE PROSPECTOS/BASE GENERAL/Patricia/Base_BI_2.xlsx
|
||||
Tabla: BaseBI_Programacion
|
||||
Columnas: NUM_INDICE, FECHA CALENDARIO, FECHA REPROGRAMADO, ESTADO, CONTAR, PAUTA_CODIGO
|
||||
Cruce: Cursos[NUM_INDICE] → BaseBI_Programacion[NUM_INDICE] ⇒ PAUTA_CODIGO
|
||||
(en el PBI: columna calculada Pauta = RELATED(BaseBI_Programacion[PAUTA_CODIGO]))
|
||||
Luego: TREATAS(Cursos[Pauta], Leads[codigo]) para contar leads.
|
||||
|
||||
Cómo leerla (3 opciones, de menos a más esfuerzo):
|
||||
1) (Recomendado) Que Patricia/usuario suba a Supabase una tabla `cursos_pauta`
|
||||
(num_indice, pauta_codigo, fecha_cuadro_bi). El backend ya tiene credenciales Supabase.
|
||||
→ cero auth de Microsoft, instantáneo, editable.
|
||||
2) Exportar esa hoja a un Google Sheet / CSV en GitHub (GITHUB_BASE ya configurado).
|
||||
3) Lectura directa SharePoint con Office365-REST-Python-Client (requiere usuario+clave M365
|
||||
o app registration). Más frágil; dejarlo como última opción.
|
||||
|
||||
Cuando exista la fuente, en `data_manager_v2.py` agregar `traer_cursos_pauta()` y mapear
|
||||
num_indice→pauta sobre los cursos; en `leads_logic.matriz_cursos` reemplazar los 0 por el
|
||||
conteo de leads cruzado por codigo (las fórmulas DAX de arriba ya están claras).
|
||||
|
||||
## 6. Base Junta (identificador de canal de origen por teléfono) — FUTURO
|
||||
Objetivo: cuando un número se MATRICULA, saber su CANAL DE ORIGEN (y sede/programa/código)
|
||||
para clasificarlo. Se usará como "memoria" de consulta por teléfono.
|
||||
|
||||
Fuente/lógica (ya PROBADA y validada con el usuario):
|
||||
- Une leads de POSTGRE (Chatwoot + mapa de campañas CAMPANIAS → canal/sede/programa/código)
|
||||
+ SUPABASE `datos_unificados` (Telefono, Fechacreada, Canal, Sede, Programa, Codigo).
|
||||
- Teléfono NORMALIZADO: quita '+51', '+', espacios, '-', '(', ')'.
|
||||
- DEDUP por teléfono → se queda con el MÁS ANTIGUO (solo por FECHA, sin hora).
|
||||
- Desempate misma fecha: 1) PAUTA_WSP 2) PAUTA_WSP_FACE 3) orden alfabético del canal.
|
||||
- Texto en MAYÚSCULAS; '-' de Supabase → vacío.
|
||||
- Resultado prueba: ~45,054 teléfonos únicos.
|
||||
|
||||
Script de prueba (standalone, corre en cualquier carpeta): `base_junta_full.py`
|
||||
(genera base_junta_full.xlsx con 6 columnas: Telefono, Canal, Fecha Creada, Sede,
|
||||
Programa, Codigo). NO está conectado al dashboard todavía.
|
||||
|
||||
Cómo conectarlo a futuro (acordado, debe ser LIVIANO):
|
||||
- Subir la base junta a una tabla en Supabase (ej. `base_junta`).
|
||||
- El backend la carga UNA vez en un dict {telefono → datos}, cacheado (refresco ~15 min).
|
||||
- Al cruzar una matrícula por teléfono → se obtiene su canal de origen y se clasifica.
|
||||
- 44-73k filas en dict = instantáneo, sin impacto en la carga.
|
||||
|
||||
ESTADO: pendiente. Cuando se retome: crear tabla Supabase + traer_base_junta() en backend
|
||||
+ cruce por teléfono en el módulo que corresponda.
|
||||
|
||||
## 7. Simplificar/normalizar nombres de programa (Personalizado) — MEJORA OPCIONAL
|
||||
Los nombres largos de los cursos se acortan con una lista de reemplazos de texto
|
||||
en `backend/leads_logic.py` → `_PERS_REEMPLAZOS` (hardcoded).
|
||||
|
||||
Problemas/mejoras:
|
||||
- Está en el código: cada nombre nuevo/largo hay que agregarlo a mano.
|
||||
- Algunos reemplazos quedan SIN sede ni frecuencia consistente
|
||||
(ej. "VOLUMEN VARIABLE VRF" quedó sin "SEDE -" ni "- FREC").
|
||||
- Ideal: formato uniforme tipo "SEDE - CORTO - FREC" para todos.
|
||||
|
||||
Ideas (opcional, cuando haya tiempo):
|
||||
1. Mover `_PERS_REEMPLAZOS` a un JSON en GitHub o tabla Supabase → editable sin tocar código.
|
||||
2. Normalizar TODOS los nombres con un mismo criterio (sede + nombre corto + frecuencia),
|
||||
no solo los VRF/CO2 ya hechos.
|
||||
|
||||
ESTADO: opcional. No urgente; los reemplazos actuales funcionan.
|
||||
|
||||
## 8. Manual de tablas de Supabase — PENDIENTE
|
||||
Mantener UN solo documento que liste cada tabla de Supabase y para qué sirve
|
||||
(evitar olvidarse). Ej.:
|
||||
- `datos_unificados` → leads (proyecto ogzjtkxnfs...).
|
||||
- `basebi_programacion` → pauta/estado/contar por num_indice (proyecto uztqscimts...).
|
||||
- `cartera_junta` → cartera total combinada por teléfono + es_origen (proyecto uztqscimts...).
|
||||
- (futuras: `programa_alias` → diccionario de normalización).
|
||||
Cada vez que se cree una tabla nueva, agregarla aquí.
|
||||
|
||||
## 9. Normalización de programa/sede en cartera_junta + alerta de valores nuevos — PENDIENTE
|
||||
Problema: en `cartera_junta` hay valores repetidos con distinta escritura
|
||||
(CÁMARAS/CAMARAS, GESTION/GESTIÓN, VRF / VRV vs VRF, SUP. DE OBRAS/SUPER.OBRAS/
|
||||
SUPERVISIÓN DE OBRAS, DIPLONADO REF typo, LINA→LIMA) y basura (vacío, "0").
|
||||
Como la tabla se regenera a diario (GitHub Actions), limpiar a mano NO sirve
|
||||
(se sobrescribe). Hay que normalizar en la GENERACIÓN.
|
||||
|
||||
Plan acordado (mínimo de tablas):
|
||||
- En `carga_cartera.py`: (a) reglas automáticas → MAYÚSCULAS + sin acentos + sin
|
||||
espacios extra (une CÁMARAS=CAMARAS, GESTIÓN=GESTION, etc. sin mantener nada);
|
||||
(b) UNA tabla-diccionario `programa_alias` (y sede) en Supabase con
|
||||
alias → valor_correcto para los sinónimos/typos que las reglas no resuelven.
|
||||
- Detección de NUEVOS: NO usar tabla de pendientes. Calcular al vuelo en el
|
||||
dashboard, comparando los valores únicos de cartera_junta vs el diccionario.
|
||||
- Alerta: 🔔 campanita/panel de notificaciones en el dashboard. Silencioso si todo
|
||||
es conocido; si aparece sede/programa nuevo no reconocido, se enciende con la
|
||||
lista para que el usuario decida (unir como alias o dejar como nuevo real).
|
||||
- Decidir aparte: valores vacíos y "0" → dejar o convertir a SIN PROGRAMA/SIN SEDE.
|
||||
|
||||
## 10. Clasificar valores no identificados DESDE la campanita — MEJORA OPCIONAL
|
||||
Hoy la campanita 🔔 solo AVISA de los programas/sedes no identificados; para
|
||||
corregirlos hay que ir a Supabase y agregar la fila en `alias_normalizacion`.
|
||||
Mejora: permitir hacerlo desde la misma web, en la notificación:
|
||||
- Al lado de cada valor no identificado, un desplegable con los programas/sedes
|
||||
YA EXISTENTES (canónicos) + botón "Asignar".
|
||||
- Al asignar, el backend inserta la fila (alias → correcto) en `alias_normalizacion`
|
||||
directamente desde la web (nuevo endpoint POST), sin entrar a Supabase.
|
||||
- Así se limpia al instante y en la siguiente corrida del workflow ya sale unido.
|
||||
ESTADO: opcional. Hoy funciona avisando; esto solo agiliza la corrección.
|
||||
|
||||
---
|
||||
|
||||
(Agregar aquí cualquier otro pendiente que vaya saliendo.)
|
||||
12
INICIAR_LEADS.bat
Normal file
12
INICIAR_LEADS.bat
Normal file
@@ -0,0 +1,12 @@
|
||||
@echo off
|
||||
REM ============================================================
|
||||
REM INICIAR DASHBOARD LEADS - Backend (8001) + Frontend (5174)
|
||||
REM ============================================================
|
||||
echo Iniciando BACKEND y FRONTEND de LEADS...
|
||||
|
||||
start "LEADS - BACKEND" cmd /k ""%~dp0_run_backend.bat""
|
||||
start "LEADS - FRONTEND" cmd /k ""%~dp0_run_frontend.bat""
|
||||
|
||||
echo Listo. Se abrieron 2 ventanas (Backend y Frontend).
|
||||
echo Cuando cargue, abre el navegador en: http://localhost:5174
|
||||
timeout /t 4 >nul
|
||||
78
OPTIMIZACION_CACHE.md
Normal file
78
OPTIMIZACION_CACHE.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# OPTIMIZACIÓN — Caché de descargas pesadas (reutilizable en Ventas, Comisiones, etc.)
|
||||
|
||||
## El problema (síntoma)
|
||||
Al cambiar un filtro (mes, sede, etc.) el dashboard se demora 15-130 segundos,
|
||||
aunque los datos "ya deberían estar cargados". Se siente como si cada filtro
|
||||
volviera a descargar todo desde cero.
|
||||
|
||||
## La causa raíz
|
||||
Una función que **descarga datos de una fuente externa** (Supabase, SQL Server,
|
||||
Postgre, API) se llama SIN caché. Cada vez que el usuario filtra, esa función
|
||||
**vuelve a descargar TODO** desde internet.
|
||||
|
||||
En LEADS el culpable fue `traer_cartera_rows()` (bajaba 86,000 filas de Supabase
|
||||
de 1000 en 1000 = 86 pedidos en serie ≈ 52-130s) y se re-descargaba en CADA
|
||||
cambio de mes, porque `matriz_web_formulario` la llamaba directo:
|
||||
|
||||
```python
|
||||
# ANTES (malo): re-descarga en cada llamada
|
||||
def _load():
|
||||
rows = get_dm().traer_cartera_rows() # baja 86k filas CADA vez
|
||||
...
|
||||
```
|
||||
|
||||
## Cómo diagnosticarlo (script de tiempos)
|
||||
Medir cada parte por separado para encontrar QUÉ tarda. Clave: medir un cambio
|
||||
de mes CON los insumos ya cargados (2a vez). Si sigue lento, algo se re-descarga.
|
||||
|
||||
```python
|
||||
import time, services as S
|
||||
def cron(label, fn):
|
||||
t0 = time.time(); fn(); print(f" {label:38} {time.time()-t0:6.1f}s")
|
||||
|
||||
cron("dashboard MES1 (1a vez)", lambda: S.mi_funcion("2026","1",...))
|
||||
cron("dashboard MES2 (2a vez)", lambda: S.mi_funcion("2026","2",...)) # <-- debe ser rapido
|
||||
# Si MES2 sale lento, desglosar: medir cada traer_* y cada matriz por separado
|
||||
# hasta ver cual linea se lleva los segundos (casi siempre un traer_* sin cache).
|
||||
```
|
||||
|
||||
## La solución (2 líneas, NO toca lógica ni resultados)
|
||||
Envolver la descarga pesada en `cache_get_or_set` con clave GLOBAL, y que TODAS
|
||||
las funciones usen ese helper cacheado en vez de descargar directo.
|
||||
|
||||
```python
|
||||
# 1) Helper cacheado (se baja UNA vez, se reutiliza)
|
||||
def _cartera_rows_cache():
|
||||
return cache_get_or_set("cartera_rows", ("GLOBAL",),
|
||||
lambda: get_dm().traer_cartera_rows())
|
||||
|
||||
# 2) Reemplazar TODAS las llamadas directas:
|
||||
# get_dm().traer_cartera_rows() -> _cartera_rows_cache()
|
||||
```
|
||||
|
||||
Resultado en LEADS:
|
||||
- Cambiar de mes: de 69-133s -> 0.4s (solo filtra lo que ya está en memoria)
|
||||
- La descarga pesada solo se paga 1 vez (en la precarga de arranque, en background).
|
||||
|
||||
## Regla general para CUALQUIER dashboard (Ventas, Comisiones, Rentabilidad...)
|
||||
1. Toda función `traer_*` / consulta a SQL/Supabase/API que se use en varias
|
||||
vistas o en cada filtro, debe estar cacheada con `cache_get_or_set`.
|
||||
2. NUNCA llamar `get_dm().traer_xxx()` directo dentro de un `_load` que depende
|
||||
de filtros; usar el helper cacheado.
|
||||
3. El filtro (mes/sede/programa) debe operar sobre datos YA en memoria, no
|
||||
re-descargar. Filtrar en memoria = milisegundos.
|
||||
4. Precargar en segundo plano (hilo daemon al startup) el mes actual/anterior
|
||||
para que el usuario no espere la 1a carga.
|
||||
5. El refresco periódico (cada 15 min) invalida caché -> considerar bajar datos
|
||||
que cambian 1 vez al día (ej. cartera) con menos frecuencia, o recargar en
|
||||
background sin dejar hueco.
|
||||
|
||||
## Mejora opcional pendiente (no aplicada aún)
|
||||
La 1a descarga de la cartera es lenta (100-155s) porque pagina de 1000 en 1000.
|
||||
Subir el tamaño de lote (ej. limit=10000) reduce los viajes: de ~155s a ~10-15s.
|
||||
Aplica a cualquier tabla grande que se baje paginada.
|
||||
|
||||
## Verificación (que no cambien números)
|
||||
Antes de optimizar, guardar una "línea base" con los totales actuales
|
||||
(diag_base.json). Tras optimizar, comparar: deben ser IDÉNTICOS. La optimización
|
||||
solo cambia CUÁNDO/CÓMO se descarga, nunca el cálculo.
|
||||
BIN
PRUEBAS_EXPORT/cartera_junta.xlsx
Normal file
BIN
PRUEBAS_EXPORT/cartera_junta.xlsx
Normal file
Binary file not shown.
107
PRUEBAS_EXPORT/export_cartera_junta.py
Normal file
107
PRUEBAS_EXPORT/export_cartera_junta.py
Normal file
@@ -0,0 +1,107 @@
|
||||
# PRUEBAS_EXPORT/export_cartera_junta.py
|
||||
"""
|
||||
Script de PRUEBA e independiente del dashboard.
|
||||
Exporta la tabla cartera_junta de Supabase a un Excel para revisarla.
|
||||
|
||||
Autodetecta en cuál proyecto Supabase vive la tabla, probando las credenciales
|
||||
que ya tienes configuradas (en el .env de esta carpeta y en el .env del backend).
|
||||
Lee TODAS las filas paginando (Supabase devuelve máx. 1000 por consulta).
|
||||
|
||||
Uso: py -3.12 export_cartera_junta.py
|
||||
"""
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
_AQUI = os.path.dirname(__file__)
|
||||
# Carga el .env de esta carpeta y también el del backend (para reutilizar credenciales)
|
||||
load_dotenv(os.path.join(_AQUI, ".env"))
|
||||
load_dotenv(os.path.join(_AQUI, "..", "backend", ".env"))
|
||||
|
||||
PAGINA = 1000
|
||||
TABLA = os.getenv("CARTERA_TABLA", "cartera_junta")
|
||||
|
||||
|
||||
def _valida(url):
|
||||
"""Descarta URLs vacías o de plantilla (XXXX)."""
|
||||
if not url:
|
||||
return False
|
||||
u = url.strip().lower()
|
||||
return u.startswith("http") and "xxxx" not in u
|
||||
|
||||
|
||||
def _candidatos():
|
||||
"""Pares (nombre, url, key) a probar, en orden de prioridad."""
|
||||
pares = [
|
||||
("CARTERA", os.getenv("CARTERA_URL"), os.getenv("CARTERA_KEY")),
|
||||
("SUPABASE", os.getenv("SUPABASE_URL"), os.getenv("SUPABASE_KEY")),
|
||||
("SUPABASE_PAUTA", os.getenv("SUPABASE_PAUTA_URL"),os.getenv("SUPABASE_PAUTA_KEY")),
|
||||
]
|
||||
vistos, out = set(), []
|
||||
for nombre, url, key in pares:
|
||||
if _valida(url) and key and (url, key) not in vistos:
|
||||
vistos.add((url, key))
|
||||
out.append((nombre, url.strip(), key.strip()))
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
from supabase import create_client
|
||||
|
||||
candidatos = _candidatos()
|
||||
if not candidatos:
|
||||
print("❌ No hay credenciales válidas. Pon CARTERA_URL/CARTERA_KEY en el .env de esta carpeta.")
|
||||
return
|
||||
|
||||
# Buscar en qué proyecto existe la tabla
|
||||
sb = None
|
||||
for nombre, url, key in candidatos:
|
||||
try:
|
||||
cli = create_client(url, key)
|
||||
cli.table(TABLA).select("*").limit(1).execute() # prueba
|
||||
sb = cli
|
||||
print(f"✅ Tabla '{TABLA}' encontrada en el proyecto: {nombre} ({url})")
|
||||
break
|
||||
except Exception as e:
|
||||
print(f" · {nombre}: no sirvió ({str(e)[:80]})")
|
||||
|
||||
if sb is None:
|
||||
print(f"\n❌ No encontré la tabla '{TABLA}' en ninguno de tus proyectos Supabase.")
|
||||
print(" Copia CARTERA_URL y CARTERA_KEY (secrets de GitHub) al .env de esta carpeta.")
|
||||
return
|
||||
|
||||
# Traer TODAS las filas paginando de a 1000
|
||||
filas = []
|
||||
inicio = 0
|
||||
while True:
|
||||
res = sb.table(TABLA).select("*").range(inicio, inicio + PAGINA - 1).execute()
|
||||
lote = res.data or []
|
||||
if not lote:
|
||||
break
|
||||
filas.extend(lote)
|
||||
print(f" leídas {len(filas)} filas...")
|
||||
if len(lote) < PAGINA:
|
||||
break
|
||||
inicio += PAGINA
|
||||
|
||||
if not filas:
|
||||
print(f"⚠️ La tabla '{TABLA}' está vacía.")
|
||||
return
|
||||
|
||||
columnas = list(filas[0].keys())
|
||||
|
||||
import openpyxl
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "cartera_junta"
|
||||
ws.append(columnas)
|
||||
for r in filas:
|
||||
ws.append([r.get(c, "") for c in columnas])
|
||||
|
||||
salida = os.path.join(_AQUI, "cartera_junta.xlsx")
|
||||
wb.save(salida)
|
||||
print(f"\n✅ Exportado: {salida}")
|
||||
print(f" Filas: {len(filas)} | Columnas: {', '.join(columnas)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
11
_run_backend.bat
Normal file
11
_run_backend.bat
Normal file
@@ -0,0 +1,11 @@
|
||||
@echo off
|
||||
REM Lanzador interno del BACKEND de LEADS
|
||||
cd /d "%~dp0backend"
|
||||
echo ====================================
|
||||
echo LEADS - BACKEND (FastAPI) puerto 8001
|
||||
echo Carpeta: %CD%
|
||||
echo ====================================
|
||||
py -3.12 main.py
|
||||
echo.
|
||||
echo (El backend termino o fallo. Revisa los mensajes de arriba.)
|
||||
pause
|
||||
12
_run_frontend.bat
Normal file
12
_run_frontend.bat
Normal file
@@ -0,0 +1,12 @@
|
||||
@echo off
|
||||
cd /d "%~dp0frontend"
|
||||
echo ====================================
|
||||
echo LEADS - FRONTEND (Vite) puerto 5174
|
||||
echo Carpeta: %CD%
|
||||
echo ====================================
|
||||
if not exist "node_modules\.bin\vite.cmd" call npm install
|
||||
echo Iniciando servidor de desarrollo...
|
||||
call npm run dev
|
||||
echo.
|
||||
echo (npm run dev termino o fallo.)
|
||||
pause
|
||||
46
backend/.env.example
Normal file
46
backend/.env.example
Normal file
@@ -0,0 +1,46 @@
|
||||
# Plantilla de variables de entorno del BACKEND.
|
||||
# Copia este archivo como .env y completa los valores reales.
|
||||
# En EasyPanel estas variables se configuran en el panel del servicio (pestaña Environment).
|
||||
|
||||
# --- PostgreSQL (Chatwoot) ---
|
||||
PG_HOST=
|
||||
PG_DATABASE=
|
||||
PG_USER=
|
||||
PG_PASSWORD=
|
||||
PG_PORT=5432
|
||||
|
||||
# --- SQL Server ---
|
||||
SQL_SERVER=
|
||||
SQL_DATABASE=
|
||||
SQL_USERNAME=
|
||||
SQL_PASSWORD=
|
||||
|
||||
# --- Fuentes de datos ---
|
||||
GITHUB_BASE=
|
||||
|
||||
# --- Supabase (leads) ---
|
||||
SUPABASE_URL=
|
||||
SUPABASE_KEY=
|
||||
SUPABASE_TABLA_LEADS=datos_unificados
|
||||
|
||||
# --- Supabase (pauta) ---
|
||||
SUPABASE_PAUTA_URL=
|
||||
SUPABASE_PAUTA_KEY=
|
||||
SUPABASE_TABLA_PAUTA=basebi_programacion
|
||||
|
||||
# --- Supabase (cartera) ---
|
||||
CARTERA_URL=
|
||||
CARTERA_KEY=
|
||||
SUPABASE_TABLA_CARTERA=cartera_junta
|
||||
SUPABASE_TABLA_ALIAS=alias_normalizacion
|
||||
SUPABASE_TABLA_CONJUNTO=conjunto_pauta
|
||||
|
||||
# --- Meta / Google Sheets ---
|
||||
META_CSV_URL=
|
||||
|
||||
# --- Servidor (opcional) ---
|
||||
# Puerto en el que corre la API. EasyPanel suele asignar 8001 internamente.
|
||||
PORT=8001
|
||||
# Origenes permitidos para CORS, separados por coma. Usa * para permitir todos.
|
||||
# Ejemplo producción: https://dashboard.tudominio.com
|
||||
CORS_ORIGINS=*
|
||||
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")
|
||||
BIN
backend/cartera_junta_export - copia.xlsx
Normal file
BIN
backend/cartera_junta_export - copia.xlsx
Normal file
Binary file not shown.
87860
backend/cartera_junta_export.csv
Normal file
87860
backend/cartera_junta_export.csv
Normal file
File diff suppressed because it is too large
Load Diff
BIN
backend/cartera_junta_export.xlsx
Normal file
BIN
backend/cartera_junta_export.xlsx
Normal file
Binary file not shown.
271
backend/data_manager.py
Normal file
271
backend/data_manager.py
Normal file
@@ -0,0 +1,271 @@
|
||||
# backend/data_manager.py
|
||||
"""
|
||||
Capa de acceso a datos del módulo LEADS.
|
||||
- PostgreSQL (Chatwoot): leads de pauta (consolidado en UNA sola consulta).
|
||||
- SQL Server (Académico): matrículas y cursos.
|
||||
- GitHub: mapa de campañas (frases -> programa/sede), config editable.
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import requests
|
||||
from datetime import datetime
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
class DataManager:
|
||||
def __init__(self):
|
||||
# PostgreSQL (Chatwoot)
|
||||
self.pg_host = os.getenv("PG_HOST", "191.98.134.81")
|
||||
self.pg_db = os.getenv("PG_DATABASE", "chatwoot_production")
|
||||
self.pg_user = os.getenv("PG_USER", "postgres")
|
||||
self.pg_pass = os.getenv("PG_PASSWORD", "")
|
||||
self.pg_port = os.getenv("PG_PORT", "5432")
|
||||
# SQL Server (Académico)
|
||||
self.sql_server = os.getenv("SQL_SERVER", "191.98.134.80")
|
||||
self.sql_db = os.getenv("SQL_DATABASE", "BDUS_CK000040_0001")
|
||||
self.sql_user = os.getenv("SQL_USERNAME", "")
|
||||
self.sql_pass = os.getenv("SQL_PASSWORD", "")
|
||||
# GitHub
|
||||
base = os.getenv("GITHUB_BASE", "")
|
||||
self.github_campanias_url = f"{base}/campanias.json" if base else ""
|
||||
self.campanias = []
|
||||
self._cargar_campanias()
|
||||
|
||||
# ── Conexiones ──────────────────────────────────────────────
|
||||
def pg_conn(self):
|
||||
import psycopg2
|
||||
return psycopg2.connect(
|
||||
host=self.pg_host, dbname=self.pg_db, user=self.pg_user,
|
||||
password=self.pg_pass, port=self.pg_port,
|
||||
)
|
||||
|
||||
def sql_conn(self):
|
||||
import pyodbc
|
||||
conn_str = (
|
||||
f"DRIVER={{SQL Server}};SERVER={self.sql_server};"
|
||||
f"DATABASE={self.sql_db};UID={self.sql_user};PWD={self.sql_pass}"
|
||||
)
|
||||
return pyodbc.connect(conn_str)
|
||||
|
||||
# ── Config de campañas (GitHub, opcional) ───────────────────
|
||||
def _cargar_campanias(self):
|
||||
if not self.github_campanias_url:
|
||||
self.campanias = []
|
||||
return
|
||||
try:
|
||||
r = requests.get(self.github_campanias_url, timeout=5)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
self.campanias = data.get("campanias", []) if isinstance(data, dict) else data
|
||||
except Exception:
|
||||
self.campanias = []
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# CHATWOOT — UN SOLO QUERY CONSOLIDADO DE LEADS
|
||||
# Reemplaza los 4 queries del PBI (procesados, asignados,
|
||||
# contactados, etiquetas) por uno solo. El resto se calcula
|
||||
# en Python (Cantidad_Veces, Ultima_Etiqueta, Estado/Objeción).
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def traer_leads_chatwoot(self):
|
||||
"""Leads de PAUTA (igual que el PBI): un mensaje cuenta como lead solo si su
|
||||
texto coincide con una FRASE de campaña y está dentro del rango de fechas de
|
||||
esa campaña. El mapa de campañas está incrustado (como en el Power BI)."""
|
||||
sql = """
|
||||
SELECT DISTINCT ON (m.id)
|
||||
REGEXP_REPLACE(c.phone_number, '[^0-9]', '', 'g') AS telefono,
|
||||
u.name AS asesor,
|
||||
(m.created_at - INTERVAL '5 hours') AS fecha_creada,
|
||||
cv.cached_label_list AS etiquetas,
|
||||
map.cargo AS programa,
|
||||
map.sede AS sede,
|
||||
map.codigo AS codigo,
|
||||
map.origen AS origen
|
||||
FROM messages m
|
||||
JOIN conversations cv ON m.conversation_id = cv.id
|
||||
JOIN contacts c ON cv.contact_id = c.id
|
||||
LEFT JOIN users u ON cv.assignee_id = u.id
|
||||
JOIN (VALUES
|
||||
('📚 Me interesa el', 'TERC', '200', 'Arequipa', 'Domingo', 'Pauta_wsp', '2025-10-01', '2025-12-31'),
|
||||
('📚 Me interesa el', 'TEAC', '227', 'Lima', 'Domingo', 'Pauta_wsp', '2026-01-15', '2026-03-30'),
|
||||
('Hola! 🚨', 'TEAC', '191', 'Piura', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
|
||||
('Hola! 💯 Me interesa', 'TERC', '193', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
|
||||
('Hola! 💼', 'TEAC', '195', 'Trujillo', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-01-31'),
|
||||
('Hola! 💸', 'TEAC', '197', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-02-28'),
|
||||
('Hola! ⚡', 'TEAC', '198', 'Trujillo', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-01-31'),
|
||||
('Hola! 🦺', 'TERC', '199', 'Piura', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-02-28'),
|
||||
('Hola! 🍾', 'TEAC', '201', 'Arequipa', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-02-28'),
|
||||
('¡Hola! 📚', 'TERC', '200', 'Arequipa', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-01-31'),
|
||||
('Hola! 🥶', 'TEAC', '202', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
|
||||
('Hola! 🎯', 'TEAC', '203', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-02-28'),
|
||||
('Hola! 🌞', 'TERC', '204', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
|
||||
('Hola! 🗿', 'TERC', '205', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-02-28'),
|
||||
('Hola! 😎', 'TEAC', '206', 'Piura', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-04-01'),
|
||||
('Hola! 🎓', 'TEAC', '207', 'Arequipa', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-01-31'),
|
||||
('Hola! 👀', 'TEAC', '208', 'Trujillo', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
|
||||
('Hola! 🎮', 'TERC', '209', 'Piura', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-04-30'),
|
||||
('Hola! 👾', 'TERC', '210', 'Arequipa', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-02-25'),
|
||||
('Hola! 🕹️', 'TERC', '211', 'Trujillo', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-04-01'),
|
||||
('Hola! 🚀', 'TEAC', '212', 'Lima', '-', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
|
||||
('Hola! 🛸', 'TERC', '213', 'Lima', '-', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
|
||||
('Hola! 🦾', 'Amoniaco', '63a', 'Lima', '-', 'Pauta_wsp', '2025-09-01', '2026-04-15'),
|
||||
('Hola! 🛠️', 'Diseño Chillers', '64a', 'Lima', '-', 'Pauta_wsp', '2025-09-01', '2026-04-30'),
|
||||
('¡Hola! 🥽', 'VRF', '65a', 'Lima', '-', 'Pauta_wsp', '2025-09-01', '2026-02-28'),
|
||||
('¡Hola! ❄️ Estoy interesado Seminario', 'Cámaras', '66a', 'Lima', '-', 'Pauta_wsp', '2026-02-01', '2026-05-28'),
|
||||
('¡Hola! 🤩', 'Diseño de Sistemas', '67a', 'Lima', '-', 'Pauta_wsp', '2026-02-01', '2026-12-31'),
|
||||
('¡Hola! 🙌🏼', 'Metrado y Costeo', '68a', 'Lima', '-', 'Pauta_wsp', '2026-02-01', '2026-12-31'),
|
||||
('¡Hola! 🥽', 'VRF', '69a', 'Lima', '-', 'Pauta_wsp', '2026-03-01', '2026-06-30'),
|
||||
(' 🧊 Más información del Seminario', 'VRF', '71a', 'Arequipa', '-', 'Pauta_wsp', '2026-03-01', '2026-06-30'),
|
||||
(' 📐 Más información del Seminario', 'VRF', '72a', 'Trujillo', '-', 'Pauta_wsp', '2026-03-01', '2026-06-30'),
|
||||
(' 👾 Más información del Seminario en Instalación', 'VRF', '73a', 'Piura', '-', 'Pauta_wsp', '2026-03-01', '2026-06-30'),
|
||||
(' 🖥️ Estoy interesado en sus Seminarios', 'Seminarios', '74a', 'Lima', '-', 'Pauta_wsp', '2026-04-01', '2026-07-30'),
|
||||
(' 🙋 Deseo más info. del Diplomado ', 'Diplomado REF', '75a', 'Lima', '-', 'Pauta_wsp', '2026-04-01', '2026-07-30'),
|
||||
('💸 Deseo más info. del Seminario', 'CAD', '76a', 'Lima', '-', 'Pauta_wsp', '2026-04-01', '2026-07-30'),
|
||||
('🦾 Estoy interesado Instalación de Sistemas', 'VRF', '77a', 'Lima', '-', 'Pauta_wsp', '2026-04-16', '2026-07-30'),
|
||||
('🙀 Estoy interesado Seminario', 'SUPER.OBRAS', '78a', 'Lima', '-', 'Pauta_wsp', '2026-05-15', '2026-07-30'),
|
||||
('🛠️ más info del Seminario Diseño de Chillers', 'Diseño', '79a', 'Lima', '-', 'Pauta_wsp', '2026-05-15', '2026-07-30'),
|
||||
('👾Estoy interesado en el Seminario Refrigeración', 'CO2', '80a', 'Lima', '-', 'Pauta_wsp', '2026-05-15', '2026-07-30'),
|
||||
('Hola! 👉', 'TEAC', '214', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
|
||||
('Hola! 🤝', 'TEAC', '215', 'Lima', 'Domingo', 'Pauta_wsp', '2025-12-01', '2026-03-15'),
|
||||
('🆕 Me interesa', 'TERC', '217', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
|
||||
('Hola! 🌬️', 'TEAC', '218', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
|
||||
('Hola! 🌍', 'TERC', '219', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
|
||||
('Hola! 🎟️', 'TEAC', '220', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-03-31'),
|
||||
('Hola! 🌈', 'TERC', '221', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-04-15'),
|
||||
('¡Hola! ❄️ Me interesa el programa', 'TEAC', '222', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
|
||||
('Hola! 🔝', 'TERC', '223', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
|
||||
('🔋 Me interesa', 'TEAC', '224', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-04-15'),
|
||||
('¡Hola! 🌡️', 'TEAC', '225', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
|
||||
('¡Hola! ☃️', 'TERC', '226', 'Lima', 'Sábado', 'Pauta_wsp', '2026-01-01', '2026-04-15'),
|
||||
('¡Hola! 📚', 'TERC', '227', 'Lima', 'Semipresencial', 'Pauta_wsp', '2026-02-01', '2026-05-10'),
|
||||
('¡Hola! 🗺️', 'TEAC', '228', 'Arequipa','Semipresencial', 'Pauta_wsp', '2026-02-01', '2026-05-05'),
|
||||
('¡Hola! 🌤️', 'TEAC', '229', 'Piura', 'Semipresencial', 'Pauta_wsp', '2026-02-01', '2026-05-30'),
|
||||
('¡Hola! ⚡', 'TEAC', '230', 'Trujillo','Semipresencial', 'Pauta_wsp', '2026-02-01', '2026-05-07'),
|
||||
('¡Hola! 🎤', 'TERC', '231', 'Lima', 'Domingo', 'Pauta_wsp', '2026-02-01', '2026-05-30'),
|
||||
('¡Hola! 👾 Me interesa el programa', 'TEAC', '232', 'Lima', 'Domingo', 'Pauta_wsp', '2026-02-26', '2026-05-30'),
|
||||
('¡Hola! 🍨', 'TEAC', '233', 'Lima', 'Sábado', 'Pauta_wsp', '2026-02-01', '2026-05-30'),
|
||||
('¡Hola! 🐶 Me interesa', 'TEAC', '234', 'Piura', 'Sábado', 'Pauta_wsp', '2026-03-01', '2026-05-30'),
|
||||
('¡Hola! 💸 Me interesa', 'TERC', '235', 'Arequipa', 'Semipresencial', 'Pauta_wsp', '2026-03-01', '2026-05-30'),
|
||||
('¡Hola! 🗿 Me interesa', 'TERC', '236', 'Trujillo', 'Sábado', 'Pauta_wsp', '2026-03-01', '2026-05-30'),
|
||||
('¡Hola! 🦺 Me interesa', 'TEAC', '237', 'Lima', 'Sábado', 'Pauta_wsp', '2026-03-01', '2026-05-30'),
|
||||
('¡Hola! 🍾 Me interesa', 'TEAC', '238', 'Lima', 'Domingo', 'Pauta_wsp', '2026-03-01', '2026-05-30'),
|
||||
('💡 deseo más info del programa', 'TERC', '239', 'Lima', 'Semipresencial', 'Pauta_wsp', '2026-03-01', '2026-05-30'),
|
||||
('¡Hola! 🎯 Me interesa el programa', 'TERC', '240', 'Lima', 'Domingo', 'Pauta_wsp', '2026-04-01', '2026-05-30'),
|
||||
('¡Hola! 🤝 Me interesa el programa', 'TERC', '241', 'Lima', 'Sábado', 'Pauta_wsp', '2026-04-01', '2026-05-30'),
|
||||
('🪐 Me interesa el programa', 'TERC', '242', 'Piura', 'Domingo', 'Pauta_wsp', '2026-04-01', '2026-05-30'),
|
||||
('🎓 Me interesa el programa', 'TEAC', '243', 'Arequipa', 'Domingo', 'Pauta_wsp', '2026-04-01', '2026-06-30'),
|
||||
('💼 Me interesa el programa ', 'TEAC', '244', 'Trujillo', 'Domingo', 'Pauta_wsp', '2026-04-01', '2026-06-30'),
|
||||
('☃️ Me interesa el programa', 'TEAC', '245', 'Arequipa', 'Sabado', 'Pauta_wsp', '2026-04-16', '2026-07-30'),
|
||||
('🔋 Me interesa el programa', 'TERC', '246', 'Arequipa', 'Sabado', 'Pauta_wsp', '2026-04-16', '2026-07-30'),
|
||||
('😎 Me interesa el programa', 'TEAC', '247', 'Arequipa', 'Domingo', 'Pauta_wsp', '2026-04-16', '2026-07-30'),
|
||||
('🌈Me interesa el programa', 'TEAC', '248', 'Lima', 'Sabado', 'Pauta_wsp', '2026-04-16', '2026-07-30'),
|
||||
('⭐ Me interesa el programa', 'TEAC', '249', 'Lima', 'Domingo', 'Pauta_wsp', '2026-05-01', '2026-07-30'),
|
||||
('🕵️ Me interesa el programa', 'TEAC', '250', 'Piura', 'Domingo', 'Pauta_wsp', '2026-05-01', '2026-07-30'),
|
||||
('📈 Me interesa el programa', 'TEAC', '251', 'Trujillo', 'Domingo', 'Pauta_wsp', '2026-05-01', '2026-07-30'),
|
||||
('🗺️ Me interesa el programa', 'TEAC', '252', 'Piura', 'Sabado', 'Pauta_wsp', '2026-05-06', '2026-07-30'),
|
||||
('🎮 Me interesa el programa', 'TEAC', '253', 'Trujillo', 'Sabado', 'Pauta_wsp', '2026-05-01', '2026-07-30'),
|
||||
('🏞️ Me interesa el programa', 'TERC', '254', 'Arequipa', 'Domingo', 'Pauta_wsp', '2026-05-01', '2026-12-31'),
|
||||
('⚙️ Me interesa el programa', 'TERC', '255', 'Piura', 'Domingo', 'Pauta_wsp', '2026-05-01', '2026-12-31'),
|
||||
('⚡ Me interesa el programa', 'TERC', '256', 'Trujillo', 'Domingo', 'Pauta_wsp', '2026-05-08', '2026-12-31'),
|
||||
('🎟️ Me interesa el programa', 'TERC', '257', 'Trujillo', 'Sabado', 'Pauta_wsp', '2026-05-01', '2026-12-31'),
|
||||
('🕑 Me interesa el', 'TERC', '258', 'Piura', 'Sabado', 'Pauta_wsp', '2026-05-01', '2026-12-31'),
|
||||
('📚 Me interesa el programa', 'TEAC', '259', 'Lima', 'Semipresencial', 'Pauta_wsp', '2026-05-11', '2026-12-31'),
|
||||
('🤗 Me interesa el programa', 'TEAC', '260', 'Lima', 'Semipresencial', 'Pauta_wsp', '2026-05-11', '2026-12-31'),
|
||||
('🕹️ Me interesa el programa', 'TERC', '261', 'Lima', 'Domingo', 'Pauta_wsp', '2026-05-11', '2026-12-31'),
|
||||
('🐶 Me interesa el programa', 'TEAC', '262', 'Piura', 'Sabado', 'Pauta_wsp', '2026-05-11', '2026-08-30'),
|
||||
('🍨 Me interesa el programa', 'TERC', '263', 'Arequipa', 'Sabado', 'Pauta_wsp', '2026-05-15', '2026-08-30'),
|
||||
('⛱️ Me interesa el programa de Aire Acondicionado', 'TEAC', '264', 'Lima', 'Sabado', 'Pauta_wsp', '2026-05-15', '2026-08-30'),
|
||||
('🌤️ Me interesa el programa', 'TEAC', '265', 'Arequipa', 'Sabado', 'Pauta_wsp', '2026-05-15', '2026-08-30'),
|
||||
('⛱️ Me interesa el programa de Refrigeración comercial', 'TERC', '266', 'Lima', 'Sabado', 'Pauta_wsp', '2026-05-15', '2026-08-30'),
|
||||
('¡Hola! Vengo de su web, quiero saber', 'TEAC', '0', 'Lima', '-', 'Web_Whatsapp', '2025-11-01', '2026-12-31')
|
||||
) AS map(frase_busqueda, cargo, codigo, sede, dia, origen, fecha_inicio, fecha_fin)
|
||||
ON m.content LIKE '%' || map.frase_busqueda || '%'
|
||||
AND m.created_at >= CAST(map.fecha_inicio AS TIMESTAMP)
|
||||
AND m.created_at <= CAST(map.fecha_fin AS TIMESTAMP) + INTERVAL '1 day'
|
||||
WHERE m.sender_type = 'Contact'
|
||||
ORDER BY m.id ASC, m.created_at ASC
|
||||
"""
|
||||
conn = self.pg_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute(sql)
|
||||
cols = [d[0] for d in cur.description]
|
||||
filas = [dict(zip(cols, row)) for row in cur.fetchall()]
|
||||
conn.close()
|
||||
return filas
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# SQL SERVER — CURSOS (igual que el PBI: Fact_SQL_Base_Cursos)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def traer_cursos(self):
|
||||
sql = """
|
||||
SELECT
|
||||
rp.num_indice,
|
||||
rp.dsc_det_programa,
|
||||
p.dsc_programa,
|
||||
rp.cod_frecuencia,
|
||||
rp.fch_inicio,
|
||||
rp.cod_estado,
|
||||
(SELECT COUNT(*) FROM sgeca_matricula m
|
||||
WHERE m.cod_periodo = rp.cod_detalle AND m.num_indice = rp.num_indice
|
||||
AND m.cod_estado NOT IN ('ANU')) AS Inscritos_Totales,
|
||||
(SELECT COUNT(*) FROM sgeca_matricula m
|
||||
WHERE m.cod_periodo = rp.cod_detalle AND m.num_indice = rp.num_indice
|
||||
AND m.cod_estado = 'RET') AS Inscritos_Retirados,
|
||||
(SELECT COUNT(*) FROM sgeca_matricula m
|
||||
WHERE m.cod_periodo = rp.cod_detalle AND m.num_indice = rp.num_indice
|
||||
AND m.cod_estado NOT IN ('ANU','RET','SUS')) AS Inscritos_Activos
|
||||
FROM sgede_RP_programa rp
|
||||
INNER JOIN sgeca_programa p ON rp.cod_programa = p.cod_programa
|
||||
WHERE YEAR(rp.fch_inicio) IN (2025, 2026)
|
||||
ORDER BY rp.fch_inicio ASC
|
||||
"""
|
||||
conn = self.sql_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute(sql)
|
||||
cols = [d[0] for d in cur.description]
|
||||
filas = [dict(zip(cols, row)) for row in cur.fetchall()]
|
||||
conn.close()
|
||||
return filas
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# SQL SERVER — MATRÍCULAS (Fact_SQL_Base_Matriculas, resumido)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def traer_matriculas(self):
|
||||
sql = """
|
||||
SELECT
|
||||
sgeca_matricula.num_matricula,
|
||||
sgeca_matricula.num_indice,
|
||||
sgeca_matricula.fch_matricula,
|
||||
sgeca_matricula.cod_estado AS estado_matricula,
|
||||
REPLACE(sgema_alumno.dsc_telefono_1,' ','') AS dsc_telefono_1,
|
||||
sgeca_programa.dsc_programa,
|
||||
sgede_RP_programa.dsc_det_programa AS dsc_promocion,
|
||||
ISNULL((SELECT sgede_cronograma_matricula.imp_saldo FROM sgede_cronograma_matricula
|
||||
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||
AND sgede_cronograma_matricula.num_cuota = 0), 0) AS imp_saldo_matricula,
|
||||
ISNULL((SELECT sgede_cronograma_matricula.imp_saldo FROM sgede_cronograma_matricula
|
||||
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||
AND sgede_cronograma_matricula.num_cuota = 1), 0) AS imp_saldo_cuota1
|
||||
FROM sgeca_matricula
|
||||
INNER JOIN sgeca_programa ON sgeca_matricula.cod_programa = sgeca_programa.cod_programa
|
||||
LEFT JOIN sgede_RP_programa ON sgeca_matricula.cod_periodo = sgede_RP_programa.cod_detalle
|
||||
AND sgeca_matricula.cod_programa = sgede_RP_programa.cod_programa
|
||||
AND sgeca_matricula.num_indice = sgede_RP_programa.num_indice
|
||||
INNER JOIN sgema_alumno ON sgeca_matricula.cod_alumno = sgema_alumno.cod_alumno
|
||||
WHERE sgeca_matricula.cod_localidad LIKE 'SCENT'
|
||||
AND sgeca_matricula.fch_matricula BETWEEN '01-11-2024 00:00:00.000' AND '31-12-2026 23:59:00.000'
|
||||
AND sgeca_matricula.cod_estado <> 'ANU'
|
||||
"""
|
||||
conn = self.sql_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute(sql)
|
||||
cols = [d[0] for d in cur.description]
|
||||
filas = [dict(zip(cols, row)) for row in cur.fetchall()]
|
||||
conn.close()
|
||||
return filas
|
||||
# --- fin data_manager ---
|
||||
722
backend/data_manager_v2.py
Normal file
722
backend/data_manager_v2.py
Normal file
@@ -0,0 +1,722 @@
|
||||
# backend/data_manager.py (v2 - reescrito completo)
|
||||
"""Capa de acceso a datos del módulo LEADS."""
|
||||
import os
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# ── Mapa de campañas (frase -> programa/sede/rango fechas), igual que el PBI ──
|
||||
CAMPANIAS = [
|
||||
('📚 Me interesa el', 'TERC', '200', 'Arequipa', 'Domingo', 'Pauta_wsp', '2025-10-01', '2025-12-31'),
|
||||
('📚 Me interesa el', 'TEAC', '227', 'Lima', 'Domingo', 'Pauta_wsp', '2026-01-15', '2026-03-30'),
|
||||
('Hola! 🚨', 'TEAC', '191', 'Piura', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
|
||||
('Hola! 💯 Me interesa', 'TERC', '193', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
|
||||
('Hola! 💼', 'TEAC', '195', 'Trujillo', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-01-31'),
|
||||
('Hola! 💸', 'TEAC', '197', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-02-28'),
|
||||
('Hola! ⚡', 'TEAC', '198', 'Trujillo', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-01-31'),
|
||||
('Hola! 🦺', 'TERC', '199', 'Piura', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-02-28'),
|
||||
('Hola! 🍾', 'TEAC', '201', 'Arequipa', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-02-28'),
|
||||
('¡Hola! 📚', 'TERC', '200', 'Arequipa', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-01-31'),
|
||||
('Hola! 🥶', 'TEAC', '202', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
|
||||
('Hola! 🎯', 'TEAC', '203', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-02-28'),
|
||||
('Hola! 🌞', 'TERC', '204', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
|
||||
('Hola! 🗿', 'TERC', '205', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-02-28'),
|
||||
('Hola! 😎', 'TEAC', '206', 'Piura', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-04-01'),
|
||||
('Hola! 🎓', 'TEAC', '207', 'Arequipa', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-01-31'),
|
||||
('Hola! 👀', 'TEAC', '208', 'Trujillo', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
|
||||
('Hola! 🎮', 'TERC', '209', 'Piura', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-04-30'),
|
||||
('Hola! 👾', 'TERC', '210', 'Arequipa', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-02-25'),
|
||||
('Hola! 🕹️', 'TERC', '211', 'Trujillo', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-04-01'),
|
||||
('Hola! 🚀', 'TEAC', '212', 'Lima', '-', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
|
||||
('Hola! 🛸', 'TERC', '213', 'Lima', '-', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
|
||||
('Hola! 🦾', 'Amoniaco', '63a', 'Lima', '-', 'Pauta_wsp', '2025-09-01', '2026-04-15'),
|
||||
('Hola! 🛠️', 'Diseño Chillers', '64a', 'Lima', '-', 'Pauta_wsp', '2025-09-01', '2026-04-30'),
|
||||
('¡Hola! 🥽', 'VRF', '65a', 'Lima', '-', 'Pauta_wsp', '2025-09-01', '2026-02-28'),
|
||||
('¡Hola! ❄️ Estoy interesado Seminario', 'Cámaras', '66a', 'Lima', '-', 'Pauta_wsp', '2026-02-01', '2026-05-28'),
|
||||
('¡Hola! 🤩', 'Diseño de Sistemas', '67a', 'Lima', '-', 'Pauta_wsp', '2026-02-01', '2026-12-31'),
|
||||
('¡Hola! 🙌🏼', 'Metrado y Costeo', '68a', 'Lima', '-', 'Pauta_wsp', '2026-02-01', '2026-12-31'),
|
||||
('¡Hola! 🥽', 'VRF', '69a', 'Lima', '-', 'Pauta_wsp', '2026-03-01', '2026-06-30'),
|
||||
(' 🧊 Más información del Seminario', 'VRF', '71a', 'Arequipa', '-', 'Pauta_wsp', '2026-03-01', '2026-06-30'),
|
||||
(' 📐 Más información del Seminario', 'VRF', '72a', 'Trujillo', '-', 'Pauta_wsp', '2026-03-01', '2026-06-30'),
|
||||
(' 👾 Más información del Seminario en Instalación', 'VRF', '73a', 'Piura', '-', 'Pauta_wsp', '2026-03-01', '2026-06-30'),
|
||||
(' 🖥️ Estoy interesado en sus Seminarios', 'Seminarios', '74a', 'Lima', '-', 'Pauta_wsp', '2026-04-01', '2026-07-30'),
|
||||
(' 🙋 Deseo más info. del Diplomado ', 'Diplomado REF', '75a', 'Lima', '-', 'Pauta_wsp', '2026-04-01', '2026-07-30'),
|
||||
('💸 Deseo más info. del Seminario', 'CAD', '76a', 'Lima', '-', 'Pauta_wsp', '2026-04-01', '2026-07-30'),
|
||||
('🦾 Estoy interesado Instalación de Sistemas', 'VRF', '77a', 'Lima', '-', 'Pauta_wsp', '2026-04-16', '2026-07-30'),
|
||||
('🙀 Estoy interesado Seminario', 'SUPER.OBRAS', '78a', 'Lima', '-', 'Pauta_wsp', '2026-05-15', '2026-07-30'),
|
||||
('🛠️ más info del Seminario Diseño de Chillers', 'Diseño', '79a', 'Lima', '-', 'Pauta_wsp', '2026-05-15', '2026-07-30'),
|
||||
('👾Estoy interesado en el Seminario Refrigeración', 'CO2', '80a', 'Lima', '-', 'Pauta_wsp', '2026-05-15', '2026-07-30'),
|
||||
('📱 Estoy interesado Instalación', 'VRF', '81a', 'Lima', '-', 'Pauta_wsp', '2026-06-25', '2026-08-30'),
|
||||
('🧊 Más información del Seminario', 'VRF', '82a', 'Arequipa', '-', 'Pauta_wsp', '2026-07-01', '2026-08-30'),
|
||||
('📐 Más información del Seminario', 'VRF', '83a', 'Trujillo', '-', 'Pauta_wsp', '2026-07-01', '2026-08-30'),
|
||||
('👾 Más información del Seminario', 'VRF', '84a', 'Piura', '-', 'Pauta_wsp', '2026-07-01', '2026-08-30'),
|
||||
('🛠️ más info del Seminario Virtual ', 'Diseño de Chillers', '85a', 'Lima', '-', 'Pauta_wsp', '2026-07-01', '2026-09-30'),
|
||||
('Hola! 👉', 'TEAC', '214', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
|
||||
('Hola! 🤝', 'TEAC', '215', 'Lima', 'Domingo', 'Pauta_wsp', '2025-12-01', '2026-03-15'),
|
||||
('🆕 Me interesa', 'TERC', '217', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
|
||||
('Hola! 🌬️', 'TEAC', '218', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
|
||||
('Hola! 🌍', 'TERC', '219', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
|
||||
('Hola! 🎟️', 'TEAC', '220', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-03-31'),
|
||||
('Hola! 🌈', 'TERC', '221', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-04-15'),
|
||||
('¡Hola! ❄️ Me interesa el programa', 'TEAC', '222', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
|
||||
('Hola! 🔝', 'TERC', '223', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
|
||||
('🔋 Me interesa', 'TEAC', '224', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-04-15'),
|
||||
('¡Hola! 🌡️', 'TEAC', '225', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
|
||||
('¡Hola! ☃️', 'TERC', '226', 'Lima', 'Sábado', 'Pauta_wsp', '2026-01-01', '2026-04-15'),
|
||||
('¡Hola! 📚', 'TERC', '227', 'Lima', 'Semipresencial', 'Pauta_wsp', '2026-02-01', '2026-05-10'),
|
||||
('¡Hola! 🗺️', 'TEAC', '228', 'Arequipa', 'Semipresencial', 'Pauta_wsp', '2026-02-01', '2026-05-05'),
|
||||
('¡Hola! 🌤️', 'TEAC', '229', 'Piura', 'Semipresencial', 'Pauta_wsp', '2026-02-01', '2026-05-30'),
|
||||
('¡Hola! ⚡', 'TEAC', '230', 'Trujillo', 'Semipresencial', 'Pauta_wsp', '2026-02-01', '2026-05-07'),
|
||||
('¡Hola! 🎤', 'TERC', '231', 'Lima', 'Domingo', 'Pauta_wsp', '2026-02-01', '2026-05-30'),
|
||||
('¡Hola! 👾 Me interesa el programa', 'TEAC', '232', 'Lima', 'Domingo', 'Pauta_wsp', '2026-02-26', '2026-05-30'),
|
||||
('¡Hola! 🍨', 'TEAC', '233', 'Lima', 'Sábado', 'Pauta_wsp', '2026-02-01', '2026-05-30'),
|
||||
('¡Hola! 🐶 Me interesa', 'TEAC', '234', 'Piura', 'Sábado', 'Pauta_wsp', '2026-03-01', '2026-05-30'),
|
||||
('¡Hola! 💸 Me interesa', 'TERC', '235', 'Arequipa', 'Semipresencial', 'Pauta_wsp', '2026-03-01', '2026-05-30'),
|
||||
('¡Hola! 🗿 Me interesa', 'TERC', '236', 'Trujillo', 'Sábado', 'Pauta_wsp', '2026-03-01', '2026-05-30'),
|
||||
('¡Hola! 🦺 Me interesa', 'TEAC', '237', 'Lima', 'Sábado', 'Pauta_wsp', '2026-03-01', '2026-05-30'),
|
||||
('¡Hola! 🍾 Me interesa', 'TEAC', '238', 'Lima', 'Domingo', 'Pauta_wsp', '2026-03-01', '2026-05-30'),
|
||||
('💡 deseo más info del programa', 'TERC', '239', 'Lima', 'Semipresencial', 'Pauta_wsp', '2026-03-01', '2026-05-30'),
|
||||
('¡Hola! 🎯 Me interesa el programa', 'TERC', '240', 'Lima', 'Domingo', 'Pauta_wsp', '2026-04-01', '2026-05-30'),
|
||||
('¡Hola! 🤝 Me interesa el programa', 'TERC', '241', 'Lima', 'Sábado', 'Pauta_wsp', '2026-04-01', '2026-05-30'),
|
||||
('🪐 Me interesa el programa', 'TERC', '242', 'Piura', 'Domingo', 'Pauta_wsp', '2026-04-01', '2026-05-30'),
|
||||
('🎓 Me interesa el programa', 'TEAC', '243', 'Arequipa', 'Domingo', 'Pauta_wsp', '2026-04-01', '2026-06-30'),
|
||||
('💼 Me interesa el programa ', 'TEAC', '244', 'Trujillo', 'Domingo', 'Pauta_wsp', '2026-04-01', '2026-06-30'),
|
||||
('☃️ Me interesa el programa', 'TEAC', '245', 'Arequipa', 'Sabado', 'Pauta_wsp', '2026-04-16', '2026-07-30'),
|
||||
('🔋 Me interesa el programa', 'TERC', '246', 'Arequipa', 'Sabado', 'Pauta_wsp', '2026-04-16', '2026-07-30'),
|
||||
('😎 Me interesa el programa', 'TEAC', '247', 'Arequipa', 'Domingo', 'Pauta_wsp', '2026-04-16', '2026-07-30'),
|
||||
('🌈Me interesa el programa', 'TEAC', '248', 'Lima', 'Sabado', 'Pauta_wsp', '2026-04-16', '2026-07-30'),
|
||||
('⭐ Me interesa el programa', 'TEAC', '249', 'Lima', 'Domingo', 'Pauta_wsp', '2026-05-01', '2026-07-30'),
|
||||
('🕵️ Me interesa el programa', 'TEAC', '250', 'Piura', 'Domingo', 'Pauta_wsp', '2026-05-01', '2026-07-30'),
|
||||
('📈 Me interesa el programa', 'TEAC', '251', 'Trujillo', 'Domingo', 'Pauta_wsp', '2026-05-01', '2026-07-30'),
|
||||
('🗺️ Me interesa el programa', 'TEAC', '252', 'Piura', 'Sabado', 'Pauta_wsp', '2026-05-06', '2026-07-30'),
|
||||
('🎮 Me interesa el programa', 'TEAC', '253', 'Trujillo', 'Sabado', 'Pauta_wsp', '2026-05-01', '2026-07-30'),
|
||||
('🏞️ Me interesa el programa', 'TERC', '254', 'Arequipa', 'Domingo', 'Pauta_wsp', '2026-05-01', '2026-12-31'),
|
||||
('⚙️ Me interesa el programa', 'TERC', '255', 'Piura', 'Domingo', 'Pauta_wsp', '2026-05-01', '2026-12-31'),
|
||||
('⚡ Me interesa el programa', 'TERC', '256', 'Trujillo', 'Domingo', 'Pauta_wsp', '2026-05-08', '2026-12-31'),
|
||||
('🎟️ Me interesa el programa', 'TERC', '257', 'Trujillo', 'Sabado', 'Pauta_wsp', '2026-05-01', '2026-12-31'),
|
||||
('🕑 Me interesa el', 'TERC', '258', 'Piura', 'Sabado', 'Pauta_wsp', '2026-05-01', '2026-12-31'),
|
||||
('📚 Me interesa el programa', 'TEAC', '259', 'Lima', 'Semipresencial', 'Pauta_wsp', '2026-05-11', '2026-12-31'),
|
||||
('🤗 Me interesa el programa', 'TEAC', '260', 'Lima', 'Semipresencial', 'Pauta_wsp', '2026-05-11', '2026-12-31'),
|
||||
('🕹️ Me interesa el programa', 'TERC', '261', 'Lima', 'Domingo', 'Pauta_wsp', '2026-05-11', '2026-12-31'),
|
||||
('🐶 Me interesa el programa', 'TEAC', '262', 'Piura', 'Sabado', 'Pauta_wsp', '2026-05-11', '2026-08-30'),
|
||||
('🍨 Me interesa el programa', 'TERC', '263', 'Arequipa', 'Sabado', 'Pauta_wsp', '2026-05-15', '2026-08-30'),
|
||||
('⛱️ Me interesa el programa de Aire Acondicionado', 'TEAC', '264', 'Lima', 'Sabado', 'Pauta_wsp', '2026-05-15', '2026-08-30'),
|
||||
('🌤️ Me interesa el programa', 'TEAC', '265', 'Arequipa', 'Sabado', 'Pauta_wsp', '2026-05-15', '2026-08-30'),
|
||||
('⛱️ Me interesa el programa de Refrigeración comercial', 'TERC', '266', 'Lima', 'Sabado', 'Pauta_wsp', '2026-05-15', '2026-08-30'),
|
||||
('¡Hola! Vengo de su web, quiero saber', 'TEAC', '0', 'Lima', '-', 'Web_Whatsapp', '2025-11-01', '2026-12-31'),
|
||||
]
|
||||
|
||||
|
||||
def _values_sql():
|
||||
"""Construye el bloque VALUES (...) del mapa de campañas para el JOIN."""
|
||||
filas = []
|
||||
for frase, cargo, codigo, sede, dia, origen, fi, ff in CAMPANIAS:
|
||||
f = frase.replace("'", "''")
|
||||
filas.append(f"('{f}','{cargo}','{codigo}','{sede}','{dia}','{origen}','{fi}','{ff}')")
|
||||
return ",\n".join(filas)
|
||||
|
||||
|
||||
class DataManager:
|
||||
def __init__(self):
|
||||
self.pg_host = os.getenv("PG_HOST", "191.98.134.81")
|
||||
self.pg_db = os.getenv("PG_DATABASE", "chatwoot_production")
|
||||
self.pg_user = os.getenv("PG_USER", "postgres")
|
||||
self.pg_pass = os.getenv("PG_PASSWORD", "")
|
||||
self.pg_port = os.getenv("PG_PORT", "5432")
|
||||
self.sql_server = os.getenv("SQL_SERVER", "191.98.134.80")
|
||||
self.sql_db = os.getenv("SQL_DATABASE", "BDUS_CK000040_0001")
|
||||
self.sql_user = os.getenv("SQL_USERNAME", "")
|
||||
self.sql_pass = os.getenv("SQL_PASSWORD", "")
|
||||
|
||||
def pg_conn(self):
|
||||
import psycopg2
|
||||
return psycopg2.connect(
|
||||
host=self.pg_host, dbname=self.pg_db, user=self.pg_user,
|
||||
password=self.pg_pass, port=self.pg_port,
|
||||
)
|
||||
|
||||
def sql_conn(self):
|
||||
import pyodbc
|
||||
conn_str = (
|
||||
f"DRIVER={{SQL Server}};SERVER={self.sql_server};"
|
||||
f"DATABASE={self.sql_db};UID={self.sql_user};PWD={self.sql_pass}"
|
||||
)
|
||||
return pyodbc.connect(conn_str)
|
||||
|
||||
# ── CHATWOOT: leads de pauta (mapa de campañas, dedup por mensaje) ──
|
||||
def traer_leads_chatwoot(self):
|
||||
sql = f"""
|
||||
SELECT DISTINCT ON (m.id)
|
||||
-- Normalización IGUAL que el PBI: quitar '+51' y '+', quedando el número
|
||||
REPLACE(REPLACE(c.phone_number, '+51', ''), '+', '') AS telefono,
|
||||
u.name AS asesor,
|
||||
m.created_at AS fecha_creada,
|
||||
cv.cached_label_list AS etiquetas,
|
||||
map.cargo AS programa, map.sede AS sede,
|
||||
map.codigo AS codigo, map.origen AS origen
|
||||
FROM messages m
|
||||
JOIN conversations cv ON m.conversation_id = cv.id
|
||||
JOIN contacts c ON cv.contact_id = c.id
|
||||
LEFT JOIN users u ON cv.assignee_id = u.id
|
||||
JOIN (VALUES
|
||||
{_values_sql()}
|
||||
) AS map(frase_busqueda, cargo, codigo, sede, dia, origen, fecha_inicio, fecha_fin)
|
||||
ON m.content LIKE '%' || map.frase_busqueda || '%'
|
||||
AND m.created_at >= CAST(map.fecha_inicio AS TIMESTAMP)
|
||||
AND m.created_at <= CAST(map.fecha_fin AS TIMESTAMP) + INTERVAL '1 day'
|
||||
WHERE m.sender_type = 'Contact'
|
||||
ORDER BY m.id ASC
|
||||
"""
|
||||
conn = self.pg_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute(sql)
|
||||
cols = [d[0] for d in cur.description]
|
||||
filas = [dict(zip(cols, row)) for row in cur.fetchall()]
|
||||
conn.close()
|
||||
return filas
|
||||
|
||||
# ── SQL SERVER: cursos ──
|
||||
def traer_cursos(self):
|
||||
sql = """
|
||||
SELECT rp.num_indice, rp.dsc_det_programa, p.dsc_programa,
|
||||
rp.cod_frecuencia, rp.fch_inicio, rp.cod_estado,
|
||||
(SELECT COUNT(*) FROM sgeca_matricula m
|
||||
WHERE m.cod_periodo = rp.cod_detalle AND m.num_indice = rp.num_indice
|
||||
AND m.cod_estado NOT IN ('ANU')) AS Inscritos_Totales,
|
||||
(SELECT COUNT(*) FROM sgeca_matricula m
|
||||
WHERE m.cod_periodo = rp.cod_detalle AND m.num_indice = rp.num_indice
|
||||
AND m.cod_estado = 'RET') AS Inscritos_Retirados,
|
||||
(SELECT COUNT(*) FROM sgeca_matricula m
|
||||
WHERE m.cod_periodo = rp.cod_detalle AND m.num_indice = rp.num_indice
|
||||
AND m.cod_estado NOT IN ('ANU','RET','SUS')) AS Inscritos_Activos
|
||||
FROM sgede_RP_programa rp
|
||||
INNER JOIN sgeca_programa p ON rp.cod_programa = p.cod_programa
|
||||
WHERE YEAR(rp.fch_inicio) IN (2025, 2026)
|
||||
ORDER BY rp.fch_inicio ASC
|
||||
"""
|
||||
conn = self.sql_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute(sql)
|
||||
cols = [d[0] for d in cur.description]
|
||||
filas = [dict(zip(cols, row)) for row in cur.fetchall()]
|
||||
conn.close()
|
||||
return filas
|
||||
|
||||
# ── SUPABASE: mapa num_indice → pauta (tabla basebi_programacion) ──
|
||||
def traer_pauta_cursos(self):
|
||||
"""Devuelve dict {str(num_indice): {pauta, estado, contar}} desde Supabase.
|
||||
Fuente: tabla BaseBI_Programacion de SharePoint, subida a Supabase."""
|
||||
url = os.getenv("SUPABASE_PAUTA_URL", "")
|
||||
key = os.getenv("SUPABASE_PAUTA_KEY", "")
|
||||
tabla = os.getenv("SUPABASE_TABLA_PAUTA", "basebi_programacion")
|
||||
if not url or not key:
|
||||
return {}
|
||||
try:
|
||||
r = requests.get(
|
||||
f"{url}/rest/v1/{tabla}",
|
||||
params={"select": "num_indice,pauta_codigo,estado,contar"},
|
||||
headers={"apikey": key, "Authorization": f"Bearer {key}"},
|
||||
timeout=30,
|
||||
)
|
||||
r.raise_for_status()
|
||||
mapa = {}
|
||||
for row in r.json():
|
||||
ni = row.get("num_indice")
|
||||
if ni is None:
|
||||
continue
|
||||
pa = row.get("pauta_codigo")
|
||||
mapa[str(ni)] = {
|
||||
"pauta": (str(pa).strip() if pa not in (None, "") else None),
|
||||
"estado": (str(row.get("estado") or "").strip().upper()),
|
||||
"contar": (str(row.get("contar") or "").strip().upper()),
|
||||
}
|
||||
return mapa
|
||||
except Exception as e:
|
||||
print(f"[traer_pauta_cursos] {e}")
|
||||
return {}
|
||||
|
||||
# ── SUPABASE: valores de programa/sede no identificados (para la campanita) ──
|
||||
def traer_no_identificados(self):
|
||||
"""Compara cartera_junta vs alias_normalizacion y devuelve los valores de
|
||||
programa/sede que NO están en el diccionario (correcto).
|
||||
Retorna {'programa': [{'valor','veces'}], 'sede': [...]}."""
|
||||
url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "")
|
||||
key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "")
|
||||
t_cart = os.getenv("SUPABASE_TABLA_CARTERA", "cartera_junta")
|
||||
t_alias = os.getenv("SUPABASE_TABLA_ALIAS", "alias_normalizacion")
|
||||
vacio = {"programa": [], "sede": []}
|
||||
if not url or not key:
|
||||
return vacio
|
||||
headers = {"apikey": key, "Authorization": f"Bearer {key}"}
|
||||
try:
|
||||
# 1) valores conocidos (correcto) por tipo
|
||||
r = requests.get(f"{url}/rest/v1/{t_alias}",
|
||||
params={"select": "tipo,correcto"}, headers=headers, timeout=30)
|
||||
r.raise_for_status()
|
||||
conocidos = {"PROGRAMA": set(), "SEDE": set()}
|
||||
for row in r.json():
|
||||
tipo = str(row.get("tipo") or "").upper()
|
||||
corr = " ".join(str(row.get("correcto") or "").upper().split())
|
||||
if tipo in conocidos:
|
||||
conocidos[tipo].add(corr)
|
||||
|
||||
# 2) traer de cartera_junta los valores que NO estén en los conocidos
|
||||
def _desconocidos(columna, tipo):
|
||||
conj = conocidos.get(tipo, set())
|
||||
if not conj:
|
||||
return []
|
||||
lista = ",".join('"' + v.replace('"', '') + '"' for v in conj)
|
||||
rr = requests.get(
|
||||
f"{url}/rest/v1/{t_cart}",
|
||||
params={"select": columna, columna: f"not.in.({lista})", "limit": "5000"},
|
||||
headers=headers, timeout=30)
|
||||
rr.raise_for_status()
|
||||
cont = {}
|
||||
for row in rr.json():
|
||||
v = " ".join(str(row.get(columna) or "").upper().split())
|
||||
cont[v] = cont.get(v, 0) + 1
|
||||
return [{"valor": k, "veces": n}
|
||||
for k, n in sorted(cont.items(), key=lambda x: -x[1])]
|
||||
|
||||
return {"programa": _desconocidos("programa", "PROGRAMA"),
|
||||
"sede": _desconocidos("sede", "SEDE")}
|
||||
except Exception as e:
|
||||
print(f"[traer_no_identificados] {e}")
|
||||
return vacio
|
||||
|
||||
# ── SUPABASE: diccionario alias (para normalizar campanias como la cartera) ──
|
||||
def traer_alias_map(self):
|
||||
"""{'PROGRAMA': {alias:correcto}, 'SEDE': {alias:correcto}}."""
|
||||
url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "")
|
||||
key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "")
|
||||
t = os.getenv("SUPABASE_TABLA_ALIAS", "alias_normalizacion")
|
||||
out = {"PROGRAMA": {}, "SEDE": {}}
|
||||
if not url or not key:
|
||||
return out
|
||||
try:
|
||||
r = requests.get(f"{url}/rest/v1/{t}", params={"select": "tipo,alias,correcto"},
|
||||
headers={"apikey": key, "Authorization": f"Bearer {key}"}, timeout=30)
|
||||
r.raise_for_status()
|
||||
for row in r.json():
|
||||
tipo = str(row.get("tipo") or "").upper()
|
||||
al = " ".join(str(row.get("alias") or "").upper().split())
|
||||
co = str(row.get("correcto") or "")
|
||||
if tipo in out:
|
||||
out[tipo][al] = co
|
||||
except Exception as e:
|
||||
print(f"[traer_alias_map] {e}")
|
||||
return out
|
||||
|
||||
# ── SUPABASE: mapa codigo → (sede, programa) desde campanias (normalizado) ──
|
||||
def traer_campanias_map(self):
|
||||
url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "")
|
||||
key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "")
|
||||
t = os.getenv("SUPABASE_TABLA_CAMPANIAS", "campanias")
|
||||
out = {}
|
||||
if not url or not key:
|
||||
return out
|
||||
alias = self.traer_alias_map()
|
||||
|
||||
def _norm(v, tipo):
|
||||
s = " ".join(str(v or "").upper().split())
|
||||
return alias.get(tipo, {}).get(s, s)
|
||||
|
||||
try:
|
||||
r = requests.get(f"{url}/rest/v1/{t}", params={"select": "codigo,sede,cargo"},
|
||||
headers={"apikey": key, "Authorization": f"Bearer {key}"}, timeout=30)
|
||||
r.raise_for_status()
|
||||
for row in r.json():
|
||||
cod = " ".join(str(row.get("codigo") or "").upper().split())
|
||||
if not cod:
|
||||
continue
|
||||
out[cod] = (_norm(row.get("sede"), "SEDE"), _norm(row.get("cargo"), "PROGRAMA"))
|
||||
except Exception as e:
|
||||
print(f"[traer_campanias_map] {e}")
|
||||
return out
|
||||
|
||||
# ── SUPABASE: filas de cartera_junta para contar (telefono, sede, programa, fecha) ──
|
||||
def traer_cartera_rows(self):
|
||||
url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "")
|
||||
key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "")
|
||||
t = os.getenv("SUPABASE_TABLA_CARTERA", "cartera_junta")
|
||||
out = []
|
||||
if not url or not key:
|
||||
return out
|
||||
headers = {"apikey": key, "Authorization": f"Bearer {key}"}
|
||||
paso, desde = 1000, 0
|
||||
try:
|
||||
while True:
|
||||
r = requests.get(
|
||||
f"{url}/rest/v1/{t}",
|
||||
params={"select": "telefono,sede,programa,fecha_creada,asesor,es_origen,canal",
|
||||
"offset": str(desde), "limit": str(paso)},
|
||||
headers=headers, timeout=60)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if not data:
|
||||
break
|
||||
out.extend(data)
|
||||
if len(data) < paso:
|
||||
break
|
||||
desde += paso
|
||||
except Exception as e:
|
||||
print(f"[traer_cartera_rows] {e}")
|
||||
return out
|
||||
|
||||
# ── SUPABASE: mapa pauta → [conjuntos de anuncios] (tabla conjunto_pauta) ──
|
||||
def traer_conjunto_pauta(self):
|
||||
"""Devuelve dict {pauta(str): [conjunto, ...]} desde la tabla conjunto_pauta.
|
||||
Una pauta puede tener varios conjuntos de anuncios."""
|
||||
url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "")
|
||||
key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "")
|
||||
t = os.getenv("SUPABASE_TABLA_CONJUNTO", "conjunto_pauta")
|
||||
out = {}
|
||||
if not url or not key:
|
||||
return out
|
||||
try:
|
||||
r = requests.get(f"{url}/rest/v1/{t}",
|
||||
params={"select": "conjunto,pauta"},
|
||||
headers={"apikey": key, "Authorization": f"Bearer {key}"},
|
||||
timeout=30)
|
||||
r.raise_for_status()
|
||||
for row in r.json():
|
||||
pa = str(row.get("pauta") or "").strip()
|
||||
co = " ".join(str(row.get("conjunto") or "").split())
|
||||
if not pa or not co:
|
||||
continue
|
||||
out.setdefault(pa, []).append(co)
|
||||
except Exception as e:
|
||||
print(f"[traer_conjunto_pauta] {e}")
|
||||
return out
|
||||
|
||||
# ── GOOGLE SHEET (CSV publicado): importe gastado por conjunto de anuncios ──
|
||||
def traer_meta_importe(self):
|
||||
"""Lee el CSV publicado de Meta_Ads_Adsets y devuelve una lista de dicts:
|
||||
[{conjunto, importe, inicio}, ...] (una por fila del CSV, inicio = 'Inicio del informe').
|
||||
Se conserva la fecha para poder filtrar por periodo (Importe Pauta en el Mes)."""
|
||||
import csv, io
|
||||
url = os.getenv("META_CSV_URL", "")
|
||||
out = []
|
||||
if not url:
|
||||
return out
|
||||
try:
|
||||
r = requests.get(url, timeout=30)
|
||||
r.raise_for_status()
|
||||
r.encoding = "utf-8"
|
||||
rows = list(csv.reader(io.StringIO(r.text)))
|
||||
if not rows:
|
||||
return out
|
||||
enc = [" ".join(str(h or "").split()).lower() for h in rows[0]]
|
||||
|
||||
def _idx(*claves):
|
||||
for k in claves:
|
||||
for i, h in enumerate(enc):
|
||||
if k in h:
|
||||
return i
|
||||
return -1
|
||||
|
||||
# OJO: buscar "nombre del conjunto..." primero (hay tambien "ID del conjunto...").
|
||||
i_conj = _idx("nombre del conjunto de anuncios", "nombre del conjunto")
|
||||
i_imp = _idx("importe gastado")
|
||||
i_ini = _idx("inicio del informe")
|
||||
i_res = _idx("resultados")
|
||||
if i_conj < 0 or i_imp < 0:
|
||||
print(f"[traer_meta_importe] columnas no encontradas: {enc}")
|
||||
return out
|
||||
for fila in rows[1:]:
|
||||
if len(fila) <= max(i_conj, i_imp):
|
||||
continue
|
||||
co = " ".join(str(fila[i_conj] or "").split())
|
||||
if not co:
|
||||
continue
|
||||
raw = str(fila[i_imp] or "").strip().replace(",", "")
|
||||
try:
|
||||
val = float(raw) if raw else 0.0
|
||||
except ValueError:
|
||||
val = 0.0
|
||||
rraw = str(fila[i_res] or "").strip().replace(",", "") if i_res >= 0 and len(fila) > i_res else ""
|
||||
try:
|
||||
res = float(rraw) if rraw else 0.0
|
||||
except ValueError:
|
||||
res = 0.0
|
||||
ini = str(fila[i_ini] or "").strip() if i_ini >= 0 and len(fila) > i_ini else ""
|
||||
out.append({"conjunto": co, "importe": val, "resultados": res, "inicio": ini})
|
||||
except Exception as e:
|
||||
print(f"[traer_meta_importe] {e}")
|
||||
return out
|
||||
|
||||
# ── CHATWOOT: leads asignados (mensaje "Asignado a..."/auto-asignado, ultimo por telefono) ──
|
||||
def traer_leads_asignados(self):
|
||||
"""Devuelve [{telefono, fecha_asignada(date, -5h), user_name}] tomando el
|
||||
mensaje de asignacion mas reciente por telefono."""
|
||||
sql = """
|
||||
SELECT * FROM (
|
||||
SELECT DISTINCT ON (c.phone_number)
|
||||
REPLACE(REPLACE(c.phone_number, '+51', ''), '+', '') AS telefono,
|
||||
m.id AS message_id,
|
||||
(m.created_at - INTERVAL '5 hours') AS created_at,
|
||||
COALESCE(u.name, 'Sin Asesor') AS user_name
|
||||
FROM messages m
|
||||
JOIN conversations cv ON m.conversation_id = cv.id
|
||||
JOIN contacts c ON cv.contact_id = c.id
|
||||
LEFT JOIN users u ON cv.assignee_id = u.id
|
||||
WHERE (
|
||||
m.content LIKE 'Asignado a %' OR
|
||||
m.content LIKE '% auto-asignado%'
|
||||
)
|
||||
ORDER BY c.phone_number, m.id DESC
|
||||
) AS t
|
||||
ORDER BY message_id DESC
|
||||
"""
|
||||
conn = self.pg_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute(sql)
|
||||
cols = [d[0] for d in cur.description]
|
||||
filas = [dict(zip(cols, row)) for row in cur.fetchall()]
|
||||
conn.close()
|
||||
return filas
|
||||
|
||||
# ── SUPABASE: actualizar/insertar la pauta de un conjunto en conjunto_pauta ──
|
||||
def upsert_conjunto_pauta(self, conjunto, pauta):
|
||||
"""Si el conjunto ya existe en conjunto_pauta, actualiza su pauta; si no, lo crea.
|
||||
Si pauta viene vacia, elimina el vinculo (deja el conjunto sin pauta)."""
|
||||
url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "")
|
||||
key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "")
|
||||
t = os.getenv("SUPABASE_TABLA_CONJUNTO", "conjunto_pauta")
|
||||
h = {"apikey": key, "Authorization": f"Bearer {key}", "Content-Type": "application/json"}
|
||||
co = str(conjunto).strip(); pa = str(pauta or "").strip()
|
||||
# ¿existe el conjunto?
|
||||
r = requests.get(f"{url}/rest/v1/{t}",
|
||||
params={"select": "conjunto", "conjunto": f"eq.{co}"},
|
||||
headers=h, timeout=30)
|
||||
r.raise_for_status()
|
||||
existe = len(r.json()) > 0
|
||||
if not pa:
|
||||
# sin pauta -> borrar vinculo si existia
|
||||
if existe:
|
||||
requests.delete(f"{url}/rest/v1/{t}", params={"conjunto": f"eq.{co}"},
|
||||
headers=h, timeout=30).raise_for_status()
|
||||
return {"conjunto": co, "pauta": None}
|
||||
if existe:
|
||||
requests.patch(f"{url}/rest/v1/{t}", params={"conjunto": f"eq.{co}"},
|
||||
headers=h, json={"pauta": pa}, timeout=30).raise_for_status()
|
||||
else:
|
||||
requests.post(f"{url}/rest/v1/{t}", headers=h,
|
||||
json={"conjunto": co, "pauta": pa}, timeout=30).raise_for_status()
|
||||
return {"conjunto": co, "pauta": pa}
|
||||
|
||||
# ── SUPABASE: conjuntos de anuncios SIN pauta conectada (del Sheet, no en conjunto_pauta) ──
|
||||
def traer_conjuntos_sin_pauta(self):
|
||||
"""Lista de conjuntos del Google Sheet que NO estan en conjunto_pauta."""
|
||||
conj_pauta = self.traer_conjunto_pauta() # {pauta: [conjuntos]}
|
||||
conectados = set()
|
||||
for cjs in conj_pauta.values():
|
||||
for c in cjs:
|
||||
conectados.add(" ".join(str(c).split()).upper())
|
||||
vistos = {}
|
||||
for f in self.traer_meta_importe():
|
||||
nombre = " ".join(str(f.get("conjunto")).split())
|
||||
if not nombre:
|
||||
continue
|
||||
if " ".join(nombre.split()).upper() not in conectados:
|
||||
vistos[nombre] = True
|
||||
return sorted(vistos.keys())
|
||||
|
||||
# ── SUPABASE: programa_pautas -> {num_indice: [pautas]} (muchos-a-muchos) ──
|
||||
def traer_programa_pautas(self):
|
||||
url = os.getenv("SUPABASE_PAUTA_URL", ""); key = os.getenv("SUPABASE_PAUTA_KEY", "")
|
||||
t = os.getenv("SUPABASE_TABLA_PROGRAMA_PAUTAS", "programa_pautas")
|
||||
out = {}
|
||||
if not url or not key:
|
||||
return out
|
||||
try:
|
||||
r = requests.get(f"{url}/rest/v1/{t}", params={"select": "num_indice,pauta"},
|
||||
headers={"apikey": key, "Authorization": f"Bearer {key}"}, timeout=30)
|
||||
r.raise_for_status()
|
||||
for row in r.json():
|
||||
ni = str(row.get("num_indice") or "").strip()
|
||||
pa = str(row.get("pauta") or "").strip()
|
||||
if ni and pa:
|
||||
out.setdefault(ni, []).append(pa)
|
||||
except Exception as e:
|
||||
print(f"[traer_programa_pautas] {e}")
|
||||
return out
|
||||
|
||||
# ── SUPABASE: agregar un vinculo num_indice<->pauta en programa_pautas (upsert) ──
|
||||
def agregar_programa_pauta(self, num_indice, pauta):
|
||||
url = os.getenv("SUPABASE_PAUTA_URL", ""); key = os.getenv("SUPABASE_PAUTA_KEY", "")
|
||||
t = os.getenv("SUPABASE_TABLA_PROGRAMA_PAUTAS", "programa_pautas")
|
||||
h = {"apikey": key, "Authorization": f"Bearer {key}", "Content-Type": "application/json"}
|
||||
ni = str(num_indice).strip(); pa = str(pauta).strip()
|
||||
# ¿ya existe ese par?
|
||||
r = requests.get(f"{url}/rest/v1/{t}",
|
||||
params={"select": "id", "num_indice": f"eq.{ni}", "pauta": f"eq.{pa}"},
|
||||
headers=h, timeout=30)
|
||||
r.raise_for_status()
|
||||
if len(r.json()) > 0:
|
||||
return {"num_indice": ni, "pauta": pa, "ya_existia": True}
|
||||
rr = requests.post(f"{url}/rest/v1/{t}", headers=h,
|
||||
json={"num_indice": ni, "pauta": pa}, timeout=30)
|
||||
rr.raise_for_status()
|
||||
return {"num_indice": ni, "pauta": pa, "ya_existia": False}
|
||||
|
||||
# ── SUPABASE: num_indices que YA usan una pauta en basebi_programacion ──
|
||||
def num_indices_de_pauta(self, pauta):
|
||||
url = os.getenv("SUPABASE_PAUTA_URL", ""); key = os.getenv("SUPABASE_PAUTA_KEY", "")
|
||||
tabla = os.getenv("SUPABASE_TABLA_PAUTA", "basebi_programacion")
|
||||
try:
|
||||
r = requests.get(f"{url}/rest/v1/{tabla}",
|
||||
params={"select": "num_indice", "pauta_codigo": f"eq.{str(pauta).strip()}"},
|
||||
headers={"apikey": key, "Authorization": f"Bearer {key}"}, timeout=30)
|
||||
r.raise_for_status()
|
||||
return [str(x.get("num_indice")).strip() for x in r.json() if x.get("num_indice") is not None]
|
||||
except Exception as e:
|
||||
print(f"[num_indices_de_pauta] {e}")
|
||||
return []
|
||||
|
||||
# ── SUPABASE: guardar pauta y/o contar de un num_indice en basebi_programacion (upsert) ──
|
||||
def guardar_pauta_basebi(self, num_indice, pauta, contar=None):
|
||||
url = os.getenv("SUPABASE_PAUTA_URL", ""); key = os.getenv("SUPABASE_PAUTA_KEY", "")
|
||||
tabla = os.getenv("SUPABASE_TABLA_PAUTA", "basebi_programacion")
|
||||
h = {"apikey": key, "Authorization": f"Bearer {key}", "Content-Type": "application/json"}
|
||||
ni = str(num_indice).strip(); pa = str(pauta or "").strip()
|
||||
campos = {}
|
||||
if pa:
|
||||
campos["pauta_codigo"] = pa # solo actualizar pauta si viene con valor
|
||||
if contar is not None:
|
||||
campos["contar"] = str(contar).strip().upper() # "SI" / "NO"
|
||||
if not campos:
|
||||
return {"num_indice": ni, "sin_cambios": True}
|
||||
r = requests.get(f"{url}/rest/v1/{tabla}",
|
||||
params={"select": "num_indice", "num_indice": f"eq.{ni}"},
|
||||
headers=h, timeout=30)
|
||||
r.raise_for_status()
|
||||
existe = len(r.json()) > 0
|
||||
if existe:
|
||||
rr = requests.patch(f"{url}/rest/v1/{tabla}",
|
||||
params={"num_indice": f"eq.{ni}"},
|
||||
headers=h, json=campos, timeout=30)
|
||||
else:
|
||||
rr = requests.post(f"{url}/rest/v1/{tabla}",
|
||||
headers=h, json={"num_indice": ni, **campos}, timeout=30)
|
||||
rr.raise_for_status()
|
||||
return {"num_indice": ni, "pauta": pa, "contar": campos.get("contar"), "creado": not existe}
|
||||
|
||||
# ── SUPABASE: vincular un conjunto de anuncios a una pauta (insert en conjunto_pauta) ──
|
||||
def guardar_conjunto_pauta(self, conjunto, pauta):
|
||||
url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "")
|
||||
key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "")
|
||||
t = os.getenv("SUPABASE_TABLA_CONJUNTO", "conjunto_pauta")
|
||||
h = {"apikey": key, "Authorization": f"Bearer {key}", "Content-Type": "application/json"}
|
||||
rr = requests.post(f"{url}/rest/v1/{t}", headers=h,
|
||||
json={"conjunto": str(conjunto).strip(), "pauta": str(pauta).strip()},
|
||||
timeout=30)
|
||||
rr.raise_for_status()
|
||||
return {"conjunto": conjunto, "pauta": pauta}
|
||||
|
||||
# ── CHATWOOT: plantillas enviadas (para matriz de plantillas cobradas) ──
|
||||
def traer_plantillas(self):
|
||||
"""Devuelve [{plantilla, telefono, user_name, created_at_peru,
|
||||
hora_anterior_contacto, siguiente_mensaje_1}] de mensajes de plantilla
|
||||
con content_attributes vacio (ISBLANK)."""
|
||||
sql = """
|
||||
SELECT
|
||||
m.additional_attributes -> 'template_params' ->> 'name' AS plantilla,
|
||||
REPLACE(REPLACE(c.phone_number, '+51', ''), '+', '') AS telefono,
|
||||
u.name AS user_name,
|
||||
(m.created_at - INTERVAL '5 hours') AS created_at_peru,
|
||||
(SELECT (mp.created_at - INTERVAL '5 hours') FROM messages mp
|
||||
WHERE mp.conversation_id = m.conversation_id AND mp.id < m.id
|
||||
AND mp.sender_type = 'Contact' ORDER BY mp.id DESC LIMIT 1) AS hora_anterior_contacto,
|
||||
(SELECT m2.content FROM messages m2
|
||||
WHERE m2.conversation_id = m.conversation_id AND m2.id > m.id
|
||||
AND m2.sender_type = 'Contact' ORDER BY m2.id ASC LIMIT 1) AS siguiente_mensaje_1
|
||||
FROM messages m
|
||||
JOIN conversations cv ON m.conversation_id = cv.id
|
||||
JOIN contacts c ON cv.contact_id = c.id
|
||||
LEFT JOIN users u ON cv.assignee_id = u.id
|
||||
WHERE m.sender_type = 'User'
|
||||
AND m.additional_attributes -> 'template_params' ->> 'name' IS NOT NULL
|
||||
AND (m.content_attributes IS NULL
|
||||
OR m.content_attributes::text = '{}'
|
||||
OR m.content_attributes::text = 'null')
|
||||
"""
|
||||
conn = self.pg_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute(sql)
|
||||
cols = [d[0] for d in cur.description]
|
||||
filas = [dict(zip(cols, row)) for row in cur.fetchall()]
|
||||
conn.close()
|
||||
return filas
|
||||
|
||||
# ── SUPABASE: campanias -> {codigo(pauta): {sede, cargo}} (crudo, sin normalizar) ──
|
||||
def traer_campanias_sede_cargo(self):
|
||||
url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "")
|
||||
key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "")
|
||||
t = os.getenv("SUPABASE_TABLA_CAMPANIAS", "campanias")
|
||||
out = {}
|
||||
if not url or not key:
|
||||
return out
|
||||
try:
|
||||
r = requests.get(f"{url}/rest/v1/{t}", params={"select": "codigo,sede,cargo"},
|
||||
headers={"apikey": key, "Authorization": f"Bearer {key}"}, timeout=30)
|
||||
r.raise_for_status()
|
||||
for row in r.json():
|
||||
cod = str(row.get("codigo") or "").strip()
|
||||
if cod:
|
||||
out[cod] = {"sede": str(row.get("sede") or "").strip(),
|
||||
"cargo": str(row.get("cargo") or "").strip()}
|
||||
except Exception as e:
|
||||
print(f"[traer_campanias_sede_cargo] {e}")
|
||||
return out
|
||||
|
||||
# ── SQL SERVER: matrículas ──
|
||||
def traer_matriculas(self):
|
||||
sql = """
|
||||
SELECT
|
||||
sgeca_matricula.num_matricula, sgeca_matricula.num_indice,
|
||||
sgeca_matricula.fch_matricula,
|
||||
sgeca_matricula.cod_estado AS estado_matricula,
|
||||
REPLACE(REPLACE(REPLACE(sgema_alumno.dsc_telefono_1,' ',''),'+51',''),'+','') AS dsc_telefono_1,
|
||||
REPLACE(REPLACE(REPLACE(sgema_alumno.dsc_telefono_2,' ',''),'+51',''),'+','') AS dsc_telefono_2,
|
||||
sgeca_matricula.cod_moneda,
|
||||
ISNULL((SELECT SUM(c.imp_total - ISNULL(c.imp_dscto,0))
|
||||
FROM sgede_cronograma_matricula c
|
||||
WHERE c.cod_localidad = sgeca_matricula.cod_localidad
|
||||
AND c.num_matricula = sgeca_matricula.num_matricula
|
||||
AND c.num_refinanciamiento = 1), 0) AS INV_NETA,
|
||||
(SELECT rhuma_trabajador.dsc_apellido_paterno + ' ' +
|
||||
SUBSTRING(rhuma_trabajador.dsc_apellido_materno, 1, 1) + '. ' +
|
||||
rhuma_trabajador.dsc_nombres
|
||||
FROM rhuma_trabajador
|
||||
WHERE rhuma_trabajador.cod_trabajador = sgeca_matricula.cod_vendedor) AS dsc_vendedor,
|
||||
sgeca_programa.dsc_programa,
|
||||
sgede_RP_programa.dsc_det_programa AS dsc_promocion,
|
||||
ISNULL((SELECT sgede_cronograma_matricula.imp_saldo FROM sgede_cronograma_matricula
|
||||
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||
AND sgede_cronograma_matricula.num_cuota = 0), 0) AS imp_saldo_matricula,
|
||||
ISNULL((SELECT sgede_cronograma_matricula.imp_saldo FROM sgede_cronograma_matricula
|
||||
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||
AND sgede_cronograma_matricula.num_cuota = 1), 0) AS imp_saldo_cuota1
|
||||
FROM sgeca_matricula
|
||||
INNER JOIN sgeca_programa ON sgeca_matricula.cod_programa = sgeca_programa.cod_programa
|
||||
LEFT JOIN sgede_RP_programa ON sgeca_matricula.cod_periodo = sgede_RP_programa.cod_detalle
|
||||
AND sgeca_matricula.cod_programa = sgede_RP_programa.cod_programa
|
||||
AND sgeca_matricula.num_indice = sgede_RP_programa.num_indice
|
||||
INNER JOIN sgema_alumno ON sgeca_matricula.cod_alumno = sgema_alumno.cod_alumno
|
||||
WHERE sgeca_matricula.cod_localidad LIKE 'SCENT'
|
||||
AND sgeca_matricula.fch_matricula BETWEEN '01-11-2024 00:00:00.000' AND '31-12-2026 23:59:00.000'
|
||||
AND sgeca_matricula.cod_estado <> 'ANU'
|
||||
"""
|
||||
conn = self.sql_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute(sql)
|
||||
cols = [d[0] for d in cur.description]
|
||||
rows = cur.fetchall()
|
||||
conn.close()
|
||||
return [dict(zip(cols, r)) for r in rows]
|
||||
86
backend/diag_base.json
Normal file
86
backend/diag_base.json
Normal file
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"2026|2|TODOS|TODOS|TODOS": {
|
||||
"leads_recibidos": 6186,
|
||||
"leads_procesados": 1489,
|
||||
"total_matriculados": 117,
|
||||
"cursos_programados": 7,
|
||||
"pauta_total_imp": 4255.16,
|
||||
"always_total_imp": 1730.51,
|
||||
"webform_total_rec": 151,
|
||||
"webform_total_matr": 15,
|
||||
"asignados_total": 1390,
|
||||
"matriz_cursos_filas": 6
|
||||
},
|
||||
"2026|2|TODOS|TEAC|LIMA": {
|
||||
"leads_recibidos": 2198,
|
||||
"leads_procesados": 499,
|
||||
"total_matriculados": 53,
|
||||
"cursos_programados": 3,
|
||||
"pauta_total_imp": 4255.16,
|
||||
"always_total_imp": 810.03,
|
||||
"webform_total_rec": 94,
|
||||
"webform_total_matr": 9,
|
||||
"asignados_total": 1390,
|
||||
"matriz_cursos_filas": 2
|
||||
},
|
||||
"2026|2|TODOS|TERC|AREQUIPA": {
|
||||
"leads_recibidos": 325,
|
||||
"leads_procesados": 64,
|
||||
"total_matriculados": 10,
|
||||
"cursos_programados": 1,
|
||||
"pauta_total_imp": 4255.16,
|
||||
"always_total_imp": 0,
|
||||
"webform_total_rec": 5,
|
||||
"webform_total_matr": 0,
|
||||
"asignados_total": 1390,
|
||||
"matriz_cursos_filas": 1
|
||||
},
|
||||
"2026|1|TODOS|TODOS|TODOS": {
|
||||
"leads_recibidos": 7417,
|
||||
"leads_procesados": 2370,
|
||||
"total_matriculados": 128,
|
||||
"cursos_programados": 9,
|
||||
"pauta_total_imp": 4499.9,
|
||||
"always_total_imp": 776.0,
|
||||
"webform_total_rec": 143,
|
||||
"webform_total_matr": 15,
|
||||
"asignados_total": 1529,
|
||||
"matriz_cursos_filas": 5
|
||||
},
|
||||
"2026|1|TODOS|SEMINARIOS|LIMA": {
|
||||
"leads_recibidos": 946,
|
||||
"leads_procesados": 342,
|
||||
"total_matriculados": 22,
|
||||
"cursos_programados": 1,
|
||||
"pauta_total_imp": 4499.9,
|
||||
"always_total_imp": 0,
|
||||
"webform_total_rec": 1,
|
||||
"webform_total_matr": 0,
|
||||
"asignados_total": 1529,
|
||||
"matriz_cursos_filas": 0
|
||||
},
|
||||
"2026|7|TODOS|TODOS|TODOS": {
|
||||
"leads_recibidos": 1664,
|
||||
"leads_procesados": 1435,
|
||||
"total_matriculados": 59,
|
||||
"cursos_programados": 16,
|
||||
"pauta_total_imp": 2212.41,
|
||||
"always_total_imp": 1540.57,
|
||||
"webform_total_rec": 34,
|
||||
"webform_total_matr": 1,
|
||||
"asignados_total": 2275,
|
||||
"matriz_cursos_filas": 16
|
||||
},
|
||||
"2026|7|TODOS|TEAC|PIURA": {
|
||||
"leads_recibidos": 104,
|
||||
"leads_procesados": 102,
|
||||
"total_matriculados": 1,
|
||||
"cursos_programados": 1,
|
||||
"pauta_total_imp": 2212.41,
|
||||
"always_total_imp": 172.48,
|
||||
"webform_total_rec": 0,
|
||||
"webform_total_matr": 0,
|
||||
"asignados_total": 2275,
|
||||
"matriz_cursos_filas": 1
|
||||
}
|
||||
}
|
||||
38
backend/diag_base.py
Normal file
38
backend/diag_base.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# diag_base.py — Captura los numeros ACTUALES (antes de optimizar) para comparar despues.
|
||||
import json
|
||||
import services as S
|
||||
|
||||
combos = [
|
||||
("2026","2","TODOS","TODOS","TODOS"),
|
||||
("2026","2","TODOS","TEAC","LIMA"),
|
||||
("2026","2","TODOS","TERC","AREQUIPA"),
|
||||
("2026","1","TODOS","TODOS","TODOS"),
|
||||
("2026","1","TODOS","SEMINARIOS","LIMA"),
|
||||
("2026","7","TODOS","TODOS","TODOS"),
|
||||
("2026","7","TODOS","TEAC","PIURA"),
|
||||
]
|
||||
|
||||
def resumen(d):
|
||||
k = d["kpis"]
|
||||
return {
|
||||
"leads_recibidos": k["leads_recibidos"],
|
||||
"leads_procesados": k["leads_procesados"],
|
||||
"total_matriculados": k["total_matriculados"],
|
||||
"cursos_programados": k["cursos_programados"],
|
||||
"pauta_total_imp": d["tabla_pauta"]["total"].get("importe"),
|
||||
"always_total_imp": d["matriz_always"]["total"]["importe"],
|
||||
"webform_total_rec": d["matriz_webform"]["total"]["recibidos"],
|
||||
"webform_total_matr": d["matriz_webform"]["total"]["matriculas"],
|
||||
"asignados_total": d["matriz_asignados"]["total"],
|
||||
"matriz_cursos_filas": len(d["matriz_cursos"]["filas"]),
|
||||
}
|
||||
|
||||
out = {}
|
||||
for c in combos:
|
||||
d = S.leads_dashboard(*c)
|
||||
out["|".join(c)] = resumen(d)
|
||||
print("|".join(c), "->", out["|".join(c)])
|
||||
|
||||
with open("diag_base.json", "w", encoding="utf-8") as f:
|
||||
json.dump(out, f, indent=2, ensure_ascii=False)
|
||||
print("\nGuardado: diag_base.json (linea base ANTES de optimizar)")
|
||||
28
backend/diag_canal.py
Normal file
28
backend/diag_canal.py
Normal file
@@ -0,0 +1,28 @@
|
||||
# diag_canal.py — Revisa num_indice 1161: pauta en basebi vs conjuntos.
|
||||
import services as S
|
||||
|
||||
NI = "1161"
|
||||
pm = S._pauta_cruda() # {num_indice: {pauta, estado, contar}}
|
||||
cp = S._conjunto_pauta() # {pauta: [conjuntos]}
|
||||
|
||||
info = pm.get(NI)
|
||||
print(f"num_indice {NI} en basebi_programacion:", info)
|
||||
cod = info.get("pauta") if info else None
|
||||
print(f" -> pauta (cod): {cod!r}")
|
||||
print(f" -> conjuntos por esa pauta: {cp.get(str(cod).strip()) if cod else '(sin cod)'}")
|
||||
|
||||
# Buscar en la matriz esa fila
|
||||
d = S.leads_dashboard("2026","5","TODOS","TODOS","TODOS")["matriz_cursos"]
|
||||
for c in d["filas"]:
|
||||
if str(c["num_indice"]) == NI:
|
||||
print(f"\nEn la matriz -> pauta={c['pauta']!r} conjuntos={c.get('conjuntos')}")
|
||||
break
|
||||
else:
|
||||
print(f"\n(num_indice {NI} no esta en la matriz de mayo)")
|
||||
|
||||
# Buscar en conjunto_pauta si algun conjunto tiene ese nombre TEAC_TRUJILLO_MAY...
|
||||
print("\nBuscando conjunto 'TEAC_TRUJILLO_MAY' en conjunto_pauta:")
|
||||
for pauta, cjs in cp.items():
|
||||
for cj in cjs:
|
||||
if "TEAC_TRUJILLO_MAY" in str(cj).upper():
|
||||
print(f" pauta={pauta!r} -> {cj}")
|
||||
43
backend/diag_content.py
Normal file
43
backend/diag_content.py
Normal file
@@ -0,0 +1,43 @@
|
||||
# diag_content.py — Revisa que valores toma content_attributes (para replicar ISBLANK).
|
||||
# Ejecutar dentro de backend/: python diag_content.py
|
||||
from data_manager_v2 import DataManager
|
||||
|
||||
dm = DataManager()
|
||||
conn = dm.pg_conn()
|
||||
cur = conn.cursor()
|
||||
|
||||
# Distribucion de content_attributes en los mensajes de plantilla
|
||||
sql = """
|
||||
SELECT
|
||||
CASE
|
||||
WHEN m.content_attributes IS NULL THEN '(NULL)'
|
||||
WHEN m.content_attributes::text = '{}' THEN '(vacio {})'
|
||||
WHEN m.content_attributes::text = 'null' THEN "(texto 'null')"
|
||||
ELSE 'CON DATOS'
|
||||
END AS tipo,
|
||||
COUNT(*) AS n
|
||||
FROM messages m
|
||||
WHERE m.sender_type = 'User'
|
||||
AND m.additional_attributes -> 'template_params' ->> 'name' IS NOT NULL
|
||||
GROUP BY 1
|
||||
ORDER BY 2 DESC
|
||||
"""
|
||||
cur.execute(sql)
|
||||
print("== content_attributes en mensajes de plantilla ==")
|
||||
for tipo, n in cur.fetchall():
|
||||
print(f" {tipo:16} {n}")
|
||||
|
||||
# Muestra 3 ejemplos de cada tipo con datos
|
||||
print("\n== ejemplos de content_attributes CON DATOS (primeros 3) ==")
|
||||
cur.execute("""
|
||||
SELECT m.additional_attributes -> 'template_params' ->> 'name', m.content_attributes::text
|
||||
FROM messages m
|
||||
WHERE m.sender_type='User'
|
||||
AND m.additional_attributes -> 'template_params' ->> 'name' IS NOT NULL
|
||||
AND m.content_attributes IS NOT NULL
|
||||
AND m.content_attributes::text NOT IN ('{}','null')
|
||||
LIMIT 3
|
||||
""")
|
||||
for nombre, ca in cur.fetchall():
|
||||
print(f" {nombre}: {ca[:120]}")
|
||||
conn.close()
|
||||
12
backend/diag_importe.py
Normal file
12
backend/diag_importe.py
Normal file
@@ -0,0 +1,12 @@
|
||||
# diag_importe.py — Verifica matriz_asignados (pivot asesor x dia).
|
||||
import services as S
|
||||
|
||||
d = S.matriz_asignados("2026", "7", "TODOS")
|
||||
print("MES 7 2026 TOTAL asignados:", d["total"])
|
||||
print("dias (columnas):", d["dias"][:5], "...", d["dias"][-3:])
|
||||
print("\nPor asesor (total):")
|
||||
for f in d["filas"]:
|
||||
dias_con = {k:v for k,v in f["por_dia"].items() if v}
|
||||
print(f" {f['asesor']:22} total={f['total']:4} dias con datos: {len(dias_con)}")
|
||||
print("\ntotal_por_dia (primeros 10):", {k:v for k,v in list(d['total_por_dia'].items())[:10]})
|
||||
print("OK.")
|
||||
22
backend/diag_multi.py
Normal file
22
backend/diag_multi.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# diag_multi.py — Revisa programa_pautas y la suma por num_indice.
|
||||
import services as S
|
||||
from cache_manager import cache_invalidate
|
||||
|
||||
# forzar leer fresco de supabase
|
||||
cache_invalidate("programa_pautas")
|
||||
cache_invalidate("pautas_de_indice")
|
||||
|
||||
pp = S._programa_pautas() # {num_indice: [pautas]} SOLO de la tabla nueva
|
||||
print("== Tabla programa_pautas (lo que se guardo) ==")
|
||||
if not pp:
|
||||
print(" (VACIA - no se guardo nada, o no se lee)")
|
||||
for ni, ps in pp.items():
|
||||
print(f" num_indice {ni} -> {ps}")
|
||||
|
||||
ppi = S._pautas_de_indice() # combinado basebi + nueva
|
||||
print("\n== num_indices con VARIAS pautas (basebi + nueva) ==")
|
||||
multi = {ni: ps for ni, ps in ppi.items() if len(ps) > 1}
|
||||
for ni, ps in list(multi.items())[:15]:
|
||||
print(f" num_indice {ni} -> {ps}")
|
||||
if not multi:
|
||||
print(" (ninguno con varias)")
|
||||
12
backend/diag_plantillas.py
Normal file
12
backend/diag_plantillas.py
Normal file
@@ -0,0 +1,12 @@
|
||||
# diag_plantillas.py — Verifica otros_general (endpoint liviano).
|
||||
import time, services as S
|
||||
|
||||
for mes in ["TODOS", "1", "2"]:
|
||||
t0 = time.time()
|
||||
d = S.otros_general("2026", mes, "TODOS")
|
||||
seg = time.time() - t0
|
||||
p = d["matriz_plantillas"]["total"]
|
||||
print(f"MES {mes:6} ({seg:.1f}s) plantillas_env={p['enviadas']} matric={p['matriculas']} "
|
||||
f"always_filas={len(d['matriz_always']['filas'])} webform_filas={len(d['matriz_webform']['filas'])} "
|
||||
f"asignados_total={d['matriz_asignados']['total']}")
|
||||
print("OK. (2da vez el mismo mes debe ser instantaneo por cache)")
|
||||
58
backend/diag_webform.py
Normal file
58
backend/diag_webform.py
Normal file
@@ -0,0 +1,58 @@
|
||||
# diag_webform.py — Compara WEB_FORMULARIO enero: datos_unificados vs cartera_junta (local xlsx).
|
||||
# Usa el export local para cartera (rapido) y baja solo enero de datos_unificados.
|
||||
import os, requests
|
||||
from datetime import datetime
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
SUP_URL = os.getenv("SUPABASE_URL"); SUP_KEY = os.getenv("SUPABASE_KEY")
|
||||
|
||||
def _norm_tel(v):
|
||||
s = str(v or "")
|
||||
for x in ("+51","+"," ","-","(",")"): s=s.replace(x,"")
|
||||
s=s.strip()
|
||||
if not s or set(s)=={"0"}: return ""
|
||||
if s.isdigit() and len(s)<=6: return ""
|
||||
return s
|
||||
|
||||
def _ene(v):
|
||||
for fmt in ("%d/%m/%Y","%Y-%m-%d"):
|
||||
try:
|
||||
d=datetime.strptime(str(v)[:10],fmt).date()
|
||||
return d.year==2026 and d.month==1
|
||||
except: pass
|
||||
return False
|
||||
|
||||
# datos_unificados WEB_FORMULARIO enero
|
||||
h={"apikey":SUP_KEY,"Authorization":f"Bearer {SUP_KEY}"}
|
||||
out=[]; desde=0
|
||||
while True:
|
||||
r=requests.get(f"{SUP_URL}/rest/v1/datos_unificados",
|
||||
params={"select":"Telefono,Canal,Fechacreada","offset":str(desde),"limit":"1000"},headers=h,timeout=60)
|
||||
r.raise_for_status(); d=r.json()
|
||||
if not d: break
|
||||
out.extend(d)
|
||||
if len(d)<1000: break
|
||||
desde+=1000
|
||||
du=[_norm_tel(r["Telefono"]) for r in out if str(r.get("Canal","")).upper()=="WEB_FORMULARIO" and _ene(r.get("Fechacreada"))]
|
||||
du=set(t for t in du if t)
|
||||
print("datos_unificados WEB_FORMULARIO enero (tel unicos):", len(du))
|
||||
|
||||
# cartera desde el excel local
|
||||
import openpyxl, glob
|
||||
xf=max(glob.glob("cartera_junta_export.xlsx"), default=None)
|
||||
wb=openpyxl.load_workbook("cartera_junta_export.xlsx", read_only=True); ws=wb.active
|
||||
rows=list(ws.iter_rows(values_only=True)); hdr=rows[0]; ci={h:i for i,h in enumerate(hdr)}
|
||||
tel_all=set(str(r[ci["telefono"]]).strip() for r in rows[1:]) # todos los tel de cartera
|
||||
tel_web_ene=set()
|
||||
for r in rows[1:]:
|
||||
if str(r[ci["canal"]]).strip().upper()=="WEB_FORMULARIO" and str(r[ci["fecha_creada"]])[:7]=="2026-01":
|
||||
tel_web_ene.add(str(r[ci["telefono"]]).strip())
|
||||
print("cartera WEB_FORMULARIO enero (tel):", len(tel_web_ene))
|
||||
|
||||
falt_total=[t for t in du if t not in tel_all]
|
||||
falt_web=[t for t in du if t not in tel_web_ene]
|
||||
print(f"\nDe {len(du)} tel de datos_unificados enero:")
|
||||
print(f" NO estan en cartera por NINGUN canal: {len(falt_total)}")
|
||||
print(f" estan en cartera pero NO como WEB_FORMULARIO enero: {len(falt_web)-len(falt_total)}")
|
||||
print(" ejemplos NO en cartera:", falt_total[:10])
|
||||
150
backend/export_base_junta.py
Normal file
150
backend/export_base_junta.py
Normal file
@@ -0,0 +1,150 @@
|
||||
# backend/export_base_junta.py
|
||||
"""
|
||||
Une los leads de Postgre (Fact_Leads_Procesados, mapa de campañas) + Supabase,
|
||||
deduplica por teléfono quedándose con el MÁS ANTIGUO (desempate: Pauta_wsp_face),
|
||||
y exporta un Excel con: Telefono, Canal, Fecha Creada.
|
||||
|
||||
Uso: py -3.12 export_base_junta.py
|
||||
"""
|
||||
import os
|
||||
from datetime import datetime
|
||||
from dotenv import load_dotenv
|
||||
from data_manager_v2 import DataManager
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Canal preferido en caso de empate de fecha
|
||||
CANAL_PREFERIDO = "Pauta_wsp_face"
|
||||
|
||||
|
||||
def _norm_tel(v):
|
||||
s = str(v or "")
|
||||
for x in ("+51", "+", " ", "-", "(", ")"):
|
||||
s = s.replace(x, "")
|
||||
return s.strip()
|
||||
|
||||
|
||||
def _txt(v):
|
||||
"""Texto en MAYÚSCULAS; '-' o vacío → ''."""
|
||||
s = str(v or "").strip()
|
||||
if s == "-":
|
||||
s = ""
|
||||
return s.upper()
|
||||
|
||||
|
||||
def _to_dt(v):
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, datetime):
|
||||
return v
|
||||
s = str(v)[:19]
|
||||
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d", "%d/%m/%Y", "%d-%m-%Y"):
|
||||
try:
|
||||
return datetime.strptime(s[:len(fmt) + 2] if "%H" in fmt else s[:10], fmt)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def traer_postgre(dm):
|
||||
"""Mismos leads que Fact_Leads_Procesados (mapa de campañas): tel, fecha, canal(origen)."""
|
||||
filas = dm.traer_leads_chatwoot() # ya aplica el mapa de campañas y trae 'origen'
|
||||
out = []
|
||||
for f in filas:
|
||||
tel = _norm_tel(f.get("telefono"))
|
||||
if not tel:
|
||||
continue
|
||||
out.append({
|
||||
"telefono": tel,
|
||||
"fecha": _to_dt(f.get("fecha_creada")),
|
||||
"canal": _txt(f.get("origen")),
|
||||
"sede": _txt(f.get("sede")),
|
||||
"programa": _txt(f.get("programa")),
|
||||
"codigo": _txt(f.get("codigo")),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def traer_supabase(dm):
|
||||
"""Lee la tabla de Supabase con columnas: telefono, fechacreada, canal."""
|
||||
try:
|
||||
from supabase import create_client
|
||||
url = os.getenv("SUPABASE_URL", "")
|
||||
key = os.getenv("SUPABASE_KEY", "")
|
||||
if not url or not key:
|
||||
print("⚠️ Falta SUPABASE_URL / SUPABASE_KEY en .env — se omite Supabase")
|
||||
return []
|
||||
sb = create_client(url, key)
|
||||
tabla = os.getenv("SUPABASE_TABLA_LEADS", "leads") # ajustar nombre real si difiere
|
||||
res = sb.table(tabla).select("Telefono,Fechacreada,Canal,Sede,Programa,Codigo").execute()
|
||||
out = []
|
||||
for r in (res.data or []):
|
||||
tel = _norm_tel(r.get("Telefono"))
|
||||
if not tel:
|
||||
continue
|
||||
out.append({
|
||||
"telefono": tel,
|
||||
"fecha": _to_dt(r.get("Fechacreada")),
|
||||
"canal": _txt(r.get("Canal")),
|
||||
"sede": _txt(r.get("Sede")),
|
||||
"programa": _txt(r.get("Programa")),
|
||||
"codigo": _txt(r.get("Codigo")),
|
||||
})
|
||||
return out
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error leyendo Supabase: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def main():
|
||||
dm = DataManager()
|
||||
print("Trayendo Postgre (Fact_Leads_Procesados)...")
|
||||
pg = traer_postgre(dm)
|
||||
print(f" Postgre: {len(pg)} filas")
|
||||
print("Trayendo Supabase...")
|
||||
sup = traer_supabase(dm)
|
||||
print(f" Supabase: {len(sup)} filas")
|
||||
|
||||
todos = pg + sup
|
||||
print(f"Total combinado (con repetidos): {len(todos)}")
|
||||
|
||||
# Dedup por teléfono: quedarse con el MÁS ANTIGUO.
|
||||
# Desempate (misma fecha): preferir CANAL_PREFERIDO.
|
||||
FUTURO = datetime(9999, 1, 1)
|
||||
mejor = {}
|
||||
for r in todos:
|
||||
tel = r["telefono"]
|
||||
f = r["fecha"] or FUTURO
|
||||
actual = mejor.get(tel)
|
||||
if actual is None:
|
||||
mejor[tel] = r
|
||||
continue
|
||||
fa = actual["fecha"] or FUTURO
|
||||
if f < fa:
|
||||
mejor[tel] = r
|
||||
elif f == fa:
|
||||
# empate de fecha → preferir el canal preferido
|
||||
if r["canal"] == CANAL_PREFERIDO and actual["canal"] != CANAL_PREFERIDO:
|
||||
mejor[tel] = r
|
||||
|
||||
final = list(mejor.values())
|
||||
print(f"Teléfonos únicos (sin repetir): {len(final)}")
|
||||
|
||||
# Exportar a Excel
|
||||
import openpyxl
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Base_Junta"
|
||||
ws.append(["Telefono", "Canal", "Fecha Creada", "Sede", "Programa", "Codigo"])
|
||||
for r in sorted(final, key=lambda x: (x["fecha"] or FUTURO)):
|
||||
fecha = r["fecha"].strftime("%d/%m/%Y") if r["fecha"] and r["fecha"] != FUTURO else ""
|
||||
ws.append([r["telefono"], r["canal"], fecha,
|
||||
r.get("sede", ""), r.get("programa", ""), r.get("codigo", "")])
|
||||
salida = os.path.join(os.path.dirname(__file__), "base_junta.xlsx")
|
||||
wb.save(salida)
|
||||
print(f"\n✅ Exportado: {salida}")
|
||||
print(f" Filas: {len(final)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
64
backend/export_cartera.py
Normal file
64
backend/export_cartera.py
Normal file
@@ -0,0 +1,64 @@
|
||||
# export_cartera.py — Exporta TODA la tabla cartera_junta de Supabase a Excel/CSV.
|
||||
# Ejecutar dentro de backend/: python export_cartera.py
|
||||
import os, csv, requests
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
URL = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "")
|
||||
KEY = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "")
|
||||
TABLA = os.getenv("SUPABASE_TABLA_CARTERA", "cartera_junta")
|
||||
HEAD = {"apikey": KEY, "Authorization": f"Bearer {KEY}"}
|
||||
|
||||
def traer_todo():
|
||||
filas, paso, desde = [], 1000, 0
|
||||
while True:
|
||||
r = requests.get(f"{URL}/rest/v1/{TABLA}",
|
||||
params={"select": "*", "offset": str(desde), "limit": str(paso)},
|
||||
headers=HEAD, timeout=120)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if not data:
|
||||
break
|
||||
filas.extend(data)
|
||||
print(f" descargadas {len(filas)} filas...")
|
||||
if len(data) < paso:
|
||||
break
|
||||
desde += paso
|
||||
return filas
|
||||
|
||||
print(f"Descargando '{TABLA}' de Supabase...")
|
||||
filas = traer_todo()
|
||||
print(f"Total: {len(filas)} filas")
|
||||
|
||||
if not filas:
|
||||
print("Sin datos. Revisa CARTERA_URL / CARTERA_KEY en .env")
|
||||
raise SystemExit
|
||||
|
||||
# columnas = union de todas las claves, en orden de la primera fila
|
||||
cols = list(filas[0].keys())
|
||||
for f in filas:
|
||||
for k in f.keys():
|
||||
if k not in cols:
|
||||
cols.append(k)
|
||||
|
||||
# 1) CSV siempre
|
||||
csv_path = "cartera_junta_export.csv"
|
||||
with open(csv_path, "w", newline="", encoding="utf-8-sig") as fout:
|
||||
w = csv.DictWriter(fout, fieldnames=cols)
|
||||
w.writeheader()
|
||||
for f in filas:
|
||||
w.writerow({c: f.get(c, "") for c in cols})
|
||||
print(f"CSV generado: {csv_path}")
|
||||
|
||||
# 2) Excel si openpyxl esta disponible
|
||||
try:
|
||||
from openpyxl import Workbook
|
||||
wb = Workbook(); ws = wb.active; ws.title = "cartera_junta"
|
||||
ws.append(cols)
|
||||
for f in filas:
|
||||
ws.append([f.get(c, "") for c in cols])
|
||||
xlsx_path = "cartera_junta_export.xlsx"
|
||||
wb.save(xlsx_path)
|
||||
print(f"Excel generado: {xlsx_path}")
|
||||
except ImportError:
|
||||
print("(openpyxl no instalado -> solo CSV. Para Excel: pip install openpyxl)")
|
||||
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: x.lower()):
|
||||
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}
|
||||
202
backend/main.py
Normal file
202
backend/main.py
Normal file
@@ -0,0 +1,202 @@
|
||||
# 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 os
|
||||
import uvicorn
|
||||
from dotenv import load_dotenv
|
||||
|
||||
import services
|
||||
from cache_manager import start_background_refresh, cache_stats
|
||||
|
||||
load_dotenv()
|
||||
|
||||
app = FastAPI(title="Dashboard Leads API", version="1.0")
|
||||
|
||||
# Origenes permitidos para CORS. Por defecto "*" (todos). En produccion se puede
|
||||
# limitar con la variable de entorno CORS_ORIGINS (dominios separados por coma).
|
||||
_cors = os.getenv("CORS_ORIGINS", "*")
|
||||
_origins = ["*"] if _cors.strip() == "*" else [o.strip() for o in _cors.split(",") if o.strip()]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware, allow_origins=_origins, 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/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))
|
||||
|
||||
|
||||
@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__":
|
||||
port = int(os.getenv("PORT", "8001"))
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=port, 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
|
||||
984
backend/services.py
Normal file
984
backend/services.py
Normal file
@@ -0,0 +1,984 @@
|
||||
# backend/services.py
|
||||
"""Servicios: traen datos crudos (cacheados) y arman la respuesta del módulo Leads."""
|
||||
from data_manager_v2 import DataManager
|
||||
import leads_logic as L
|
||||
from cache_manager import cache_get_or_set
|
||||
|
||||
_DM = None
|
||||
|
||||
|
||||
def get_dm() -> DataManager:
|
||||
global _DM
|
||||
if _DM is None:
|
||||
_DM = DataManager()
|
||||
return _DM
|
||||
|
||||
|
||||
# ── Datos crudos cacheados (consultas pesadas, una sola vez) ────
|
||||
def _leads_crudos():
|
||||
return cache_get_or_set("leads_raw", ("GLOBAL",),
|
||||
lambda: L.procesar_leads(get_dm().traer_leads_chatwoot()))
|
||||
|
||||
|
||||
def _cursos_crudos():
|
||||
return cache_get_or_set("cursos_raw", ("GLOBAL",), lambda: get_dm().traer_cursos())
|
||||
|
||||
|
||||
def _matriculas_crudas():
|
||||
return cache_get_or_set("mat_raw", ("GLOBAL",), lambda: get_dm().traer_matriculas())
|
||||
|
||||
|
||||
def _pauta_cruda():
|
||||
return cache_get_or_set("pauta_raw", ("GLOBAL",), lambda: get_dm().traer_pauta_cursos())
|
||||
|
||||
|
||||
def _programa_pautas():
|
||||
"""{num_indice: [pautas]} desde la tabla programa_pautas (muchos-a-muchos)."""
|
||||
return cache_get_or_set("programa_pautas", ("GLOBAL",),
|
||||
lambda: get_dm().traer_programa_pautas())
|
||||
|
||||
|
||||
def _pautas_de_indice():
|
||||
"""{num_indice: [pautas]} combinando:
|
||||
- la pauta de basebi_programacion (1 por num_indice), y
|
||||
- las pautas extra de programa_pautas (varias).
|
||||
Sin duplicados. Un num_indice puede terminar con varias pautas que se SUMAN."""
|
||||
def _load():
|
||||
pm = _pauta_cruda() # {num_indice: {pauta,...}}
|
||||
extra = _programa_pautas() # {num_indice: [pautas]}
|
||||
out = {}
|
||||
for ni, info in pm.items():
|
||||
p = info.get("pauta")
|
||||
if p:
|
||||
out.setdefault(str(ni).strip(), []).append(str(p).strip())
|
||||
for ni, pautas in extra.items():
|
||||
lst = out.setdefault(str(ni).strip(), [])
|
||||
for p in pautas:
|
||||
if str(p).strip() not in lst:
|
||||
lst.append(str(p).strip())
|
||||
return out
|
||||
return cache_get_or_set("pautas_de_indice", ("GLOBAL",), _load)
|
||||
|
||||
|
||||
def _campanias_map():
|
||||
return cache_get_or_set("campanias_map", ("GLOBAL",),
|
||||
lambda: get_dm().traer_campanias_map())
|
||||
|
||||
|
||||
def _conjunto_pauta():
|
||||
return cache_get_or_set("conjunto_pauta", ("GLOBAL",),
|
||||
lambda: get_dm().traer_conjunto_pauta())
|
||||
|
||||
|
||||
def _meta_filas():
|
||||
"""Filas crudas del CSV de Meta: [{conjunto, importe, inicio}, ...]."""
|
||||
return cache_get_or_set("meta_filas", ("GLOBAL",),
|
||||
lambda: get_dm().traer_meta_importe())
|
||||
|
||||
|
||||
def _importe_por_pauta():
|
||||
"""{pauta: importe_gastado_total} = suma del importe de TODAS las filas de los
|
||||
conjuntos ligados a esa pauta (conjunto_pauta × Meta CSV). Sin filtro de fecha."""
|
||||
def _load():
|
||||
conj_pauta = _conjunto_pauta() # {pauta: [conjunto,...]}
|
||||
imp_norm = {}
|
||||
for f in _meta_filas():
|
||||
k = " ".join(str(f.get("conjunto")).split()).upper()
|
||||
imp_norm[k] = imp_norm.get(k, 0.0) + float(f.get("importe") or 0.0)
|
||||
out = {}
|
||||
for pauta, conjuntos in conj_pauta.items():
|
||||
total = sum(imp_norm.get(" ".join(str(co).split()).upper(), 0.0) for co in conjuntos)
|
||||
out[str(pauta).strip()] = round(total, 2)
|
||||
return out
|
||||
return cache_get_or_set("importe_pauta", ("GLOBAL",), _load)
|
||||
|
||||
|
||||
def _importe_por_pauta_periodo(ano, mes, dia):
|
||||
"""{pauta: importe} sumando solo las filas del CSV cuyo 'Inicio del informe'
|
||||
cae dentro del periodo filtrado (año/mes/día). Respeta el filtro de arriba."""
|
||||
def _load():
|
||||
conj_pauta = _conjunto_pauta()
|
||||
imp_norm = {}
|
||||
sin_filtro = (ano in ("TODOS", None) and mes in ("TODOS", None) and dia in ("TODOS", None))
|
||||
for f in _meta_filas():
|
||||
fx = L._to_date(f.get("inicio"))
|
||||
if sin_filtro:
|
||||
pass # sin filtro: cuenta todo (incluye filas sin fecha) = total
|
||||
elif not L._en_periodo(fx, ano, mes, dia):
|
||||
continue
|
||||
k = " ".join(str(f.get("conjunto")).split()).upper()
|
||||
imp_norm[k] = imp_norm.get(k, 0.0) + float(f.get("importe") or 0.0)
|
||||
out = {}
|
||||
for pauta, conjuntos in conj_pauta.items():
|
||||
total = sum(imp_norm.get(" ".join(str(co).split()).upper(), 0.0) for co in conjuntos)
|
||||
out[str(pauta).strip()] = round(total, 2)
|
||||
return out
|
||||
return cache_get_or_set("importe_pauta_periodo", (ano, mes, dia), _load)
|
||||
|
||||
|
||||
def _resultados_por_pauta_periodo(ano, mes, dia):
|
||||
"""{pauta: resultados} sumando solo las filas del CSV cuyo 'Inicio del informe'
|
||||
cae en el periodo filtrado. Analogo a _importe_por_pauta_periodo pero con Resultados."""
|
||||
def _load():
|
||||
conj_pauta = _conjunto_pauta()
|
||||
res_norm = {}
|
||||
sin_filtro = (ano in ("TODOS", None) and mes in ("TODOS", None) and dia in ("TODOS", None))
|
||||
for f in _meta_filas():
|
||||
if not sin_filtro and not L._en_periodo(L._to_date(f.get("inicio")), ano, mes, dia):
|
||||
continue
|
||||
k = " ".join(str(f.get("conjunto")).split()).upper()
|
||||
res_norm[k] = res_norm.get(k, 0.0) + float(f.get("resultados") or 0.0)
|
||||
out = {}
|
||||
for pauta, conjuntos in conj_pauta.items():
|
||||
total = sum(res_norm.get(" ".join(str(co).split()).upper(), 0.0) for co in conjuntos)
|
||||
out[str(pauta).strip()] = int(total)
|
||||
return out
|
||||
return cache_get_or_set("resultados_pauta_periodo", (ano, mes, dia), _load)
|
||||
|
||||
|
||||
def _campanias_sede_cargo():
|
||||
return cache_get_or_set("campanias_sc", ("GLOBAL",),
|
||||
lambda: get_dm().traer_campanias_sede_cargo())
|
||||
|
||||
|
||||
def matriz_always(ano="TODOS", mes="TODOS", dia="TODOS", sede="TODOS", programa="TODOS"):
|
||||
"""Matriz de gasto Meta Ads de pautas ALWAYS (con gasto pero SIN num_indice).
|
||||
Agrupa Sede -> Programa (TEAC/TERC/SEMINARIOS) con Importe, Resultados,
|
||||
Leads Nuevos (fecha_creada en el mes) y Leads Nuevos Asignados (con asesor).
|
||||
Sede/programa salen de la tabla campanias. Pautas sin campanias NO se cuentan."""
|
||||
def _load():
|
||||
conj_pauta = _conjunto_pauta() # {pauta: [conjuntos]}
|
||||
camp = _campanias_sede_cargo() # {pauta: {sede,cargo}}
|
||||
pauta_map = _pauta_cruda() # {num_indice: {pauta,...}}
|
||||
con_indice = {str(v.get("pauta")).strip() for v in pauta_map.values() if v.get("pauta")}
|
||||
|
||||
# Leads nuevos (unicos) por pauta = codigo, cuya fecha_creada cae en el mes filtrado
|
||||
leads = _leads_crudos()
|
||||
leads_nuevos = {} # codigo -> nº leads unicos del mes
|
||||
leads_nuevos_asig = {} # codigo -> nº leads unicos del mes con asesor
|
||||
tel_lead_mes = {} # telefono -> (codigo, fecha_creada) del lead unico del mes
|
||||
for l in leads:
|
||||
if not l.get("es_unico"):
|
||||
continue
|
||||
if not L._en_periodo(l.get("fecha"), ano, mes, dia):
|
||||
continue
|
||||
cod = str(l.get("codigo") or "").strip()
|
||||
if not cod:
|
||||
continue
|
||||
leads_nuevos[cod] = leads_nuevos.get(cod, 0) + 1
|
||||
if str(l.get("asesor") or "").strip():
|
||||
leads_nuevos_asig[cod] = leads_nuevos_asig.get(cod, 0) + 1
|
||||
tel = str(l.get("telefono") or "").strip()
|
||||
if tel:
|
||||
tel_lead_mes[tel] = (cod, l.get("fecha"))
|
||||
|
||||
# MATRICULAS por pauta: matriculas cuyo telefono = un lead nuevo del mes, y con
|
||||
# fch_matricula >= fecha_creada del lead. Cuenta CADA matricula (una vez c/u),
|
||||
# atribuida a la pauta del lead. Cruce por telefono (tel_1, o tel_2 si vacio).
|
||||
matriculas_pauta = {} # codigo -> nº matriculas
|
||||
venta_pauta = {} # codigo -> suma INV_NETA en soles (DOL ×3.34)
|
||||
_vistas = set() # (num_matricula) para no contar 2 veces
|
||||
for m in _matriculas_crudas():
|
||||
t1 = str(m.get("dsc_telefono_1") or "").strip()
|
||||
t2 = str(m.get("dsc_telefono_2") or "").strip()
|
||||
ph = t1 if t1 else t2
|
||||
if ph not in tel_lead_mes:
|
||||
continue
|
||||
cod, f_lead = tel_lead_mes[ph]
|
||||
fm = L._to_date(m.get("fch_matricula"))
|
||||
if not fm or not f_lead or fm < f_lead:
|
||||
continue # matricula anterior al ingreso del lead -> no cuenta
|
||||
nm = str(m.get("num_matricula"))
|
||||
if nm in _vistas:
|
||||
continue
|
||||
_vistas.add(nm)
|
||||
matriculas_pauta[cod] = matriculas_pauta.get(cod, 0) + 1
|
||||
# INV_NETA en soles: si moneda DOL, ×3.34 (igual que tabla por Sede)
|
||||
try:
|
||||
inv = float(m.get("INV_NETA", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
inv = 0.0
|
||||
if str(m.get("cod_moneda", "")).strip().upper() == "DOL":
|
||||
inv *= 3.34
|
||||
venta_pauta[cod] = venta_pauta.get(cod, 0.0) + inv
|
||||
|
||||
# Importe y Resultados por conjunto, filtrando por el MES de arriba
|
||||
imp = {}; res = {}
|
||||
sin_filtro = (ano in ("TODOS", None) and mes in ("TODOS", None) and dia in ("TODOS", None))
|
||||
for f in _meta_filas():
|
||||
if not sin_filtro and not L._en_periodo(L._to_date(f.get("inicio")), ano, mes, dia):
|
||||
continue
|
||||
k = " ".join(str(f.get("conjunto")).split()).upper()
|
||||
imp[k] = imp.get(k, 0.0) + float(f.get("importe") or 0.0)
|
||||
res[k] = res.get(k, 0.0) + float(f.get("resultados") or 0.0)
|
||||
|
||||
# Acumular por Sede -> Programa -> Pauta (solo pautas ALWAYS que esten en campanias)
|
||||
from collections import defaultdict
|
||||
grupos = defaultdict(lambda: defaultdict(lambda: {"importe": 0.0, "resultados": 0.0,
|
||||
"nuevos": 0, "nuevos_asig": 0,
|
||||
"matriculas": 0, "venta": 0.0, "pautas": []}))
|
||||
for pauta, conjuntos in conj_pauta.items():
|
||||
p = str(pauta).strip()
|
||||
if p in con_indice: # tiene num_indice -> NO es always
|
||||
continue
|
||||
info = camp.get(p)
|
||||
if not info: # no esta en campanias -> no se cuenta
|
||||
continue
|
||||
se = str(info.get("sede") or "").upper()
|
||||
gr = L.grupo_programa_lead(info.get("cargo")) # TEAC/TERC/SEMINARIOS
|
||||
ti = sum(imp.get(" ".join(str(c).split()).upper(), 0.0) for c in conjuntos)
|
||||
tr = sum(res.get(" ".join(str(c).split()).upper(), 0.0) for c in conjuntos)
|
||||
if ti == 0 and tr == 0:
|
||||
continue # sin gasto en el mes -> no aparece
|
||||
# filtros de arriba
|
||||
if sede not in ("TODOS", None) and se != str(sede).upper():
|
||||
continue
|
||||
if programa not in ("TODOS", None) and gr != str(programa).upper():
|
||||
continue
|
||||
nv = leads_nuevos.get(p, 0)
|
||||
na = leads_nuevos_asig.get(p, 0)
|
||||
mt = matriculas_pauta.get(p, 0)
|
||||
vv = venta_pauta.get(p, 0.0)
|
||||
g = grupos[se][gr]
|
||||
g["importe"] += ti
|
||||
g["resultados"] += tr
|
||||
g["nuevos"] += nv
|
||||
g["nuevos_asig"] += na
|
||||
g["matriculas"] += mt
|
||||
g["venta"] += vv
|
||||
g["pautas"].append({"pauta": p, "importe": round(ti, 2), "resultados": int(tr),
|
||||
"nuevos": nv, "nuevos_asig": na, "matriculas": mt,
|
||||
"venta": round(vv, 0)})
|
||||
|
||||
# Armar filas: Sede -> subfilas por programa -> pautas (solo con gasto)
|
||||
filas = []
|
||||
for se in sorted(grupos.keys()):
|
||||
subs = []
|
||||
s_imp = s_res = 0.0; s_nv = s_na = s_mt = 0; s_vv = 0.0
|
||||
for gr in ("TEAC", "TERC", "SEMINARIOS"):
|
||||
if gr in grupos[se]:
|
||||
d = grupos[se][gr]
|
||||
pautas = sorted(d["pautas"], key=lambda x: -x["importe"])
|
||||
subs.append({"programa": gr, "importe": round(d["importe"], 2),
|
||||
"resultados": int(d["resultados"]),
|
||||
"nuevos": d["nuevos"], "nuevos_asig": d["nuevos_asig"],
|
||||
"matriculas": d["matriculas"], "venta": round(d["venta"], 0),
|
||||
"pautas": pautas})
|
||||
s_imp += d["importe"]; s_res += d["resultados"]
|
||||
s_nv += d["nuevos"]; s_na += d["nuevos_asig"]; s_mt += d["matriculas"]; s_vv += d["venta"]
|
||||
filas.append({"sede": se, "importe": round(s_imp, 2), "resultados": int(s_res),
|
||||
"nuevos": s_nv, "nuevos_asig": s_na, "matriculas": s_mt,
|
||||
"venta": round(s_vv, 0), "subfilas": subs})
|
||||
tot_i = round(sum(f["importe"] for f in filas), 2)
|
||||
tot_r = int(sum(f["resultados"] for f in filas))
|
||||
tot_nv = sum(f["nuevos"] for f in filas)
|
||||
tot_na = sum(f["nuevos_asig"] for f in filas)
|
||||
tot_mt = sum(f["matriculas"] for f in filas)
|
||||
tot_vv = round(sum(f["venta"] for f in filas), 0)
|
||||
return {"filas": filas, "total": {"importe": tot_i, "resultados": tot_r,
|
||||
"nuevos": tot_nv, "nuevos_asig": tot_na,
|
||||
"matriculas": tot_mt, "venta": tot_vv}}
|
||||
|
||||
return cache_get_or_set("matriz_always", (ano, mes, dia, sede, programa), _load)
|
||||
|
||||
|
||||
def matriz_web_formulario(ano="TODOS", mes="TODOS", dia="TODOS", sede="TODOS", programa="TODOS"):
|
||||
"""Matriz de leads con canal WEB_FORMULARIO (cartera_junta), agrupados Sede -> Programa.
|
||||
Valores: Leads Recibidos (todos), Leads Procesados (con asesor), Matriculas y
|
||||
Valor Venta (cruce por telefono, fch_matricula >= fecha_creada del lead)."""
|
||||
def _load():
|
||||
rows = _cartera_rows_cache()
|
||||
# 1) leads WEB_FORMULARIO cuya fila es el ORIGEN del telefono en TODA la cartera
|
||||
# (es_origen=SI) y con fecha_creada en el periodo. Asi NO cuenta si el telefono
|
||||
# ya vino antes por otro canal (igual criterio que las demas tablas).
|
||||
vistos = {} # telefono -> {fecha, sede, prog, asesor}
|
||||
for r in rows:
|
||||
if str(r.get("canal") or "").strip().upper() != "WEB_FORMULARIO":
|
||||
continue
|
||||
if str(r.get("es_origen") or "").strip().upper() != "SI":
|
||||
continue # solo el origen real del telefono
|
||||
tel = str(r.get("telefono") or "").strip()
|
||||
if not tel:
|
||||
continue
|
||||
f = L._to_date(r.get("fecha_creada"))
|
||||
if not L._en_periodo(f, ano, mes, dia):
|
||||
continue
|
||||
# sede y programa YA vienen normalizados en cartera_junta. Reducir a 4 grupos.
|
||||
se = " ".join(str(r.get("sede") or "").upper().split()) or "SIN SEDE"
|
||||
pr = " ".join(str(r.get("programa") or "").upper().split())
|
||||
if pr == "TEAC":
|
||||
gr = "TEAC"
|
||||
elif pr == "TERC":
|
||||
gr = "TERC"
|
||||
elif pr in ("SEMINARIOS", "VRF", "CO2", "DIPLOMADO", "VENTILACIÓN", "VENTILACION"):
|
||||
gr = "SEMINARIOS"
|
||||
else:
|
||||
gr = "OTROS"
|
||||
vistos[tel] = {"fecha": f, "sede": se, "prog": gr,
|
||||
"asesor": str(r.get("asesor") or "").strip()}
|
||||
|
||||
# 2) matriculas por telefono (num_matricula, fecha, inv en soles)
|
||||
from collections import defaultdict
|
||||
mat_por_tel = defaultdict(list)
|
||||
for m in _matriculas_crudas():
|
||||
t1 = str(m.get("dsc_telefono_1") or "").strip()
|
||||
t2 = str(m.get("dsc_telefono_2") or "").strip()
|
||||
ph = t1 if t1 else t2
|
||||
if not ph:
|
||||
continue
|
||||
fm = L._to_date(m.get("fch_matricula"))
|
||||
try:
|
||||
inv = float(m.get("INV_NETA", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
inv = 0.0
|
||||
if str(m.get("cod_moneda", "")).strip().upper() == "DOL":
|
||||
inv *= 3.34
|
||||
mat_por_tel[ph].append((str(m.get("num_matricula")), fm, inv))
|
||||
|
||||
# 3) agrupar Sede -> Programa
|
||||
grupos = defaultdict(lambda: defaultdict(lambda: {"recibidos": 0, "procesados": 0,
|
||||
"matriculas": 0, "venta": 0.0}))
|
||||
for tel, info in vistos.items():
|
||||
se = info["sede"]; gr = info["prog"]; f_lead = info["fecha"]
|
||||
if sede not in ("TODOS", None) and se != str(sede).upper():
|
||||
continue
|
||||
if programa not in ("TODOS", None) and gr != str(programa).upper():
|
||||
continue
|
||||
g = grupos[se][gr]
|
||||
g["recibidos"] += 1
|
||||
if info["asesor"]:
|
||||
g["procesados"] += 1
|
||||
for (nm, fm, inv) in mat_por_tel.get(tel, []):
|
||||
if fm and f_lead and fm >= f_lead:
|
||||
g["matriculas"] += 1
|
||||
g["venta"] += inv
|
||||
|
||||
# 4) filas
|
||||
filas = []
|
||||
for se in sorted(grupos.keys()):
|
||||
subs = []
|
||||
s_rec = s_proc = s_mt = 0; s_vv = 0.0
|
||||
for gr in ("TEAC", "TERC", "SEMINARIOS", "OTROS"):
|
||||
if gr in grupos[se]:
|
||||
d = grupos[se][gr]
|
||||
subs.append({"programa": gr, "recibidos": d["recibidos"],
|
||||
"procesados": d["procesados"], "matriculas": d["matriculas"],
|
||||
"venta": round(d["venta"], 0)})
|
||||
s_rec += d["recibidos"]; s_proc += d["procesados"]
|
||||
s_mt += d["matriculas"]; s_vv += d["venta"]
|
||||
filas.append({"sede": se, "recibidos": s_rec, "procesados": s_proc,
|
||||
"matriculas": s_mt, "venta": round(s_vv, 0), "subfilas": subs})
|
||||
tot = {"recibidos": sum(f["recibidos"] for f in filas),
|
||||
"procesados": sum(f["procesados"] for f in filas),
|
||||
"matriculas": sum(f["matriculas"] for f in filas),
|
||||
"venta": round(sum(f["venta"] for f in filas), 0)}
|
||||
return {"filas": filas, "total": tot}
|
||||
|
||||
return cache_get_or_set("matriz_webform", (ano, mes, dia, sede, programa), _load)
|
||||
|
||||
|
||||
def _leads_asignados_crudos():
|
||||
return cache_get_or_set("leads_asignados", ("GLOBAL",),
|
||||
lambda: get_dm().traer_leads_asignados())
|
||||
|
||||
|
||||
def matriz_asignados(ano="TODOS", mes="TODOS", dia="TODOS"):
|
||||
"""Matriz pivot: filas = asesor (user_name) con sus telefonos; columnas = dias del
|
||||
mes; valor = cantidad de telefonos asignados a ese asesor ese dia.
|
||||
Fecha = created_at del mensaje de asignacion (ya con -5h)."""
|
||||
def _load():
|
||||
import calendar
|
||||
from collections import defaultdict
|
||||
rows = _leads_asignados_crudos()
|
||||
# dias del mes filtrado (columnas)
|
||||
if mes not in ("TODOS", None) and ano not in ("TODOS", None):
|
||||
ndias = calendar.monthrange(int(ano), int(mes))[1]
|
||||
dias_cols = list(range(1, ndias + 1))
|
||||
else:
|
||||
dias_cols = list(range(1, 32))
|
||||
|
||||
# asesores excluidos de esta matriz
|
||||
EXCLUIR = {"SIN ASESOR", "COPITO RIVERA"}
|
||||
# asesor -> dia -> [telefonos]
|
||||
por = defaultdict(lambda: defaultdict(list))
|
||||
for r in rows:
|
||||
f = L._to_date(r.get("created_at"))
|
||||
if not L._en_periodo(f, ano, mes, dia):
|
||||
continue
|
||||
ase = str(r.get("user_name") or "Sin Asesor").strip() or "Sin Asesor"
|
||||
if ase.upper() in EXCLUIR:
|
||||
continue
|
||||
tel = str(r.get("telefono") or "").strip()
|
||||
por[ase][f.day].append(tel)
|
||||
|
||||
filas = []
|
||||
tot_por_dia = defaultdict(int)
|
||||
for ase in sorted(por.keys()):
|
||||
pordia = {d: len(por[ase].get(d, [])) for d in dias_cols}
|
||||
total = sum(pordia.values())
|
||||
# telefonos por dia (para expandir con +)
|
||||
tels_dia = {d: sorted(por[ase].get(d, [])) for d in dias_cols if por[ase].get(d)}
|
||||
for d, n in pordia.items():
|
||||
tot_por_dia[d] += n
|
||||
filas.append({"asesor": ase, "por_dia": pordia, "total": total, "tels_dia": tels_dia})
|
||||
filas.sort(key=lambda x: -x["total"])
|
||||
total_gral = sum(f["total"] for f in filas)
|
||||
return {"dias": dias_cols, "filas": filas,
|
||||
"total_por_dia": {d: tot_por_dia.get(d, 0) for d in dias_cols},
|
||||
"total": total_gral}
|
||||
return cache_get_or_set("matriz_asignados", (ano, mes, dia), _load)
|
||||
|
||||
|
||||
def _plantillas_crudas():
|
||||
return cache_get_or_set("plantillas_raw", ("GLOBAL",),
|
||||
lambda: get_dm().traer_plantillas())
|
||||
|
||||
|
||||
def matriz_plantillas(ano="TODOS", mes="TODOS", dia="TODOS"):
|
||||
"""Matriz de plantillas COBRADAS por plantilla. Columnas:
|
||||
ENVIADAS = Mas_de_24h=SI, created_at_peru en el periodo filtrado
|
||||
RESPONDIDAS= de esas, con respuesta del contacto (siguiente_mensaje_1)
|
||||
ASIGNADAS = de esas, con asesor Y respondida por el contacto
|
||||
MATRICULAS = matriculas de esos telefonos (fch_matricula >= envio mas antiguo). Sin filtro de mes.
|
||||
VENTA = INV_NETA (DOL x3.34) de esas matriculas."""
|
||||
from datetime import timedelta, datetime
|
||||
from collections import defaultdict
|
||||
|
||||
def _load():
|
||||
rows = _plantillas_crudas()
|
||||
UMBRAL = timedelta(hours=24)
|
||||
|
||||
def _lleno(s):
|
||||
return s is not None and str(s).strip() != ""
|
||||
|
||||
enviadas = defaultdict(int)
|
||||
respondidas = defaultdict(int)
|
||||
asignadas = defaultdict(int)
|
||||
prim_por_tel = {} # telefono -> (fecha_envio, plantilla) la cobrada mas antigua DEL PERIODO
|
||||
|
||||
for r in rows:
|
||||
plantilla = r.get("plantilla")
|
||||
envio = r.get("created_at_peru")
|
||||
f = L._to_date(envio)
|
||||
# ENVIADAS/RESP/ASIG respetan el filtro de mes (por created_at_peru)
|
||||
if not L._en_periodo(f, ano, mes, dia):
|
||||
continue
|
||||
anterior = r.get("hora_anterior_contacto")
|
||||
sig = r.get("siguiente_mensaje_1")
|
||||
es_si = False
|
||||
if plantilla == "descuento_egresados_seminarios":
|
||||
es_si = True
|
||||
elif anterior is not None and (envio - anterior) > UMBRAL:
|
||||
es_si = True
|
||||
if not es_si:
|
||||
continue
|
||||
enviadas[plantilla] += 1
|
||||
if _lleno(sig):
|
||||
respondidas[plantilla] += 1
|
||||
if _lleno(r.get("user_name")) and _lleno(sig):
|
||||
asignadas[plantilla] += 1
|
||||
tel = str(r.get("telefono") or "").strip()
|
||||
if tel and envio is not None:
|
||||
if tel not in prim_por_tel or envio < prim_por_tel[tel][0]:
|
||||
prim_por_tel[tel] = (envio, plantilla)
|
||||
|
||||
# MATRICULAS + VENTA de los telefonos filtrados (sin filtro de mes en la matricula)
|
||||
mat_por_tel = defaultdict(list)
|
||||
for m in _matriculas_crudas():
|
||||
t1 = str(m.get("dsc_telefono_1") or "").strip()
|
||||
t2 = str(m.get("dsc_telefono_2") or "").strip()
|
||||
ph = t1 if t1 else t2
|
||||
if not ph:
|
||||
continue
|
||||
fm = m.get("fch_matricula")
|
||||
if isinstance(fm, datetime): fm = fm.date()
|
||||
try:
|
||||
inv = float(m.get("INV_NETA", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
inv = 0.0
|
||||
if str(m.get("cod_moneda", "")).strip().upper() == "DOL":
|
||||
inv *= 3.34
|
||||
mat_por_tel[ph].append((str(m.get("num_matricula")), fm, inv))
|
||||
|
||||
matriculas = defaultdict(int)
|
||||
venta = defaultdict(float)
|
||||
_vistas = set()
|
||||
for tel, (fenvio, plantilla) in prim_por_tel.items():
|
||||
fenvio_d = fenvio.date() if hasattr(fenvio, "date") else fenvio
|
||||
for (nm, fm, inv) in mat_por_tel.get(tel, []):
|
||||
if not fm or nm in _vistas:
|
||||
continue
|
||||
if fm >= fenvio_d:
|
||||
_vistas.add(nm)
|
||||
matriculas[plantilla] += 1
|
||||
venta[plantilla] += inv
|
||||
|
||||
filas = []
|
||||
for p in sorted(enviadas.keys(), key=lambda x: -enviadas[x]):
|
||||
filas.append({"plantilla": p, "enviadas": enviadas[p],
|
||||
"respondidas": respondidas.get(p, 0),
|
||||
"asignadas": asignadas.get(p, 0),
|
||||
"matriculas": matriculas.get(p, 0),
|
||||
"venta": round(venta.get(p, 0.0), 0)})
|
||||
tot = {"enviadas": sum(enviadas.values()), "respondidas": sum(respondidas.values()),
|
||||
"asignadas": sum(asignadas.values()), "matriculas": sum(matriculas.values()),
|
||||
"venta": round(sum(venta.values()), 0)}
|
||||
return {"filas": filas, "total": tot}
|
||||
|
||||
return cache_get_or_set("matriz_plantillas", (ano, mes, dia), _load)
|
||||
|
||||
|
||||
def _norm_ni(x):
|
||||
"""Normaliza num_indice a texto (quita el '.0' si viniera como float)."""
|
||||
s = str(x or "").strip()
|
||||
return s[:-2] if s.endswith(".0") else s
|
||||
|
||||
|
||||
def _mat_por_indice():
|
||||
"""{num_indice: [(telefono_resuelto, fecha_matricula), ...]} desde las matrículas.
|
||||
Teléfono resuelto = dsc_telefono_1 si existe; si no, dsc_telefono_2."""
|
||||
def _load():
|
||||
from collections import defaultdict
|
||||
out = defaultdict(list)
|
||||
for m in _matriculas_crudas():
|
||||
ni = _norm_ni(m.get("num_indice"))
|
||||
t1 = str(m.get("dsc_telefono_1") or "").strip()
|
||||
t2 = str(m.get("dsc_telefono_2") or "").strip()
|
||||
fm = L._to_date(m.get("fch_matricula"))
|
||||
out[ni].append((t1 if t1 else t2, fm))
|
||||
return out
|
||||
return cache_get_or_set("mat_por_indice", ("GLOBAL",), _load)
|
||||
|
||||
|
||||
def _cartera_rows_cache():
|
||||
"""Filas crudas de cartera_junta cacheadas (se bajan de Supabase UNA sola vez).
|
||||
Evita re-descargar las 86k filas en cada llamada/cambio de mes."""
|
||||
return cache_get_or_set("cartera_rows", ("GLOBAL",),
|
||||
lambda: get_dm().traer_cartera_rows())
|
||||
|
||||
|
||||
def _cartera_idx():
|
||||
"""Devuelve {'all':..., 'asig':...}, cada uno {(sede,programa):{telefono:fecha_min}}.
|
||||
'all' = todos los teléfonos; 'asig' = solo los que tienen asesor (no vacío).
|
||||
Se baja la cartera UNA vez y queda cacheado."""
|
||||
def _load():
|
||||
from collections import defaultdict
|
||||
CANAL_PAUTA = {"COPITO", "PAUTA_WSP", "PAUTA_WSP_FACE"}
|
||||
CANAL_WEB = {"WEB_FORMULARIO", "WEB_WHATSAPP", "WHATSAPP WEB"}
|
||||
|
||||
def _clase(canal):
|
||||
if canal in CANAL_PAUTA: return "PAUTA"
|
||||
if canal in CANAL_WEB: return "WEB"
|
||||
return "OTROS"
|
||||
|
||||
# PASO 1: DEDUP por (sede, prog, telefono) -> quedarse con la fila mas antigua.
|
||||
# Se guarda la fecha, si tiene asesor, y el CANAL de esa fila ganadora.
|
||||
# (Asi el canal de PAUTA/OTROS se decide DESPUES de deduplicar, como pide el usuario.)
|
||||
ganador = {} # (sede, prog, tel) -> {"f":fecha, "asesor":bool, "canal":canal}
|
||||
tels = set()
|
||||
tels_canal = {} # telefono -> canal de su fila ganadora GLOBAL (para matriculas)
|
||||
gan_global = {} # telefono -> fecha ganadora global (para decidir canal de matriculas)
|
||||
origen = {}
|
||||
for r in _cartera_rows_cache():
|
||||
tel = str(r.get("telefono") or "").strip()
|
||||
if not tel:
|
||||
continue
|
||||
tels.add(tel)
|
||||
sede = " ".join(str(r.get("sede") or "").upper().split())
|
||||
prog = " ".join(str(r.get("programa") or "").upper().split())
|
||||
canal = " ".join(str(r.get("canal") or "").upper().split())
|
||||
f = L._to_date(r.get("fecha_creada"))
|
||||
if str(r.get("es_origen") or "").upper() == "SI" and f is not None:
|
||||
origen[tel] = f
|
||||
if f is None:
|
||||
continue
|
||||
con_asesor = bool(str(r.get("asesor") or "").strip())
|
||||
# ganador por sede+prog+tel (para la tabla)
|
||||
k = (sede, prog, tel)
|
||||
g = ganador.get(k)
|
||||
if g is None or f < g["f"]:
|
||||
ganador[k] = {"f": f, "asesor": con_asesor, "canal": canal}
|
||||
# ganador global por tel (para clasificar matriculas por canal)
|
||||
if tel not in gan_global or f < gan_global[tel]:
|
||||
gan_global[tel] = f
|
||||
tels_canal[tel] = canal
|
||||
|
||||
# PASO 2: armar indices con la fila ganadora ya deduplicada
|
||||
idx_all = defaultdict(dict); idx_asig = defaultdict(dict)
|
||||
# por clase (PAUTA/WEB/OTROS): {clase: {"all":..,"asig":..}}
|
||||
CLASES = ("PAUTA", "WEB", "OTROS")
|
||||
idx_cl = {cl: {"all": defaultdict(dict), "asig": defaultdict(dict)} for cl in CLASES}
|
||||
tels_cl = {cl: set() for cl in CLASES}
|
||||
for (sede, prog, tel), g in ganador.items():
|
||||
f = g["f"]; cl = _clase(g["canal"])
|
||||
idx_all[(sede, prog)][tel] = f
|
||||
if g["asesor"]: idx_asig[(sede, prog)][tel] = f
|
||||
idx_cl[cl]["all"][(sede, prog)][tel] = f
|
||||
if g["asesor"]: idx_cl[cl]["asig"][(sede, prog)][tel] = f
|
||||
# telefonos por clase (segun ganador GLOBAL) para matriculas
|
||||
for tel, canal in tels_canal.items():
|
||||
tels_cl[_clase(canal)].add(tel)
|
||||
|
||||
out = {"all": idx_all, "asig": idx_asig, "tels": tels, "origen": origen}
|
||||
for cl in CLASES:
|
||||
out[f"all_{cl.lower()}"] = idx_cl[cl]["all"]
|
||||
out[f"asig_{cl.lower()}"] = idx_cl[cl]["asig"]
|
||||
out[f"tels_{cl.lower()}"] = tels_cl[cl]
|
||||
return out
|
||||
return cache_get_or_set("cartera_idx", ("GLOBAL",), _load)
|
||||
|
||||
|
||||
# ── Respuesta del dashboard de Leads (con filtros) ──────────────
|
||||
def leads_dashboard(ano="TODOS", mes="TODOS", dia="TODOS", programa="TODOS", sede="TODOS"):
|
||||
def _load():
|
||||
leads = _leads_crudos()
|
||||
cursos = _cursos_crudos()
|
||||
mats = _matriculas_crudas()
|
||||
|
||||
# Filtro por PROGRAMA (4 grupos: TEAC / TERC / SEMINARIOS / OTROS).
|
||||
# Recalcula toda la pantalla filtrando las listas base ANTES de calcular.
|
||||
# - leads: grupo por 'cargo' del código de campaña
|
||||
# - cursos y matrículas: grupo por dsc_programa
|
||||
if programa not in ("TODOS", None):
|
||||
g = str(programa).upper()
|
||||
leads = [l for l in leads if L.grupo_programa_lead(l.get("cargo")) == g]
|
||||
cursos = [c for c in cursos if L.grupo_programa_curso(c.get("dsc_programa")) == g]
|
||||
mats = [m for m in mats if L.grupo_programa_curso(m.get("dsc_programa")) == g]
|
||||
|
||||
# Filtro por SEDE (recalcula toda la pantalla). Se filtran las listas base
|
||||
# ANTES de calcular, para no tocar las funciones de cálculo existentes.
|
||||
# - leads: sede POR CÓDIGO de campaña (NO sede_act; coincide con conteo real)
|
||||
# - cursos y matrículas: sede por clasificar_sede(dsc_programa)
|
||||
if sede not in ("TODOS", None):
|
||||
s = str(sede).upper()
|
||||
sede_cod = L.sede_por_codigo_map()
|
||||
leads = [l for l in leads
|
||||
if sede_cod.get(str(l.get("codigo") or "").strip()) == s]
|
||||
cursos = [c for c in cursos if L.clasificar_sede(c.get("dsc_programa")) == s]
|
||||
mats = [m for m in mats if L.clasificar_sede(m.get("dsc_programa")) == s]
|
||||
|
||||
kpis = L.kpis_leads(leads, cursos, mats, ano, mes, dia)
|
||||
tabla = L.tabla_estado_objecion(leads, ano, mes, dia)
|
||||
por_dia = L.matriculas_por_dia(mats, cursos, ano, mes, dia)
|
||||
leads_dia = L.leads_por_dia(leads, ano, mes, dia)
|
||||
pauta = L.tabla_pauta(leads, mats, ano, mes, dia,
|
||||
_importe_por_pauta_periodo(ano, mes, dia),
|
||||
_resultados_por_pauta_periodo(ano, mes, dia),
|
||||
_campanias_sede_cargo())
|
||||
ci = _cartera_idx()
|
||||
cartera_canal = {
|
||||
"PAUTA": {"all": ci.get("all_pauta"), "asig": ci.get("asig_pauta"), "tels": ci.get("tels_pauta")},
|
||||
"WEB": {"all": ci.get("all_web"), "asig": ci.get("asig_web"), "tels": ci.get("tels_web")},
|
||||
"OTROS": {"all": ci.get("all_otros"), "asig": ci.get("asig_otros"), "tels": ci.get("tels_otros")},
|
||||
}
|
||||
matriz = L.matriz_cursos(cursos, leads, ano, mes, dia, _pauta_cruda(),
|
||||
_campanias_map(), ci.get("all"), ci.get("asig"),
|
||||
_mat_por_indice(), ci.get("tels"), ci.get("origen"),
|
||||
_importe_por_pauta(),
|
||||
_importe_por_pauta_periodo(ano, mes, dia),
|
||||
cartera_canal, _conjunto_pauta(), _pautas_de_indice())
|
||||
return {"kpis": kpis, "estado_objecion": tabla, "matriculas_por_dia": por_dia,
|
||||
"leads_por_dia": leads_dia, "tabla_pauta": pauta, "matriz_cursos": matriz}
|
||||
|
||||
return cache_get_or_set("leads_dash", (ano, mes, dia, programa, sede), _load)
|
||||
|
||||
|
||||
def otros_general(ano="TODOS", mes="TODOS", dia="TODOS"):
|
||||
"""Solo las 4 matrices de la pagina 'Otros General' (sin KPIs/graficos/tabla_pauta).
|
||||
Mas liviano que leads_dashboard -> filtrar por mes es mas rapido."""
|
||||
def _load():
|
||||
return {
|
||||
"matriz_always": matriz_always(ano, mes, dia, "TODOS", "TODOS"),
|
||||
"matriz_webform": matriz_web_formulario(ano, mes, dia, "TODOS", "TODOS"),
|
||||
"matriz_asignados": matriz_asignados(ano, mes, dia),
|
||||
"matriz_plantillas": matriz_plantillas(ano, mes, dia),
|
||||
}
|
||||
return cache_get_or_set("otros_general", (ano, mes, dia), _load)
|
||||
|
||||
|
||||
def conjuntos_sin_pauta():
|
||||
"""Lista de conjuntos de anuncios sin pauta (para el desplegable del pop-up)."""
|
||||
return get_dm().traer_conjuntos_sin_pauta()
|
||||
|
||||
|
||||
def programas_ocultos():
|
||||
"""Programas con contar=NO en basebi_programacion (ocultos de la matriz).
|
||||
Cada item: {num_indice, dsc_det_programa, fch_inicio, pauta}."""
|
||||
pm = _pauta_cruda()
|
||||
ocultos_ni = {ni for ni, info in pm.items() if str(info.get("contar") or "").upper() == "NO"}
|
||||
prog_de_ni = {}
|
||||
fch_de_ni = {}
|
||||
for c in _cursos_crudos():
|
||||
ni = _norm_ni(c.get("num_indice"))
|
||||
prog_de_ni[ni] = str(c.get("dsc_det_programa") or c.get("dsc_programa") or "").strip()
|
||||
f = L._to_date(c.get("fch_inicio"))
|
||||
fch_de_ni[ni] = f.strftime("%d/%m/%Y") if f else ""
|
||||
filas = []
|
||||
for ni in ocultos_ni:
|
||||
filas.append({"num_indice": ni,
|
||||
"dsc_det_programa": prog_de_ni.get(ni, "(sin nombre)"),
|
||||
"fch_inicio": fch_de_ni.get(ni, ""),
|
||||
"pauta": (pm.get(ni) or {}).get("pauta") or ""})
|
||||
filas.sort(key=lambda x: x["dsc_det_programa"])
|
||||
return {"filas": filas}
|
||||
|
||||
|
||||
def encender_programas(num_indices):
|
||||
"""Pone contar=SI a los num_indice dados en basebi_programacion (los reactiva)."""
|
||||
from cache_manager import cache_invalidate
|
||||
dm = get_dm()
|
||||
for ni in num_indices:
|
||||
dm.guardar_pauta_basebi(ni, "", "SI") # pauta vacia -> no toca pauta, solo contar
|
||||
for pref in ("pauta_raw", "leads_dash", "otros_general", "leads_filtros"):
|
||||
cache_invalidate(pref)
|
||||
return {"encendidos": len(num_indices)}
|
||||
|
||||
|
||||
def programas_disponibles():
|
||||
"""Lista de dsc_det_programa de cursos con fch_inicio >= 2026-01-01 (para el
|
||||
desplegable de Programa en la Leyenda). Cada item: {num_indice, dsc_det_programa}."""
|
||||
from datetime import date
|
||||
corte = date(2026, 1, 1)
|
||||
vistos = {}
|
||||
for c in _cursos_crudos():
|
||||
f = L._to_date(c.get("fch_inicio"))
|
||||
if not f or f < corte:
|
||||
continue
|
||||
ni = _norm_ni(c.get("num_indice"))
|
||||
nombre = str(c.get("dsc_det_programa") or c.get("dsc_programa") or "").strip()
|
||||
if not nombre:
|
||||
continue
|
||||
finicio = f.strftime("%d/%m/%Y")
|
||||
label = f"{nombre} · {finicio}"
|
||||
vistos[ni] = {"num_indice": ni, "dsc_det_programa": nombre,
|
||||
"fch_inicio": finicio, "label": label}
|
||||
filas = sorted(vistos.values(), key=lambda x: x["label"])
|
||||
return {"programas": filas}
|
||||
|
||||
|
||||
def leyenda_anuncios():
|
||||
"""Tabla: todos los conjuntos del Sheet con su pauta (si tiene) y el programa
|
||||
(dsc_det_programa) del num_indice que usa esa pauta. Cacheado (usa insumos ya
|
||||
en memoria) para que abrir la Leyenda sea rapido."""
|
||||
def _load():
|
||||
conj_pauta = _conjunto_pauta() # cacheado
|
||||
pauta_de_conj = {}
|
||||
for pa, cjs in conj_pauta.items():
|
||||
for c in cjs:
|
||||
pauta_de_conj[" ".join(str(c).split())] = str(pa).strip()
|
||||
# pauta -> [num_indices] usando TODAS las vinculaciones (basebi + programa_pautas)
|
||||
ni_de_pauta = {}
|
||||
for ni, pautas in _pautas_de_indice().items():
|
||||
for p in pautas:
|
||||
ni_de_pauta.setdefault(str(p).strip(), []).append(str(ni).strip())
|
||||
prog_de_ni = {}
|
||||
for c in _cursos_crudos(): # cacheado
|
||||
prog_de_ni[_norm_ni(c.get("num_indice"))] = str(c.get("dsc_det_programa") or c.get("dsc_programa") or "").strip()
|
||||
def _programa(pauta):
|
||||
if not pauta:
|
||||
return ""
|
||||
nis = ni_de_pauta.get(str(pauta).strip(), [])
|
||||
nombres = [prog_de_ni.get(n, "") for n in nis if prog_de_ni.get(n)]
|
||||
return " | ".join(sorted(set(nombres)))
|
||||
filas = []
|
||||
vistos = set()
|
||||
for f in _meta_filas(): # cacheado (CSV de Meta)
|
||||
co = " ".join(str(f.get("conjunto")).split())
|
||||
if not co or co in vistos:
|
||||
continue
|
||||
vistos.add(co)
|
||||
pa = pauta_de_conj.get(co, "")
|
||||
filas.append({"conjunto": co, "pauta": pa, "programa": _programa(pa)})
|
||||
filas.sort(key=lambda x: x["conjunto"])
|
||||
return {"filas": filas}
|
||||
return cache_get_or_set("leyenda_anuncios", ("GLOBAL",), _load)
|
||||
|
||||
|
||||
def guardar_leyenda(cambios):
|
||||
"""cambios = [{conjunto, pauta, num_indice?}].
|
||||
- conjunto -> pauta: upsert en conjunto_pauta.
|
||||
- si viene num_indice: conecta ese num_indice a la pauta en basebi_programacion.
|
||||
Invalida solo cachés dependientes de pauta/conjunto (no la cartera)."""
|
||||
from cache_manager import cache_invalidate
|
||||
dm = get_dm()
|
||||
for c in cambios:
|
||||
conjunto = c.get("conjunto")
|
||||
pauta = (c.get("pauta") or "").strip()
|
||||
ni = c.get("num_indice")
|
||||
if conjunto:
|
||||
dm.upsert_conjunto_pauta(conjunto, pauta)
|
||||
# si eligió un programa (num_indice), AGREGAR el vínculo num_indice<->pauta
|
||||
# en programa_pautas (muchos-a-muchos, se SUMA, no reemplaza).
|
||||
if ni and pauta:
|
||||
dm.agregar_programa_pauta(str(ni).strip(), pauta)
|
||||
for pref in ("conjunto_pauta", "pauta_raw", "programa_pautas", "pautas_de_indice",
|
||||
"meta_filas", "importe_pauta", "importe_pauta_periodo",
|
||||
"resultados_pauta_periodo", "leads_dash", "otros_general",
|
||||
"matriz_always", "matriz_plantillas", "leyenda_anuncios"):
|
||||
cache_invalidate(pref)
|
||||
return {"guardados": len(cambios)}
|
||||
|
||||
|
||||
def uso_de_pauta(pauta, excluir_num_indice=None):
|
||||
"""Verifica si una pauta ya esta en uso. Devuelve los cursos (dsc_det_programa)
|
||||
y conjuntos que la usan (para la advertencia antes de guardar)."""
|
||||
dm = get_dm()
|
||||
nis = dm.num_indices_de_pauta(pauta)
|
||||
excl = str(excluir_num_indice or "").strip()
|
||||
nis = [n for n in nis if n != excl]
|
||||
# dsc_det_programa de esos num_indice (desde cursos de SQL, ya cacheados)
|
||||
cursos = _cursos_crudos()
|
||||
detalle = []
|
||||
for c in cursos:
|
||||
ni = _norm_ni(c.get("num_indice"))
|
||||
if ni in nis:
|
||||
detalle.append({"num_indice": ni,
|
||||
"dsc_det_programa": str(c.get("dsc_det_programa") or c.get("dsc_programa") or "").strip()})
|
||||
conjuntos = _conjunto_pauta().get(str(pauta).strip(), [])
|
||||
return {"en_uso": bool(nis) or bool(conjuntos),
|
||||
"cursos": detalle, "num_indices": nis, "conjuntos": conjuntos}
|
||||
|
||||
|
||||
def guardar_edicion_curso(num_indice, pauta, conjunto=None, contar=None):
|
||||
"""Guarda pauta y contar (SI/NO) en basebi_programacion (upsert) y, si se eligió,
|
||||
vincula el conjunto a esa pauta en conjunto_pauta. Invalida SOLO los cachés que
|
||||
dependen de pauta/conjunto (NO la cartera, para que la recarga sea rápida)."""
|
||||
from cache_manager import cache_invalidate
|
||||
dm = get_dm()
|
||||
res = dm.guardar_pauta_basebi(num_indice, pauta, contar)
|
||||
if conjunto:
|
||||
dm.guardar_conjunto_pauta(conjunto, pauta)
|
||||
# invalidar solo lo afectado por pauta/conjunto (la cartera NO se re-baja)
|
||||
for pref in ("pauta_raw", "pautas_de_indice", "conjunto_pauta", "meta_filas",
|
||||
"importe_pauta", "importe_pauta_periodo", "resultados_pauta_periodo",
|
||||
"leads_dash", "otros_general", "matriz_always", "matriz_plantillas",
|
||||
"leads_filtros"):
|
||||
cache_invalidate(pref)
|
||||
return res
|
||||
|
||||
|
||||
def _tipo_de_curso(c):
|
||||
"""Categoría de tipo de programa para el filtro (PROGRAMAS/PROVINCIA/SEMINARIOS/OTROS)."""
|
||||
dscp = c.get("dsc_programa", "")
|
||||
otros = L.clasificar_tipo_programa(dscp)
|
||||
if otros == "OTROS":
|
||||
return "OTROS"
|
||||
up = str(dscp).upper()
|
||||
cat = "TEAC" if ("AIRE ACONDICIONADO" in up or "TEAC" in up) else (
|
||||
"TERC" if ("REFRIGERACION COMERCIAL" in up or "REFRIGERACIÓN COMERCIAL" in up or "TERC" in up) else "SEM")
|
||||
sede = L.clasificar_sede(dscp)
|
||||
return L.tipo_programa_cohorte(sede, cat)
|
||||
|
||||
|
||||
# ── Listas para los filtros (años, tipos de programa) ───────────
|
||||
def opciones_filtros():
|
||||
def _load():
|
||||
cursos = _cursos_crudos()
|
||||
anos = sorted({L._to_date(c.get("fch_inicio")).year for c in cursos
|
||||
if L._to_date(c.get("fch_inicio"))}, reverse=True)
|
||||
# Grupos de programa fijos para el filtro PROGRAMA
|
||||
tipos = ["TEAC", "TERC", "SEMINARIOS", "OTROS"]
|
||||
return {"anos": anos or [2026], "tipos": tipos}
|
||||
return cache_get_or_set("leads_filtros", ("GLOBAL",), _load)
|
||||
|
||||
|
||||
# ── Alertas: valores de programa/sede no identificados en cartera_junta ──
|
||||
def alertas():
|
||||
return cache_get_or_set("leads_alertas", ("GLOBAL",),
|
||||
lambda: get_dm().traer_no_identificados())
|
||||
|
||||
|
||||
# ── Diagnóstico de cursos (para cuadrar ocupabilidad) ───────────
|
||||
def debug_cursos(ano, mes):
|
||||
cursos = _cursos_crudos()
|
||||
periodo = [c for c in cursos if L._en_periodo(L._to_date(c.get("fch_inicio")), ano, mes, "TODOS")]
|
||||
detalle = []
|
||||
tot_insc = 0
|
||||
tot_meta = 0
|
||||
for c in periodo:
|
||||
insc = int(c.get("Inscritos_Totales", 0) or 0)
|
||||
meta = L.meta_curso(c.get("dsc_programa"))
|
||||
tp = L.tipo_programa_curso(c.get("dsc_programa"))
|
||||
tot_insc += insc
|
||||
tot_meta += meta
|
||||
detalle.append({
|
||||
"num_indice": c.get("num_indice"),
|
||||
"dsc_programa": c.get("dsc_programa"),
|
||||
"fch_inicio": str(c.get("fch_inicio"))[:10],
|
||||
"cod_estado": c.get("cod_estado"),
|
||||
"inscritos": insc,
|
||||
"tipo": tp,
|
||||
"meta": meta,
|
||||
})
|
||||
return {
|
||||
"num_cursos": len(periodo),
|
||||
"suma_inscritos": tot_insc,
|
||||
"suma_meta": tot_meta,
|
||||
"ocupabilidad": round((tot_insc / tot_meta * 100), 2) if tot_meta else 0,
|
||||
"cursos": detalle,
|
||||
}
|
||||
|
||||
|
||||
# ── Precarga / refresco ─────────────────────────────────────────
|
||||
def _periodos_actual_anterior():
|
||||
"""[(ano, mes), ...] = mes actual y mes anterior (segun fecha de hoy)."""
|
||||
from datetime import date
|
||||
hoy = date.today()
|
||||
ano, mes = hoy.year, hoy.month
|
||||
if mes == 1:
|
||||
prev = (ano - 1, 12)
|
||||
else:
|
||||
prev = (ano, mes - 1)
|
||||
return [(str(ano), str(mes)), (str(prev[0]), str(prev[1]))]
|
||||
|
||||
|
||||
def precargar_todo():
|
||||
"""Precarga en segundo plano: insumos crudos + el dashboard del mes actual y
|
||||
anterior (con filtros en TODOS), para que el usuario no espere al abrir/filtrar."""
|
||||
try:
|
||||
_leads_crudos(); _cursos_crudos(); _matriculas_crudas()
|
||||
_leads_asignados_crudos()
|
||||
# Dashboard (Leads) del mes actual y anterior (lo mas usado). Otros General
|
||||
# NO se precarga aqui (sus consultas son pesadas); se calcula al entrar.
|
||||
for (a, m) in _periodos_actual_anterior():
|
||||
leads_dashboard(a, m, "TODOS", "TODOS", "TODOS")
|
||||
print(f"[precarga] listo {a}-{m}")
|
||||
_marcar_actualizacion()
|
||||
except Exception as e:
|
||||
print(f"[precarga] {e}")
|
||||
|
||||
|
||||
def precargar_otros_general():
|
||||
"""Precarga (aparte, mas pesada) las 4 matrices de Otros General del mes actual
|
||||
y anterior. Se llama en su propio hilo para no bloquear."""
|
||||
try:
|
||||
_plantillas_crudas()
|
||||
_cartera_rows_cache() # baja la cartera 1 vez (queda cacheada para todas las matrices)
|
||||
for (a, m) in _periodos_actual_anterior():
|
||||
otros_general(a, m, "TODOS")
|
||||
print(f"[precarga-otros] listo {a}-{m}")
|
||||
except Exception as e:
|
||||
print(f"[precarga-otros] {e}")
|
||||
|
||||
|
||||
# Hora de la última actualización (hora Perú)
|
||||
_ULTIMA_ACTUALIZACION = None
|
||||
|
||||
|
||||
def _marcar_actualizacion():
|
||||
global _ULTIMA_ACTUALIZACION
|
||||
from datetime import datetime, timezone, timedelta
|
||||
peru = timezone(timedelta(hours=-5))
|
||||
_ULTIMA_ACTUALIZACION = datetime.now(peru).strftime("%d/%m/%Y %H:%M")
|
||||
|
||||
|
||||
def ultima_actualizacion():
|
||||
return {"hora": _ULTIMA_ACTUALIZACION}
|
||||
|
||||
|
||||
def refrescar_todo():
|
||||
from cache_manager import cache_invalidate
|
||||
cache_invalidate() # vaciar y recargar TODO
|
||||
precargar_todo() # leads (mes actual/anterior)
|
||||
precargar_otros_general() # cartera + Otros General (para que no quede vacia)
|
||||
_marcar_actualizacion()
|
||||
7
frontend/.env.example
Normal file
7
frontend/.env.example
Normal file
@@ -0,0 +1,7 @@
|
||||
# Plantilla de variables de entorno del FRONTEND.
|
||||
# Copia este archivo como .env y completa los valores.
|
||||
|
||||
# URL pública del backend (API). Sin barra final.
|
||||
# Local: http://localhost:8001
|
||||
# Producción: https://api.tudominio.com
|
||||
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"
|
||||
}
|
||||
}
|
||||
28
frontend/src/App.jsx
Normal file
28
frontend/src/App.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useState } from "react";
|
||||
import Sidebar from "./components/Sidebar";
|
||||
import Leads from "./pages/Leads";
|
||||
import OtrosGeneral from "./pages/OtrosGeneral";
|
||||
import CampanitaAlertas from "./components/CampanitaAlertas";
|
||||
import ConfigModal from "./components/ConfigModal";
|
||||
|
||||
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 />;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<Sidebar active={pagina} onChange={setPagina} onConfig={() => setConfig(true)} />
|
||||
<main className="main">{render()}</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>
|
||||
);
|
||||
}
|
||||
313
frontend/src/components/ConfigModal.jsx
Normal file
313
frontend/src/components/ConfigModal.jsx
Normal file
@@ -0,0 +1,313 @@
|
||||
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 "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({});
|
||||
} 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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({});
|
||||
} 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: 1180, 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, 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,
|
||||
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,
|
||||
background: seccion === "programas" ? "#e2e8f0" : "transparent", color: "#1e293b" }}>
|
||||
Programas
|
||||
</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" : "Programas"}
|
||||
</div>
|
||||
<button onClick={onClose}
|
||||
style={{ background: "transparent", border: "none", fontSize: 24, cursor: "pointer", color: "#64748b" }}>×</button>
|
||||
</div>
|
||||
|
||||
{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} style={{ borderBottom: "1px solid #f1f5f9" }}>
|
||||
<td style={{ padding: "8px 14px", fontSize: 13, color: "#334155", textAlign: "left",
|
||||
borderBottom: "1px solid #f1f5f9", wordBreak: "break-word" }}>{f.conjunto}</td>
|
||||
<td style={{ padding: "8px 14px", borderBottom: "1px solid #f1f5f9" }}>
|
||||
<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", borderBottom: "1px solid #f1f5f9",
|
||||
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>
|
||||
);
|
||||
}
|
||||
76
frontend/src/components/Sidebar.jsx
Normal file
76
frontend/src/components/Sidebar.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { api } from "../lib/api";
|
||||
|
||||
const MENU = [
|
||||
{ id: "leads", ico: "🎯", label: "Leads" },
|
||||
{ id: "otros", ico: "📊", label: "Otros General" },
|
||||
];
|
||||
|
||||
export default function Sidebar({ active, onChange, onConfig }) {
|
||||
const [menuUser, setMenuUser] = useState(false);
|
||||
const [ultima, setUltima] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const cargar = () => api.ultimaActualizacion()
|
||||
.then((r) => setUltima(r.hora || "")).catch(() => {});
|
||||
cargar();
|
||||
const id = setInterval(cargar, 60 * 1000); // refresca la hora cada minuto
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
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>
|
||||
|
||||
{/* Bloque de usuario con menú desplegable */}
|
||||
{ultima && (
|
||||
<div style={{ padding: "0 16px 2px", fontSize: 10, color: "#64748b", textAlign: "left" }}>
|
||||
Actualizado: {ultima}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
41
frontend/src/lib/api.js
Normal file
41
frontend/src/lib/api.js
Normal file
@@ -0,0 +1,41 @@
|
||||
// Cliente del backend FastAPI de Leads.
|
||||
// La URL del backend se toma de la variable de entorno VITE_API_URL (definida en .env / EasyPanel).
|
||||
// Si no existe, usa localhost:8001 como valor por defecto para desarrollo local.
|
||||
const BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8001";
|
||||
|
||||
async function get(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();
|
||||
}
|
||||
|
||||
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 }),
|
||||
ultimaActualizacion: () => get("/api/ultima-actualizacion"),
|
||||
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) =>
|
||||
fetch(`${BASE_URL}/api/programas-ocultos/encender`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ num_indices }),
|
||||
}).then((r) => { if (!r.ok) throw new Error("Error al guardar"); return r.json(); }),
|
||||
guardarLeyenda: (cambios) =>
|
||||
fetch(`${BASE_URL}/api/leyenda-anuncios/guardar`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ cambios }),
|
||||
}).then((r) => { if (!r.ok) throw new Error("Error al guardar"); return r.json(); }),
|
||||
guardarPauta: (num_indice, pauta, conjunto, contar) =>
|
||||
fetch(`${BASE_URL}/api/curso/guardar-pauta`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ num_indice, pauta, conjunto, contar }),
|
||||
}).then((r) => { if (!r.ok) throw new Error("Error al guardar"); return r.json(); }),
|
||||
alertas: () => get("/api/leads/alertas"),
|
||||
};
|
||||
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>
|
||||
);
|
||||
760
frontend/src/pages/Leads.jsx
Normal file
760
frontend/src/pages/Leads.jsx
Normal file
@@ -0,0 +1,760 @@
|
||||
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)
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setReload((n) => n + 1), 15 * 60 * 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// 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(() => 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).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).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).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: "#1e40af",
|
||||
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>
|
||||
);
|
||||
}
|
||||
331
frontend/src/pages/OtrosGeneral.jsx
Normal file
331
frontend/src/pages/OtrosGeneral.jsx
Normal file
@@ -0,0 +1,331 @@
|
||||
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)
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setReload((n) => n + 1), 15 * 60 * 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
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(() => 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).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 style={{ textAlign: "left" }}>Sede</th>
|
||||
<th>Importe Gastado</th>
|
||||
<th>Resultados</th>
|
||||
<th>Leads Recibidos</th>
|
||||
<th>Leads Procesados</th>
|
||||
<th>Matrículas</th>
|
||||
<th>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: "#1e40af",
|
||||
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: "#1e40af",
|
||||
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>
|
||||
);
|
||||
}
|
||||
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; }
|
||||
td { padding: 9px 12px; font-size: 13px; border-bottom: 1px solid #f1f5f9; text-align: center; }
|
||||
tr:last-child td { border-bottom: 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