commit 514bcbda45e805a1c00586e7129a5e7bf9f6e084 Author: Panchito Date: Thu Aug 20 10:54:03 2026 -0500 Dashboard Leads - version limpia para servidor (.env y config de despliegue restaurados) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..596b42b --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Dependencias del frontend (se regeneran con npm install en el servidor) +node_modules/ +frontend/node_modules/ + +# Build del frontend +dist/ +frontend/dist/ + +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ + +# Logs y temporales +*.log +_ELIMINAR/ + +# Scripts auxiliares de esta migracion (no van al repo) +SUBIR_A_GIT.bat +LIMPIAR_PARA_SERVIDOR.bat +APLICAR_ENV.bat +_ENV_NUEVOS/ + +# NOTA: backend/.env SI se versiona a proposito (repo privado interno). +# Si algun dia el repo deja de ser privado, descomenta la linea siguiente: +#backend/.env diff --git a/backend/.env b/backend/.env new file mode 100644 index 0000000..6bf4899 --- /dev/null +++ b/backend/.env @@ -0,0 +1,22 @@ +PG_HOST=191.98.134.81 +PG_DATABASE=chatwoot_production +PG_USER=postgres +PG_PASSWORD=2165$%sd3%DFG +PG_PORT=5432 +SQL_SERVER=191.98.134.80 +SQL_DATABASE=BDUS_CK000040_0001 +SQL_USERNAME=ASEBASTIAN +SQL_PASSWORD=24MY36$z>&Uf +GITHUB_BASE=https://raw.githubusercontent.com/aron1798/prueba1/main/DASHBOARD_LEADS +SUPABASE_URL=https://ogzjtkxnfswpbmnbhnjd.supabase.co +SUPABASE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9nemp0a3huZnN3cGJtbmJobmpkIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc3NjYzNDk4MywiZXhwIjoyMDkyMjEwOTgzfQ.D7XB6GIs8UvI97lcMdf6Y6-8ON2ENhh0DOaiMro2f9Y +SUPABASE_TABLA_LEADS=datos_unificados +SUPABASE_PAUTA_URL=https://uztqscimtsihrzgybsyb.supabase.co +SUPABASE_PAUTA_KEY=sb_publishable_aJCh5J6UghfKmSz6Swy4iA_orAqeHb7 +SUPABASE_TABLA_PAUTA=basebi_programacion +CARTERA_URL=https://uztqscimtsihrzgybsyb.supabase.co +CARTERA_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InV6dHFzY2ltdHNpaHJ6Z3lic3liIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc2MTMyMzQ0MCwiZXhwIjoyMDc2ODk5NDQwfQ.pvqzwJ7fBxggNka3oga7SdhiADIgIVKGEuxGbzHQj0E +SUPABASE_TABLA_CARTERA=cartera_junta +SUPABASE_TABLA_ALIAS=alias_normalizacion +SUPABASE_TABLA_CONJUNTO=conjunto_pauta +META_CSV_URL=https://docs.google.com/spreadsheets/d/e/2PACX-1vQa5BFEeF-p0-6-FuEXDXT4eb_ZK0fkYgLMxK_Ly3EJJPwQBsYDfNsSW09ppMNrH2LIiwVdAnC5q64C/pub?gid=1241390845&single=true&output=csv \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..c535160 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,48 @@ +# ============================================================ +# DASHBOARD LEADS - Plantilla de variables de entorno (backend) +# Copiar como .env y rellenar los valores reales. +# ============================================================ + +# ── Base de Datos PostgreSQL (Chatwoot) ── +PG_HOST=191.98.134.81 +PG_DATABASE=chatwoot_production +PG_USER=postgres +PG_PASSWORD=tu_password_postgres_aqui +PG_PORT=5432 + +# ── Base de Datos SQL Server (Académico) ── +SQL_SERVER=191.98.134.80 +SQL_DATABASE=BDUS_CK000040_0001 +SQL_USERNAME=tu_usuario_sql_aqui +SQL_PASSWORD=tu_password_sql_aqui +# Driver ODBC. Dejar vacío/comentado para autodetección: +# Windows -> SQL Server | Linux/Docker -> ODBC Driver 18 for SQL Server +#SQL_DRIVER=ODBC Driver 18 for SQL Server + +# ── Repositorio GitHub (mapa de campañas, opcional) ── +GITHUB_BASE=https://raw.githubusercontent.com/aron1798/prueba1/main/DASHBOARD_LEADS + +# ── Supabase (Leads) ── +SUPABASE_URL=https://ogzjtkxnfswpbmnbhnjd.supabase.co +SUPABASE_KEY=tu_supabase_service_role_key_aqui +SUPABASE_TABLA_LEADS=datos_unificados + +# ── Supabase (Pauta) ── +SUPABASE_PAUTA_URL=https://uztqscimtsihrzgybsyb.supabase.co +SUPABASE_PAUTA_KEY=tu_supabase_pauta_key_aqui +SUPABASE_TABLA_PAUTA=basebi_programacion +SUPABASE_TABLA_PROGRAMA_PAUTAS=programa_pautas + +# ── Supabase (Cartera / catálogos) ── +CARTERA_URL=https://uztqscimtsihrzgybsyb.supabase.co +CARTERA_KEY=tu_cartera_key_aqui +SUPABASE_TABLA_CARTERA=cartera_junta +SUPABASE_TABLA_ALIAS=alias_normalizacion +SUPABASE_TABLA_CONJUNTO=conjunto_pauta +SUPABASE_TABLA_CAMPANIAS=campanias + +# ── URL Google Sheets Meta CSV ── +META_CSV_URL=https://docs.google.com/spreadsheets/d/e/.../pub?gid=1241390845&single=true&output=csv + +# ── Orígenes CORS permitidos (separados por coma) ── +CORS_ORIGINS=https://dashboard-leads.escueladerefrigeracion.lat,http://dashboard-leads.escueladerefrigeracion.lat,http://localhost:5174,* diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..a900e98 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.10-slim + +# Instalar dependencias del sistema y el Driver Oficial de Microsoft ODBC 18 para SQL Server en Linux +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl gnupg2 ca-certificates unixodbc unixodbc-dev gcc g++ \ + && curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg \ + && curl -fsSL https://packages.microsoft.com/config/debian/12/prod.list > /etc/apt/sources.list.d/mssql-release.list \ + && apt-get update \ + && ACCEPT_EULA=Y apt-get install -y msodbcsql18 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8001 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001"] 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_v2.py b/backend/data_manager_v2.py new file mode 100644 index 0000000..d313689 --- /dev/null +++ b/backend/data_manager_v2.py @@ -0,0 +1,1055 @@ +# backend/data_manager.py (v2 - reescrito completo) +"""Capa de acceso a datos del módulo LEADS.""" +import os +import time +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'), +] + + +# Cache en memoria de las campañas leídas de Supabase (expira cada 15 min, +# igual que el dashboard de ventas). Así las campañas nuevas aparecen solas +# sin reiniciar el servidor. +_CAMPANIAS_CACHE = None +_CAMPANIAS_TS = 0.0 +_CAMPANIAS_TTL = 900 # 15 min + + +def _campanias_supabase(): + """Lee las campañas desde la tabla 'campanias' de Supabase (misma fuente que + la cartera). Devuelve lista de tuplas (frase,cargo,codigo,sede,dia,origen,fi,ff). + Si falla o está vacía, cae a la lista hardcodeada CAMPANIAS como respaldo. + El cache expira cada 15 min para tomar campañas nuevas sin reiniciar.""" + global _CAMPANIAS_CACHE, _CAMPANIAS_TS + if _CAMPANIAS_CACHE is not None and (time.time() - _CAMPANIAS_TS < _CAMPANIAS_TTL): + return _CAMPANIAS_CACHE + 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 url and key: + try: + r = requests.get( + f"{url}/rest/v1/{t}", + params={"select": "frase_busqueda,cargo,codigo,sede,dia,origen,fecha_inicio,fecha_fin"}, + headers={"apikey": key, "Authorization": f"Bearer {key}"}, timeout=30) + r.raise_for_status() + for row in r.json(): + frase = str(row.get("frase_busqueda") or "") + if not frase: + continue + out.append(( + frase, str(row.get("cargo") or ""), str(row.get("codigo") or ""), + str(row.get("sede") or ""), str(row.get("dia") or ""), + str(row.get("origen") or ""), + str(row.get("fecha_inicio") or "")[:10], + str(row.get("fecha_fin") or "")[:10], + )) + except Exception as e: + print(f"[_campanias_supabase] {e}") + if not out: + print("[_campanias_supabase] sin datos de Supabase -> uso lista hardcodeada") + out = list(CAMPANIAS) + _CAMPANIAS_CACHE = out + _CAMPANIAS_TS = time.time() + return out + + +def invalidar_campanias_cache(): + """Vacía el cache de campañas para forzar re-lectura de Supabase en la + próxima llamada. Se invoca desde refrescar_todo() cada 15 min, para que + las campañas se refresquen junto con el resto del dashboard.""" + global _CAMPANIAS_CACHE, _CAMPANIAS_TS + _CAMPANIAS_CACHE = None + _CAMPANIAS_TS = 0.0 + + +def _values_sql(): + """Construye el bloque VALUES (...) del mapa de campañas para el JOIN. + Lee las campañas desde la tabla 'campanias' de Supabase (ya no hardcodeadas).""" + filas = [] + for frase, cargo, codigo, sede, dia, origen, fi, ff in _campanias_supabase(): + 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 + # Driver ODBC configurable (SQL_DRIVER). Si no se define, se autodetecta: + # en Windows suele existir "SQL Server"; en Linux/Docker, msodbcsql18/17. + driver = os.getenv("SQL_DRIVER") + if not driver: + installed = pyodbc.drivers() + if "ODBC Driver 18 for SQL Server" in installed: + driver = "ODBC Driver 18 for SQL Server" + elif "ODBC Driver 17 for SQL Server" in installed: + driver = "ODBC Driver 17 for SQL Server" + elif "FreeTDS" in installed: + driver = "FreeTDS" + else: + driver = "SQL Server" + + conn_str = ( + f"DRIVER={{{driver}}};SERVER={self.sql_server};" + f"DATABASE={self.sql_db};UID={self.sql_user};PWD={self.sql_pass};" + "Encrypt=no;TrustServerCertificate=yes;" + ) + 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() + try: + cur = conn.cursor() + cur.execute(sql) + cols = [d[0] for d in cur.description] + filas = [dict(zip(cols, row)) for row in cur.fetchall()] + finally: + 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() + try: + cur = conn.cursor() + cur.execute(sql) + cols = [d[0] for d in cur.description] + filas = [dict(zip(cols, row)) for row in cur.fetchall()] + finally: + 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: asesores -> {alias_norm: correcto} y lista de correctos (los vendedores) ── + def traer_asesores(self): + """Devuelve {'map': {alias_norm: correcto}, 'lista': [correctos ordenados]} + de tipo ASESOR (solo con correcto no vacío).""" + 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") + mapa = {}; correctos = set() + if not url or not key: + return {"map": mapa, "lista": []} + try: + r = requests.get(f"{url}/rest/v1/{t}", + params={"select": "alias,correcto", "tipo": "eq.ASESOR"}, + headers={"apikey": key, "Authorization": f"Bearer {key}"}, timeout=30) + r.raise_for_status() + for row in r.json(): + al = " ".join(str(row.get("alias") or "").upper().split()) + co = str(row.get("correcto") or "").strip() + if co: + mapa[al] = co + correctos.add(co) + # el propio correcto tambien es su alias + mapa[" ".join(co.upper().split())] = co + except Exception as e: + print(f"[traer_asesores] {e}") + return {"map": mapa, "lista": sorted(correctos)} + + # ── 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 + paso, desde = 1000, 0 + sel = "telefono,sede,programa,fecha_creada,asesor,es_origen,canal,origen_base" + try: + while True: + hdr = {"apikey": key, "Authorization": f"Bearer {key}", + "Range-Unit": "items", "Range": f"{desde}-{desde + paso - 1}"} + r = requests.get(f"{url}/rest/v1/{t}", + params={"select": sel}, + headers=hdr, timeout=60) + r.raise_for_status() + data = r.json() + if not data: + break + out.extend(data) + desde += len(data) # avanza por lo realmente recibido (Supabase puede topar el bloque) + if len(data) < paso: + break + 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 + + # ── SUPABASE: asignacion manual conjunto -> sede + programa (tabla conjunto_sede_programa) ── + def traer_conjunto_sede_programa(self): + """Devuelve {conjunto_UPPER: {"sede":..., "programa":...}} desde la tabla.""" + url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "") + key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "") + t = "conjunto_sede_programa" + out = {} + if not url or not key: + return out + try: + r = requests.get(f"{url}/rest/v1/{t}", + params={"select": "conjunto,sede,programa"}, + headers={"apikey": key, "Authorization": f"Bearer {key}"}, + timeout=30) + r.raise_for_status() + for row in r.json(): + co = " ".join(str(row.get("conjunto") or "").split()) + if not co: + continue + out[co.upper()] = { + "sede": " ".join(str(row.get("sede") or "").upper().split()), + "programa": " ".join(str(row.get("programa") or "").upper().split()), + } + except Exception as e: + print(f"[traer_conjunto_sede_programa] {e}") + return out + + def guardar_conjunto_sede_programa(self, cambios): + """Upsert de asignaciones. cambios = [{conjunto, sede, programa}, ...].""" + url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "") + key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "") + t = "conjunto_sede_programa" + if not url or not key or not cambios: + return {"guardados": 0} + headers = {"apikey": key, "Authorization": f"Bearer {key}", + "Content-Type": "application/json", + "Prefer": "resolution=merge-duplicates,return=minimal"} + payload = [] + for c in cambios: + co = " ".join(str(c.get("conjunto") or "").split()) + if not co: + continue + payload.append({ + "conjunto": co, + "sede": " ".join(str(c.get("sede") or "").upper().split()) or None, + "programa": " ".join(str(c.get("programa") or "").upper().split()) or None, + }) + if not payload: + return {"guardados": 0} + r = requests.post(f"{url}/rest/v1/{t}?on_conflict=conjunto", + json=payload, headers=headers, timeout=30) + r.raise_for_status() + return {"guardados": len(payload)} + + def borrar_conjunto_sede_programa(self, conjunto): + """Borra la asignacion de un conjunto (lo deja sin sede/programa).""" + url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "") + key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "") + t = "conjunto_sede_programa" + co = " ".join(str(conjunto or "").split()) + if not url or not key or not co: + return {"borrado": 0} + headers = {"apikey": key, "Authorization": f"Bearer {key}"} + r = requests.delete(f"{url}/rest/v1/{t}", + params={"conjunto": f"eq.{co}"}, + headers=headers, timeout=30) + r.raise_for_status() + return {"borrado": 1} + + # ── 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, + cv.cached_label_list AS etiquetas + 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() + try: + cur = conn.cursor() + cur.execute(sql) + cols = [d[0] for d in cur.description] + filas = [dict(zip(cols, row)) for row in cur.fetchall()] + finally: + conn.close() + return filas + + # ── CHATWOOT: ultima asignacion por telefono + primer mensaje del asesor (respuesta) ── + def traer_asignacion_respuesta(self): + """Por telefono: la ULTIMA asignacion (T1) y el primer mensaje posterior de un + ASESOR real (sender_id 15-25) como respuesta (T2). Fechas en hora Peru (-5h). + Devuelve [{telefono, user_id, user_name, created_at(T1), respuesta_fecha(T2)}]. + Si respuesta_fecha es None -> no respondio.""" + 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, + u.id AS user_id, + COALESCE(u.name, 'Sin Asesor') AS user_name, + cv.cached_label_list AS etiquetas, + (respuesta.created_at - INTERVAL '5 hours') AS respuesta_fecha + 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 + LEFT JOIN LATERAL ( + SELECT m2.created_at + FROM messages m2 + WHERE m2.conversation_id = m.conversation_id + AND m2.id > m.id + AND m2.sender_id BETWEEN 15 AND 25 + ORDER BY m2.id ASC + LIMIT 1 + ) AS respuesta ON TRUE + 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() + try: + cur = conn.cursor() + cur.execute(sql) + cols = [d[0] for d in cur.description] + filas = [dict(zip(cols, row)) for row in cur.fetchall()] + finally: + conn.close() + return filas + + # ── CHATWOOT: actividad de asesores (para detectar turno del dia). ── + def traer_actividad_asesores(self): + """Mensajes ENVIADOS por asesores reales (sender_id 15-25), en hora Peru (-5h). + Sirve para detectar el turno de cada asesor cada dia por concentracion de + actividad. Devuelve [{user_id, created_at}]. Liviano: solo id + fecha.""" + sql = """ + SELECT + m.sender_id AS user_id, + (m.created_at - INTERVAL '5 hours') AS created_at + FROM messages m + WHERE m.sender_id BETWEEN 15 AND 25 + """ + conn = self.pg_conn() + try: + cur = conn.cursor() + cur.execute(sql) + cols = [d[0] for d in cur.description] + filas = [dict(zip(cols, row)) for row in cur.fetchall()] + finally: + 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() + try: + cur = conn.cursor() + cur.execute(sql) + cols = [d[0] for d in cur.description] + filas = [dict(zip(cols, row)) for row in cur.fetchall()] + finally: + 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 + + # ── SUPABASE campanias: LISTAR todas (con id) para el apartado Leyenda ── + def listar_campanias(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": "id,frase_busqueda,cargo,codigo,sede,dia,origen,fecha_inicio,fecha_fin", + "order": "codigo"}, + headers={"apikey": key, "Authorization": f"Bearer {key}"}, timeout=30) + r.raise_for_status() + for row in r.json(): + out.append({ + "id": row.get("id"), + "frase_busqueda": str(row.get("frase_busqueda") or ""), + "cargo": str(row.get("cargo") or ""), + "codigo": str(row.get("codigo") or ""), + "sede": str(row.get("sede") or ""), + "dia": str(row.get("dia") or ""), + "origen": str(row.get("origen") or ""), + "fecha_inicio": str(row.get("fecha_inicio") or "")[:10], + "fecha_fin": str(row.get("fecha_fin") or "")[:10], + }) + except Exception as e: + print(f"[listar_campanias] {e}") + return out + + # ── SUPABASE campanias: AGREGAR una fila ── + def agregar_campania(self, datos): + 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") + h = {"apikey": key, "Authorization": f"Bearer {key}", + "Content-Type": "application/json", "Prefer": "return=representation"} + fila = { + "frase_busqueda": str(datos.get("frase_busqueda") or "").strip(), + "cargo": str(datos.get("cargo") or "").strip(), + "codigo": str(datos.get("codigo") or "").strip(), + "sede": str(datos.get("sede") or "").strip(), + "dia": str(datos.get("dia") or "").strip(), + "origen": str(datos.get("origen") or "").strip(), + "fecha_inicio": str(datos.get("fecha_inicio") or "").strip() or None, + "fecha_fin": str(datos.get("fecha_fin") or "").strip() or None, + } + r = requests.post(f"{url}/rest/v1/{t}", headers=h, json=fila, timeout=30) + r.raise_for_status() + return r.json() + + # ── SUPABASE campanias: EDITAR una fila por id ── + def editar_campania(self, cid, datos): + 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") + h = {"apikey": key, "Authorization": f"Bearer {key}", + "Content-Type": "application/json", "Prefer": "return=representation"} + fila = { + "frase_busqueda": str(datos.get("frase_busqueda") or "").strip(), + "cargo": str(datos.get("cargo") or "").strip(), + "codigo": str(datos.get("codigo") or "").strip(), + "sede": str(datos.get("sede") or "").strip(), + "dia": str(datos.get("dia") or "").strip(), + "origen": str(datos.get("origen") or "").strip(), + "fecha_inicio": str(datos.get("fecha_inicio") or "").strip() or None, + "fecha_fin": str(datos.get("fecha_fin") or "").strip() or None, + } + r = requests.patch(f"{url}/rest/v1/{t}", params={"id": f"eq.{cid}"}, + headers=h, json=fila, timeout=30) + r.raise_for_status() + return r.json() + + # ── SUPABASE campanias: BORRAR una fila por id ── + def borrar_campania(self, cid): + 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") + h = {"apikey": key, "Authorization": f"Bearer {key}"} + r = requests.delete(f"{url}/rest/v1/{t}", params={"id": f"eq.{cid}"}, headers=h, timeout=30) + r.raise_for_status() + return {"id": cid, "borrado": True} + + # ── 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() + try: + cur = conn.cursor() + cur.execute(sql) + cols = [d[0] for d in cur.description] + rows = cur.fetchall() + finally: + conn.close() + return [dict(zip(cols, r)) for r in rows] diff --git a/backend/leads_logic.py b/backend/leads_logic.py new file mode 100644 index 0000000..b3efe68 --- /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: -agg[x]): # mayor a menor por cantidad + asesores = sorted(por_asesor[estado].items(), key=lambda x: -x[1]) + filas.append({ + "estado": estado, + "cantidad": agg[estado], + "asesores": [ + {"asesor": a, "cantidad": c, + "telefonos": sorted(telefonos.get((estado, a), []))} + for a, c in asesores + ], + }) + total = sum(agg.values()) + return {"filas": filas, "total": total} + + +# ── Matrículas por día (gráfico de línea) ── +# Cuenta TODAS las matrículas del día (misma base que Total Matriculados): +# estado ALU/PRE, excluyendo las 2 vendedoras, por día de fch_matricula. +def matriculas_por_dia(matriculas, cursos, ano, mes, dia): + VENDEDORES_EXCLUIDOS = {"CALDERON S. LISSA GENA", "CRUZ G. FIORELLA MELISSA"} + conteo = {} # dia -> total + por_tipo = {} # dia -> {tipo_programa: cantidad} + for m in matriculas: + if str(m.get("estado_matricula", "")).strip().upper() not in ("ALU", "PRE"): + continue + if str(m.get("dsc_vendedor", "")).strip() in VENDEDORES_EXCLUIDOS: + continue + d = _to_date(m.get("fch_matricula")) + if not _en_periodo(d, ano, mes, dia): + continue + conteo[d.day] = conteo.get(d.day, 0) + 1 + tp = tipo_programa_curso(m.get("dsc_programa")) # TEAC/TERC/PROVINCIA.../SEMINARIOS/OTROS + por_tipo.setdefault(d.day, {}) + por_tipo[d.day][tp] = por_tipo[d.day].get(tp, 0) + 1 + + def _detalle(day): + items = sorted(por_tipo.get(day, {}).items(), key=lambda x: -x[1]) + return [{"tipo": t, "cantidad": c} for t, c in items] + + import calendar + if mes not in ("TODOS", None) and ano not in ("TODOS", None): + ndias = calendar.monthrange(int(ano), int(mes))[1] + return [{"dia": d, "cantidad": conteo.get(d, 0), "detalle": _detalle(d)} + for d in range(1, ndias + 1)] + return [{"dia": d, "cantidad": conteo[d], "detalle": _detalle(d)} + for d in sorted(conteo.keys())] + + +# ── Gráfico "Leads por Programa": serie diaria Totales vs Procesados ── +def leads_por_dia(leads, ano, mes, dia): + lp = [l for l in leads if l["es_unico"] and _en_periodo(l["fecha"], ano, mes, dia)] + tot = {} # dia -> totales + proc = {} # dia -> procesados (con asesor) + for l in lp: + d = l["fecha"].day + tot[d] = tot.get(d, 0) + 1 + if l["asesor"]: + proc[d] = proc.get(d, 0) + 1 + import calendar + if mes not in ("TODOS", None) and ano not in ("TODOS", None): + ndias = calendar.monthrange(int(ano), int(mes))[1] + dias = range(1, ndias + 1) + else: + dias = sorted(set(list(tot.keys()) + list(proc.keys()))) + return [{"dia": d, "totales": tot.get(d, 0), "procesados": proc.get(d, 0)} for d in dias] + + +# ── Tabla por Sede_Act -> Tipo_Programa con 4 medidas (cohorte de leads) ── +def tabla_pauta(leads, matriculas, ano, mes, dia, importe_por_pauta_periodo=None, + resultados_por_pauta_periodo=None, campanias_sede_cargo=None): + importe_por_pauta_periodo = importe_por_pauta_periodo or {} + resultados_por_pauta_periodo = resultados_por_pauta_periodo or {} + campanias_sede_cargo = campanias_sede_cargo or {} + # 1) Leads únicos del periodo con su teléfono, fecha, sede y tipo + lp = [l for l in leads if l["es_unico"] and _en_periodo(l["fecha"], ano, mes, dia)] + # mapa teléfono -> fecha del lead (para "matrícula posterior al lead") + fecha_lead = {} + for l in lp: + fecha_lead.setdefault(l["telefono"], l["fecha"]) + + # 2) Matrículas que cruzan por teléfono y son POSTERIORES a la fecha del lead + # (réplica de Matriculas_Cohorte_Lead e Inversion_x_Pauta_Cohorte) + mat_por_tel = {} # telefono -> {"mats": set(num_matricula), "inv": float} + for m in matriculas: + tel = str(m.get("dsc_telefono_1") or "").strip() + if tel not in fecha_lead: + continue + fm = _to_date(m.get("fch_matricula")) + fl = fecha_lead[tel] + if not fm or not fl or not (fm > fl): + continue + # INV_NETA_FINAL: si DOL, ×3.34 + try: inv = float(m.get("INV_NETA", 0) or 0) + except: inv = 0.0 + if str(m.get("cod_moneda", "")).strip().upper() == "DOL": + inv *= 3.34 + e = mat_por_tel.setdefault(tel, {"mats": set(), "inv": 0.0}) + e["mats"].add(str(m.get("num_matricula"))) + e["inv"] += inv + + # 3) Agrupar por Sede -> Tipo_Programa -> Código + def _nuevo(): + return {"recibidos": 0, "procesados": 0, "mats": set(), "inv": 0.0} + grupos = {} # sede -> tipo -> codigo -> métricas + for l in lp: + sede = l["sede"]; tp = grupo_tipo_cohorte(l["tipo_programa"]); cod = l.get("codigo", "-"); tel = l["telefono"] + g = grupos.setdefault(sede, {}).setdefault(tp, {}).setdefault(cod, _nuevo()) + g["recibidos"] += 1 + if l["asesor"]: + g["procesados"] += 1 + if tel in mat_por_tel: + g["mats"] |= mat_por_tel[tel]["mats"] + g["inv"] += mat_por_tel[tel]["inv"] + + def _fila(d): + return {"recibidos": d["recibidos"], "procesados": d["procesados"], + "matriculas": len(d["mats"]), "inversion": round(d["inv"], 0)} + + # IMPORTE y RESULTADOS por PAUTA -> se asignan a su (sede, tipo) de CAMPANIAS, + # una sola vez por pauta (no por sede del lead), para NO duplicar. + # imp_st[(sede,tipo)][cod] = importe ; res_st[(sede,tipo)][cod] = resultados + imp_st = {} + res_st = {} + for cod, imp_val in importe_por_pauta_periodo.items(): + info = campanias_sede_cargo.get(str(cod).strip()) + if not info: + continue + se = str(info.get("sede") or "").upper() + tp = grupo_tipo_cohorte(tipo_programa_cohorte(se, info.get("cargo"))) + imp_st.setdefault((se, tp), {})[str(cod).strip()] = float(imp_val or 0.0) + for cod, res_val in resultados_por_pauta_periodo.items(): + info = campanias_sede_cargo.get(str(cod).strip()) + if not info: + continue + se = str(info.get("sede") or "").upper() + tp = grupo_tipo_cohorte(tipo_programa_cohorte(se, info.get("cargo"))) + res_st.setdefault((se, tp), {})[str(cod).strip()] = int(res_val or 0) + + filas = [] + for sede in sorted(grupos.keys()): + subfilas = [] + s_rec = s_proc = 0; s_mats = set(); s_inv = 0.0; s_imp = 0.0; s_res = 0 + for tp in sorted(grupos[sede].keys()): + cods = grupos[sede][tp] + imp_map = imp_st.get((sede.upper(), tp), {}) # importe por pauta de esta sede/tipo + res_map = res_st.get((sede.upper(), tp), {}) + # métricas del tipo (sumando sus códigos) + t_rec = t_proc = 0; t_mats = set(); t_inv = 0.0; t_imp = 0.0; t_res = 0 + codigos = [] + for cod in sorted(cods.keys()): + d = cods[cod] + imp_cod = float(imp_map.get(str(cod).strip(), 0.0)) + res_cod = int(res_map.get(str(cod).strip(), 0)) + codigos.append({"codigo": cod, **_fila(d), "importe": round(imp_cod, 2), + "resultados": res_cod}) + t_rec += d["recibidos"]; t_proc += d["procesados"] + t_mats |= d["mats"]; t_inv += d["inv"]; t_imp += imp_cod; t_res += res_cod + subfilas.append({ + "tipo": tp, "recibidos": t_rec, "procesados": t_proc, + "matriculas": len(t_mats), "inversion": round(t_inv, 0), + "importe": round(t_imp, 2), "resultados": t_res, "codigos": codigos, + }) + s_rec += t_rec; s_proc += t_proc; s_mats |= t_mats; s_inv += t_inv; s_imp += t_imp; s_res += t_res + filas.append({ + "sede": sede, "recibidos": s_rec, "procesados": s_proc, + "matriculas": len(s_mats), "inversion": round(s_inv, 0), + "importe": round(s_imp, 2), "resultados": s_res, "subfilas": subfilas, + }) + # Totales + t_rec = sum(f["recibidos"] for f in filas) + t_proc = sum(f["procesados"] for f in filas) + t_inv = round(sum(f["inversion"] for f in filas), 0) + # Total de importe/resultados: suma de TODAS las pautas que estan en campanias + # (una vez cada una), aunque no tengan fila con leads. Asi el total no se pierde. + t_imp = round(sum(float(v or 0.0) for cod, v in importe_por_pauta_periodo.items() + if str(cod).strip() in campanias_sede_cargo), 2) + t_res = sum(int(v or 0) for cod, v in resultados_por_pauta_periodo.items() + if str(cod).strip() in campanias_sede_cargo) + # matrículas total: unión global + all_mats = set() + for tel, e in mat_por_tel.items(): + all_mats |= e["mats"] + return {"filas": filas, "total": {"recibidos": t_rec, "procesados": t_proc, + "matriculas": len(all_mats), "inversion": t_inv, + "importe": t_imp, "resultados": t_res}} + + +# ── Personalizado: replica la columna Fact_SQL_Base_Cursos[Personalizado] (M) ── +# Base = dsc_det_programa, limpiando saltos de línea, luego cadena de reemplazos. +_PERS_REEMPLAZOS = [ + ("TECNICO ESPECIALISTA EN AIRE ACONDICIONADO", "TEAC"), + ("TECNICO ESPECIALISTA EN REFRIGERACION COMERCIAL", "TERC"), + ("TECNICO ESPECIALISTA EN REFRIGERACION DOMÉSTICA Y COMERCIAL", "TERC"), + ("CARRERA TECNICA DE AIRE ACONDICIONADO Y REFRIGERACION ", "CARRERA"), + ("VIRTUAL SEMINARIO SUPERVISION DE OBRAS EN AIRE ACONDICIONADO", "SUPERVISION DE OBRAS"), + ("SEMINARIO APLICACIÓN DE VARIADORES DE FRECUENCIA EN SISTEMAS HVAC", "VARIADORES DE FRECUENCIA"), + ("VIRTUAL - SISTEMAS DE REFRIGERACION CON CO2 - FASE SUBCRITICA Y TRANSCRITICA", "REFRIGERACION CON CO2"), + ("VIRTUAL SEMINARIO DISEÑO DE CAMARAS DE REFRIGERACION CON SISTEMAS CON FREON", "DISEÑO DE CAMARAS"), + ("VIRTUAL SEM. DETERMINACIÓN DE CAPACIDAD DE EQUIPOS DE AIRE ACONDICIONADO- CARGAS TÉRMICAS -(A/A)", "CARGAS TERMICAS"), + ("VIRTUAL SEMINARIO DIBUJO TECNICO Y DISEÑO ASISTIDO POR COMPUTADORA (CAD) PARA HVAC -(A/A)", "DIBUJO TECNICO (CAD)"), + ("VIRTUAL SEMINARIO: SISTEMAS DE REFRIGERACIÓN INDUSTRIAL POR AMONIACO (NH3)", "AMONIACO (NH3)"), + ("SEMINARIO VIRTUAL METRADO , COSTEO Y PRESUPUESTOS DE HVAC (AA)", "METRADO, COSTEO Y PRESUPUESTOS"), + ("CARRERA TECNICA LIMA GESTION DE EMPRESAS", "GESTION DE EMPRESAS"), + ("VIRTUAL SEMINARIO DISEÑO DE CHILLERS PARA PROCESOS INDUSTRIALES DE REFRIGERACIÓN", "DISEÑO DE CHILLERS"), + ("VIRTUAL SEMINARIO PRACTICO VENTILACION DE SOTANO Y PREZURIZACION DE ESCALERA -(A/A)", "VENTILACION DE SOTANO"), + ("SEMINARIO USO DE SOFTWARE EN CÁLCULOS DE AIRE ACONDICIONADO", "SOFTWARE EN CALCULOS DE A/A"), + ("DISEÑO DE SISTEMAS DE AIRE ACONDICIONADO", "DISEÑO DE SISTEMAS DE A/A"), + ("SEMINARIO INSTALACIÓN DE SISTEMAS DE A/A DE VOLUMEN VARIABLE VRV/VRF - SAB", "VOLUMEN VARIABLE VRF"), + # Seminarios VRF por sede y CO2 → nombre corto (la frecuencia se conserva al final) + ("PIURA - SEMINARIO INSTALACIÓN DE SISTEMAS DE A/A DE VOLUMEN VARIABLE - VRF", "PIURA - VRF"), + ("AREQUIPA - SEMINARIO INSTALACIÓN DE SISTEMAS DE A/A DE VOLUMEN VARIABLE - VRF", "AREQUIPA - VRF"), + ("TRUJILLO - SEMINARIO INSTALACIÓN DE SISTEMAS DE A/A DE VOLUMEN VARIABLE - VRF", "TRUJILLO - VRF"), + ("VIRTUAL - DISEÑO Y OPERACIÓN DE SISTEMAS DE REFRIGERACIÓN CON CO2 EN FASE SUBCRITICA Y TRANSCRITICA", "CO2"), + # Reemplazos sobre Nombre_Programa_Detalle (bloque ductos y otros) + ("VIRTUAL - DIMENSIONAMIENTO DE", ""), + ("METÁLICOS EN AIRE ACONDICIONADO Y VENTILACIÓN", ""), + ("MASTERCLASS: PROGRAMACIÓN DE CONTROLADORES PARA REFRIGERACIÓN", "PROGRAMACION DE CONTROLADORES"), + ("DIPLOMADO INTERNACIONAL DE AIRE ACONDICIONADO", "DIPLOMADO DE A/A"), + # Reemplazos finales (sedes/carreras/masterclass) + ("AREQUIPA - CARRERA TÉCNICA DE AIRE ACONDICIONADO Y REFRIGERACIÓN - TAR", "AREQUIPA - CARRERA - TARDE"), + ("PIURA - CARRERA TÉCNICA DE AIRE ACONDICIONADO Y REFRIGERACIÓN - TAR", "PIURA - CARRERA - TARDE"), + ("TRUJILLO - CARRERA TÉCNICA DE AIRE ACONDICIONADO Y REFRIGERACIÓN - TAR", "TRUJILLO - CARRERA - TARDE"), + ("AREQUIPA - CARRERA TÉCNICA DE AIRE ACONDICIONADO Y REFRIGERACIÓN - MAN", "AREQUIPA - CARRERA - MAÑANA"), + ("PIURA - CARRERA TÉCNICA DE AIRE ACONDICIONADO Y REFRIGERACIÓN - MAN", "PIURA - CARRERA - MAÑANA"), + ("TRUJILLO - CARRERA TÉCNICA DE AIRE ACONDICIONADO Y REFRIGERACIÓN - MAN", "TRUJILLO - CARRERA - MAÑANA"), + ("GESTIÓN DE VENTA DE EQUIPOS DE AIRE ACONDICIONADO Y EQUIPOS DE REFRIGERACIÓN - NOC", "GESTIÓN DE VENTA - NOC"), + ("MASTERCLASS SELECCIÓN E INSTALACIÓN DE TARJETAS ELECTRÓNICAS UNIVERSALES EN EQUIPOS DE A/A .INVERTER - MAN", "MASTERCLASS TARJETAS UNIVERSALES - MAN"), + ("MASTERCLASS: SISTEMAS DE A/A CON VRF - SAB", "MASTERCLASS: A/A CON VRF - SAB"), + ("GESTIÓN DE VENTA DE EQUIPOS DE AIRE ACONDICIONADO Y EQUIPOS DE REFRIGERACIÓN - TAR", "GESTIÓN DE VENTA - TAR"), +] + + +def personalizado_curso(dsc_programa, cod_frecuencia=""): + """Replica Fact_SQL_Base_Cursos[Personalizado] del PBI: + base = dsc_programa & ' - ' & cod_frecuencia (NO dsc_det_programa).""" + s = f"{str(dsc_programa or '')} - {str(cod_frecuencia or '')}" + # Text.Clean + reemplazar saltos de línea por espacio, luego Trim + s = s.replace("\r", " ").replace("\n", " ") + s = "".join(ch for ch in s if ch >= " " or ch == " ") # Text.Clean (quita control) + s = s.strip() + for buscar, reemplazo in _PERS_REEMPLAZOS: + s = s.replace(buscar, reemplazo) + # colapsar espacios múltiples que pudieran quedar de reemplazos vacíos + s = " ".join(s.split()) + return s + + +# ── Matriz por Curso (num_indice): Personalizado + métricas de leads por pauta ── +# NOTA: las métricas de leads cruzan por el CÓDIGO DE PAUTA del curso, que viene de +# SharePoint (no disponible aún). Por eso salen en 0 hasta conectar esa fuente. +# Ver FUTUROS_CAMBIOS.md punto 5. Lo que sí se calcula: Personalizado y Fecha Inicio. +def matriz_cursos(cursos, leads, ano, mes, dia, pauta_map=None, + campanias_map=None, cartera_all=None, cartera_asig=None, + mat_por_indice=None, cartera_tels=None, cartera_origen=None, + importe_por_pauta=None, importe_por_pauta_periodo=None, + cartera_canal=None, conjunto_por_pauta=None, pautas_por_indice=None): + pauta_map = pauta_map or {} + pautas_por_indice = pautas_por_indice or {} # {num_indice: [pautas]} (muchos-a-muchos) + campanias_map = campanias_map or {} + cartera_all = cartera_all or {} + cartera_asig = cartera_asig or {} + mat_por_indice = mat_por_indice or {} + cartera_tels = cartera_tels or set() + cartera_origen = cartera_origen or {} + importe_por_pauta = importe_por_pauta or {} + importe_por_pauta_periodo = importe_por_pauta_periodo or {} + conjunto_por_pauta = conjunto_por_pauta or {} # {pauta: [conjuntos de anuncios]} + # cartera_canal = {"PAUTA": {"all","asig","tels"}, "OTROS": {...}} para el desglose por canal + cartera_canal = cartera_canal or {} + + CURSOS_EXCLUIDOS = {"1121", "1154"} + + def _excluir(c): + ni = str(c.get("num_indice", "")) + if ni in CURSOS_EXCLUIDOS: + return True + info = pauta_map.get(ni) + if not info: + return False # sin info en Supabase → no se excluye + # Excluir SUSPENDIDO o contar = NO + return info.get("contar") == "NO" or info.get("estado") == "SUSPENDIDO" + + cursos_periodo = [c for c in cursos + if _en_periodo(_to_date(c.get("fch_inicio")), ano, mes, dia) + and not _excluir(c)] + + # Leads únicos agrupados por codigo (= Pauta). Históricos y del mes (filtro fecha). + leads_unicos = [l for l in leads if l.get("es_unico")] + hist_por_cod = {} # codigo → set teléfonos + hist_ases_cod = {} # codigo → set teléfonos con asesor + mes_por_cod = {} # codigo → set teléfonos (en periodo filtrado) + mes_ases_cod = {} # codigo → set teléfonos con asesor (en periodo) + for l in leads_unicos: + cod = l.get("codigo", "-") + tel = l["telefono"] + hist_por_cod.setdefault(cod, set()).add(tel) + if l.get("asesor"): + hist_ases_cod.setdefault(cod, set()).add(tel) + if _en_periodo(l["fecha"], ano, mes, dia): + mes_por_cod.setdefault(cod, set()).add(tel) + if l.get("asesor"): + mes_ases_cod.setdefault(cod, set()).add(tel) + + # Construir filas con sus medidas + pre = [] + for c in sorted(cursos_periodo, key=lambda x: (_to_date(x.get("fch_inicio")) or date.min)): + nombre = personalizado_curso(c.get("dsc_programa"), c.get("cod_frecuencia")) + fch = _to_date(c.get("fch_inicio")) + ni = str(c.get("num_indice", "")).strip() + # LISTA de pautas del num_indice (muchos-a-muchos). Si no hay en la tabla nueva, + # usa la de basebi (compatibilidad). + cods = pautas_por_indice.get(ni) + if not cods: + _info = pauta_map.get(ni) + cods = [_info.get("pauta")] if _info and _info.get("pauta") else [] + cods = [str(x).strip() for x in cods if x] + # Leads = union de telefonos de TODAS las pautas (sin duplicar) + def _union(mapa): + s = set() + for cd in cods: + s |= mapa.get(cd, set()) + return len(s) + nuevos = _union(hist_por_cod) + nuevos_a = _union(hist_ases_cod) + nuevos_m = _union(mes_por_cod) + nuevos_ma = _union(mes_ases_cod) + pre.append({ + "num_indice": ni, + "personalizado": nombre, + "fch": fch, + "pauta": cods[0] if cods else None, # pauta principal (para mostrar) + "pautas": cods, # todas las pautas (para sumar) + "fecha_inicio": fch.strftime("%d/%m/%Y") if fch else "", + "leads_nuevos": nuevos, + "leads_nuevos_asesor": nuevos_a, + "leads_nuevos_mes": nuevos_m, + "leads_nuevos_mes_asesor": nuevos_ma, + }) + + # Índice HISTÓRICO completo (TODOS los cursos, sin filtro de fecha) por + # personalizado → lista de (fecha, leads_nuevos). Para el acumulado. + hist_cursos = {} + for c in cursos: + if _excluir(c): + continue + nom = personalizado_curso(c.get("dsc_programa"), c.get("cod_frecuencia")) + f = _to_date(c.get("fch_inicio")) + info = pauta_map.get(str(c.get("num_indice", ""))) + cd = info.get("pauta") if info else None + nv = len(hist_por_cod.get(cd, ())) if cd else 0 + hist_cursos.setdefault(nom, []).append((f, nv)) + + # Leads_Acumulados_Historico: suma de leads_nuevos de TODOS los cursos del + # MISMO personalizado con fecha <= la del curso actual (histórico completo). + filas = [] + for r in pre: + acum = sum(nv for (f, nv) in hist_cursos.get(r["personalizado"], []) + if f and r["fch"] and f <= r["fch"]) + # CARTERA TOTAL: teléfonos únicos de cartera_junta con la misma SEDE+PROGRAMA + # del curso (según su pauta → campanias) cuya fecha_creada <= fch_inicio del curso. + # cartera_total = todos; cartera_total_asig = solo con asesor asignado. + cartera_total = 0 + cartera_total_asig = 0 + cod = r.get("pauta") + cods = r.get("pautas") or ([cod] if cod else []) + if cods and r["fch"]: + # Cartera = union de telefonos de la sede+programa de TODAS las pautas del num_indice + tels_all = {}; tels_asig = {} + for cd in cods: + sp = campanias_map.get(str(cd).upper()) + if not sp: + continue + for tel, fx in cartera_all.get(sp, {}).items(): + if fx and (tel not in tels_all or fx < tels_all[tel]): + tels_all[tel] = fx + for tel, fx in cartera_asig.get(sp, {}).items(): + if fx and (tel not in tels_asig or fx < tels_asig[tel]): + tels_asig[tel] = fx + cartera_total = sum(1 for fx in tels_all.values() if fx <= r["fch"]) + cartera_total_asig = sum(1 for fx in tels_asig.values() if fx <= r["fch"]) + # MATRÍCULAS NO IDENTIFICADAS: matrículas de este curso (num_indice) cuyo teléfono + # (tel_1, o tel_2 si el 1 está vacío) NO aparece en cartera_junta. Sin filtro de fecha. + _ni = r["num_indice"] + _ni = _ni[:-2] if _ni.endswith(".0") else _ni + _mats = mat_por_indice.get(_ni, []) # lista de (telefono, fecha_matricula) + _tels_no_iden = [ph for (ph, fm) in _mats if ph not in cartera_tels] + matriculas_no_iden = len(_tels_no_iden) + # MATRICULAS LEADS NUEVOS: matrícula hecha dentro de 45 días DESPUÉS del origen + # del lead (registro es_origen=SI de ese teléfono en la cartera). + mat_leads_nuevos = 0 + mat_leads_antiguos = 0 + _tels_nuevos = [] + _tels_antiguos = [] + for (ph, fm) in _mats: + if not ph or not fm: + continue + forig = cartera_origen.get(ph) + if forig is None: + continue + dias = (fm - forig).days + if 0 <= dias <= 45: + mat_leads_nuevos += 1 + _tels_nuevos.append(ph) + else: + # >45 días, o matrícula anterior al origen del lead (dias<0) → Antiguo + mat_leads_antiguos += 1 + _tels_antiguos.append(ph) + # IMPORTE PAUTA: gasto de Meta Ads sumando TODAS las pautas del num_indice. + importe_pauta = round(sum(float(importe_por_pauta.get(str(cd), 0.0)) for cd in cods), 2) + # IMPORTE PAUTA EN EL MES: igual, pero solo del periodo filtrado. + importe_pauta_mes = round(sum(float(importe_por_pauta_periodo.get(str(cd), 0.0)) for cd in cods), 2) + + # ── SUBFILAS por CANAL (PAUTA / OTROS) ── mismo criterio que la fila padre + # (SEDE+PROGRAMA vía campanias, misma fecha) + filtro de canal desde cartera. + def _subfila(clave): + cc = cartera_canal.get(clave, {}) + c_all = cc.get("all", {}); c_asig = cc.get("asig", {}); c_tels = cc.get("tels", set()) + ct = ct_asig = 0 + l_rec = l_proc = 0 + l_rec_mes = l_proc_mes = 0 + if cod and r["fch"]: + sp = campanias_map.get(str(cod).upper()) + if sp: + fa = c_all.get(sp, {}) + fg = c_asig.get(sp, {}) + # Cartera Total/Asig del canal: fecha_creada <= inicio del curso (igual que padre) + ct = sum(1 for fx in fa.values() if fx and fx <= r["fch"]) + ct_asig = sum(1 for fx in fg.values() if fx and fx <= r["fch"]) + # L. Recibidos/Procesados del canal = misma cartera del canal (mismo corte) + l_rec = ct + l_proc = ct_asig + # "del Mes" = fecha_creada en el periodo filtrado + l_rec_mes = sum(1 for fx in fa.values() if _en_periodo(fx, ano, mes, dia)) + l_proc_mes = sum(1 for fx in fg.values() if _en_periodo(fx, ano, mes, dia)) + # Matrículas del curso, clasificadas por canal: + # - NO identificadas (tel NO en cartera) -> todas a OTROS. + # - identificadas -> al canal de su telefono (c_tels), regla 45 dias. + mt_noiden = mt_nuevos = mt_antiguos = 0 + for (ph, fm) in _mats: + if not ph: + continue + en_cartera = ph in cartera_tels + if not en_cartera: + # no identificada: solo cuenta en la subfila OTROS + if clave == "OTROS": + mt_noiden += 1 + continue + # identificada: solo cuenta si su tel pertenece a ESTE canal + if ph not in c_tels: + continue + forig = cartera_origen.get(ph) + if fm and forig: + d = (fm - forig).days + if 0 <= d <= 45: mt_nuevos += 1 + else: mt_antiguos += 1 + return {"canal": clave, "cartera_total": ct, "cartera_total_asig": ct_asig, + "leads_nuevos": l_rec, "leads_nuevos_asesor": l_proc, + "leads_nuevos_mes": l_rec_mes, "leads_nuevos_mes_asesor": l_proc_mes, + "matriculas_no_iden": mt_noiden, "mat_leads_nuevos": mt_nuevos, + "mat_leads_antiguos": mt_antiguos} + subfilas = [_subfila("PAUTA"), _subfila("WEB"), _subfila("OTROS")] if cartera_canal else [] + + filas.append({ + "num_indice": r["num_indice"], + "personalizado": r["personalizado"], + "fecha_inicio": r["fecha_inicio"], + "pauta": r.get("pauta"), + "importe_pauta": importe_pauta, + "importe_pauta_mes": importe_pauta_mes, + "cartera_total": cartera_total, + "cartera_total_asig": cartera_total_asig, + "matriculas_no_iden": matriculas_no_iden, + "matriculas_no_iden_tels": _tels_no_iden, + "mat_leads_nuevos": mat_leads_nuevos, + "mat_leads_antiguos": mat_leads_antiguos, + "mat_leads_nuevos_tels": _tels_nuevos, + "mat_leads_antiguos_tels": _tels_antiguos, + "leads_acumulados": acum, + "leads_nuevos": r["leads_nuevos"], + "leads_nuevos_asesor": r["leads_nuevos_asesor"], + "leads_nuevos_mes": r["leads_nuevos_mes"], + "leads_nuevos_mes_asesor": r["leads_nuevos_mes_asesor"], + "subfilas_canal": subfilas, + "conjuntos": [cj for cd in cods for cj in conjunto_por_pauta.get(str(cd).strip(), [])], + "contar": (pauta_map.get(str(r["num_indice"]).strip()) or {}).get("contar") or "SI", + }) + return {"filas": filas} diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..c9e4d31 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,289 @@ +# backend/main.py +"""API REST del Dashboard de LEADS (FastAPI).""" +from fastapi import FastAPI, Query, HTTPException, Body +from fastapi.middleware.cors import CORSMiddleware +from datetime import datetime +import uvicorn + +import os +from dotenv import load_dotenv + +# Cargar variables de entorno desde .env +load_dotenv() + +import services +from cache_manager import start_background_refresh, cache_stats + +app = FastAPI(title="Dashboard Leads API", version="1.0") + + +# Orígenes CORS configurables (por defecto permite todo o el dominio configurado) +cors_env = os.getenv("CORS_ORIGINS", "*") +origins = [o.strip() for o in cors_env.split(",") if o.strip()] if cors_env != "*" else ["*"] + +app.add_middleware( + CORSMiddleware, + allow_origins=origins if origins else ["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.on_event("startup") +def _startup(): + # Precarga en SEGUNDO PLANO (hilo aparte) para no bloquear el arranque del server. + import threading + def _precarga_bg(): + print("[startup] Precargando datos de Leads (2do plano)...") + try: + services.precargar_todo() + print("[startup] Precarga completa.") + except Exception as e: + print(f"[startup] Precarga falló: {e}") + threading.Thread(target=_precarga_bg, daemon=True).start() + # Otros General en un hilo aparte (sus consultas son mas pesadas) + def _precarga_otros_bg(): + try: + services.precargar_otros_general() + except Exception as e: + print(f"[startup] Precarga Otros falló: {e}") + threading.Thread(target=_precarga_otros_bg, daemon=True).start() + start_background_refresh(services.refrescar_todo, interval=900) + + +@app.get("/api/health") +def health(): + return {"status": "ok", "hora": datetime.now().isoformat()} + + +@app.get("/api/cache/stats") +def stats(): + return cache_stats() + + +@app.post("/api/cache/refresh") +def refresh(): + services.refrescar_todo() + return {"status": "refrescado"} + + +@app.get("/api/leads/filtros") +def get_filtros(): + try: + return services.opciones_filtros() + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/leads") +def get_leads( + ano: str = Query("TODOS"), + mes: str = Query("TODOS"), + dia: str = Query("TODOS"), + programa: str = Query("TODOS"), + sede: str = Query("TODOS"), +): + try: + return services.leads_dashboard(ano, mes, dia, programa, sede) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/vendedores") +def get_vendedores( + ano: str = Query("TODOS"), + mes: str = Query("TODOS"), + dia: str = Query("TODOS"), +): + try: + return services.vendedores_dashboard(ano, mes, dia) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/roas") +def get_roas( + ano: str = Query("TODOS"), + mes: str = Query("TODOS"), + dia: str = Query("TODOS"), +): + try: + return services.roas_dashboard(ano, mes, dia) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/conjuntos-sin-sede") +def get_conjuntos_sin_sede(): + try: + return services.conjuntos_sin_sede() + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/conjuntos-sin-sede/guardar") +def post_conjunto_sede(body: dict = Body(...)): + try: + cambios = body.get("cambios") or [] + return services.guardar_conjunto_sede(cambios) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/conjuntos-sin-sede/borrar") +def post_borrar_conjunto_sede(body: dict = Body(...)): + try: + return services.borrar_conjunto_sede(body.get("conjunto") or "") + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/otros-general") +def get_otros_general( + ano: str = Query("TODOS"), + mes: str = Query("TODOS"), + dia: str = Query("TODOS"), +): + try: + return services.otros_general(ano, mes, dia) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/conjuntos-sin-pauta") +def get_conjuntos_sin_pauta(): + try: + return {"conjuntos": services.conjuntos_sin_pauta()} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/programas-ocultos") +def get_programas_ocultos(): + try: + return services.programas_ocultos() + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/programas-ocultos/encender") +def post_encender_programas(body: dict = Body(...)): + try: + nis = body.get("num_indices") or [] + return services.encender_programas(nis) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/programas-disponibles") +def get_programas_disponibles(): + try: + return services.programas_disponibles() + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/leyenda-anuncios") +def get_leyenda(): + try: + return services.leyenda_anuncios() + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/leyenda-anuncios/guardar") +def post_leyenda(body: dict = Body(...)): + try: + cambios = body.get("cambios") or [] + return services.guardar_leyenda(cambios) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ── LEYENDA (campanias de Supabase): listar / agregar / borrar ── +@app.get("/api/campanias") +def get_campanias(): + try: + return {"filas": services.campanias_listar()} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/campanias/agregar") +def post_campania(body: dict = Body(...)): + try: + return services.campania_agregar(body or {}) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/campanias/editar") +def post_editar_campania(body: dict = Body(...)): + try: + cid = body.get("id") + return services.campania_editar(cid, body or {}) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/campanias/borrar") +def post_borrar_campania(body: dict = Body(...)): + try: + return services.campania_borrar(body.get("id")) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/pauta/uso") +def get_uso_pauta(pauta: str = Query(...), excluir: str = Query("")): + try: + return services.uso_de_pauta(pauta, excluir) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/curso/guardar-pauta") +def post_guardar_pauta(body: dict = Body(...)): + try: + ni = str(body.get("num_indice", "")).strip() + pa = str(body.get("pauta", "")).strip() + cj = body.get("conjunto") or None + contar = body.get("contar") # "SI" / "NO" / None + if not ni: + raise HTTPException(status_code=400, detail="num_indice es obligatorio") + return services.guardar_edicion_curso(ni, pa, cj, contar) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/ultima-actualizacion") +def get_ultima_actualizacion(): + try: + return services.ultima_actualizacion() + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/leads/alertas") +def get_alertas(): + """Valores de programa/sede en cartera_junta que NO están en el diccionario.""" + try: + return services.alertas() + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/debug/cursos") +def debug_cursos(ano: str = "2026", mes: str = "1"): + """Diagnóstico: lista cursos del periodo con inscritos, tipo y meta.""" + try: + return services.debug_cursos(ano, mes) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +if __name__ == "__main__": + uvicorn.run("main:app", host="0.0.0.0", port=8001, reload=False) 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..c12d124 --- /dev/null +++ b/backend/services.py @@ -0,0 +1,1768 @@ +# 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, cache_invalidate + +_DM = None + + +def get_dm() -> DataManager: + global _DM + if _DM is None: + _DM = DataManager() + return _DM + + +# ── Prefijos de cache que dependen del mapa de campañas (leyendas). Al cambiar +# una leyenda se invalidan estos para que TODOS los apartados se reconstruyan +# en su proxima peticion (perezoso: solo lo que se pida, ~2-5s), sin esperar +# los 15 min ni recargar todo. NO se toca la logica, solo se refresca el cache. +_PREF_CAMPANIAS = ( + "campanias_map", "campanias_sc", "leads_raw", "leads_dash", "leads_filtros", + "leads_alertas", "vendedores_dash", "roas_dash", "otros_general", + "matriz_always", "matriz_webform", "matriz_asignados", "matriz_plantillas", + "tiempo_resp", "importe_pauta_periodo", "resultados_pauta_periodo", +) + + +def _invalidar_por_campanias(): + """Invalida el cache de campañas (Supabase) y de todos los apartados que + dependen de las leyendas, para que el cambio se vea de inmediato.""" + from data_manager_v2 import invalidar_campanias_cache + invalidar_campanias_cache() + for p in _PREF_CAMPANIAS: + cache_invalidate(p) + + +# ── 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 _asignacion_respuesta_crudos(): + return cache_get_or_set("asig_resp", ("GLOBAL",), + lambda: get_dm().traer_asignacion_respuesta()) + + +def _leads_asignados_crudos(): + # UNIFICADO: usa la MISMA consulta/cache que el grafico (asignacion+respuesta+etiquetas), + # asi la tarjeta, la matriz de asignados y el grafico nunca se desfasan. + return _asignacion_respuesta_crudos() + + +def _actividad_asesores_crudos(): + return cache_get_or_set("act_asesores", ("GLOBAL",), + lambda: get_dm().traer_actividad_asesores()) + + +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 _asesores(): + """{'map': {alias_norm: correcto}, 'lista': [los 8 vendedores]}.""" + return cache_get_or_set("asesores", ("GLOBAL",), lambda: get_dm().traer_asesores()) + + +def _norm_asesor(nombre, mapa): + """Normaliza un nombre de asesor a su 'correcto' via el diccionario.""" + k = " ".join(str(nombre or "").upper().split()) + return mapa.get(k) # None si no es uno de los vendedores + + +def _norm_tel_pe(v): + """Normaliza telefono igual que la cartera: quita simbolos y el '51' inicial + de celulares peruanos (rango 51900000000-51999999999). Asi cruzan con cartera_junta.""" + s = str(v or "") + for x in ("+51", "+", " ", "-", "(", ")"): + s = s.replace(x, "") + s = s.strip() + if s.isdigit() and 51900000000 <= int(s) <= 51999999999: + s = s[2:] + return s + + +def tiempo_respuesta_dashboard(ano="TODOS", mes="TODOS", dia="TODOS"): + """Tiempo de respuesta LABORAL (minutos) del asesor tras la asignacion. + - Turno detectado por dia/asesor segun donde hay MAS actividad: + tarde (13-18) -> Turno 1 (09-18, refrigerio 13-14 excluido). + noche (18-22) -> Turno 2 (09-13 + 18-22, sin refrigerio). + sabado: manana(09-13) o tarde(14-18), 4h, donde haya mas actividad. + sin actividad ese dia -> no trabajo (se salta). + - El tiempo cuenta SOLO minutos dentro del turno; si la asignacion cae fuera, + el reloj arranca al inicio del siguiente bloque laboral (saltando dias sin + actividad). Leads asignados en refrigerio (13-14) de turno1 se excluyen. + - Serie dia a dia por FECHA DE ASIGNACION: promedio de minutos de los leads + asignados ese dia (solo respondidos). Filtro ano/mes/dia por fecha asignacion. + - No respondidos -> se marcan aparte, NO entran en el promedio.""" + from datetime import datetime, timedelta, time as _time + def _load(): + from collections import defaultdict + ase = _asesores(); amap = ase["map"]; lista = ase["lista"] + + # ── 1) Detectar turno por (asesor_norm, fecha) segun actividad ── + # Mapa user_id -> nombre normalizado (de asignacion_respuesta). + uid2vend = {} + for r in _asignacion_respuesta_crudos(): + uid = r.get("user_id") + v = _norm_asesor(r.get("user_name"), amap) + if uid is not None and v: + uid2vend[uid] = v + + # Contar actividad por (vend, fecha, franja): tarde=13-18, noche=18-22, + # sab_manana=9-13, sab_tarde=14-18. + act = defaultdict(lambda: defaultdict(int)) # (vend,fecha) -> franja -> conteo + for a in _actividad_asesores_crudos(): + uid = a.get("user_id"); ts = a.get("created_at") + if uid not in uid2vend or not isinstance(ts, datetime): + continue + v = uid2vend[uid]; d = ts.date(); h = ts.hour + ts.minute / 60.0 + key = (v, d) + if d.weekday() == 6: # domingo: no trabaja + continue + if d.weekday() == 5: # sabado + if 9 <= h < 13: act[key]["sab_manana"] += 1 + elif 14 <= h < 18: act[key]["sab_tarde"] += 1 + else: # lun-vie + if 13 <= h < 18: act[key]["tarde"] += 1 + elif 18 <= h < 22: act[key]["noche"] += 1 + + def _turno_dia(v, d): + """Devuelve lista de bloques [(h_ini,h_fin), ...] del turno de ese dia, + o None si no trabajo. Refrigerio de turno1 (13-14) NO se incluye.""" + if d.weekday() == 6: + return None + fr = act.get((v, d)) + if not fr: + return None # sin actividad -> no trabajo + if d.weekday() == 5: # sabado (4h) + m = fr.get("sab_manana", 0); t = fr.get("sab_tarde", 0) + if m == 0 and t == 0: + return None + return [(9, 13)] if m >= t else [(14, 18)] + # lun-vie: comparar tarde vs noche + t = fr.get("tarde", 0); n = fr.get("noche", 0) + if t == 0 and n == 0: + return None + if n > t: + return [(9, 13), (18, 22)] # Turno 2 + return [(9, 13), (14, 18)] # Turno 1 (refrigerio 13-14 fuera) + + def _dt(d, hh): + return datetime(d.year, d.month, d.day) + timedelta(hours=hh) + + def _en_refrigerio_t1(v, ts): + """True si ts cae 13-15 y ese dia el asesor es turno 1 (lun-vie). + Estos leads NO se excluyen: cuentan como respondidos, pero su tiempo + (diferencia directa T2-T1) se reduce 45 min si supera 45.""" + d = ts.date() + if d.weekday() >= 5: + return False + fr = act.get((v, d)) + if not fr: + return False + t = fr.get("tarde", 0); n = fr.get("noche", 0) + es_t1 = not (n > t) and (t > 0 or n > 0) + h = ts.hour + ts.minute / 60.0 + return es_t1 and (13 <= h < 15) + + def _minutos_laborales(v, t1, t2): + """Minutos de turno entre t1 (asignacion) y t2 (respuesta), saltando + huecos y dias sin actividad. Devuelve None si no se puede (raro).""" + if not isinstance(t1, datetime) or not isinstance(t2, datetime) or t2 <= t1: + return 0 + total = 0.0 + cur = t1 + saltos = 0 + while cur < t2 and saltos < 60: # tope 60 dias por seguridad + d = cur.date() + bloques = _turno_dia(v, d) + if not bloques: + # dia sin trabajo -> saltar al dia siguiente 00:00 + cur = _dt(d, 24); saltos += 1; continue + avanzo = False + for (hi, hf) in bloques: + ini = _dt(d, hi); fin = _dt(d, hf) + if t2 <= ini: # la respuesta es antes de este bloque + break + if cur >= fin: # ya paso este bloque + continue + a = max(cur, ini) + b = min(t2, fin) + if b > a: + total += (b - a).total_seconds() / 60.0 + cur = fin # avanzar al fin del bloque + avanzo = True + if cur >= t2: + break + # pasar al dia siguiente + if cur.date() == d: + cur = _dt(d, 24); saltos += 1 + return round(total) + + # ── 2) Por lead: minutos laborales, agrupado por FECHA DE ASIGNACION ── + por_dia_sum = defaultdict(float) # (vend,dia) -> suma minutos + por_dia_cnt = defaultdict(int) # (vend,dia) -> nº respondidos + no_resp = defaultdict(int) # (vend,dia) -> nº sin responder + for r in _asignacion_respuesta_crudos(): + v = _norm_asesor(r.get("user_name"), amap) + if not v: + continue + t1 = r.get("created_at") + if not isinstance(t1, datetime): + continue + if not L._en_periodo(t1.date(), ano, mes, dia): + continue # filtro por fecha de asignacion + t2 = r.get("respuesta_fecha") + if not isinstance(t2, datetime): + no_resp[(v, t1.day)] += 1 # no respondio + continue + if _en_refrigerio_t1(v, t1): + # asignado 13-15 en turno1: tiempo = diferencia DIRECTA (T2-T1), + # y si supera 45 min se le restan 45 (nunca negativo). + dif = (t2 - t1).total_seconds() / 60.0 + mins = round(dif - 45) if dif > 45 else round(dif) + if mins < 0: + mins = 0 + else: + mins = _minutos_laborales(v, t1, t2) + por_dia_sum[(v, t1.day)] += mins + por_dia_cnt[(v, t1.day)] += 1 + + # ── 2b) Igual pero agrupado por FECHA DE RESPUESTA (dia en que respondio). + # Solo leads respondidos. Filtro por fecha de respuesta (t2). + # Si el tiempo laboral supera 960 min (2 dias laborales) -> NO entra en + # el promedio; se cuenta aparte como "respuesta tardia". + LIMITE_TARDIA = 960 # 2 dias laborales (8h/dia) + pr_sum = defaultdict(float) # (vend,dia_respuesta) -> suma minutos (validos) + pr_cnt = defaultdict(int) # (vend,dia_respuesta) -> nº validos (<=960) + pr_tardia = defaultdict(int) # (vend,dia_respuesta) -> nº tardios (>960) + for r in _asignacion_respuesta_crudos(): + v = _norm_asesor(r.get("user_name"), amap) + if not v: + continue + t1 = r.get("created_at"); t2 = r.get("respuesta_fecha") + if not isinstance(t1, datetime) or not isinstance(t2, datetime): + continue # sin respuesta -> no entra en este grafico + if not L._en_periodo(t2.date(), ano, mes, dia): + continue # filtro por FECHA DE RESPUESTA + if _en_refrigerio_t1(v, t1): + dif = (t2 - t1).total_seconds() / 60.0 + mins = round(dif - 45) if dif > 45 else round(dif) + if mins < 0: + mins = 0 + else: + mins = _minutos_laborales(v, t1, t2) + if mins > LIMITE_TARDIA: + pr_tardia[(v, t2.day)] += 1 # respuesta tardia -> fuera del promedio + continue + pr_sum[(v, t2.day)] += mins + pr_cnt[(v, t2.day)] += 1 + + # ── 3) Serie dia a dia (promedio min) por vendedor y total ── + def _serie(vends): + out = [] + for dnum in range(1, 32): + s = sum(por_dia_sum.get((v, dnum), 0) for v in vends) + c = sum(por_dia_cnt.get((v, dnum), 0) for v in vends) + nr = sum(no_resp.get((v, dnum), 0) for v in vends) + out.append({"dia": dnum, + "promedio_min": round(s / c) if c else 0, + "respondidos": c, "no_respondidos": nr}) + return out + + def _serie_resp(vends): + out = [] + for dnum in range(1, 32): + s = sum(pr_sum.get((v, dnum), 0) for v in vends) + c = sum(pr_cnt.get((v, dnum), 0) for v in vends) + td = sum(pr_tardia.get((v, dnum), 0) for v in vends) + out.append({"dia": dnum, + "promedio_min": round(s / c) if c else 0, + "respondidos": c, "tardios": td}) + return out + + serie_por_vend = {v: _serie([v]) for v in lista} + serie_total = _serie(lista) + serie_resp_por_vend = {v: _serie_resp([v]) for v in lista} + serie_resp_total = _serie_resp(lista) + return {"serie_por_vend": serie_por_vend, "serie_total": serie_total, + "serie_resp_por_vend": serie_resp_por_vend, + "serie_resp_total": serie_resp_total, + "vendedores": lista} + return cache_get_or_set("tiempo_resp", (ano, mes, dia), _load) + + +def vendedores_dashboard(ano="TODOS", mes="TODOS", dia="TODOS"): + """Por vendedor (los 8 de alias_normalizacion). Filtro Año/Mes/Día: + - Cartera (total/copito/otros): filtra por fecha_creada de cartera_junta. + - Matriculas: se cruzan por TELEFONO con los leads del mes (es_origen=SI, + fecha_creada en el periodo); la matricula cuenta en CUALQUIER fecha. + - Leads Procesados/Contactados: filtran por fecha del lead (mensaje campaña).""" + def _load(): + from collections import defaultdict + ase = _asesores() + amap = ase["map"]; lista = ase["lista"] + CANAL_COPITO = {"COPITO"} + + # 1) CARTERA por vendedor. Fila ORIGEN de cada telefono (es_origen=SI) cuya + # fecha_creada cae en el periodo filtrado. Cada telefono cuenta para UN solo asesor. + # Ademas guardamos {vendedor: set(telefonos del periodo)} para cruzar matriculas. + cart_total = defaultdict(int); cart_copito = defaultdict(int); cart_otros = defaultdict(int) + tels_por_vend = defaultdict(set) # vendedor -> telefonos (es_origen=SI del periodo) + cop_tels = defaultdict(set) # vendedor -> telefonos ya contados como Copito (sin repetir) + for r in _cartera_rows_cache(): + if str(r.get("es_origen") or "").strip().upper() != "SI": + continue # solo la fila origen de cada telefono + f = L._to_date(r.get("fecha_creada")) + if not L._en_periodo(f, ano, mes, dia): + continue # filtro por fecha_creada de la cartera + v = _norm_asesor(r.get("asesor"), amap) + if not v: # el asesor del origen no es uno de los 8 + continue + tel = str(r.get("telefono") or "").strip() + canal = " ".join(str(r.get("canal") or "").upper().split()) + ob = " ".join(str(r.get("origen_base") or "").upper().split()) + cart_total[v] += 1 + # Copito = union (origen_base=POSTGRE) OR (canal=COPITO), sin repetir telefono + es_copito = (ob == "POSTGRE") or (canal in CANAL_COPITO) + if es_copito: + if tel and tel in cop_tels[v]: + pass # ese telefono ya se conto como Copito -> no repetir + else: + cart_copito[v] += 1 + if tel: + cop_tels[v].add(tel) + else: + cart_otros[v] += 1 + if tel: + tels_por_vend[v].add(tel) + + # 2) MATRICULAS por vendedor. Se CRUZAN por TELEFONO con los leads del periodo + # (tels_por_vend). La matricula cuenta en CUALQUIER fecha (no se filtra por + # fch_matricula). Telefono de matricula: dsc_telefono_1, o _2 si el 1 esta vacio. + # Un telefono puede tener varias matriculas -> se cuentan todas (no unico). + # MATRICULAS: por dsc_vendedor + fch_matricula en el periodo (NO se cruza + # con la cartera por telefono). Filtro Año/Mes/Día sobre fch_matricula. + mat_total = defaultdict(int); mat_curso = defaultdict(int); mat_retir = defaultdict(int) + for m in _matriculas_crudas(): + vend = _norm_asesor(m.get("dsc_vendedor"), amap) + if not vend: + continue + estado = str(m.get("estado_matricula") or "").strip().upper() + if estado == "ANU": + continue # anuladas no cuentan + fm = L._to_date(m.get("fch_matricula")) + if not L._en_periodo(fm, ano, mes, dia): + continue # filtro por fch_matricula + mat_total[vend] += 1 + if estado in ("ALU", "PRE"): + mat_curso[vend] += 1 + elif estado == "RET": + mat_retir[vend] += 1 + + # 3) LEADS Procesados/Contactados por vendedor (leads de Chatwoot). Filtro por + # fecha del lead (mensaje de campaña). Procesados = lead unico con asesor; + # Contactados = de esos, ultima_etiqueta != "NO TRABAJADO". + lead_proc = defaultdict(int); lead_cont = defaultdict(int) + etiq = defaultdict(int) # clave: (vendedor, ultima_etiqueta) -> nº leads procesados + for l in _leads_crudos(): + if not l.get("es_unico"): + continue + if not L._en_periodo(l.get("fecha"), ano, mes, dia): + continue # filtro por fecha del lead + v = _norm_asesor(l.get("asesor"), amap) + if not v: + continue + lead_proc[v] += 1 # tiene asesor de los 8 => procesado + ult = l.get("ultima") or "NO TRABAJADO" + etiq[(v, ult)] += 1 # para la matriz Estado/Objeción + # NOTA: "Contactados" ya NO se cuenta aqui; ahora sale de los ASIGNADOS + # (bloque 3b), usando su ultima etiqueta. + + # 3b) LEADS ASIGNADOS por vendedor (mensajes "Asignado a..." de Chatwoot). + # Se cuenta por telefono (1 asignacion por telefono, la mas reciente). + # Filtro por fecha de asignacion (created_at). Asesor via user_name normalizado. + # CONTACTADOS = de los asignados, cuya ULTIMA ETIQUETA != "NO TRABAJADO". + lead_asig = defaultdict(int) + etiq_asig = defaultdict(int) # (vendedor, ultima_etiqueta) -> nº asignados (para matriz) + for a in _leads_asignados_crudos(): + f = L._to_date(a.get("created_at")) + if not L._en_periodo(f, ano, mes, dia): + continue # filtro por fecha de asignacion + v = _norm_asesor(a.get("user_name"), amap) + if not v: + continue # el asesor asignado no es uno de los 8 + lead_asig[v] += 1 + ult = L.ultima_etiqueta(a.get("etiquetas")) or "NO TRABAJADO" + etiq_asig[(v, ult)] += 1 # para la matriz Estado/Objeción (asignados) + if ult != "NO TRABAJADO": + lead_cont[v] += 1 # contactado = asignado con ultima etiqueta trabajada + + # SERIE DIARIA: Leads Asignados (por fecha de asignacion) y Matriculas (por fch_matricula), + # por vendedor y por dia del mes. clave: (vendedor, dia) -> conteo. + serie_proc = defaultdict(int) # (vendedor, dia) -> leads asignados + serie_mat = defaultdict(int) # (vendedor, dia) -> matriculas + for a in _leads_asignados_crudos(): + f = L._to_date(a.get("created_at")) + if not L._en_periodo(f, ano, mes, dia): + continue + v = _norm_asesor(a.get("user_name"), amap) + if v and f: + serie_proc[(v, f.day)] += 1 + for m in _matriculas_crudas(): + vend = _norm_asesor(m.get("dsc_vendedor"), amap) + if not vend: + continue + estado = str(m.get("estado_matricula") or "").strip().upper() + if estado == "ANU": + continue + fm = L._to_date(m.get("fch_matricula")) + if fm and L._en_periodo(fm, ano, mes, dia): + serie_mat[(vend, fm.day)] += 1 + + def _serie(vendedores): + """Lista 1..31 con asignados y matriculas del conjunto de vendedores.""" + out = [] + for d in range(1, 32): + p = sum(serie_proc.get((v, d), 0) for v in vendedores) + mm = sum(serie_mat.get((v, d), 0) for v in vendedores) + out.append({"dia": d, "asignados": p, "matriculas": mm}) + return out + + serie_por_vend = {v: _serie([v]) for v in lista} + serie_total = _serie(lista) + + # Matriz Estado/Objeción (Ultima_Etiqueta) por vendedor y total. + # Ahora se basa en los LEADS ASIGNADOS (etiq_asig), no en los de campaña. + etiquetas_all = sorted({e for (_, e) in etiq_asig.keys()}, key=lambda x: x.lower()) + + def _matriz_etiq(vendedores): + out = [] + for e in etiquetas_all: + c = sum(etiq_asig.get((v, e), 0) for v in vendedores) + if c > 0: + out.append({"estado": e, "cantidad": c}) + out.sort(key=lambda x: -x["cantidad"]) # mayor a menor por cantidad + return out + + matriz_etiq_por_vend = {v: _matriz_etiq([v]) for v in lista} + matriz_etiq_total = _matriz_etiq(lista) + + # 4) MATRIZ por TIPO DE PROGRAMA (TEAC/TERC/SEMINARIOS/OTROS). + # Filtro por fch_matricula (periodo) y dsc_vendedor. Total y Retiradas (RET). + # Se guarda por (vendedor, grupo) para poder filtrar por vendedor en el front. + GRUPOS_PROG = ["TEAC", "TERC", "SEMINARIOS", "OTROS"] + prog_tot = defaultdict(int); prog_ret = defaultdict(int) # clave: (vendedor, grupo) + for m in _matriculas_crudas(): + vend = _norm_asesor(m.get("dsc_vendedor"), amap) + if not vend: + continue + estado = str(m.get("estado_matricula") or "").strip().upper() + if estado == "ANU": + continue + fm = L._to_date(m.get("fch_matricula")) + if not L._en_periodo(fm, ano, mes, dia): + continue # filtro por fch_matricula + grupo = L.grupo_programa_curso(m.get("dsc_programa")) + prog_tot[(vend, grupo)] += 1 + if estado == "RET": + prog_ret[(vend, grupo)] += 1 + + def _matriz_prog(vendedores): + """Suma total/retiradas por grupo para el conjunto de vendedores dado.""" + out = [] + for g in GRUPOS_PROG: + t = sum(prog_tot.get((v, g), 0) for v in vendedores) + r = sum(prog_ret.get((v, g), 0) for v in vendedores) + out.append({"programa": g, "total": t, "retiradas": r, + "pct_retiradas": round(r / t * 100, 1) if t else 0}) + return out + + # matriz por programa para CADA vendedor y para el TOTAL (todos) + matriz_prog_por_vend = {v: _matriz_prog([v]) for v in lista} + matriz_prog_total = _matriz_prog(lista) + + filas = [] + for v in lista: + proc = lead_proc.get(v, 0); cont = lead_cont.get(v, 0) + asig = lead_asig.get(v, 0) + filas.append({ + "vendedor": v, + "cartera_total": cart_total.get(v, 0), + "cartera_copito": cart_copito.get(v, 0), + "cartera_otros": cart_otros.get(v, 0), + "mat_total": mat_total.get(v, 0), + "mat_curso": mat_curso.get(v, 0), + "mat_retiradas": mat_retir.get(v, 0), + "pct_retiradas": round(mat_retir.get(v, 0) / mat_total.get(v, 0) * 100, 1) + if mat_total.get(v, 0) else 0, + "leads_procesados": proc, + "leads_contactados": cont, + # % Contactados = contactados / ASIGNADOS (ambos de la misma fuente) + "pct_contactados": round(cont / asig * 100, 1) if asig else 0, + "leads_asignados": asig, + }) + tot = {"cartera_total": sum(f["cartera_total"] for f in filas), + "cartera_copito": sum(f["cartera_copito"] for f in filas), + "cartera_otros": sum(f["cartera_otros"] for f in filas), + "mat_total": sum(f["mat_total"] for f in filas), + "mat_curso": sum(f["mat_curso"] for f in filas), + "mat_retiradas": sum(f["mat_retiradas"] for f in filas), + "leads_procesados": sum(f["leads_procesados"] for f in filas), + "leads_contactados": sum(f["leads_contactados"] for f in filas), + "leads_asignados": sum(f["leads_asignados"] for f in filas)} + tot["pct_retiradas"] = (round(tot["mat_retiradas"] / tot["mat_total"] * 100, 1) + if tot["mat_total"] else 0) + tot["pct_contactados"] = (round(tot["leads_contactados"] / tot["leads_asignados"] * 100, 1) + if tot["leads_asignados"] else 0) + # Tiempo de respuesta laboral (min) dia a dia por asesor (por fecha asignacion) + tr = tiempo_respuesta_dashboard(ano, mes, dia) + return {"filas": filas, "total": tot, "vendedores": lista, + "matriz_prog": matriz_prog_por_vend, "matriz_prog_total": matriz_prog_total, + "matriz_etiq": matriz_etiq_por_vend, "matriz_etiq_total": matriz_etiq_total, + "serie_dia": serie_por_vend, "serie_dia_total": serie_total, + "tiempo_resp": tr.get("serie_por_vend", {}), + "tiempo_resp_total": tr.get("serie_total", []), + "tiempo_resp_r": tr.get("serie_resp_por_vend", {}), + "tiempo_resp_r_total": tr.get("serie_resp_total", [])} + return cache_get_or_set("vendedores_dash", (ano, mes, dia), _load) + + +def _conjunto_sede_prog(): + """{conjunto_UPPER: {sede, programa}} asignaciones manuales (tabla Supabase).""" + return cache_get_or_set("conjunto_sede_prog", ("GLOBAL",), + lambda: get_dm().traer_conjunto_sede_programa()) + + +def conjuntos_sin_sede(): + """Lista los conjuntos del Meta CSV que NO estan ligados a ninguna sede + (ni por pauta->campania, ni por asignacion manual). Para el apartado de + asignacion. Sin filtro de mes. Incluye la sede/programa YA asignados (si tiene). + Devuelve [{conjunto, importe, resultados, sede, programa}].""" + def _load(): + SEDES = {"LIMA", "PIURA", "AREQUIPA", "TRUJILLO"} + conj_pauta = _conjunto_pauta() + camp = _campanias_sede_cargo() + manual = _conjunto_sede_prog() + # conjuntos que SÍ tienen sede via pauta -> campania + con_sede = set() + for pauta, conjuntos in conj_pauta.items(): + sp = camp.get(str(pauta).strip()) + sede = " ".join(str((sp or {}).get("sede") or "").upper().split()) + if sp and sede in SEDES: + for co in conjuntos: + con_sede.add(" ".join(str(co).split()).upper()) + agg = {} + for f in _meta_filas(): + co = " ".join(str(f.get("conjunto")).split()) + if not co: + continue + k = co.upper() + if k in con_sede: + continue # ya tiene sede por campania -> no aparece aqui + d = agg.setdefault(k, {"conjunto": co, "importe": 0.0, "resultados": 0, + "sede": manual.get(k, {}).get("sede", ""), + "programa": manual.get(k, {}).get("programa", "")}) + d["importe"] += float(f.get("importe") or 0.0) + d["resultados"] += int(float(f.get("resultados") or 0.0)) + out = sorted(agg.values(), key=lambda x: -x["importe"]) + for d in out: + d["importe"] = round(d["importe"], 2) + return out + return cache_get_or_set("conjuntos_sin_sede", ("GLOBAL",), _load) + + +def guardar_conjunto_sede(cambios): + """Guarda asignaciones conjunto->sede/programa e invalida cachés dependientes.""" + r = get_dm().guardar_conjunto_sede_programa(cambios) + for pref in ("conjunto_sede_prog", "conjuntos_sin_sede", "roas_dash"): + cache_invalidate(prefix=pref) + return r + + +def borrar_conjunto_sede(conjunto): + """Borra la asignacion de un conjunto e invalida cachés dependientes.""" + r = get_dm().borrar_conjunto_sede_programa(conjunto) + for pref in ("conjunto_sede_prog", "conjuntos_sin_sede", "roas_dash"): + cache_invalidate(prefix=pref) + return r + + +def roas_dashboard(ano="TODOS", mes="TODOS", dia="TODOS"): + """Matriz ROAS por SEDE (LIMA, PIURA, AREQUIPA, TRUJILLO): Importe Gastado y + Resultados del Google Sheet (Meta Ads), filtrado por Año/Mes (Inicio del informe). + Cadena: conjunto -> pauta (conjunto_pauta) -> sede (campanias). Conjuntos sin + pauta/sede se omiten por ahora.""" + def _load(): + SEDES = ["LIMA", "PIURA", "AREQUIPA", "TRUJILLO", "OTROS"] # OTROS = 5ta fila (manual) + CAMP_SEDES = {"LIMA", "PIURA", "AREQUIPA", "TRUJILLO"} # sedes validas por campania + conj_pauta = _conjunto_pauta() # {pauta: [conjunto,...]} + camp = _campanias_sede_cargo() # {pauta: {sede, cargo}} + + PROGS_OK = {"TEAC", "TERC", "SEMINARIOS", "OTROS"} + + # conjunto (normalizado) -> (sede, programa) via su pauta -> campania + conj_sede = {} # conjunto -> sede + conj_prog = {} # conjunto -> programa (grupo) + for pauta, conjuntos in conj_pauta.items(): + sp = camp.get(str(pauta).strip()) + if not sp: + continue + sede = " ".join(str(sp.get("sede") or "").upper().split()) + if sede not in CAMP_SEDES: + continue + prog = L.grupo_programa_lead(sp.get("cargo")) # TEAC/TERC/SEMINARIOS + for co in conjuntos: + k = " ".join(str(co).split()).upper() + conj_sede[k] = sede + conj_prog[k] = prog + + # + asignaciones MANUALES (sede + programa). Prioridad sobre la campania. + for k, v in _conjunto_sede_prog().items(): + sede = v.get("sede") + if sede in SEDES: + conj_sede[k] = sede + prog = str(v.get("programa") or "").strip().upper() + if prog: # acepta CUALQUIER programa asignado manualmente (no solo los fijos) + conj_prog[k] = prog + + # sumar importe y resultados por sede y por (sede, programa), filtrando por Año/Mes + imp = {s: 0.0 for s in SEDES} + res = {s: 0 for s in SEDES} + imp_pg = {} # (sede, prog) -> importe + res_pg = {} # (sede, prog) -> resultados + # conjuntos por (sede, prog): nombre_conjunto -> {importe, resultados} + conj_det = {} # (sede, prog) -> { conjunto_original: {importe, resultados} } + 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 + co = " ".join(str(f.get("conjunto")).split()) + k = co.upper() + sede = conj_sede.get(k) + if not sede: + continue # conjunto sin pauta/sede -> se omite + i = float(f.get("importe") or 0.0) + rr = int(float(f.get("resultados") or 0.0)) + imp[sede] += i + res[sede] += rr + prog = conj_prog.get(k, "SIN PROGRAMA") + imp_pg[(sede, prog)] = imp_pg.get((sede, prog), 0.0) + i + res_pg[(sede, prog)] = res_pg.get((sede, prog), 0) + rr + dd = conj_det.setdefault((sede, prog), {}) + d = dd.setdefault(co, {"conjunto": co, "importe": 0.0, "resultados": 0}) + d["importe"] += i + d["resultados"] += rr + + ORDEN_PROG = ["TEAC", "TERC", "SEMINARIOS", "OTROS", "SIN PROGRAMA"] + + def _conjuntos(sede, prog): + """Lista de conjuntos de anuncio de un (sede, programa), ordenada por importe.""" + det = list(conj_det.get((sede, prog), {}).values()) + det.sort(key=lambda x: -x["importe"]) + for d in det: + d["importe"] = round(d["importe"], 2) + return det + + def _sub(sede): + """Desglose por programa de una sede (solo los que tienen algo). + Primero los fijos en su orden, luego CUALQUIER otro programa asignado + (ej. BRANDING), y al final SIN PROGRAMA.""" + progs_de_sede = [p for (s, p) in imp_pg.keys() if s == sede] + extras = sorted(set(progs_de_sede) - set(ORDEN_PROG)) + orden = [p for p in ORDEN_PROG if p != "SIN PROGRAMA"] + extras + ["SIN PROGRAMA"] + out = [] + for p in orden: + i = imp_pg.get((sede, p), 0.0); r = res_pg.get((sede, p), 0) + if i or r: + out.append({"programa": p, "importe": round(i, 2), "resultados": r, + "conjuntos": _conjuntos(sede, p)}) + return out + + # Las 4 sedes reales siempre se muestran. OTROS solo si tiene algo (>0). + filas = [] + for s in SEDES: + if s == "OTROS" and imp[s] == 0 and res[s] == 0: + continue + filas.append({"sede": s, "importe": round(imp[s], 2), "resultados": res[s], + "programas": _sub(s)}) + tot = {"importe": round(sum(imp.values()), 2), "resultados": sum(res.values())} + return {"filas": filas, "total": tot} + return cache_get_or_set("roas_dash", (ano, mes, dia), _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} + + +# ── LEYENDA (campanias de Supabase): listar / agregar / borrar ── +def campanias_listar(): + return get_dm().listar_campanias() + + +def campania_agregar(datos): + res = get_dm().agregar_campania(datos) + _invalidar_por_campanias() # la leyenda nueva se refleja de inmediato en todo el dashboard + return res + + +def campania_editar(cid, datos): + res = get_dm().editar_campania(cid, datos) + _invalidar_por_campanias() # el cambio se refleja de inmediato en todo el dashboard + return res + + +def campania_borrar(cid): + res = get_dm().borrar_campania(cid) + _invalidar_por_campanias() + return res + + +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", "roas_dash"): + 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]))] + + +import os as _os +_LOG_CARGA = _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "carga_tiempos.log") + + +def _log_carga(linea, reset=False): + """Escribe una linea en carga_tiempos.log (lo lee la ventana de tiempos).""" + try: + with open(_LOG_CARGA, "w" if reset else "a", encoding="utf-8") as f: + f.write(linea + "\n") + except Exception: + pass + + +import threading as _thr +_TIEMPOS = {} # etiqueta -> segundos (para el resumen final) +_TIEMPOS_LOCK = _thr.Lock() +_HILOS_LISTOS = set() # {"leads","otros"} — cuando estan los 2, se imprime resumen + + +def _cron(etiqueta, base, fn, apartado=None): + """Ejecuta fn() midiendo el tiempo. Imprime y ademas guarda en el log: + paso, base de datos y segundos. Se ve en la ventana del BACKEND y en la + ventana de TIEMPOS al iniciar, para saber que carga cada cosa. + Si 'apartado' se indica, acumula el tiempo para el resumen final.""" + import time as _t + t0 = _t.perf_counter() + r = fn() + dt = _t.perf_counter() - t0 + n = "" + try: + if isinstance(r, (list, dict)): + n = f"({len(r)} filas)" + except Exception: + pass + linea = f" [carga] {etiqueta:32} {base:14} {dt:6.2f}s {n}" + print(linea, flush=True) + _log_carga(linea) + with _TIEMPOS_LOCK: + _TIEMPOS[apartado or etiqueta] = _TIEMPOS.get(apartado or etiqueta, 0.0) + dt + return r + + +def _hilo_termino(nombre): + """Marca un hilo como listo. Cuando ambos ("leads" y "otros") terminan, + imprime el resumen final por apartado en el log.""" + with _TIEMPOS_LOCK: + _HILOS_LISTOS.add(nombre) + listos = {"leads", "otros"}.issubset(_HILOS_LISTOS) + tiempos = dict(_TIEMPOS) + if not listos: + return + _log_carga("") + _log_carga("=" * 60) + _log_carga(" RESUMEN — tiempo listo para abrir cada apartado") + _log_carga("=" * 60) + orden = ["Leads", "Vendedores", "Otros General", "ROAS"] + for ap in orden: + seg = tiempos.get(ap) + if seg is not None: + estado = "instantaneo al abrir" if seg < 1 else f"listo tras {seg:.1f}s" + _log_carga(f" {ap:16} {seg:6.2f}s -> {estado}") + _log_carga("-" * 60) + _log_carga(" Ya precargados: al hacer clic se muestran AL INSTANTE (cache).") + _log_carga("=" * 60) + + +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: + import time as _t + _log_carga("=" * 60, reset=True) + _log_carga(" CARGA DEL DASHBOARD LEADS — " + + _t.strftime("%d/%m/%Y %H:%M:%S")) + _log_carga("=" * 60) + print(">>> Precargando LEADS (hilo 1)...", flush=True) + _log_carga(">>> Hilo 1: LEADS (PostgreSQL + SQL Server)") + _cron("Leads (mensajes)", "PostgreSQL", _leads_crudos) + _cron("Cursos", "SQL Server", _cursos_crudos) + _cron("Matriculas", "SQL Server", _matriculas_crudas) + _cron("Leads asignados/respuesta", "PostgreSQL", _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. + p = _periodos_actual_anterior() + a0, m0 = p[0] + for (a, m) in p: + _cron(f"Apartado Leads {a}-{m}", "cache", + lambda a=a, m=m: leads_dashboard(a, m, "TODOS", "TODOS", "TODOS"), apartado="Leads") + # Vendedores y ROAS del periodo actual: se precargan para abrir al instante. + _cron("Apartado Vendedores", "cache", + lambda: vendedores_dashboard(a0, m0, "TODOS"), apartado="Vendedores") + _cron("Apartado ROAS", "cache", + lambda: roas_dashboard(a0, m0, "TODOS"), apartado="ROAS") + _marcar_actualizacion() + print(">>> Hilo 1 (Leads) LISTO.", flush=True) + _log_carga(">>> Hilo 1 (Leads) LISTO.") + _hilo_termino("leads") + except Exception as e: + print(f"[precarga] {e}", flush=True) + _log_carga(f"[precarga] ERROR: {e}") + _hilo_termino("leads") + + +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: + print(">>> Precargando CARTERA + OTROS GENERAL (hilo 2)...", flush=True) + _log_carga(">>> Hilo 2: CARTERA (Supabase) + OTROS GENERAL") + _cron("Plantillas", "Google Sheet", _plantillas_crudas) + _cron("Cartera", "Supabase", _cartera_rows_cache) # la mas pesada + for (a, m) in _periodos_actual_anterior(): + _cron(f"Otros General {a}-{m}", "cache", + lambda a=a, m=m: otros_general(a, m, "TODOS"), apartado="Otros General") + print(">>> Hilo 2 (Cartera/Otros) LISTO.", flush=True) + _log_carga(">>> Hilo 2 (Cartera/Otros) LISTO.") + _hilo_termino("otros") + except Exception as e: + print(f"[precarga-otros] {e}", flush=True) + _log_carga(f"[precarga-otros] ERROR: {e}") + _hilo_termino("otros") + + +# 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 + from data_manager_v2 import invalidar_campanias_cache + cache_invalidate() # vaciar y recargar TODO + invalidar_campanias_cache() # tambien las campañas (cache aparte) + precargar_todo() # leads (mes actual/anterior) + precargar_otros_general() # cartera + Otros General (para que no quede vacia) + _marcar_actualizacion() diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..d63f023 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,4 @@ +# URL base del backend FastAPI que consume el frontend. +# Local: http://localhost:8001 +# Despliegue: https://api-leads.escueladerefrigeracion.lat +VITE_API_URL=http://localhost:8001 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/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/public/50x.html b/frontend/public/50x.html new file mode 100644 index 0000000..a57c2f9 --- /dev/null +++ b/frontend/public/50x.html @@ -0,0 +1,19 @@ + + + +Error + + + +

