commit cd18c4ca5e9fe34cda8f994ae3f50fa179b3a68d Author: desoladorxx Date: Wed Jul 22 16:11:54 2026 -0500 Diagnóstico y fix SQL Server: SERVERNAME, TDS_Version, Puerto explicitos diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a0dfc18 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Entornos y variables sensibles +.env +.env.local +.env.*.local +*.env + +# Python +__pycache__/ +*.py[cod] +*$py.class +.venv/ +env/ +venv/ +ENV/ + +# Node / Frontend +node_modules/ +dist/ +build/ +.cache/ + +# Archivos de datos pesados / exportaciones locales +*.csv +*.xlsx + +# Logs y SO +*.log +.DS_Store +Thumbs.db +.vscode/ +.idea/ diff --git a/FUTUROS_CAMBIOS.md b/FUTUROS_CAMBIOS.md new file mode 100644 index 0000000..4a4dd82 --- /dev/null +++ b/FUTUROS_CAMBIOS.md @@ -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.) diff --git a/INICIAR_LEADS.bat b/INICIAR_LEADS.bat new file mode 100644 index 0000000..afec3d1 --- /dev/null +++ b/INICIAR_LEADS.bat @@ -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 diff --git a/OPTIMIZACION_CACHE.md b/OPTIMIZACION_CACHE.md new file mode 100644 index 0000000..a9603b5 --- /dev/null +++ b/OPTIMIZACION_CACHE.md @@ -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. diff --git a/PRUEBAS_EXPORT/export_cartera_junta.py b/PRUEBAS_EXPORT/export_cartera_junta.py new file mode 100644 index 0000000..22cb7db --- /dev/null +++ b/PRUEBAS_EXPORT/export_cartera_junta.py @@ -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() diff --git a/_run_backend.bat b/_run_backend.bat new file mode 100644 index 0000000..ecf8d40 --- /dev/null +++ b/_run_backend.bat @@ -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 diff --git a/_run_frontend.bat b/_run_frontend.bat new file mode 100644 index 0000000..3f7d2da --- /dev/null +++ b/_run_frontend.bat @@ -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 diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..eb22fba --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,37 @@ +# Configuración de Base de Datos PostgreSQL +PG_HOST=191.98.134.81 +PG_DATABASE=chatwoot_production +PG_USER=postgres +PG_PASSWORD=tu_password_aqui +PG_PORT=5432 + +# Configuración de Base de Datos SQL Server +SQL_SERVER=191.98.134.80 +SQL_DATABASE=BDUS_CK000040_0001 +SQL_USERNAME=ASEBASTIAN +SQL_PASSWORD=tu_password_aqui + +# Repositorio GitHub +GITHUB_BASE=https://raw.githubusercontent.com/aron1798/prueba1/main/DASHBOARD_LEADS + +# Configuración Supabase (Leads) +SUPABASE_URL=https://ogzjtkxnfswpbmnbhnjd.supabase.co +SUPABASE_KEY=tu_supabase_service_role_key_aqui +SUPABASE_TABLA_LEADS=datos_unificados + +# Configuración Supabase (Pauta & Cartera) +SUPABASE_PAUTA_URL=https://uztqscimtsihrzgybsyb.supabase.co +SUPABASE_PAUTA_KEY=tu_supabase_pauta_key_aqui +SUPABASE_TABLA_PAUTA=basebi_programacion + +CARTERA_URL=https://uztqscimtsihrzgybsyb.supabase.co +CARTERA_KEY=tu_cartera_key_aqui +SUPABASE_TABLA_CARTERA=cartera_junta +SUPABASE_TABLA_ALIAS=alias_normalizacion +SUPABASE_TABLA_CONJUNTO=conjunto_pauta + +# URL Google Sheets Meta CSV +META_CSV_URL=https://docs.google.com/spreadsheets/d/e/.../pub?gid=1241390845&single=true&output=csv + +# Orígenes CORS permitidos (separados por coma) +CORS_ORIGINS=https://dashboard-leads.escueladerefrigeracion.lat,http://dashboard-leads.escueladerefrigeracion.lat,http://localhost:5174,* diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..30cb3b2 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.10-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc g++ unixodbc-dev curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8001 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001"] diff --git a/backend/cache_manager.py b/backend/cache_manager.py new file mode 100644 index 0000000..9f63369 --- /dev/null +++ b/backend/cache_manager.py @@ -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") diff --git a/backend/data_manager.py b/backend/data_manager.py new file mode 100644 index 0000000..cbc7fb9 --- /dev/null +++ b/backend/data_manager.py @@ -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 --- diff --git a/backend/data_manager_v2.py b/backend/data_manager_v2.py new file mode 100644 index 0000000..bf0e1ac --- /dev/null +++ b/backend/data_manager_v2.py @@ -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] diff --git a/backend/diag_base.json b/backend/diag_base.json new file mode 100644 index 0000000..d5dbe49 --- /dev/null +++ b/backend/diag_base.json @@ -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 + } +} \ No newline at end of file diff --git a/backend/diag_base.py b/backend/diag_base.py new file mode 100644 index 0000000..ea8de7f --- /dev/null +++ b/backend/diag_base.py @@ -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)") diff --git a/backend/diag_canal.py b/backend/diag_canal.py new file mode 100644 index 0000000..a9f3ebb --- /dev/null +++ b/backend/diag_canal.py @@ -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}") diff --git a/backend/diag_content.py b/backend/diag_content.py new file mode 100644 index 0000000..1625e86 --- /dev/null +++ b/backend/diag_content.py @@ -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() diff --git a/backend/diag_importe.py b/backend/diag_importe.py new file mode 100644 index 0000000..5e8c0b7 --- /dev/null +++ b/backend/diag_importe.py @@ -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.") diff --git a/backend/diag_multi.py b/backend/diag_multi.py new file mode 100644 index 0000000..b4e4bd0 --- /dev/null +++ b/backend/diag_multi.py @@ -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)") diff --git a/backend/diag_plantillas.py b/backend/diag_plantillas.py new file mode 100644 index 0000000..0c5d259 --- /dev/null +++ b/backend/diag_plantillas.py @@ -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)") diff --git a/backend/diag_webform.py b/backend/diag_webform.py new file mode 100644 index 0000000..af612ea --- /dev/null +++ b/backend/diag_webform.py @@ -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]) diff --git a/backend/export_base_junta.py b/backend/export_base_junta.py new file mode 100644 index 0000000..e08e458 --- /dev/null +++ b/backend/export_base_junta.py @@ -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() diff --git a/backend/export_cartera.py b/backend/export_cartera.py new file mode 100644 index 0000000..df7ceb8 --- /dev/null +++ b/backend/export_cartera.py @@ -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)") diff --git a/backend/leads_logic.py b/backend/leads_logic.py new file mode 100644 index 0000000..5fd30e4 --- /dev/null +++ b/backend/leads_logic.py @@ -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} diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..dc2e36c --- /dev/null +++ b/backend/main.py @@ -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 uvicorn + +import os +from dotenv import load_dotenv + +# Cargar variables de entorno desde .env +load_dotenv() + +app = FastAPI(title="Dashboard Leads API", version="1.0") + +# Orígenes CORS configurables (por defecto permite todo o el dominio configurado) +cors_env = os.getenv("CORS_ORIGINS", "*") +origins = [o.strip() for o in cors_env.split(",") if o.strip()] if cors_env != "*" else ["*"] + +app.add_middleware( + CORSMiddleware, + allow_origins=origins if origins else ["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.on_event("startup") +def _startup(): + # Precarga en SEGUNDO PLANO (hilo aparte) para no bloquear el arranque del server. + import threading + def _precarga_bg(): + print("[startup] Precargando datos de Leads (2do plano)...") + try: + services.precargar_todo() + print("[startup] Precarga completa.") + except Exception as e: + print(f"[startup] Precarga falló: {e}") + threading.Thread(target=_precarga_bg, daemon=True).start() + # Otros General en un hilo aparte (sus consultas son mas pesadas) + def _precarga_otros_bg(): + try: + services.precargar_otros_general() + except Exception as e: + print(f"[startup] Precarga Otros falló: {e}") + threading.Thread(target=_precarga_otros_bg, daemon=True).start() + start_background_refresh(services.refrescar_todo, interval=900) + + +@app.get("/api/health") +def health(): + return {"status": "ok", "hora": datetime.now().isoformat()} + + +@app.get("/api/cache/stats") +def stats(): + return cache_stats() + + +@app.post("/api/cache/refresh") +def refresh(): + services.refrescar_todo() + return {"status": "refrescado"} + + +@app.get("/api/leads/filtros") +def get_filtros(): + try: + return services.opciones_filtros() + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/leads") +def get_leads( + ano: str = Query("TODOS"), + mes: str = Query("TODOS"), + dia: str = Query("TODOS"), + programa: str = Query("TODOS"), + sede: str = Query("TODOS"), +): + try: + return services.leads_dashboard(ano, mes, dia, programa, sede) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/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__": + uvicorn.run("main:app", host="0.0.0.0", port=8001, reload=False) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..0b80d1a --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,6 @@ +fastapi +uvicorn +python-dotenv +pyodbc +psycopg2-binary +requests diff --git a/backend/services.py b/backend/services.py new file mode 100644 index 0000000..252c8b6 --- /dev/null +++ b/backend/services.py @@ -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() diff --git a/deploy/DEPLOYMENT_GUIDE_UBUNTU.md b/deploy/DEPLOYMENT_GUIDE_UBUNTU.md new file mode 100644 index 0000000..065abea --- /dev/null +++ b/deploy/DEPLOYMENT_GUIDE_UBUNTU.md @@ -0,0 +1,164 @@ +# Guía de Despliegue en Ubuntu Server (Dashboard Leads) + +Esta guía explica el proceso paso a paso para desplegar la aplicación **Dashboard Leads** (Frontend React + Vite y Backend FastAPI) en un servidor **Ubuntu Server 20.04 / 22.04 / 24.04 LTS**. + +--- + +## 1. Requisitos Previos en el Servidor Ubuntu + +Conéctate por SSH a tu servidor e instala los paquetes necesarios: + +```bash +sudo apt update && sudo apt upgrade -y +sudo apt install -y python3 python3-pip python3-venv nodejs npm nginx certbot python3-certbot-nginx git +``` + +*(Opcional: Si requieres conectar a SQL Server mediante pyodbc en Ubuntu, instala msodbcsql18 y unixodbc-dev).* + +--- + +## 2. Estructura de Directorios en el Servidor + +Crea el directorio del proyecto y otorga permisos al usuario actual (ejemplo: `ubuntu`): + +```bash +sudo mkdir -p /var/www/dashboard-leads +sudo chown -R $USER:$USER /var/www/dashboard-leads +``` + +Sube los archivos del proyecto a `/var/www/dashboard-leads/` (o clónalos mediante git). + +--- + +## 3. Configuración del Backend (FastAPI) + +1. Ve a la carpeta `backend`: + ```bash + cd /var/www/dashboard-leads/backend + ``` + +2. Crea el entorno virtual e instala dependencias: + ```bash + python3 -m venv venv + source venv/bin/activate + pip install --upgrade pip + pip install -r requirements.txt + ``` + +3. Configura las variables de entorno `.env`: + - Copia la plantilla `.env.example`: + ```bash + cp .env.example .env + ``` + - Edita las credenciales reales de forma segura: + ```bash + nano .env + ``` + - Verifica que la variable `CORS_ORIGINS` contenga tus dominios: + ```env + CORS_ORIGINS=https://dashboard-leads.escueladerefrigeracion.lat,http://dashboard-leads.escueladerefrigeracion.lat,http://api-leads.escueladerefrigeracion.lat + ``` + +4. Prueba la ejecución manual del backend: + ```bash + uvicorn main:app --host 127.0.0.1 --port 8001 + ``` + *(Presiona `Ctrl + C` para detener después de verificar).* + +--- + +## 4. Configuración del Servicio Systemd para Backend + +1. Copia el archivo de servicio `dashboard-leads-backend.service`: + ```bash + sudo cp /var/www/dashboard-leads/deploy/dashboard-leads-backend.service /etc/systemd/system/ + ``` + +2. Recarga systemd, inicia y habilita el servicio: + ```bash + sudo systemctl daemon-reload + sudo systemctl start dashboard-leads-backend + sudo systemctl enable dashboard-leads-backend + ``` + +3. Revisa el estado del servicio: + ```bash + sudo systemctl status dashboard-leads-backend + ``` + +--- + +## 5. Configuración y Compilación del Frontend (React + Vite) + +1. Ve a la carpeta `frontend`: + ```bash + cd /var/www/dashboard-leads/frontend + ``` + +2. Configura la variable de entorno en `.env`: + ```bash + cp .env.example .env + nano .env + ``` + Asegúrate de que contenga la URL del backend: + ```env + VITE_API_BASE_URL=https://api-leads.escueladerefrigeracion.lat + ``` + *(Recomendado usar HTTPS para evitar advertencias de Mixed Content en el navegador).* + +3. Instala dependencias y genera la build de producción: + ```bash + npm install + npm run build + ``` + Esto generará los archivos finales en `/var/www/dashboard-leads/frontend/dist`. + +--- + +## 6. Configuración de Nginx + +1. Copia el archivo de configuración de Nginx: + ```bash + sudo cp /var/www/dashboard-leads/deploy/nginx-leads.conf /etc/nginx/sites-available/leads.conf + ``` + +2. Crea el enlace simbólico para activarlo: + ```bash + sudo ln -s /etc/nginx/sites-available/leads.conf /etc/nginx/sites-enabled/ + ``` + +3. Prueba la sintaxis de Nginx y recárgalo: + ```bash + sudo nginx -t + sudo systemctl reload nginx + ``` + +--- + +## 7. Configuración de Certificados SSL (HTTPS Gratis con Certbot) + +Para habilitar SSL en ambos dominios y proteger las transmisiones: + +```bash +sudo certbot --nginx -d dashboard-leads.escueladerefrigeracion.lat -d api-leads.escueladerefrigeracion.lat +``` + +Certbot actualizará automáticamente la configuración de Nginx para redirigir todo el tráfico a HTTPS de forma segura. + +--- + +## 8. Mantenimiento y Comandos Útiles + +- **Ver logs del Backend**: + ```bash + journalctl -u dashboard-leads-backend -f + ``` +- **Reiniciar el Backend**: + ```bash + sudo systemctl restart dashboard-leads-backend + ``` +- **Actualizar cambios en Frontend**: + ```bash + cd /var/www/dashboard-leads/frontend + npm run build + ``` diff --git a/deploy/dashboard-leads-backend.service b/deploy/dashboard-leads-backend.service new file mode 100644 index 0000000..c9fb96a --- /dev/null +++ b/deploy/dashboard-leads-backend.service @@ -0,0 +1,14 @@ +[Unit] +Description=Dashboard Leads Backend Service (FastAPI) +After=network.target + +[Service] +User=ubuntu +WorkingDirectory=/var/www/dashboard-leads/backend +ExecStart=/var/www/dashboard-leads/backend/venv/bin/uvicorn main:app --host 127.0.0.1 --port 8001 --workers 2 +Restart=always +RestartSec=5 +EnvironmentFile=/var/www/dashboard-leads/backend/.env + +[Install] +WantedBy=multi-user.target diff --git a/deploy/nginx-leads.conf b/deploy/nginx-leads.conf new file mode 100644 index 0000000..5309121 --- /dev/null +++ b/deploy/nginx-leads.conf @@ -0,0 +1,46 @@ +# Configuración de Nginx para Dashboard Leads (Ubuntu Server) + +# 1. FRONTEND: https://dashboard-leads.escueladerefrigeracion.lat +server { + listen 80; + server_name dashboard-leads.escueladerefrigeracion.lat; + + # Ruta a la carpeta dist del frontend construida con 'npm run build' + root /var/www/dashboard-leads/frontend/dist; + index index.html; + + # Compresión Gzip + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + location / { + try_files $uri $uri/ /index.html; + } + + # Caché de archivos estáticos + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 30d; + add_header Cache-Control "public, no-transform"; + } +} + +# 2. BACKEND: http://api-leads.escueladerefrigeracion.lat +server { + listen 80; + server_name api-leads.escueladerefrigeracion.lat; + + client_max_body_size 50M; + + location / { + proxy_pass http://127.0.0.1:8001; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Timeouts para cargas largas si aplica + proxy_connect_timeout 300s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..707c6ea --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +version: '3.8' + +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: leads_backend + restart: always + ports: + - "8001:8001" + env_file: + - ./backend/.env + environment: + - PORT=8001 + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + args: + - VITE_API_BASE_URL=http://api-leads.escueladerefrigeracion.lat + container_name: leads_frontend + restart: always + ports: + - "80:80" + depends_on: + - backend diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..3e9e112 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,3 @@ +# Plantilla de variables de entorno para el Frontend (React + Vite) +# En producción o desarrollo, reemplazar con la URL correspondiente del backend +VITE_API_BASE_URL=http://api-leads.escueladerefrigeracion.lat diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..ebd2fac --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,21 @@ +FROM node:18-alpine AS build + +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +COPY . . + +ARG VITE_API_BASE_URL=http://api-leads.escueladerefrigeracion.lat +ENV VITE_API_BASE_URL=$VITE_API_BASE_URL + +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..a467813 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Dashboard Leads — Escuela Refrigeración + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..3615cdb --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,16 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + error_page 500 502 503 504 /5x.x.html; + location = /5x.x.html { + root /usr/share/nginx/html; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..e9d6f3a --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2098 @@ +{ + "name": "dashboard-leads", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dashboard-leads", + "version": "1.0.0", + "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" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.40", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", + "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.378", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.378.tgz", + "integrity": "sha512-VinvOAuuPmdD1guEgGv5f2Qp7/vlfqOrUOMYNnOD4wj3pit8kRsQHzfIf6teyUGWo15Tg5+bOJaRunvyltpVWQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..b7ffe74 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..a9d49f3 --- /dev/null +++ b/frontend/src/App.jsx @@ -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 ; + case "otros": return ; + default: return null; + } + } + + return ( +
+ setConfig(true)} /> +
{render()}
+ + {config && setConfig(false)} />} +
+ ); +} diff --git a/frontend/src/components/CampanitaAlertas.jsx b/frontend/src/components/CampanitaAlertas.jsx new file mode 100644 index 0000000..ead6532 --- /dev/null +++ b/frontend/src/components/CampanitaAlertas.jsx @@ -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 ( +
+ + + {abierto && ( +
+
🔔 Alertas de normalización
+ {!hay ? ( +
✓ Todo identificado, nada por revisar.
+ ) : ( + <> + {(data.programa?.length > 0) && ( +
+
+ Nuevo programa no identificado: +
+ {data.programa.map((p, i) => ( +
+ {p.valor || "(vacío)"} · {p.veces} +
+ ))} +
+ )} + {(data.sede?.length > 0) && ( +
+
+ Nueva sede no identificada: +
+ {data.sede.map((s, i) => ( +
+ {s.valor || "(vacío)"} · {s.veces} +
+ ))} +
+ )} +
+ Agrégalos al diccionario (alias_normalizacion) para clasificarlos. +
+ + )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/ConfigModal.jsx b/frontend/src/components/ConfigModal.jsx new file mode 100644 index 0000000..7d7239c --- /dev/null +++ b/frontend/src/components/ConfigModal.jsx @@ -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 ( +
+ + {abierto && ( +
+
+ setQ(e.target.value)} placeholder="Buscar..." + style={{ width: "100%", padding: "8px 11px", border: "1px solid #cbd5e1", borderRadius: 8, + fontSize: 13, fontFamily: "inherit", outline: "none" }} /> +
+
+ {filtradas.length === 0 ? ( +
Sin opciones
+ ) : filtradas.map((o) => ( +
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} +
+ ))} +
+
+ )} +
+ ); +} + +// 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 ( + <> +
+
+ Aquí están los programas ocultos del Detalle por Curso. Enciende el interruptor + y guarda para que vuelvan a aparecer en la tabla del dashboard. +
+
+
+ {cargando ? ( +
Cargando...
+ ) : filas.length === 0 ? ( +
+ No hay programas ocultos. +
+ ) : ( +
+ {filas.map((f) => { + const on = !!enc[f.num_indice]; + return ( +
+
+
{f.dsc_det_programa}
+
+ Índice {f.num_indice}{f.fch_inicio ? ` · ${f.fch_inicio}` : ""}{f.pauta ? ` · Pauta ${f.pauta}` : ""} +
+
+
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" }}> +
+
+
+ ); + })} +
+ )} +
+
+ {msg} + +
+ + ); +} + +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 ( +
+
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 */} +
+
Configuración
+ + +
+ {/* Contenido */} +
+
+
+ {seccion === "leyenda" ? "Leyenda de Anuncios" : "Programas"} +
+ +
+ + {seccion === "programas" ? ( + + ) : ( + <> +
+ setBusca(e.target.value)} + placeholder="Buscar conjunto, pauta o programa..." + style={{ width: "100%", padding: "9px 12px", border: "1px solid #cbd5e1", + borderRadius: 9, fontSize: 13 }} /> +
+ +
+ {cargando ? ( +
Cargando...
+ ) : ( +
+ + + + + + + + + + + + + + + {filtradas.map((f) => ( + + + + + + ))} + +
Conjunto de AnunciosPautaPrograma
{f.conjunto} + 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" }} /> + + setEditProg((p) => ({ ...p, [f.conjunto]: o }))} /> +
+
+ )} +
+ +
+ {msg} + +
+ + )} +
+
+
+ ); +} diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx new file mode 100644 index 0000000..89d12e0 --- /dev/null +++ b/frontend/src/components/Sidebar.jsx @@ -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 ( + + ); +} diff --git a/frontend/src/components/UI.jsx b/frontend/src/components/UI.jsx new file mode 100644 index 0000000..a4d5d81 --- /dev/null +++ b/frontend/src/components/UI.jsx @@ -0,0 +1,31 @@ +export function Loader({ text = "Cargando..." }) { + return ( +
+
+
{text}
+
+ ); +} + +export function ErrorBox({ msg }) { + return
⚠️ {msg}
; +} + +export function Filters({ children }) { + return
{children}
; +} + +export function Select({ label, value, options, onChange }) { + return ( +
+ {label && } + +
+ ); +} diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js new file mode 100644 index 0000000..057d3f9 --- /dev/null +++ b/frontend/src/lib/api.js @@ -0,0 +1,41 @@ +// Cliente del backend FastAPI de Leads. +const rawUrl = import.meta.env.VITE_API_BASE_URL || "http://localhost:8001"; +const BASE_URL = rawUrl.replace(/\/+$/, ""); + + +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"), +}; diff --git a/frontend/src/lib/useColumnasAjustables.jsx b/frontend/src/lib/useColumnasAjustables.jsx new file mode 100644 index 0000000..e224f7c --- /dev/null +++ b/frontend/src/lib/useColumnasAjustables.jsx @@ -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 ( + + {anchos.map((w, i) => )} + + ); + } + + function Resizer({ index }) { + return ( + 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 }; +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..5733886 --- /dev/null +++ b/frontend/src/main.jsx @@ -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( + + + +); diff --git a/frontend/src/pages/Leads.jsx b/frontend/src/pages/Leads.jsx new file mode 100644 index 0000000..f321207 --- /dev/null +++ b/frontend/src/pages/Leads.jsx @@ -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 ( +
+