An error occurred.

+

Sorry, the page you are looking for is currently unavailable.
+Please try again later.

+

If you are the system administrator of this resource then you should check +the error log for details.

+

Faithfully yours, nginx.

+ + diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..ff52521 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,35 @@ +import { useState } from "react"; +import Sidebar from "./components/Sidebar"; +import Leads from "./pages/Leads"; +import OtrosGeneral from "./pages/OtrosGeneral"; +import Vendedores from "./pages/Vendedores"; +import Roas from "./pages/Roas"; +import CampanitaAlertas from "./components/CampanitaAlertas"; +import ConfigModal from "./components/ConfigModal"; +import ErrorBoundary from "./components/ErrorBoundary"; + +export default function App() { + const [pagina, setPagina] = useState("leads"); + const [config, setConfig] = useState(false); + + function render() { + switch (pagina) { + case "leads": return ; + case "otros": return ; + case "vendedores": return ; + case "roas": 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..6033862 --- /dev/null +++ b/frontend/src/components/ConfigModal.jsx @@ -0,0 +1,969 @@ +import { useState, useEffect, useRef } from "react"; +import { api } from "../lib/api"; + +// Desplegable con búsqueda (estilo Claude). +function ProgramaSelect({ valor, opciones = [], onSelect }) { + const [abierto, setAbierto] = useState(false); + const [q, setQ] = useState(""); + const ref = useRef(null); + + useEffect(() => { + function fuera(e) { if (ref.current && !ref.current.contains(e.target)) setAbierto(false); } + document.addEventListener("mousedown", fuera); + return () => document.removeEventListener("mousedown", fuera); + }, []); + + const filtradas = opciones.filter((o) => o.label.toLowerCase().includes(q.toLowerCase())); + + return ( +
+ + {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 "Sedes de Anuncios": conjuntos del CSV sin sede -> asignar sede manual. +// FASE 1: solo vista (dropdown de sede por fila). El guardar se agrega despues. +function SedesAnuncios() { + const [filas, setFilas] = useState([]); + const [cargando, setCargando] = useState(true); + const [sel, setSel] = useState({}); // conjunto -> sede elegida (decorativo por ahora) + const [selProg, setSelProg] = useState({}); // conjunto -> programa elegido (decorativo) + const [busca, setBusca] = useState(""); + // Opciones fijas (no se pueden borrar) y opciones ampliables con "Agregar". + const SEDES_FIJAS = ["LIMA", "PIURA", "AREQUIPA", "TRUJILLO"]; + const PROGS_FIJOS = ["TEAC", "TERC", "SEMINARIOS", "OTROS"]; + const [sedes, setSedes] = useState(SEDES_FIJAS); + const [programas, setProgramas] = useState(PROGS_FIJOS); + const SEDES = sedes; + const PROGRAMAS = programas; + + // Quita una opción creada (no fija): la saca del dropdown y de cualquier + // conjunto que la tenga asignada (se aplica al Guardar). + function quitarOpcion(tipo, val) { + if (tipo === "TIPO") { + if (SEDES_FIJAS.includes(val)) return; + setSedes((s) => s.filter((x) => x !== val)); + setSel((cur) => { + const n = { ...cur }; + Object.keys(n).forEach((k) => { if (n[k] === val) n[k] = ""; }); + return n; + }); + } else { + if (PROGS_FIJOS.includes(val)) return; + setProgramas((p) => p.filter((x) => x !== val)); + setSelProg((cur) => { + const n = { ...cur }; + Object.keys(n).forEach((k) => { if (n[k] === val) n[k] = ""; }); + return n; + }); + } + } + // Pop-up "Agregar" + const [popup, setPopup] = useState(false); + const [nvTipo, setNvTipo] = useState("TIPO"); // "TIPO" o "PROGRAMA" + const [nvValor, setNvValor] = useState(""); + + function agregar() { + const val = nvValor.trim().toUpperCase(); + if (!val) return; + if (nvTipo === "TIPO") { + if (!sedes.includes(val)) setSedes((s) => [...s, val]); + } else { + if (!programas.includes(val)) setProgramas((p) => [...p, val]); + } + setNvValor(""); setPopup(false); + } + + const [guardando, setGuardando] = useState(false); + const [msg, setMsg] = useState(""); + + useEffect(() => { + api.conjuntosSinSede() + .then((r) => { + const rows = r || []; + setFilas(rows); + // precargar sede/programa ya asignados + ampliar opciones si hay valores nuevos + const s0 = {}, p0 = {}; const sExtra = new Set(), pExtra = new Set(); + rows.forEach((f) => { + if (f.sede) { s0[f.conjunto] = f.sede; sExtra.add(f.sede); } + if (f.programa) { p0[f.conjunto] = f.programa; pExtra.add(f.programa); } + }); + setSel(s0); setSelProg(p0); + setSedes((cur) => [...cur, ...[...sExtra].filter((x) => !cur.includes(x))]); + // Carga los programas ya asignados (incluye los nuevos que crees, ej. + // BRANDING). El dashboard ahora SÍ reconoce cualquier programa asignado. + setProgramas((cur) => [...cur, ...[...pExtra].filter((x) => !cur.includes(x))]); + setCargando(false); + }) + .catch(() => setCargando(false)); + }, []); + + async function guardar() { + setGuardando(true); setMsg(""); + try { + const cambios = filas + .filter((f) => sel[f.conjunto] || selProg[f.conjunto]) + .map((f) => ({ conjunto: f.conjunto, sede: sel[f.conjunto] || "", programa: selProg[f.conjunto] || "" })); + await api.guardarConjuntoSede(cambios); + setMsg("✓ Guardado. Se reflejará en ROAS."); + window.dispatchEvent(new Event("roas-actualizar")); // avisa al ROAS que recargue + window.dispatchEvent(new Event("datos-actualizar")); // avisa a TODOS los apartados + } catch (e) { + setMsg("Error al guardar"); + } + setGuardando(false); + } + + async function borrar(conjunto) { + // limpia la seleccion en pantalla y borra la asignacion en Supabase + setSel((s) => { const n = { ...s }; delete n[conjunto]; return n; }); + setSelProg((s) => { const n = { ...s }; delete n[conjunto]; return n; }); + try { + await api.borrarConjuntoSede(conjunto); + setMsg("✓ Asignación borrada."); + window.dispatchEvent(new Event("roas-actualizar")); // avisa al ROAS que recargue + window.dispatchEvent(new Event("datos-actualizar")); // avisa a TODOS los apartados + } catch (e) { + setMsg("Error al borrar"); + } + } + + const vis = filas.filter((f) => f.conjunto.toLowerCase().includes(busca.toLowerCase())); + + return ( + <> +
+
+ setBusca(e.target.value)} + placeholder="Buscar conjunto de anuncio..." + style={{ flex: 1, padding: "9px 12px", border: "1px solid #cbd5e1", + borderRadius: 9, fontSize: 13 }} /> + +
+
+ Conjuntos de anuncios sin sede asignada. Elige la sede y el programa de cada uno. +
+
+ + {/* Pop-up Agregar nueva opción (Tipo o Programa) */} + {popup && ( +
setPopup(false)} + style={{ position: "fixed", inset: 0, background: "rgba(15,23,42,0.4)", zIndex: 60, + display: "flex", alignItems: "center", justifyContent: "center" }}> +
e.stopPropagation()} + style={{ background: "#fff", borderRadius: 14, width: 360, padding: 22, + boxShadow: "0 20px 50px rgba(0,0,0,0.3)" }}> +
+ Agregar nueva opción +
+ + + + setNvValor(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && agregar()} + placeholder="Ej: CHICLAYO" + style={{ width: "100%", padding: "9px 10px", border: "1px solid #cbd5e1", + borderRadius: 8, fontSize: 14, marginTop: 5, marginBottom: 14 }} /> + + {/* Lista de opciones CREADAS (no fijas) para poder borrarlas */} + {(() => { + const esSede = nvTipo === "TIPO"; + const lista = (esSede ? sedes : programas) + .filter((x) => !(esSede ? SEDES_FIJAS : PROGS_FIJOS).includes(x)); + if (lista.length === 0) return null; + return ( +
+
+ {esSede ? "Sedes creadas" : "Programas creados"} (clic en × para borrar) +
+
+ {lista.map((x) => ( + + {x} + + + ))} +
+
+ ); + })()} + +
+ + +
+
+
+ )} +
+ {cargando ? ( +
Cargando...
+ ) : ( +
+ + + + + + + + + + + {vis.map((f, i) => ( + + + + + + + ))} + {vis.length === 0 && ( + + )} + +
Conjunto de AnuncioTipoPrograma
{f.conjunto} + + + + + {(sel[f.conjunto] || selProg[f.conjunto]) && ( + + )} +
+ No hay conjuntos sin sede.
+
+ )} +
+
+ + {msg || `${vis.length} conjuntos sin sede`} + + +
+ + ); +} + +// Apartado "Programas": lista de programas con contar=NO (ocultos), con switch para reactivar. +function ProgramasOcultos() { + const [filas, setFilas] = useState([]); + const [cargando, setCargando] = useState(true); + const [enc, setEnc] = useState({}); // num_indice -> true si se va a encender + const [guardando, setGuardando] = useState(false); + const [msg, setMsg] = useState(""); + + useEffect(() => { + api.programasOcultos() + .then((r) => { setFilas(r.filas || []); setCargando(false); }) + .catch(() => setCargando(false)); + }, []); + + async function guardar() { + const nis = Object.entries(enc).filter(([, v]) => v).map(([ni]) => ni); + if (nis.length === 0) { setMsg("No hay programas para activar."); return; } + setGuardando(true); setMsg(""); + try { + await api.encenderProgramas(nis); + setMsg("✓ Activados. Ya aparecen en la matriz."); + const r = await api.programasOcultos(); + setFilas(r.filas || []); setEnc({}); + window.dispatchEvent(new Event("datos-actualizar")); // avisa a TODOS los apartados + } catch (e) { setMsg("Error: " + e.message); } + finally { setGuardando(false); } + } + + return ( + <> +
+
+ 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} + +
+ + ); +} + +// ── Mini-apartado LEYENDA: tabla de campanias + Agregar (popup) + eliminar (visual) ── +function LeyendaCampanias() { + const CARGOS = ["TEAC", "TERC", "VRF", "CO2", "Seminarios", "Diseño de Chillers", "Diplomado REF", "CAD", "Amoniaco", "Cámaras", "Diseño de Sistemas", "Metrado y Costeo"]; + const SEDES = ["Lima", "Piura", "Arequipa", "Trujillo"]; + const ORIGENES = ["Pauta_wsp", "Web_Whatsapp"]; + const DIAS = ["Domingo", "Sábado", "Semipresencial", "-"]; + + const [filas, setFilas] = useState([]); + const [cargando, setCargando] = useState(true); + const [busca, setBusca] = useState(""); + const [popup, setPopup] = useState(false); + const [editandoId, setEditandoId] = useState(null); // id de la fila en edicion, o null = agregar + const [confirmar, setConfirmar] = useState(null); // fila a eliminar, o null + const [choques, setChoques] = useState(null); // [filas que chocan] o null (popup de aviso) + const [guardando, setGuardando] = useState(false); + const [msg, setMsg] = useState(""); + const nuevaVacia = { frase_busqueda: "", cargo: "TEAC", codigo: "", sede: "Lima", dia: "-", origen: "Pauta_wsp", fecha_inicio: "", fecha_fin: "" }; + const [nueva, setNueva] = useState(nuevaVacia); + + function recargar() { + setCargando(true); + api.campanias().then((r) => { setFilas(r.filas || []); setCargando(false); }) + .catch(() => setCargando(false)); + } + useEffect(() => { recargar(); }, []); + + const vis = filas.filter((f) => + !busca || ((f.frase_busqueda || "") + (f.cargo || "") + (f.codigo || "") + (f.sede || "")).toLowerCase().includes(busca.toLowerCase())); + + // Extrae los emojis de un texto (pictográficos). Devuelve un Set de emojis. + function emojisDe(txt) { + const s = new Set(); + for (const ch of String(txt || "")) { + const cp = ch.codePointAt(0); + // rangos de emojis/pictogramas comunes + if ((cp >= 0x1F000 && cp <= 0x1FAFF) || (cp >= 0x2600 && cp <= 0x27BF) || + (cp >= 0x2B00 && cp <= 0x2BFF) || cp === 0x2764 || (cp >= 0x2190 && cp <= 0x21FF)) { + s.add(ch); + } + } + return s; + } + // ¿los rangos [i1,f1] y [i2,f2] se solapan? (fechas en texto YYYY-MM-DD) + function fechasSolapan(i1, f1, i2, f2) { + if (!i1 || !f1 || !i2 || !f2) return false; + return i1 <= f2 && i2 <= f1; + } + // Devuelve las campañas existentes que chocan con "nueva": + // mismo emoji compartido Y fechas solapadas. + function detectarChoques() { + const emN = emojisDe(nueva.frase_busqueda); + if (emN.size === 0) return []; + const iN = (nueva.fecha_inicio || "").slice(0, 10); + const fN = (nueva.fecha_fin || "").slice(0, 10); + return filas.filter((f) => { + if (editandoId != null && f.id === editandoId) return false; // no chocar contra si misma + const emE = emojisDe(f.frase_busqueda); + const comparte = [...emN].some((e) => emE.has(e)); // ¿comparten algún emoji? + if (!comparte) return false; + return fechasSolapan(iN, fN, (f.fecha_inicio || "").slice(0, 10), (f.fecha_fin || "").slice(0, 10)); + }); + } + + function abrirAgregar() { + setEditandoId(null); setNueva(nuevaVacia); setMsg(""); setPopup(true); + } + function abrirEditar(f) { + setEditandoId(f.id); + setNueva({ + frase_busqueda: f.frase_busqueda || "", cargo: f.cargo || "TEAC", + codigo: f.codigo || "", sede: f.sede || "Lima", dia: f.dia || "-", + origen: f.origen || "Pauta_wsp", + fecha_inicio: (f.fecha_inicio || "").slice(0, 10), + fecha_fin: (f.fecha_fin || "").slice(0, 10), + }); + setMsg(""); setPopup(true); + } + + async function agregar() { + if (!nueva.frase_busqueda.trim()) { setMsg("Escribe la frase de búsqueda."); return; } + // Aviso de choque: mismo emoji + fechas solapadas. Si las fechas NO chocan, no avisa. + const ch = detectarChoques(); + if (ch.length > 0) { setChoques(ch); return; } + await guardarCampania(); + } + + async function guardarCampania() { + setChoques(null); + setGuardando(true); setMsg(""); + try { + if (editandoId != null) await api.editarCampania(editandoId, nueva); + else await api.agregarCampania(nueva); + setPopup(false); setNueva(nuevaVacia); setEditandoId(null); + recargar(); + window.dispatchEvent(new Event("datos-actualizar")); + } catch (e) { setMsg("Error al guardar: " + e.message); } + finally { setGuardando(false); } + } + async function borrarConfirmado() { + const f = confirmar; + setConfirmar(null); + try { + await api.borrarCampania(f.id); + recargar(); + window.dispatchEvent(new Event("datos-actualizar")); + } catch (e) { setMsg("Error al borrar: " + e.message); } + } + + const inp = { width: "100%", height: 38, border: "1px solid #e2e8f0", borderRadius: 8, + padding: "0 11px", fontSize: 13, boxSizing: "border-box" }; + const lab = { fontSize: 12.5, fontWeight: 600, color: "#475569", display: "block", marginBottom: 5 }; + + return ( + <> +
+ setBusca(e.target.value)} + placeholder="Buscar frase, cargo, código o sede..." + style={{ flex: 1, padding: "9px 12px", border: "1px solid #cbd5e1", borderRadius: 9, fontSize: 13 }} /> + +
+ +
+
+ + + + {["Frase de búsqueda", "Cargo", "Código", "Sede", "Día", "Origen", "Inicio", "Fin", ""].map((h, i, arr) => ( + + ))} + + + + {cargando && ( + + )} + {!cargando && vis.length === 0 && ( + + )} + {!cargando && vis.map((f) => ( + + + + + + + + + + + + ))} + +
{h}
Cargando...
Sin leyendas.
{f.frase_busqueda}{f.cargo}{f.codigo}{f.sede}{f.dia}{f.origen}{f.fecha_inicio}{f.fecha_fin} +
+ + +
+
+
+
+ + {/* Popup Agregar leyenda */} + {popup && ( +
setPopup(false)} + style={{ position: "fixed", inset: 0, background: "rgba(15,23,42,0.45)", + display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1300 }}> +
e.stopPropagation()} + style={{ background: "#fff", borderRadius: 14, width: 520, maxWidth: "94vw", + boxShadow: "0 20px 50px rgba(0,0,0,.25)", overflow: "hidden" }}> +
+
{editandoId != null ? "Editar leyenda" : "Agregar leyenda"}
+ +
+
+
+ + setNueva({ ...nueva, frase_busqueda: e.target.value })} /> +
+
+
+ + +
+
+ + setNueva({ ...nueva, codigo: e.target.value })} /> +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + setNueva({ ...nueva, fecha_inicio: e.target.value })} /> +
+
+ + setNueva({ ...nueva, fecha_fin: e.target.value })} /> +
+
+
+
+ + +
+ {msg &&
{msg}
} +
+
+ )} + + {/* Popup de confirmación de eliminación */} + {confirmar !== null && ( +
setConfirmar(null)} + style={{ position: "fixed", inset: 0, background: "rgba(15,23,42,0.45)", + display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1400 }}> +
e.stopPropagation()} + style={{ background: "#fff", borderRadius: 14, width: 400, maxWidth: "92vw", + boxShadow: "0 20px 50px rgba(0,0,0,.25)", overflow: "hidden", textAlign: "center" }}> +
+
🗑️
+
+ ¿Eliminar esta leyenda? +
+
+ {confirmar && ( + <>Se eliminará la pauta {confirmar.codigo} + {" "}({confirmar.cargo} — {confirmar.sede}). Esta acción no se puede deshacer. + )} +
+
+
+ + +
+
+
+ )} + + {/* Popup de ADVERTENCIA de choque: mismo emoji + fechas solapadas */} + {choques !== null && ( +
setChoques(null)} + style={{ position: "fixed", inset: 0, background: "rgba(15,23,42,0.45)", + display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1500 }}> +
e.stopPropagation()} + style={{ background: "#fff", borderRadius: 14, width: 460, maxWidth: "94vw", + boxShadow: "0 20px 50px rgba(0,0,0,.25)", overflow: "hidden" }}> +
+
⚠️
+
+ Esta leyenda podría duplicar leads +
+
+ La frase que vas a crear comparte emoji y fechas con {choques.length === 1 ? "esta pauta existente" : `estas ${choques.length} pautas existentes`}. + Un mismo mensaje podría contarse dos veces. +
+
+
+ {choques.map((c) => ( +
+
+ Pauta {c.codigo} ({c.cargo} — {c.sede}) +
+
“{c.frase_busqueda}”
+
+ Vigente: {c.fecha_inicio} → {c.fecha_fin} +
+
+ ))} +
+
+ + +
+
+
+ )} + + ); +} + +export default function ConfigModal({ onClose }) { + const [seccion, setSeccion] = useState("leyenda"); + const [filas, setFilas] = useState([]); + const [cargando, setCargando] = useState(true); + const [guardando, setGuardando] = useState(false); + const [msg, setMsg] = useState(""); + const [busca, setBusca] = useState(""); + const [edit, setEdit] = useState({}); // conjunto -> pauta editada + const [editProg, setEditProg] = useState({}); // conjunto -> {num_indice, label} programa elegido + const [progOpc, setProgOpc] = useState([]); // opciones del desplegable de Programa + + useEffect(() => { + api.leyendaAnuncios() + .then((r) => { setFilas(r.filas || []); setCargando(false); }) + .catch(() => setCargando(false)); + api.programasDisponibles() + .then((r) => setProgOpc(r.programas || [])) + .catch(() => setProgOpc([])); + }, []); + + const setPauta = (conjunto, val) => setEdit((p) => ({ ...p, [conjunto]: val })); + const valorPauta = (f) => (edit[f.conjunto] !== undefined ? edit[f.conjunto] : (f.pauta || "")); + // Programa mostrado: el elegido si hay, si no el que ya trae la fila + const valorPrograma = (f) => (editProg[f.conjunto] !== undefined ? editProg[f.conjunto].label : (f.programa || "")); + + async function guardar() { + // Un cambio por conjunto que tenga pauta editada o programa elegido + const conjuntos = new Set([...Object.keys(edit), ...Object.keys(editProg)]); + const cambios = []; + for (const conjunto of conjuntos) { + const f = filas.find((x) => x.conjunto === conjunto) || {}; + const pauta = (edit[conjunto] !== undefined ? edit[conjunto] : (f.pauta || "")).trim(); + const prog = editProg[conjunto]; // {num_indice, label} o undefined + cambios.push({ conjunto, pauta, num_indice: prog ? prog.num_indice : null }); + } + if (cambios.length === 0) { setMsg("No hay cambios."); return; } + setGuardando(true); setMsg(""); + try { + await api.guardarLeyenda(cambios); + setMsg("✓ Guardado correctamente."); + const r = await api.leyendaAnuncios(); + setFilas(r.filas || []); setEdit({}); setEditProg({}); + window.dispatchEvent(new Event("datos-actualizar")); // avisa a TODOS los apartados + } catch (e) { setMsg("Error: " + e.message); } + finally { setGuardando(false); } + } + + // Orden: 1) pauta Y programa, 2) pauta sin programa, 3) programa sin pauta, 4) nada + const _rango = (f) => { + const p = valorPauta(f).trim() !== ""; + const g = (f.programa || "").trim() !== ""; + if (p && g) return 0; + if (p && !g) return 1; + if (!p && g) return 2; + return 3; + }; + const filtradas = filas + .filter((f) => + !busca || f.conjunto.toLowerCase().includes(busca.toLowerCase()) + || String(f.pauta).includes(busca) + || (f.programa || "").toLowerCase().includes(busca.toLowerCase())) + .sort((a, b) => (_rango(a) - _rango(b)) || a.conjunto.localeCompare(b.conjunto)); + + return ( +
+
e.stopPropagation()} + style={{ background: "#fff", borderRadius: 16, width: 1320, maxWidth: "96vw", + height: 680, maxHeight: "92vh", display: "flex", overflow: "hidden", + boxShadow: "0 24px 70px rgba(0,0,0,0.4)", + fontFamily: "system-ui, -apple-system, 'Segoe UI', sans-serif" }}> + {/* Sidebar del modal */} +
+
Configuración
+ + + + +
+ {/* Contenido */} +
+
+
+ {seccion === "leyenda" ? "Leyenda de Anuncios" + : seccion === "programas" ? "Programas" + : seccion === "leyendas" ? "Leyenda" + : "Sedes de Anuncios"} +
+ +
+ + {seccion === "leyendas" ? ( + + ) : seccion === "sedes" ? ( + + ) : 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/ErrorBoundary.jsx b/frontend/src/components/ErrorBoundary.jsx new file mode 100644 index 0000000..48b9efb --- /dev/null +++ b/frontend/src/components/ErrorBoundary.jsx @@ -0,0 +1,41 @@ +import { Component } from "react"; + +// Captura errores de render de sus hijos y muestra un mensaje en vez de +// dejar la pantalla en blanco. Botón para reintentar (re-monta el contenido). +export default class ErrorBoundary extends Component { + constructor(props) { + super(props); + this.state = { error: null }; + } + static getDerivedStateFromError(error) { + return { error }; + } + componentDidCatch(error, info) { + // Log para diagnóstico en consola del navegador + console.error("[ErrorBoundary]", error, info); + } + reintentar = () => this.setState({ error: null }); + render() { + if (this.state.error) { + return ( +
+
⚠️
+
+ Ocurrió un problema al mostrar esta sección +
+
+ El resto del dashboard sigue funcionando. Puedes reintentar o cambiar de apartado. +
+ +
+ ); + } + return this.props.children; + } +} diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx new file mode 100644 index 0000000..1cc752e --- /dev/null +++ b/frontend/src/components/Sidebar.jsx @@ -0,0 +1,113 @@ +import { useState, useEffect } from "react"; +import { api } from "../lib/api"; + +const MENU = [ + { id: "leads", ico: "🎯", label: "Leads" }, + { id: "otros", ico: "📊", label: "Otros General" }, + { id: "vendedores", ico: "🧑‍💼", label: "Vendedores" }, + { id: "roas", ico: "📈", label: "ROAS" }, +]; + +export default function Sidebar({ active, onChange, onConfig }) { + const [menuUser, setMenuUser] = useState(false); + const [ultima, setUltima] = useState(""); + const [actualizando, setActualizando] = useState(false); + + const cargarHora = () => api.ultimaActualizacion() + .then((r) => setUltima(r.hora || "")).catch(() => {}); + + useEffect(() => { + cargarHora(); + const id = setInterval(cargarHora, 60 * 1000); // refresca la hora cada minuto + return () => clearInterval(id); + }, []); + + // Actualiza TODO manualmente (como si pasaran los 15 min, pero al instante). + async function actualizarTodo() { + if (actualizando) return; + setActualizando(true); + try { + const r = await api.refrescarTodo(); + if (r && r.hora) setUltima(r.hora); // hora del refresco manual recien hecho + else await cargarHora(); + window.dispatchEvent(new Event("datos-actualizar")); // recarga el apartado abierto + } catch (e) { + // silencioso: no romper la UI si falla + } finally { + setActualizando(false); + } + } + + return ( + + ); +} 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..3c65006 --- /dev/null +++ b/frontend/src/lib/api.js @@ -0,0 +1,148 @@ +// Cliente del backend FastAPI de Leads. +// 1) Si existe VITE_API_URL (archivo .env del frontend), se usa esa. +// 2) Si no, se decide por el host desde el que se abrió la página: +// localhost -> backend local http://localhost:8001 +// cualquier otro -> API publicada del servidor +// Así funciona en desarrollo y en el servidor sin necesitar ningún .env. +const API_LOCAL = "http://localhost:8001"; +const API_SERVIDOR = "https://api-leads.escueladerefrigeracion.lat"; + +function _baseUrlPorDefecto() { + if (typeof window === "undefined") return API_LOCAL; + const h = window.location.hostname; + return h === "localhost" || h === "127.0.0.1" || h === "::1" + ? API_LOCAL + : API_SERVIDOR; +} + +const BASE_URL = ( + import.meta.env.VITE_API_URL || _baseUrlPorDefecto() +).replace(/\/+$/, ""); + +// ── Cache en memoria (frontend) con TTL 15 min y "stale-while-revalidate" ── +// Guarda la respuesta de cada GET (por path+params). Al volver a pedir: +// - si hay dato vigente (< 15 min) -> lo devuelve al instante, SIN fetch. +// - si venció -> devuelve el viejo al instante Y refresca en 2º plano. +// Así, al cambiar de apartado y volver, NO se recarga; y a los 15 min se +// actualiza solo sin mostrar loader. Los POST invalidan el cache. +const TTL = 15 * 60 * 1000; // 15 minutos +const _cache = new Map(); // key -> { ts, data } +const _enVuelo = new Map(); // key -> Promise (evita fetch duplicado) + +function _key(path, params) { + return path + "?" + new URLSearchParams(params).toString(); +} + +async function _fetch(path, params) { + const qs = new URLSearchParams(params).toString(); + const r = await fetch(`${BASE_URL}${path}${qs ? "?" + qs : ""}`); + if (!r.ok) throw new Error(`Error ${r.status} en ${path}`); + return r.json(); +} + +// GET con cache. Devuelve SIEMPRE una promesa que resuelve rápido si hay cache. +function get(path, params = {}) { + const key = _key(path, params); + const hit = _cache.get(key); + const now = Date.now(); + + if (hit) { + // Hay algo cacheado -> devolverlo YA. + if (now - hit.ts >= TTL && !_enVuelo.has(key)) { + // Venció: refrescar en 2º plano (no bloquea, no muestra loader). + const p = _fetch(path, params) + .then((data) => { _cache.set(key, { ts: Date.now(), data }); return data; }) + .finally(() => _enVuelo.delete(key)); + _enVuelo.set(key, p); + } + return Promise.resolve(hit.data); + } + + // No hay cache: si ya hay una petición en vuelo para esta key, reusarla. + if (_enVuelo.has(key)) return _enVuelo.get(key); + + const p = _fetch(path, params) + .then((data) => { _cache.set(key, { ts: Date.now(), data }); return data; }) + .finally(() => _enVuelo.delete(key)); + _enVuelo.set(key, p); + return p; +} + +// Invalida entradas del cache cuyo path empiece con alguno de los prefijos dados. +function invalidar(...prefijos) { + for (const k of _cache.keys()) { + if (prefijos.some((p) => k.startsWith(p))) _cache.delete(k); + } +} + +// POST helper: hace el POST y luego invalida los caches afectados. +async function post(path, body, invalidarPrefijos = []) { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!r.ok) throw new Error(`Error al guardar en ${path}`); + const data = await r.json(); + if (invalidarPrefijos.length) invalidar(...invalidarPrefijos); + return data; +} + +export const api = { + leadsFiltros: () => get("/api/leads/filtros"), + leads: (ano = "TODOS", mes = "TODOS", dia = "TODOS", programa = "TODOS", sede = "TODOS") => + get("/api/leads", { ano, mes, dia, programa, sede }), + otrosGeneral: (ano = "TODOS", mes = "TODOS", dia = "TODOS") => + get("/api/otros-general", { ano, mes, dia }), + vendedores: (ano = "TODOS", mes = "TODOS", dia = "TODOS") => + get("/api/vendedores", { ano, mes, dia }), + roas: (ano = "TODOS", mes = "TODOS", dia = "TODOS") => + get("/api/roas", { ano, mes, dia }), + conjuntosSinSede: () => get("/api/conjuntos-sin-sede"), + guardarConjuntoSede: (cambios) => + post("/api/conjuntos-sin-sede/guardar", { cambios }, ["/api/conjuntos-sin-sede", "/api/roas"]), + borrarConjuntoSede: (conjunto) => + post("/api/conjuntos-sin-sede/borrar", { conjunto }, ["/api/conjuntos-sin-sede", "/api/roas"]), + ultimaActualizacion: () => get("/api/ultima-actualizacion"), + // Actualiza TODO manualmente (como si pasaran los 15 min), pero EN SEGUNDO + // PLANO: NO se toca el cache del cliente mientras el backend trabaja, así el + // usuario sigue viendo los datos viejos sin pantallas de carga durante los + // ~29s. Recien cuando el backend responde OK se limpia el cache del cliente, + // para que a partir de ahi todo muestre los datos nuevos de inmediato. + refrescarTodo: async () => { + const r = await fetch(`${BASE_URL}/api/cache/refresh`, { method: "POST" }); + if (!r.ok) throw new Error("Error al actualizar"); + await r.json(); // espera a que el backend TERMINE de rehacer todo + _cache.clear(); // solo AHORA se invalida el cache del cliente + // Trae la hora de actualizacion FRESCA (sin cache) y la deja cacheada, para + // que el sidebar muestre la hora del refresco manual recien hecho. + const hr = await _fetch("/api/ultima-actualizacion", {}); + _cache.set(_key("/api/ultima-actualizacion", {}), { ts: Date.now(), data: hr }); + return hr; // { hora: "..." } + }, + conjuntosSinPauta: () => get("/api/conjuntos-sin-pauta"), + usoPauta: (pauta, excluir) => get("/api/pauta/uso", { pauta, excluir: excluir || "" }), + leyendaAnuncios: () => get("/api/leyenda-anuncios"), + programasDisponibles: () => get("/api/programas-disponibles"), + programasOcultos: () => get("/api/programas-ocultos"), + encenderProgramas: (num_indices) => + post("/api/programas-ocultos/encender", { num_indices }, + ["/api/programas-ocultos", "/api/programas-disponibles", "/api/leads"]), + guardarLeyenda: (cambios) => + post("/api/leyenda-anuncios/guardar", { cambios }, ["/api/leyenda-anuncios", "/api/roas"]), + guardarPauta: (num_indice, pauta, conjunto, contar) => + post("/api/curso/guardar-pauta", { num_indice, pauta, conjunto, contar }, + ["/api/leads", "/api/conjuntos-sin-pauta", "/api/roas"]), + alertas: () => get("/api/leads/alertas"), + // Leyenda (campanias de Supabase). Al agregar/borrar, invalida TODOS los + // apartados que dependen de las leyendas para que el cambio se vea al instante. + campanias: () => get("/api/campanias"), + agregarCampania: (datos) => + post("/api/campanias/agregar", datos, + ["/api/campanias", "/api/leads", "/api/vendedores", "/api/roas", "/api/otros-general"]), + editarCampania: (id, datos) => + post("/api/campanias/editar", { id, ...datos }, + ["/api/campanias", "/api/leads", "/api/vendedores", "/api/roas", "/api/otros-general"]), + borrarCampania: (id) => + post("/api/campanias/borrar", { id }, + ["/api/campanias", "/api/leads", "/api/vendedores", "/api/roas", "/api/otros-general"]), +}; 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..4b381b3 --- /dev/null +++ b/frontend/src/pages/Leads.jsx @@ -0,0 +1,764 @@ +import { useState, useEffect, useMemo, Fragment } from "react"; +import { api } from "../lib/api"; +import { Loader, ErrorBox, Filters, Select } from "../components/UI"; +import { useColumnasAjustables } from "../lib/useColumnasAjustables"; +import { + LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LabelList, + AreaChart, Area, Legend, ComposedChart, +} from "recharts"; + +const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"]; + +export default function Leads() { + const hoy = new Date(); + const [ano, setAno] = useState(String(hoy.getFullYear())); + const [mes, setMes] = useState(String(hoy.getMonth() + 1)); + const [dia, setDia] = useState("TODOS"); + const [tipoProg, setTipoProg] = useState("TODOS"); + const [sede, setSede] = useState("TODOS"); + + const [filtros, setFiltros] = useState({ anos: [2026], tipos: [] }); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [expandido, setExpandido] = useState({}); // estado -> abierto + const [expAsesor, setExpAsesor] = useState({}); // "estado|asesor" -> abierto + const [expSede, setExpSede] = useState({}); // sede -> abierto (tabla pauta) + const [expTipo, setExpTipo] = useState({}); // "sede|tipo" -> abierto (códigos) + const [expMat, setExpMat] = useState({}); // num_indice -> abierto (subfilas canal) + const [modalCurso, setModalCurso] = useState(null); // curso seleccionado para el pop-up "Ver" + const [editando, setEditando] = useState(false); // modo edición del pop-up + const [editPauta, setEditPauta] = useState(""); // valor pauta en edición + const [editConj, setEditConj] = useState(""); // conjunto elegido + const [editContar, setEditContar] = useState("SI"); // switch contar SI/NO + const [avisoContar, setAvisoContar] = useState(false);// mostró la alerta al cambiar a NO + const [conjOpc, setConjOpc] = useState([]); // conjuntos sin pauta (desplegable) + const [guardando, setGuardando] = useState(false); + const [msgGuardar, setMsgGuardar] = useState(""); + const [reload, setReload] = useState(0); + const [avisoUso, setAvisoUso] = useState(null); // {cursos, conjuntos} si la pauta ya se usa + // Vista de columnas de la matriz: TOTAL y/o ASIGNADO. Si ninguno → ambos (todo). + const [verTotal, setVerTotal] = useState(true); + const [verAsig, setVerAsig] = useState(true); + + // Definición de columnas de la matriz. modo: "fija" | "total" | "asig". + // key = campo en la fila; label = encabezado; w = ancho; render opcional. + const _fmtMoney = (v) => v ? `$${Number(v).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : "—"; + const COLS_MAT = [ + { key: "personalizado", label: "Programa", w: 340, modo: "fija", prog: true }, + { key: "fecha_inicio", label: "Fecha Inicio", w: 110, modo: "fija" }, + { key: "importe_pauta", label: "Importe Pauta", w: 120, modo: "fija", bold: true, render: (c) => _fmtMoney(c.importe_pauta) }, + { key: "importe_pauta_mes", label: "Importe Pauta en el Mes", w: 150, modo: "fija", bold: true, render: (c) => _fmtMoney(c.importe_pauta_mes) }, + { key: "cartera_total", label: "Cartera Total", w: 110, modo: "total" }, + { key: "cartera_total_asig", label: "Cartera Total Asig.", w: 130, modo: "asig", bold: true }, + { key: "leads_nuevos", label: "L. Recibidos", w: 120, modo: "total" }, + { key: "leads_nuevos_asesor", label: "L. Procesados", w: 130, modo: "asig" }, + { key: "leads_nuevos_mes", label: "L. Recibidos del Mes", w: 150, modo: "total" }, + { key: "leads_nuevos_mes_asesor", label: "L. Procesados del Mes", w: 160, modo: "asig" }, + { key: "matriculas_no_iden", label: "Matriculas no iden.", w: 140, modo: "fija", bold: true }, + { key: "mat_leads_nuevos", label: "Matriculas Leads Nuevos", w: 160, modo: "fija", bold: true }, + { key: "mat_leads_antiguos", label: "Matriculas Leads Antiguos", w: 170, modo: "fija", bold: true }, + { key: "__ver", label: "", w: 70, modo: "fija", ver: true }, + ]; + // Si desmarcan ambos → mostrar todo (como si ambos activos) + const _t = verTotal || (!verTotal && !verAsig); + const _a = verAsig || (!verTotal && !verAsig); + const colsVisibles = COLS_MAT.filter( + (c) => c.modo === "fija" || (c.modo === "total" && _t) || (c.modo === "asig" && _a) + ); + // Columnas redimensionables de la matriz (según columnas visibles) + const colsMat = useColumnasAjustables(colsVisibles.map((c) => c.w)); + + // Cargar opciones de filtros una vez + useEffect(() => { + api.leadsFiltros().then(setFiltros).catch(() => {}); + }, []); + + // Auto-refresco: cada 15 min vuelve a pedir datos frescos (sin que el usuario haga F5) + // y al recibir "datos-actualizar" (tras guardar en Config) recarga al instante. + useEffect(() => { + const id = setInterval(() => setReload((n) => n + 1), 15 * 60 * 1000); + const onActualizar = () => setReload((n) => n + 1); + window.addEventListener("datos-actualizar", onActualizar); + return () => { clearInterval(id); window.removeEventListener("datos-actualizar", onActualizar); }; + }, []); + + // Cargar dashboard cuando cambian los filtros (igual que el PBI) + useEffect(() => { + let activo = true; + // Mostrar spinner solo si aún no hay datos (primera carga). En recargas/auto- + // refresco, actualiza en silencio para no tapar la tabla. + if (!data) setLoading(true); + setError(null); + api.leads(ano, mes, dia, tipoProg, sede) + .then((res) => { if (activo) { setData(res); setLoading(false); } }) + .catch((e) => { if (activo) { setError(e.message); setLoading(false); } }); + return () => { activo = false; }; + }, [ano, mes, dia, tipoProg, sede, reload]); + + const k = data?.kpis; + const fmtPct = (v) => `${Number(v ?? 0).toFixed(2)} %`; + + // Cierra el pop-up y resetea el modo edición (para que reabra limpio) + function cerrarModal() { + setModalCurso(null); setEditando(false); setAvisoUso(null); + setAvisoContar(false); setMsgGuardar(""); + } + + // Guarda la pauta/conjunto (llamado tras confirmar o si la pauta no está en uso) + async function hacerGuardado() { + setGuardando(true); setMsgGuardar(""); setAvisoUso(null); + try { + await api.guardarPauta(modalCurso.num_indice, editPauta.trim(), editConj || null, editContar); + setMsgGuardar("✓ Guardado. Refrescando datos..."); + setTimeout(() => { setModalCurso(null); setEditando(false); setReload((n) => n + 1); }, 800); + } catch (e) { + setMsgGuardar("Error al guardar: " + e.message); + } finally { setGuardando(false); } + } + + // opciones de filtros + const optAnos = useMemo(() => (filtros.anos || []).map(String), [filtros]); + const optMes = useMemo(() => [{ value: "TODOS", label: "TODOS" }, + ...MESES.map((m,i)=>({ value: String(i+1), label: m }))], []); + const optDia = useMemo(() => ["TODOS", ...Array.from({length:31}, (_,i)=>String(i+1))], []); + const optTipo = useMemo(() => ["TODOS", "TEAC", "TERC", "SEMINARIOS", "OTROS"], []); + const optSede = useMemo(() => ["TODOS", "LIMA", "AREQUIPA", "TRUJILLO", "PIURA"], []); + + return ( +
+

🎯 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..5b034a5 --- /dev/null +++ b/frontend/src/pages/OtrosGeneral.jsx @@ -0,0 +1,334 @@ +import { useState, useEffect, useMemo, Fragment } from "react"; +import { api } from "../lib/api"; +import { Loader, ErrorBox, Filters, Select } from "../components/UI"; + +const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"]; + +export default function OtrosGeneral() { + const hoy = new Date(); + const [ano, setAno] = useState(String(hoy.getFullYear())); + const [mes, setMes] = useState(String(hoy.getMonth() + 1)); + const [dia, setDia] = useState("TODOS"); + + const [filtros, setFiltros] = useState({ anos: [2026] }); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [expAlways, setExpAlways] = useState({}); + const [expAlwaysProg, setExpAlwaysProg] = useState({}); + const [expWeb, setExpWeb] = useState({}); + const [expAsig, setExpAsig] = useState({}); + const [reload, setReload] = useState(0); + + useEffect(() => { + api.leadsFiltros().then(setFiltros).catch(() => {}); + }, []); + + // Auto-refresco cada 15 min (sin F5) + recarga al guardar en Config + useEffect(() => { + const id = setInterval(() => setReload((n) => n + 1), 15 * 60 * 1000); + const onActualizar = () => setReload((n) => n + 1); + window.addEventListener("datos-actualizar", onActualizar); + return () => { clearInterval(id); window.removeEventListener("datos-actualizar", onActualizar); }; + }, []); + + useEffect(() => { + let activo = true; + if (!data) setLoading(true); // spinner solo en la primera carga + setError(null); + api.otrosGeneral(ano, mes, dia) + .then((res) => { if (activo) { setData(res); setLoading(false); } }) + .catch((e) => { if (activo) { setError(e.message); setLoading(false); } }); + return () => { activo = false; }; + }, [ano, mes, dia, reload]); + + const optAnos = useMemo(() => (filtros.anos || []).map(String), [filtros]); + const optMes = useMemo(() => [{ value: "TODOS", label: "TODOS" }, + ...MESES.map((m,i)=>({ value: String(i+1), label: m }))], []); + const optDia = useMemo(() => ["TODOS", ...Array.from({length:31}, (_,i)=>String(i+1))], []); + + const money = (v) => `$${Number(v || 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + const sol = (v) => `S/ ${Number(v || 0).toLocaleString("en-US", { maximumFractionDigits: 0 })}`; + + return ( +
+

📊 Otros General

+ + + + + + + setModoTR(e.target.value)} + style={{ fontSize: 12.5, padding: "6px 10px", borderRadius: 8, + border: "1px solid #e2e8f0", background: "#f8fafc", + color: "#334155", fontWeight: 600, cursor: "pointer" }}> + + + +
+
+ + + + + + { + if (!active || !payload || !payload.length) return null; + const d = payload[0].payload || {}; + return ( +
+
Día {label}
+
Promedio: {d.promedio_min || 0} min
+
Respondidos{esResp ? " ese día" : ""}: {d.respondidos || 0}
+ {esResp + ?
⚠ Respuesta tardía (+2 días): {d.tardios || 0}
+ :
Sin responder: {d.no_respondidos || 0}
} +
+ ); + }} /> + +
+
+
+
+ + ); + })()} + + {/* Leads SIN responder por día (fecha de asignación) — barras */} +
+
+
+ + Leads Sin Responder por Día{vend !== "TODOS" ? ` — ${vend}` : ""} +
+
+ {(() => { + const datosNR = serieTR.filter((d) => (d.no_respondidos || 0) >= 1); + if (datosNR.length === 0) { + return ( +
+
+
Sin leads pendientes
+
Todos los leads del periodo fueron respondidos.
+
+ ); + } + return ( + + + + + + + + + + + + { + if (!active || !payload || !payload.length) return null; + const d = payload[0].payload || {}; + return ( +
+
Día {label}
+
Sin responder: {d.no_respondidos || 0}
+
Respondidos: {d.respondidos || 0}
+
+ ); + }} /> + +
+
+ ); + })()} +
+
+
+ + + {/* Matriz por tipo de programa (al final) */} +
+
+
+ Matrículas por Tipo de Programa{vend !== "TODOS" ? ` — ${vend}` : ""} +
+
+ + + + + + + + + + + {matrizProg.map((p) => ( + + + + + + + ))} + {matrizProg.length > 0 && ( + + + + + + + )} + +
Tipo de ProgramaTotal MatrículasRetiradas% Retirados
{p.programa}{num(p.total)}{num(p.retiradas)}{Number(p.pct_retiradas || 0).toLocaleString("es-PE")}%
Total{num(matrizProg.reduce((s, p) => s + (p.total || 0), 0))}{num(matrizProg.reduce((s, p) => s + (p.retiradas || 0), 0))}{(() => { + const t = matrizProg.reduce((s, p) => s + (p.total || 0), 0); + const r = matrizProg.reduce((s, p) => s + (p.retiradas || 0), 0); + return `${(t ? Math.round(r / t * 1000) / 10 : 0).toLocaleString("es-PE")}%`; + })()}
+
+
+
+ + } + + ); +} + +// ── Tarjetas de resumen: 3 grupos (Cartera / Matrículas / Leads) en lista ───── +function ResumenTarjetas({ r, num }) { + const pct = Number(r.pct_retiradas || 0); + const pctCont = Number(r.pct_contactados || 0); + const grupos = [ + { + titulo: "Cartera", acento: "#2563eb", + filas: [ + { label: "Total", valor: num(r.cartera_total) }, + { label: "Copito", valor: num(r.cartera_copito) }, + { label: "Otros", valor: num(r.cartera_otros) }, + ], + }, + { + titulo: "Matrículas", acento: "#0f766e", + filas: [ + { label: "Total", valor: num(r.mat_total) }, + { label: "Retiradas", valor: num(r.mat_retiradas) }, + { label: "% Retirados", valor: `${pct.toLocaleString("es-PE")}%`, alerta: true }, + ], + }, + { + titulo: "Leads", acento: "#7c3aed", + filas: [ + { label: "Asignados", valor: num(r.leads_asignados) }, + { label: "Contactados", valor: num(r.leads_contactados) }, + { label: "% Contactados", valor: `${pctCont.toLocaleString("es-PE")}%`, resalta: "#7c3aed" }, + ], + }, + ]; + + return ( +
+ {grupos.map((g) => ( +
+
+ {g.titulo} +
+ {g.filas.map((f, i) => ( +
+ {f.label} + + {f.valor} + +
+ ))} +
+ ))} +
+ ); +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..1b00ba0 --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,74 @@ +* { box-sizing: border-box; margin: 0; padding: 0; } +body { font-family: 'Segoe UI', system-ui, sans-serif; background: #f1f5f9; color: #0f172a; } + +.app { display: flex; height: 100vh; overflow: hidden; } + +/* ── Sidebar fija ── */ +.sidebar { + width: 240px; background: #0f172a; color: #e2e8f0; + display: flex; flex-direction: column; flex-shrink: 0; + height: 100vh; position: sticky; top: 0; +} +.sidebar-logo { padding: 22px 18px; font-size: 18px; font-weight: 700; border-bottom: 1px solid rgba(255,255,255,0.08); } +.sidebar-logo span { color: #60a5fa; } +.nav { padding: 12px 8px; } +.nav-item { + width: 100%; text-align: left; padding: 11px 14px; margin-bottom: 4px; + background: transparent; border: none; color: #cbd5e1; border-radius: 8px; + cursor: pointer; font-size: 14px; display: flex; align-items: center; gap: 10px; +} +.nav-item:hover { background: rgba(255,255,255,0.06); } +.nav-item.active { background: #2563eb; color: #fff; font-weight: 600; } + +.main { flex: 1; padding: 24px 28px; overflow-y: auto; height: 100vh; } +.page-title { font-size: 24px; font-weight: 700; margin-bottom: 18px; color: #0f172a; } + +/* ── Filtros ── */ +.filters { display: flex; gap: 14px; flex-wrap: wrap; align-items: flex-end; margin-bottom: 20px; } +.filter-group { display: flex; flex-direction: column; gap: 4px; } +.filter-group label { font-size: 11px; font-weight: 700; color: #64748b; text-transform: uppercase; letter-spacing: .3px; } +.filter-group select { + padding: 8px 12px; border: 1px solid #cbd5e1; border-radius: 8px; font-size: 14px; + background: #fff; min-width: 130px; cursor: pointer; +} + +/* ── KPIs ── */ +.kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 18px; } +.kpi { background: #fff; border: 1px solid #e2e8f0; border-radius: 14px; padding: 16px; } +.kpi .ico { font-size: 22px; margin-bottom: 6px; } +.kpi .label { font-size: 13px; font-weight: 600; color: #475569; } +.kpi .value { font-size: 26px; font-weight: 800; color: #0f172a; margin: 4px 0; } +.kpi .sub { font-size: 11px; color: #94a3b8; } + +/* tarjeta compuesta (varios sub-kpis) */ +.kpi-multi { background: #fff; border: 1px solid #e2e8f0; border-radius: 14px; padding: 14px 16px; + box-shadow: 0 1px 3px rgba(0,0,0,0.04); } +.kpi-row { display: flex; align-items: center; gap: 12px; padding: 9px 0; } +.kpi-row + .kpi-row { border-top: 1px solid #f1f5f9; } +.kpi-row .ico { font-size: 24px; width: 34px; text-align: center; flex-shrink: 0; } +.kpi-row .txt { line-height: 1.25; } +.kpi-row .txt .label { font-size: 12px; font-weight: 600; color: #64748b; } +.kpi-row .txt .value { font-size: 21px; font-weight: 800; color: #0f172a; } + +/* ── Tablas ── */ +.table-wrap { background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; overflow: auto; } +table { width: 100%; border-collapse: collapse; } +th { background: #1e3a5f; color: #f1f5f9; padding: 10px 12px; font-size: 12px; font-weight: 700; text-align: center; white-space: nowrap; border-right: 1px solid rgba(255,255,255,0.12); } +td { padding: 9px 12px; font-size: 13px; border-right: 1px solid #eef2f7; text-align: center; } +th:last-child, td:last-child { border-right: none; } +.total-row td { background: #eff6ff; font-weight: 700; } +.col-name { text-align: left !important; } + +/* ── Loader / error ── */ +.loader-wrap { display: flex; flex-direction: column; align-items: center; padding: 50px; color: #64748b; } +.spinner { width: 36px; height: 36px; border: 4px solid #e2e8f0; border-top-color: #2563eb; border-radius: 50%; animation: spin 1s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } +.loader-text { margin-top: 12px; font-size: 14px; } +.error-box { background: #fee2e2; color: #991b1b; padding: 14px 18px; border-radius: 10px; font-size: 14px; } + +.card { background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; padding: 16px; } +.card-title { font-size: 14px; font-weight: 700; color: #1e293b; margin-bottom: 12px; } + +/* Matriz "Detalle por Curso": líneas verticales suaves entre columnas (solo cuerpo) */ +.matriz-grid tbody td { border-right: 1px solid #eef2f7; } +.matriz-grid tbody td:last-child { border-right: none; } diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..e552ac2 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { port: 5174 }, +});