🎯 Leads

+ + + + + 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%" }} /> + ) : ( +
{modalCurso.pauta || "—"}
+ )} +
+
+ + {editando ? ( + <> +
+ Vincular Conjunto de Anuncio (opcional) +
+ + + {/* Switch: contar SI/NO */} +
+
+
Mostrar en la lista
+
+ {editContar === "SI" ? "Este programa aparece en el Detalle por Curso" : "Este programa quedará oculto"} +
+
+
{ + 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 }}> +
+
+
+ {avisoContar && editContar === "NO" && ( +
+ ⚠️ Al poner NO y guardar, este programa ya no aparecerá en la lista. + Solo podrás volver a añadirlo desde Configuración. +
+ )} + + {msgGuardar &&
{msgGuardar}
} +
+ + {(() => { + const cambioContar = editContar !== (modalCurso.contar || "SI"); + const puedeGuardar = editPauta.trim() || cambioContar; + return ( + + ); + })()} +
+ + ) : ( + <> +
+ Conjuntos de Anuncios ({(modalCurso.conjuntos || []).length}) +
+ {(modalCurso.conjuntos || []).length === 0 ? ( +
Sin conjuntos vinculados a esta pauta.
+ ) : ( +
+ {modalCurso.conjuntos.map((cj, i) => ( +
+ {cj} +
+ ))} +
+ )} +
+ +
+ + )} +
+
+ + )} + + {/* ── Advertencia: la pauta ya está en uso ── */} + {avisoUso && ( +
+
+
+ ⚠️ Esta pauta ya está en uso +
+
+
+ La pauta {editPauta} ya está asignada a: +
+ {(avisoUso.cursos || []).length > 0 && ( +
+
Programas
+ {avisoUso.cursos.map((cu, i) => ( +
+ {cu.dsc_det_programa || "(sin nombre)"} · índice {cu.num_indice} +
+ ))} +
+ )} + {(avisoUso.conjuntos || []).length > 0 && ( +
+
Conjuntos de anuncios
+ {avisoUso.conjuntos.map((cj, i) => ( +
+ {cj} +
+ ))} +
+ )} +
+ ¿Estás seguro de continuar? +
+
+ + +
+
+
+
+ )} + + ); +} + +// 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 ( +
+
+ Día {label} — {p.cantidad} matrícula(s) +
+ {detalle.length === 0 ? ( +
Sin matrículas
+ ) : detalle.map((d) => ( +
+ {d.tipo} +
+
+
+ {d.cantidad} +
+ ))} +
+ ); +} + +function KpiRow({ ico, label, value }) { + return ( +
+ {ico} +
+
{label}
+
{value}
+
+
+ ); +} diff --git a/frontend/src/pages/OtrosGeneral.jsx b/frontend/src/pages/OtrosGeneral.jsx new file mode 100644 index 0000000..ab91639 --- /dev/null +++ b/frontend/src/pages/OtrosGeneral.jsx @@ -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 ( +
+

📊 Otros General

+ + + +