Actualizar dominios a dashboardapi y dashboardv1

This commit is contained in:
Administrator
2026-08-29 12:21:21 -05:00
parent 191b5bb1ba
commit 54581c21ab
77 changed files with 7679 additions and 11728 deletions

28
.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# Dependencias del frontend (se regeneran con npm install en el servidor)
node_modules/
frontend/node_modules/
# Build del frontend
dist/
frontend/dist/
# Python
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
# Logs y temporales
*.log
_ELIMINAR/
# Scripts auxiliares de esta migracion (no van al repo)
SUBIR_A_GIT.bat
LIMPIAR_PARA_SERVIDOR.bat
APLICAR_ENV.bat
_ENV_NUEVOS/
# NOTA: backend/.env SI se versiona a proposito (repo privado interno).
# Si algun dia el repo deja de ser privado, descomenta la linea siguiente:
#backend/.env

23
backend/.env Normal file
View File

@@ -0,0 +1,23 @@
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
CORS_ORIGINS=https://dashboardv1.escueladerefrigeracion.edu.pe,http://dashboardv1.escueladerefrigeracion.edu.pe,https://dashboardapi.escueladerefrigeracion.edu.pe,http://dashboardapi.escueladerefrigeracion.edu.pe,http://localhost:3002,http://localhost:5174,http://192.168.1.107:3002,http://161.132.96.129:3002,*

View File

@@ -1,19 +1,48 @@
# === SQL SERVER === # ============================================================
SQL_SERVER=191.98.134.80 # DASHBOARD LEADS - Plantilla de variables de entorno (backend)
SQL_DATABASE=BDUS_CK000040_0001 # Copiar como .env y rellenar los valores reales.
SQL_USERNAME=ASEBASTIAN # ============================================================
SQL_PASSWORD=tu_password_aqui
# === POSTGRESQL (Chatwoot) === # ── Base de Datos PostgreSQL (Chatwoot) ──
PG_HOST=191.98.134.81 PG_HOST=191.98.134.81
PG_DATABASE=chatwoot_production PG_DATABASE=chatwoot_production
PG_USER=postgres PG_USER=postgres
PG_PASSWORD=tu_password_aqui PG_PASSWORD=tu_password_postgres_aqui
PG_PORT=5432 PG_PORT=5432
# === SUPABASE === # ── Base de Datos SQL Server (Académico) ──
SUPABASE_URL=tu_url_supabase SQL_SERVER=191.98.134.80
SUPABASE_KEY=tu_key_supabase 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
# === GITHUB === # ── Repositorio GitHub (mapa de campañas, opcional) ──
GITHUB_BASE=https://raw.githubusercontent.com/aron1798/prueba1/main/DASHBOARD 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://dashboardv1.escueladerefrigeracion.edu.pe,http://dashboardv1.escueladerefrigeracion.edu.pe,https://dashboardapi.escueladerefrigeracion.edu.pe,http://dashboardapi.escueladerefrigeracion.edu.pe,http://localhost:3002,http://localhost:5174,http://192.168.1.107:3002,http://161.132.96.129:3002,*

6
backend/.gitignore vendored
View File

@@ -1,6 +0,0 @@
.env
__pycache__/
*.pyc
*.xlsx
venv/
.venv/

View File

@@ -1,34 +1,21 @@
# Usamos una versión estable y probada de Debian 12 (Bookworm) FROM python:3.10-slim
FROM python:3.10-slim-bookworm
# Evitamos que Python guarde caché o bloquee los logs # Instalar dependencias del sistema y el Driver Oficial de Microsoft ODBC 18 para SQL Server en Linux
ENV PYTHONUNBUFFERED=1 RUN apt-get update && apt-get install -y --no-install-recommends \
curl gnupg2 ca-certificates unixodbc unixodbc-dev gcc g++ \
# 1. Instalar las herramientas base
RUN apt-get update && apt-get install -y \
curl apt-transport-https gnupg2 unixodbc-dev \
# 2. Descargar la llave de Microsoft usando el método moderno (sin apt-key)
&& curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg \ && curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg \
# 3. Agregar el repositorio oficial de Microsoft para Debian 12 && curl -fsSL https://packages.microsoft.com/config/debian/12/prod.list > /etc/apt/sources.list.d/mssql-release.list \
&& curl -fsSL https://packages.microsoft.com/config/debian/12/prod.list | tee /etc/apt/sources.list.d/mssql-release.list \
&& apt-get update \ && apt-get update \
# 4. Instalar el ODBC Driver 17 aceptando los términos automáticamente && ACCEPT_EULA=Y apt-get install -y msodbcsql18 \
&& ACCEPT_EULA=Y apt-get install -y msodbcsql17 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Crear la carpeta de trabajo
WORKDIR /app WORKDIR /app
# Copiar las librerías e instalarlas
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Copiar todo el código de tu backend
COPY . . COPY . .
# Exponer el puerto EXPOSE 8001
EXPOSE 8000
# Arrancar FastAPI CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001"]
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -1,74 +0,0 @@
# Backend — Dashboard API (FastAPI)
API REST que reutiliza toda la lógica de negocio del dashboard original.
El frontend (React) consume estos endpoints.
## Requisitos
- Python 3.10+
- Driver ODBC para SQL Server (ODBC Driver 17 o 18)
## Instalación
```bash
cd backend
pip install -r requirements.txt
```
En Windows, si falta el driver ODBC de SQL Server, descárgalo de:
https://learn.microsoft.com/sql/connect/odbc/download-odbc-driver-for-sql-server
## Configuración
El archivo `.env` ya contiene las credenciales (copiado del proyecto original).
Si necesitas cambiarlas, edita `.env` (usa `.env.example` como referencia).
## Ejecutar
```bash
python main.py
```
El servidor arranca en: **http://localhost:8000**
- Documentación interactiva: http://localhost:8000/docs
- Health check: http://localhost:8000/api/health
## Cómo funciona el rendimiento
1. **Al arrancar** → precarga todos los módulos (datos en memoria)
2. **Refresco en segundo plano** → cada 4 min recarga el caché silenciosamente
3. **Respuestas** → instantáneas porque vienen de memoria
4. El frontend solo pide datos (JSON), nunca recarga toda la página
## Endpoints principales
| Método | Ruta | Descripción |
|--------|------|-------------|
| GET | `/api/periodo-actual` | Año y mes actuales |
| GET | `/api/ocupabilidad?ano=&mes=&sede=&programa=` | Datos ocupabilidad |
| GET | `/api/ventas?ano=&mes=` | Datos ventas |
| GET | `/api/ventas/detalle?vendedor=&ano=&mes=&tipo=` | Detalle vendedor |
| GET | `/api/cobranza?ano=&mes=&sectorista=&agrupacion=` | Datos cobranza |
| GET | `/api/cobranza/detalle?grupo=&ano=&mes=&sectorista=&agrupacion=` | Estado de cuenta |
| GET | `/api/rentabilidad?ano=&mes=&sede=&programa=` | Datos rentabilidad |
| GET | `/api/rentabilidad/detalle?programa=&ano=&mes=` | Detalle programa |
| GET | `/api/rentabilidad/costos?programa=&ano=&mes=` | Costos programa |
| GET | `/api/saldo-pendiente?tipo_cuota=` | Saldos pendientes |
| GET | `/api/asesores` | Lista de asesores (Chatwoot) |
| POST | `/api/asesores/estado?agent_id=&online=` | Cambiar estado asesor |
| POST | `/api/cache/refresh` | Forzar refresco de caché |
## Estructura
```
backend/
├── main.py # FastAPI + endpoints
├── services.py # Capa que envuelve la lógica con caché
├── cache_manager.py # Caché en memoria + refresco background
├── requirements.txt
├── .env # Credenciales (no subir a git)
├── core/ # DataManager (reutilizado del original)
├── modules/ # Lógica de negocio (reutilizada del original)
└── SQL_QUERY_2/ # Queries SQL optimizadas
```

View File

@@ -1,59 +0,0 @@
SELECT
-- Cronograma de Matrícula (T1)
T1.num_matricula,
T1.num_cuota,
T1.fch_vencimiento,
T1.fch_cancelacion,
T1.cod_estado,
T1.imp_total,
T1.imp_total_pagado,
T1.imp_saldo,
T1.imp_dscto,
-- Matrícula y Programa
M.fch_matricula,
M.fch_retiro,
M.cod_estado AS estado_matricula,
P.dsc_programa,
RP.dsc_det_programa,
M.num_indice,
RP.fch_inicio,
M.cod_moneda,
M.imp_tc,
-- Sectorista
(ISNULL(EMP.dsc_apellido_paterno, '') + ' ' +
ISNULL(EMP.dsc_apellido_materno, '') + ', ' +
ISNULL(EMP.dsc_nombres, '')) AS dsc_sectorista,
RP.cod_frecuencia
FROM
[BDUS_CK000040_0001].[dbo].[sgede_cronograma_matricula] T1
INNER JOIN
sgeca_matricula M
ON T1.cod_localidad = M.cod_localidad
AND T1.num_matricula = M.num_matricula
LEFT JOIN
sgeca_programa P
ON M.cod_programa = P.cod_programa
LEFT JOIN
sgede_RP_programa RP
ON M.cod_programa = RP.cod_programa
AND M.cod_periodo = RP.cod_detalle
AND M.num_indice = RP.num_indice
LEFT JOIN
vtama_cuota C
ON M.cod_cuota = C.cod_cuota
LEFT JOIN
[BDUS_CK000040_0001].[dbo].[sgede_RP_programa_sectorista] SEC_ASIG
ON M.cod_programa = SEC_ASIG.cod_programa
AND M.cod_periodo = SEC_ASIG.cod_detalle
AND M.num_indice = SEC_ASIG.num_indice
LEFT JOIN
[BDUS_CK000040_0001].[dbo].[rhuma_trabajador] EMP
ON SEC_ASIG.cod_trabajador = EMP.cod_trabajador
WHERE
YEAR(RP.fch_inicio) IN (2025, 2026)
ORDER BY
T1.fch_vencimiento ASC;

View File

@@ -1,46 +0,0 @@
SELECT
-- Cronograma de Matrícula (T1)
T1.cod_localidad,
T1.num_matricula,
T1.num_refinanciamiento,
T1.num_cuota,
T1.fch_vencimiento,
T1.fch_cancelacion,
T1.cod_estado,
T1.imp_total,
T1.imp_total_pagado,
T1.imp_saldo,
T1.imp_dscto,
-- Matrícula y Programa
M.fch_matricula,
M.cod_estado AS estado_matricula,
P.dsc_programa,
RP.dsc_det_programa,
RP.fch_inicio,
M.cod_moneda,
C.dsc_cuota,
M.imp_tc
FROM
[BDUS_CK000040_0001].[dbo].[sgede_cronograma_matricula] T1
INNER JOIN
sgeca_matricula M
ON T1.cod_localidad = M.cod_localidad
AND T1.num_matricula = M.num_matricula
LEFT JOIN
sgeca_programa P
ON M.cod_programa = P.cod_programa
LEFT JOIN
sgede_RP_programa RP
ON M.cod_programa = RP.cod_programa
AND M.cod_periodo = RP.cod_detalle
AND M.num_indice = RP.num_indice
LEFT JOIN
vtama_cuota C
ON M.cod_cuota = C.cod_cuota
WHERE
YEAR(RP.fch_inicio) = ?
AND MONTH(RP.fch_inicio) = ?
AND T1.fch_vencimiento > '2020-01-01 00:00:00:000'
ORDER BY
T1.fch_vencimiento ASC;

View File

@@ -1,38 +0,0 @@
SELECT
rp.[num_indice],
rp.[dsc_det_programa],
rp.[flg_activo],
rp.[fch_inicio],
rp.[cod_frecuencia],
rp.[cod_estado],
p.[dsc_programa],
(
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', 'SUS')
) 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
[BDUS_CK000040_0001].[dbo].[sgede_RP_programa] rp
INNER JOIN [BDUS_CK000040_0001].[dbo].[sgeca_programa] p
ON rp.cod_programa = p.cod_programa
WHERE
YEAR(rp.fch_inicio) = ?
AND MONTH(rp.fch_inicio) = ?
ORDER BY
rp.fch_inicio ASC

View File

@@ -1,51 +0,0 @@
SELECT
comp.[num_correlativo],
comp.[fch_emision],
comp.[fch_cancelacion],
comp.[cod_estado],
comp.[cod_moneda],
comp.[imp_total],
comp.[imp_saldo],
comp.[imp_tc],
comp.[flg_nc],
-- Centro de Responsabilidad
cr_prog.dsc_centroresp AS dsc_cr,
-- Programa académico
rp.dsc_det_programa,
rp.fch_inicio,
prog.dsc_programa,
mat.num_indice,
mat.cod_estado AS estado_matricula,
mat.fch_retiro,
-- Cuotas y matrícula
cxc.num_matricula,
cxc.num_cuota,
COALESCE(cxc.imp_emitido, comp.imp_total) as imp_emitido,
-- Cuota
cron.fch_vencimiento as fch_vencimiento_cuota
FROM [BDUS_CK000040_0001].[dbo].[vtaca_comprobante] comp
LEFT JOIN sgevi_cuotas_x_comprobante cxc ON comp.cod_localidad = cxc.cod_localidad
AND comp.num_correlativo = cxc.num_correlativo
LEFT JOIN sgeca_matricula mat ON cxc.cod_localidad_c = mat.cod_localidad
AND cxc.num_matricula = mat.num_matricula
LEFT JOIN sgede_RP_programa rp ON mat.cod_programa = rp.cod_programa
AND mat.cod_periodo = rp.cod_detalle
AND mat.num_indice = rp.num_indice
LEFT JOIN sgeca_programa prog ON mat.cod_programa = prog.cod_programa
LEFT JOIN rhuma_centroresponsabilidad cr_prog ON prog.cod_centroresp = cr_prog.cod_centroresp
LEFT JOIN sgede_cronograma_matricula cron ON cxc.cod_localidad_c = cron.cod_localidad
AND cxc.num_matricula = cron.num_matricula
AND cxc.num_refinanciamiento = cron.num_refinanciamiento
AND cxc.num_cuota = cron.num_cuota
WHERE comp.fch_emision BETWEEN '01-01-2024 00:00:00.000' AND '31-12-2028 00:00:00.000'
ORDER BY comp.fch_emision ASC;

View File

@@ -1,211 +0,0 @@
SELECT
sgede_RP_programa.num_indice,
vtama_localidad.dsc_localidad,
sgeca_matricula.num_matricula,
sgeca_matricula.fch_matricula,
sgeca_matricula.cod_estado AS estado_matricula,
sgema_alumno.dsc_telefono_1,
(
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_matricula.cod_moneda,
ISNULL((
SELECT
SUM(sgede_cronograma_matricula.imp_total - ISNULL(sgede_cronograma_matricula.imp_dscto, 0))
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
), 0) AS INV_NETA,
(
SELECT vtama_cuota.dsc_cuota
FROM vtama_cuota
WHERE vtama_cuota.cod_cuota = sgeca_matricula.cod_cuota
) AS dsc_cuota,
(
SELECT sgema_beca.dsc_beca
FROM sgema_beca
WHERE sgema_beca.cod_beca = sgeca_matricula.cod_beca
) AS dsc_beca,
sgema_alumno.dsc_apellido_paterno + ' ' + sgema_alumno.dsc_apellido_materno + ', ' + sgema_alumno.dsc_nombres AS dsc_alumno,
vtama_tipo_documento.dsc_tipo_documento,
sgema_alumno.dsc_documento,
sgeca_programa.dsc_programa,
sgede_RP_programa.dsc_det_programa AS dsc_promocion,
sgede_RP_programa.fch_inicio,
-- ==========================================
-- SALDOS Y VENCIMIENTOS: MATRICULA Y CUOTAS 1-5
-- ==========================================
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,
(
SELECT TOP 1 sgede_cronograma_matricula.fch_vencimiento
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
) AS fch_venc_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,
(
SELECT TOP 1 sgede_cronograma_matricula.fch_vencimiento
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
) AS fch_venc_cuota1,
(
SELECT TOP 1 sgede_cronograma_matricula.fch_cancelacion
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
) AS fch_cancelacion_cuota1,
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 = 2
), 0) AS imp_saldo_cuota2,
(
SELECT TOP 1 sgede_cronograma_matricula.fch_vencimiento
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 = 2
) AS fch_venc_cuota2,
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 = 3
), 0) AS imp_saldo_cuota3,
(
SELECT TOP 1 sgede_cronograma_matricula.fch_vencimiento
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 = 3
) AS fch_venc_cuota3,
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 = 4
), 0) AS imp_saldo_cuota4,
(
SELECT TOP 1 sgede_cronograma_matricula.fch_vencimiento
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 = 4
) AS fch_venc_cuota4,
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 = 5
), 0) AS imp_saldo_cuota5,
(
SELECT TOP 1 sgede_cronograma_matricula.fch_vencimiento
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 = 5
) AS fch_venc_cuota5,
-- ==========================================
-- TIPO DE CAMBIO
-- ==========================================
ISNULL((
SELECT TOP 1 comp.imp_tc
FROM vtaca_comprobante comp
INNER JOIN sgevi_cuotas_x_comprobante cxc ON comp.cod_localidad = cxc.cod_localidad
AND comp.num_correlativo = cxc.num_correlativo
WHERE cxc.num_matricula = sgeca_matricula.num_matricula
AND cxc.cod_localidad_c = sgeca_matricula.cod_localidad
AND comp.cod_estado NOT IN ('ANU', 'ELI')
ORDER BY comp.fch_emision DESC
), 0) AS imp_tc,
-- ==========================================
-- IMPORTE TOTAL PAGADO
-- ==========================================
(SELECT SUM(sgede_cronograma_matricula.imp_total_pagado)
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 = sgeca_matricula.num_refinanciamiento) AS imp_total_pagado
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
INNER JOIN vtama_localidad
ON sgeca_matricula.cod_localidad = vtama_localidad.cod_localidad
INNER JOIN vtama_tipo_documento
ON sgema_alumno.cod_tipo_documento = vtama_tipo_documento.cod_tipo_documento
WHERE
sgeca_matricula.cod_localidad LIKE 'SCENT'
AND YEAR(sgeca_matricula.fch_matricula) = ?
AND MONTH(sgeca_matricula.fch_matricula) = ?
AND sgeca_matricula.cod_estado NOT IN ('ANU', 'SUS')
ORDER BY
num_indice ASC

View File

@@ -1,8 +1,5 @@
# backend/cache_manager.py # backend/cache_manager.py
""" """Caché global en memoria con refresco en segundo plano (igual que el dashboard de ventas)."""
Caché global en memoria con refresco en segundo plano.
Independiente de Streamlit. Mantiene los datos calientes para respuestas instantáneas.
"""
import time import time
import threading import threading
from typing import Any, Callable, Dict, Tuple from typing import Any, Callable, Dict, Tuple
@@ -16,37 +13,25 @@ def _make_key(prefix: str, args: tuple) -> str:
return prefix + ":" + "|".join(str(a) for a in args) return prefix + ":" + "|".join(str(a) for a in args)
def cache_get_or_set(prefix: str, args: tuple, loader: Callable[[], Any], def cache_get_or_set(prefix: str, args: tuple, loader: Callable[[], Any], ttl: int = _DEFAULT_TTL) -> Any:
ttl: int = _DEFAULT_TTL) -> Any:
"""Devuelve el valor cacheado si está fresco; si no, lo calcula y guarda."""
key = _make_key(prefix, args) key = _make_key(prefix, args)
now = time.time() now = time.time()
with _LOCK: with _LOCK:
hit = _CACHE.get(key) hit = _CACHE.get(key)
if hit and (now - hit[0] < ttl): if hit and (now - hit[0] < ttl):
return hit[1] return hit[1]
# Calcular fuera del lock (puede tardar)
value = loader() value = loader()
with _LOCK: with _LOCK:
_CACHE[key] = (now, value) _CACHE[key] = (now, value)
return value return value
def cache_set(prefix: str, args: tuple, value: Any) -> None:
key = _make_key(prefix, args)
with _LOCK:
_CACHE[key] = (time.time(), value)
def cache_keys(): def cache_keys():
"""Lista las claves actualmente cacheadas (para refrescarlas sin vaciar)."""
with _LOCK: with _LOCK:
return list(_CACHE.keys()) return list(_CACHE.keys())
def cache_refresh_existing(loader_for_key): def cache_refresh_existing(loader_for_key):
"""Recalcula SOLO las entradas que ya existen, sin vaciar el caché.
Así los meses ya visitados se mantienen calientes y nunca quedan 'fríos'."""
for key in cache_keys(): for key in cache_keys():
try: try:
nuevo = loader_for_key(key) nuevo = loader_for_key(key)
@@ -58,7 +43,6 @@ def cache_refresh_existing(loader_for_key):
def cache_invalidate(prefix: str = None) -> None: def cache_invalidate(prefix: str = None) -> None:
"""Invalida todo el caché, o solo las claves de un prefijo."""
with _LOCK: with _LOCK:
if prefix is None: if prefix is None:
_CACHE.clear() _CACHE.clear()
@@ -73,13 +57,10 @@ def cache_stats() -> dict:
return {"entradas": len(_CACHE), "claves": list(_CACHE.keys())} return {"entradas": len(_CACHE), "claves": list(_CACHE.keys())}
# ── Refresco en segundo plano ──────────────────────────────────────────────
_background_started = False _background_started = False
def start_background_refresh(refresh_fn: Callable[[], None], interval: int = 240): def start_background_refresh(refresh_fn: Callable[[], None], interval: int = 900):
"""Lanza un hilo que ejecuta refresh_fn cada `interval` segundos (4 min por defecto,
antes de que expire el TTL de 5 min, para mantener el caché siempre caliente)."""
global _background_started global _background_started
if _background_started: if _background_started:
return return

View File

@@ -1,830 +0,0 @@
# core/data_manager.py
import os
import requests
import json
from datetime import datetime
from dotenv import load_dotenv
load_dotenv()
class DataManager:
def __init__(self):
# --- SQL SERVER ---
self.server = os.getenv("SQL_SERVER", "191.98.134.80")
self.database = os.getenv("SQL_DATABASE", "BDUS_CK000040_0001")
self.username = os.getenv("SQL_USERNAME", "ASEBASTIAN")
self.password = os.getenv("SQL_PASSWORD", "")
# --- POSTGRESQL ---
self.pg_host = os.getenv("PG_HOST", "191.98.134.81")
self.pg_database = os.getenv("PG_DATABASE", "chatwoot_production")
self.pg_user = os.getenv("PG_USER", "postgres")
self.pg_password = os.getenv("PG_PASSWORD", "")
self.pg_port = os.getenv("PG_PORT", "5432")
# --- SUPABASE ---
self.supabase_url = os.getenv("SUPABASE_URL", "")
self.supabase_key = os.getenv("SUPABASE_KEY", "")
self.supabase_client = None
self._init_supabase()
# --- GITHUB ---
base = os.getenv("GITHUB_BASE", "https://raw.githubusercontent.com/aron1798/prueba1/main/DASHBOARD")
self.github_json_url = f"{base}/actualizador.json"
self.github_replace_url = f"{base}/REPLACE_CURSO.json"
self.github_meta_url = f"{base}/REPLACE_META.json"
self.github_costos_url = f"{base}/REPLACE_COSTOS.json"
self.github_pronostico_url = f"{base}/BASE_PRONOSTICO.json"
self.github_pendientes_url = f"{base}/BASE_PENDIENTES.json"
self.github_sede_url = f"{base}/sede.json"
self.github_horario_url = f"{base}/HORARIO_ASESOR.json"
self.github_query_url = f"{base}/SQL_QUERY_2/BASE_CURSO_2.query"
self.github_cuota_url = f"{base}/SQL_QUERY_2/BASE_CUOTAS_2.query"
self.github_cronograma_url = f"{base}/SQL_QUERY_2/BASE_CRONOGRAMA_2.query"
self.github_facturas_url = f"{base}/SQL_QUERY_2/BASE_FACTURAS_2.query"
self.github_matriculas_url = f"{base}/SQL_QUERY_2/BASE_MATRICULADOS_2.query"
# --- Variables de estado ---
self.config_data = {}
self.replace_data = {}
self.meta_data = {}
self.sede_data = {}
self.pronostico_data = {}
self.costos_data = {}
self.historico_pendientes = {}
self.pendientes_data = set()
self.query_sql = ""
self.query_matriculas_sql = ""
self.query_cronograma_sql = ""
self.query_facturas_sql = ""
# Caché en memoria de las consultas ANUALES de cobranza (evita re-ejecutar
# la consulta del año completo en cada combinación de filtros).
self._cache_cronograma = {} # str(ano) -> filas
self._cache_facturas = {} # str(ano) -> filas
self.cargar_toda_configuracion()
# =========================================================================
# CARGA DE CONFIGURACIÓN
# =========================================================================
def cargar_toda_configuracion(self):
# OPTIMIZACIÓN: descargar todas las URLs en paralelo
from concurrent.futures import ThreadPoolExecutor
json_tasks = {
"config_data": (self.github_json_url, {"config_general": {"auto_update_minutos": 5}}),
"replace_data": (self.github_replace_url, {}),
"meta_data": (self.github_meta_url, {}),
"sede_data": (self.github_sede_url, {}),
"pronostico_data": (self.github_pronostico_url, {"pronosticos": []}),
"costos_data": (self.github_costos_url, {}),
"_pendientes_raw": (self.github_pendientes_url, {}),
}
text_tasks = {
"query_sql": self.github_query_url,
"query_matriculas_sql": self.github_matriculas_url,
"query_cronograma_sql": self.github_cronograma_url,
"query_facturas_sql": self.github_facturas_url,
}
with ThreadPoolExecutor(max_workers=15) as ex:
json_futs = {k: ex.submit(self._get_json, url, default) for k,(url,default) in json_tasks.items()}
text_futs = {k: ex.submit(self._get_text, url) for k,url in text_tasks.items()}
for k, f in json_futs.items(): setattr(self, k, f.result())
for k, f in text_futs.items(): setattr(self, k, f.result())
# pendientes: parsear el dict ya descargado
try:
self.historico_pendientes = self._pendientes_raw.get("historico_pendientes", {})
except:
self.historico_pendientes = {}
self.pendientes_data = set()
def _get_json(self, url, default=None):
# 3 intentos, timeout 15s. Si falla, avisa en consola y devuelve el default.
for intento in range(1, 4):
try:
r = requests.get(url, timeout=15)
r.raise_for_status()
return r.json()
except Exception as e:
print(f"⚠️ [GitHub] intento {intento}/3 fallo JSON {url}: {e}")
print(f"❌ [GitHub] NO se pudo descargar (JSON): {url}")
return default or {}
def _get_text(self, url):
# 3 intentos, timeout 15s. Si falla, avisa en consola y devuelve texto vacio.
for intento in range(1, 4):
try:
r = requests.get(url, timeout=15)
r.raise_for_status()
return r.text
except Exception as e:
print(f"⚠️ [GitHub] intento {intento}/3 fallo TEXT {url}: {e}")
print(f"❌ [GitHub] NO se pudo descargar (TEXT): {url}")
return ""
def _cargar_pendientes(self):
# Ya cargado en paralelo dentro de cargar_toda_configuracion
if not hasattr(self, 'historico_pendientes'):
self.historico_pendientes = {}
if not hasattr(self, 'pendientes_data'):
self.pendientes_data = set()
# =========================================================================
# CONEXIONES A BASE DE DATOS
# =========================================================================
def get_connection(self):
import pyodbc
conn_str = (
f"DRIVER={{ODBC Driver 17 for SQL Server}};" # <--- ¡ESTE ES EL CAMBIO MÁGICO!
f"SERVER={self.server};"
f"DATABASE={self.database};"
f"UID={self.username};"
f"PWD={self.password}"
)
return pyodbc.connect(conn_str)
def get_pg_connection(self):
try:
import psycopg2
except ImportError:
raise ImportError(
"psycopg2 no está instalado. Para usar PostgreSQL ejecuta:\n"
"pip install psycopg2-binary"
)
try:
return psycopg2.connect(
host=self.pg_host,
database=self.pg_database,
user=self.pg_user,
password=self.pg_password,
port=self.pg_port
)
except Exception as e:
print(f"❌ Error PostgreSQL: {e}")
return None
def _init_supabase(self):
self.supabase_error = None
try:
from supabase import create_client
if self.supabase_url and self.supabase_key:
self.supabase_client = create_client(self.supabase_url, self.supabase_key)
print(f"✅ Supabase conectado: {self.supabase_url[:30]}...")
else:
self.supabase_error = "SUPABASE_URL o SUPABASE_KEY vacíos"
print(f"⚠️ {self.supabase_error}")
except Exception as e:
self.supabase_client = None
self.supabase_error = f"{type(e).__name__}: {e}"
print(f"❌ Error al conectar Supabase: {self.supabase_error}")
# =========================================================================
# CONSULTAS SQL
# =========================================================================
def ejecutar_consulta_cursos(self, ano, mes):
try:
if not self.query_sql:
raise Exception("No se pudo cargar la query BASE_CURSO")
ids_invitados = []
cursos_personalizados = self.config_data.get('cursos_personalizados', {})
for id_curso, datos in cursos_personalizados.items():
if 'fch_inicio' in datos:
try:
f_obj = datetime.strptime(datos['fch_inicio'], '%d-%m-%Y')
if str(f_obj.year) == str(ano) and str(f_obj.month) == str(mes):
ids_invitados.append(str(int(id_curso)))
except:
pass
sql_final = self.query_sql
if ids_invitados:
ids_str = ",".join(ids_invitados)
if "ORDER BY" in sql_final:
parts = sql_final.split("ORDER BY")
sql_final = parts[0] + f" OR rp.num_indice IN ({ids_str}) \nORDER BY" + parts[1]
else:
sql_final += f" OR rp.num_indice IN ({ids_str})"
conn = self.get_connection()
cursor = conn.cursor()
cursor.execute(sql_final, ano, mes)
rows = cursor.fetchall()
cols = ['num_indice', 'dsc_det_programa', 'flg_activo', 'fch_inicio',
'cod_frecuencia', 'cod_estado', 'dsc_programa',
'Inscritos_Totales', 'Inscritos_Retirados', 'Inscritos_Activos']
datos = [{cols[i]: row[i] for i in range(len(cols))} for row in rows]
conn.close()
return datos
except Exception as e:
print(f"❌ Error ejecutar_consulta_cursos: {e}")
raise
def cargar_datos_matriculas(self, ano, mes):
try:
if not self.query_matriculas_sql:
return {}
conn = self.get_connection()
cursor = conn.cursor()
cursor.execute(self.query_matriculas_sql, ano, mes)
rows = cursor.fetchall()
d = {}
for row in rows:
d[row[0]] = d.get(row[0], 0) + 1
conn.close()
return d
except:
return {}
def ejecutar_consulta_matriculados_detalle(self, ano, mes):
try:
conn = self.get_connection()
cursor = conn.cursor()
ids_invitados = []
cursos_personalizados = self.config_data.get('cursos_personalizados', {})
for id_curso, datos in cursos_personalizados.items():
if 'fch_inicio' in datos:
try:
f_obj = datetime.strptime(datos['fch_inicio'], '%d-%m-%Y')
if str(f_obj.year) == str(ano) and str(f_obj.month) == str(mes):
ids_invitados.append(str(int(id_curso)))
except:
pass
sql_or = ""
if ids_invitados:
sql_or = f" OR sgede_RP_programa.num_indice IN ({','.join(ids_invitados)}) "
query = f"""
SELECT
sgede_RP_programa.num_indice, sgeca_matricula.num_matricula,
sgeca_matricula.cod_estado AS estado_matricula,
sgeca_programa.dsc_programa, sgeca_matricula.cod_moneda,
sgema_alumno.dsc_documento,
ISNULL((SELECT SUM(sgede_cronograma_matricula.imp_total - ISNULL(sgede_cronograma_matricula.imp_dscto, 0))
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), 0) AS INV_NETA,
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,
ISNULL((SELECT TOP 1 comp.imp_tc FROM vtaca_comprobante comp
INNER JOIN sgevi_cuotas_x_comprobante cxc
ON comp.cod_localidad = cxc.cod_localidad
AND comp.num_correlativo = cxc.num_correlativo
WHERE cxc.num_matricula = sgeca_matricula.num_matricula
AND cxc.cod_localidad_c = sgeca_matricula.cod_localidad
AND comp.cod_estado NOT IN ('ANU', 'ELI')
ORDER BY comp.fch_emision DESC), 0) AS imp_tc
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 ((YEAR(sgede_RP_programa.fch_inicio) = ? AND MONTH(sgede_RP_programa.fch_inicio) = ?) {sql_or})
AND sgeca_matricula.cod_estado NOT IN ('ANU', 'SUS')
"""
cursor.execute(query, ano, mes)
columns = [col[0] for col in cursor.description]
results = [dict(zip(columns, row)) for row in cursor.fetchall()]
conn.close()
return results
except Exception as e:
print(f"❌ Error ejecutar_consulta_matriculados_detalle: {e}")
return []
def consultar_historial_continuidad(self, lista_documentos):
if not lista_documentos:
return {}
try:
dnis = list(set([str(d).strip() for d in lista_documentos if d]))
if not dnis:
return {}
placeholders = ','.join(['?'] * len(dnis))
query = f"""
SELECT a.dsc_documento, p.dsc_programa, rp.fch_inicio
FROM sgeca_matricula m
INNER JOIN sgema_alumno a ON m.cod_alumno = a.cod_alumno
INNER JOIN sgeca_programa p ON m.cod_programa = p.cod_programa
INNER JOIN sgede_RP_programa rp
ON m.cod_periodo = rp.cod_detalle
AND m.cod_programa = rp.cod_programa
AND m.num_indice = rp.num_indice
WHERE a.dsc_documento IN ({placeholders})
AND m.cod_estado IN ('ALU', 'PRE', 'RET')
AND m.cod_localidad LIKE 'SCENT'
AND (p.dsc_programa LIKE '%TEAC%' OR p.dsc_programa LIKE '%TERC%'
OR p.dsc_programa LIKE '%AREQUIPA%' OR p.dsc_programa LIKE '%TRUJILLO%'
OR p.dsc_programa LIKE '%PIURA%' OR p.dsc_programa LIKE '%TECNICO ESPECIALISTA%')
"""
conn = self.get_connection()
cursor = conn.cursor()
cursor.execute(query, dnis)
historial = {}
for row in cursor.fetchall():
dni = str(row[0]).strip()
if dni not in historial:
historial[dni] = []
historial[dni].append({'programa': row[1], 'fecha': row[2]})
conn.close()
return historial
except Exception as e:
print(f"❌ Error historial continuidad: {e}")
return {}
def _get_query_ventas_hibrido(self, lista_ids_vip):
if not lista_ids_vip:
sql_in_clause = "(-1)"
else:
ids_str = [str(x) for x in lista_ids_vip]
sql_in_clause = "(" + ",".join(ids_str) + ")"
return f"""
SELECT
(
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, -- [0]
ISNULL((
SELECT
SUM(sgede_cronograma_matricula.imp_total - ISNULL(sgede_cronograma_matricula.imp_dscto, 0))
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
), 0) AS INV_NETA, -- [1]
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, -- [2]
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, -- [3]
sgeca_matricula.num_matricula, -- [4]
YEAR(sgeca_matricula.fch_matricula) as anio_mat, -- [5]
MONTH(sgeca_matricula.fch_matricula) as mes_mat, -- [6]
(
SELECT TOP 1 sgede_cronograma_matricula.fch_cancelacion
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
) AS fch_cancelacion_cuota1, -- [7]
sgeca_matricula.cod_moneda, -- [8]
ISNULL((
SELECT TOP 1 comp.imp_tc
FROM vtaca_comprobante comp
INNER JOIN sgevi_cuotas_x_comprobante cxc ON comp.cod_localidad = cxc.cod_localidad
AND comp.num_correlativo = cxc.num_correlativo
WHERE cxc.num_matricula = sgeca_matricula.num_matricula
AND cxc.cod_localidad_c = sgeca_matricula.cod_localidad
AND comp.cod_estado NOT IN ('ANU', 'ELI')
ORDER BY comp.fch_emision DESC
), 1) AS imp_tc, -- [9]
sgede_RP_programa.dsc_det_programa, -- [10] PROGRAMA (mismo num_indice)
sgede_RP_programa.fch_inicio, -- [11] FECHA INICIO (mismo num_indice)
sgeca_programa.dsc_programa, -- [12] PROGRAMA GENERAL (respaldo)
sgeca_matricula.cod_estado -- [13] ESTADO (ALU/PRE/RET)
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
INNER JOIN vtama_localidad ON sgeca_matricula.cod_localidad = vtama_localidad.cod_localidad
INNER JOIN vtama_tipo_documento ON sgema_alumno.cod_tipo_documento = vtama_tipo_documento.cod_tipo_documento
WHERE
sgeca_matricula.cod_localidad LIKE 'SCENT'
AND (
sgeca_matricula.cod_estado IN ('ALU', 'PRE', 'RET')
OR sgeca_matricula.num_matricula IN {sql_in_clause}
)
AND (
(YEAR(sgeca_matricula.fch_matricula) = ? AND MONTH(sgeca_matricula.fch_matricula) = ?)
OR
sgeca_matricula.num_matricula IN {sql_in_clause}
OR
EXISTS (
SELECT 1 FROM sgede_cronograma_matricula crono
WHERE crono.cod_localidad = sgeca_matricula.cod_localidad
AND crono.num_matricula = sgeca_matricula.num_matricula
AND crono.num_cuota = 1
AND crono.fch_cancelacion IS NOT NULL
AND YEAR(crono.fch_cancelacion) = ?
AND MONTH(crono.fch_cancelacion) = ?
)
)
ORDER BY
dsc_vendedor ASC
"""
def ejecutar_consulta_ventas(self, ano, mes, sede="TODOS", programa="TODOS"):
try:
key_mes = f"{int(mes):02d}-{ano}"
lista_raw = self.historico_pendientes.get(key_mes, [])
lista_vip_list = []
for item in lista_raw:
try: lista_vip_list.append(int(item))
except: pass
# Lista PURA de BASE_PENDIENTES (sin overrides). Venta Pendientes usa SOLO esto.
set_lista_pendientes = set(lista_vip_list)
# Incluir matrículas con override de fecha (para que la query las traiga, incluso ANU)
_fov = getattr(self, '_fecha_canc_overrides', None) or {}
for mk in _fov.keys():
try: lista_vip_list.append(int(float(str(mk))))
except: pass
correcciones = self.config_data.get("correcciones_matriculas", {})
query_hibrido = self._get_query_ventas_hibrido(lista_vip_list)
conn = self.get_connection()
cursor = conn.cursor()
# Clasificadores para filtros (mismos que Cobranza/Ocupabilidad)
_sede_sel = str(sede or "TODOS").upper()
_prog_sel = str(programa or "TODOS").upper()
def _clasif_sede(dscp):
if not dscp: return "LIMA"
up = str(dscp).upper()
sedes_data = self.sede_data.get("clasificacion_sedes", {})
for s, data in sedes_data.items():
if s == "DEFAULT": continue
for pat in data.get("patrones", []):
if pat.upper() in up: return s
_def = self.sede_data.get("clasificacion_default", {})
return _def.get("sede", "LIMA") if isinstance(_def, dict) else "LIMA"
def _clasif_prog(dscp):
# Clasifica el programa en UNA sola categoría, con prioridad de orden.
# Específicas primero (SEMINARIOS, TEAC, TERC); OTROS y el sobrante al final.
up = str(dscp or "").upper()
fdata = self.meta_data.get("clasificacion_filtro_programa", {})
orden = ["OTROS", "SEMINARIOS", "TEAC", "TERC"]
for cat in orden:
data = fdata.get(cat, {})
for pat in data.get("patrones", []):
if pat.upper() in up:
return cat
# Sobrante (no coincide con nada) → DEFAULT
return fdata.get("DEFAULT", "OTROS") if isinstance(fdata.get("DEFAULT"), str) else "OTROS"
cursor.execute(query_hibrido, ano, mes, ano, mes)
rows = cursor.fetchall()
ventas_por_vendedor = {}
set_vip_actual = set(lista_vip_list)
vendedores_excluidos = {
"CASTILLO B. KARINA LISSET",
"CASTILLO B. KARINA LISSET",
"CALDERON S. LISSA GENA",
"CRUZ G. FIORELLA MELISSA",
"URIBE G. MARIA MERCEDES"
}
for row in rows:
vendedor = row[0] if row[0] else "SIN VENDEDOR"
if vendedor in vendedores_excluidos: continue
# Filtro SEDE/PROGRAMA (mismas reglas que Ocupabilidad/Cobranza)
_dscp = row[10] if (len(row) > 10 and row[10]) else (row[12] if len(row) > 12 else "")
if _sede_sel != "TODOS" and _clasif_sede(_dscp) != _sede_sel:
continue
if _prog_sel != "TODOS" and _clasif_prog(_dscp) != _prog_sel:
continue
inv_neta_raw = float(row[1]) if row[1] else 0.0
cod_moneda = row[8]
imp_tc = float(row[9]) if row[9] else 1.0
saldo_mat = float(row[2]) if row[2] else 0.0
saldo_c1 = float(row[3]) if row[3] else 0.0
raw_mat = row[4]
row_year = row[5]
row_month = row[6]
fch_cancelacion_raw = row[7]
matricula_int = -1
matricula_str = ""
try:
if raw_mat is not None:
val_str = str(raw_mat).strip()
if val_str.endswith('.0'): val_str = val_str[:-2]
matricula_str = val_str
matricula_int = int(float(val_str))
except: pass
if matricula_str in correcciones:
datos_corregidos = correcciones[matricula_str]
if "imp_tc" in datos_corregidos: imp_tc = float(datos_corregidos["imp_tc"])
if "imp_saldo_cuota1" in datos_corregidos: saldo_c1 = float(datos_corregidos["imp_saldo_cuota1"])
if "imp_saldo_matricula" in datos_corregidos: saldo_mat = float(datos_corregidos["imp_saldo_matricula"])
if "fch_cancelacion_cuota1" in datos_corregidos: fch_cancelacion_raw = datos_corregidos["fch_cancelacion_cuota1"]
# Override de FECHA CANCELACIÓN 1 desde Supabase (prioridad para clasificación)
_fov = getattr(self, '_fecha_canc_overrides', None)
_vacio_forzado = False
if _fov and matricula_str in _fov:
fov_val = _fov[matricula_str]
s = str(fov_val).strip()
if s == "__VACIO__":
# Fecha borrada a propósito → anula la del SQL (no cuenta como pagado)
fch_cancelacion_raw = None
_vacio_forzado = True
else:
# Normalizar dd/mm/yyyy o d/m/yyyy → yyyy-mm-dd
if '/' in s:
p = s.split('/')
if len(p) == 3:
s = f"{p[2]}-{int(p[1]):02d}-{int(p[0]):02d}"
fch_cancelacion_raw = s
# Prioridad TC: Supabase (mes) > corrección manual > SQL
_tc_ov = getattr(self, '_tc_override_mes', None)
if _tc_ov: imp_tc = float(_tc_ov)
if cod_moneda == 'DOL': inv_neta = inv_neta_raw * imp_tc
else: inv_neta = inv_neta_raw
# Override de INVERSIÓN NETA (Supabase): monto final en soles, reemplaza al SQL.
_inv_ov = getattr(self, '_inv_neta_overrides', None)
if _inv_ov and matricula_str in _inv_ov:
inv_neta = float(_inv_ov[matricula_str])
if vendedor not in ventas_por_vendedor:
ventas_por_vendedor[vendedor] = {
'monto': 0.0, 'cantidad': 0, 'monto_pc': 0.0, 'cantidad_pc': 0,
'monto_pendientes': 0.0, 'cantidad_pendientes': 0
}
es_venta_del_mes = (str(row_year) == str(ano)) and (str(row_month) == str(mes))
saldos_ok = (saldo_mat == 0 and saldo_c1 == 0)
# Si hay override de fecha de cancelación 1 (con fecha), se considera PAGADO.
# Si el override la vació a propósito, NO se fuerza pagado.
tiene_override_fecha = bool(_fov and matricula_str in _fov) and not _vacio_forzado
if tiene_override_fecha:
saldos_ok = True
# Si es RET y NO pagó (sin override ni saldos), NO entra en la tabla principal.
estado_row = str(row[13]).strip().upper() if len(row) > 13 and row[13] else ""
if estado_row == "RET" and not saldos_ok:
continue
# ¿Pagó la 1° cuota en el MES del filtro?
pago_en_fecha_correcta = False
if fch_cancelacion_raw:
try:
if isinstance(fch_cancelacion_raw, str):
f_obj = datetime.strptime(fch_cancelacion_raw[:10], '%Y-%m-%d')
f_ano, f_mes = f_obj.year, f_obj.month
else:
f_ano, f_mes = fch_cancelacion_raw.year, fch_cancelacion_raw.month
if str(f_ano) == str(ano) and str(f_mes) == str(mes):
pago_en_fecha_correcta = True
except: pass
# ¿La matrícula es de un mes ANTERIOR al filtro? (mismo año mes menor, o año anterior)
matricula_mes_pasado = False
try:
ym_mat = int(row_year) * 100 + int(row_month)
ym_filtro = int(ano) * 100 + int(mes)
matricula_mes_pasado = ym_mat < ym_filtro
except: pass
# COLUMNA 2 y 3 — MES EN CURSO: matrícula del mes + pagó en el mes
if es_venta_del_mes:
ventas_por_vendedor[vendedor]['monto'] += inv_neta
ventas_por_vendedor[vendedor]['cantidad'] += 1
if saldos_ok and pago_en_fecha_correcta:
ventas_por_vendedor[vendedor]['monto_pc'] += inv_neta
ventas_por_vendedor[vendedor]['cantidad_pc'] += 1
# COLUMNA 4 y 5 — MES PASADO: matrícula anterior + pagó 1° cuota en el mes del filtro
if matricula_int in set_lista_pendientes and pago_en_fecha_correcta:
ventas_por_vendedor[vendedor]['monto_pendientes'] += inv_neta
ventas_por_vendedor[vendedor]['cantidad_pendientes'] += 1
datos_ventas = []
for vendedor, datos in ventas_por_vendedor.items():
datos_ventas.append({
'VENDEDOR': vendedor,
'MONTO': round(datos['monto'], 2),
'CANTIDAD': datos['cantidad'],
'VENTAS_PC': round(datos['monto_pc'], 2),
'INSCRITOS_PC': datos['cantidad_pc'],
'PENDIENTES': round(datos['monto_pendientes'], 2),
'INSCRITOS_PENDIENTES': datos['cantidad_pendientes']
})
datos_ventas.sort(key=lambda x: x['MONTO'], reverse=True)
conn.close()
return datos_ventas
except Exception as e:
return []
def ejecutar_consulta_cronograma_cobranza(self, ano):
clave = str(ano)
en_cache = self._cache_cronograma.get(clave)
if en_cache is not None:
return en_cache
try:
if not self.query_cronograma_sql:
return []
conn = self.get_connection()
cursor = conn.cursor()
cursor.execute(self.query_cronograma_sql)
columns = [col[0] for col in cursor.description]
results = [dict(zip(columns, row)) for row in cursor.fetchall()]
conn.close()
self._cache_cronograma[clave] = results # cachear SOLO si tuvo éxito
return results
except Exception as e:
print(f"❌ Error cronograma cobranza: {e}")
return []
def ejecutar_consulta_facturas_cobranza(self, ano):
clave = str(ano)
en_cache = self._cache_facturas.get(clave)
if en_cache is not None:
return en_cache
try:
if not self.query_facturas_sql:
return []
conn = self.get_connection()
cursor = conn.cursor()
cursor.execute(self.query_facturas_sql)
columns = [col[0] for col in cursor.description]
results = [dict(zip(columns, row)) for row in cursor.fetchall()]
conn.close()
self._cache_facturas[clave] = results # cachear SOLO si tuvo éxito
return results
except Exception as e:
print(f"❌ Error facturas cobranza: {e}")
return []
def limpiar_cache_cobranza(self, ano=None):
"""Vacía el caché anual de cobranza para forzar re-lectura desde SQL.
Se llama en cada refresco (manual o ciclo de 15 min)."""
if ano is None:
self._cache_cronograma.clear()
self._cache_facturas.clear()
else:
self._cache_cronograma.pop(str(ano), None)
self._cache_facturas.pop(str(ano), None)
def ejecutar_consulta_saldos_anual(self, ano):
try:
if not self.query_matriculas_sql:
return []
sql_anual = self.query_matriculas_sql.replace(
"AND MONTH(sgeca_matricula.fch_matricula) = ?", ""
)
conn = self.get_connection()
cursor = conn.cursor()
cursor.execute(sql_anual, ano)
columns = [col[0] for col in cursor.description]
results = [dict(zip(columns, row)) for row in cursor.fetchall()]
conn.close()
return results
except Exception as e:
print(f"❌ Error saldos anual: {e}")
return []
# =========================================================================
# SUPABASE - OVERRIDES DE COSTOS
# =========================================================================
def cargar_overrides_costos(self):
if not self.supabase_client:
return {}
try:
response = self.supabase_client.table('costos_overrides').select('*').execute()
overrides = {}
for row in response.data:
idx = str(row.get('num_indice', '')).strip()
tipo = str(row.get('tipo_costo', '')).strip().lower()
if not idx or tipo not in ('inicial', 'actual'):
continue
if idx not in overrides:
overrides[idx] = {}
overrides[idx][tipo] = {
'epp': row.get('costo_epp'),
'certificado': row.get('costo_certificado'),
'docente': row.get('costo_docente'),
'consumibles': row.get('costo_consumibles'),
'marketing': row.get('costo_marketing'),
}
return overrides
except Exception as e:
print(f"⚠️ Error overrides Supabase: {e}")
return {}
def obtener_fechas_originales(self):
"""Descarga todas las fechas originales guardadas en Supabase (inicios reprogramados)."""
if not self.supabase_client:
return {}
try:
respuesta = self.supabase_client.table('fechas_originales_cursos').select('*').execute()
datos = respuesta.data
diccionario_fechas = {}
for fila in datos:
indice = str(fila.get('num_indice'))
fecha = fila.get('fecha_inicio_original')
programa = fila.get('programa', 'CURSO REPROGRAMADO')
diccionario_fechas[indice] = {'fecha': fecha, 'programa': programa}
return diccionario_fechas
except Exception as e:
print(f"Error al cargar fechas originales: {e}")
return {}
def guardar_fecha_original(self, num_indice, programa, fecha_inicio):
"""Guarda silenciosamente un nuevo curso y su fecha inicial en Supabase."""
if not self.supabase_client:
return False
try:
payload = {
'num_indice': str(num_indice),
'programa': str(programa),
'fecha_inicio_original': str(fecha_inicio)
}
self.supabase_client.table('fechas_originales_cursos').upsert(payload).execute()
return True
except Exception as e:
print(f"Error al guardar fecha original: {e}")
return False
def guardar_override_costo(self, num_indice, tipo_costo, costos_dict):
if not self.supabase_client:
return False
try:
payload = {
'num_indice': str(num_indice),
'tipo_costo': tipo_costo,
'costo_epp': costos_dict.get('epp'),
'costo_certificado': costos_dict.get('certificado'),
'costo_docente': costos_dict.get('docente'),
'costo_consumibles': costos_dict.get('consumibles'),
'costo_marketing': costos_dict.get('marketing'),
'fecha_actualizacion': datetime.now().isoformat(),
}
self.supabase_client.table('costos_overrides').upsert(payload).execute()
return True
except Exception as e:
print(f"❌ Error guardar override: {e}")
return False
def eliminar_overrides_costo(self, num_indice):
if not self.supabase_client:
return False
try:
self.supabase_client.table('costos_overrides').delete().eq(
'num_indice', str(num_indice)
).execute()
return True
except Exception as e:
print(f"❌ Error eliminar overrides: {e}")
return False
# =========================================================================
# HELPERS
# =========================================================================
def get_current_year(self):
return datetime.now().year
def get_current_month(self):
return datetime.now().month

1055
backend/data_manager_v2.py Normal file

File diff suppressed because it is too large Load Diff

894
backend/leads_logic.py Normal file
View File

@@ -0,0 +1,894 @@
# backend/leads_logic.py
"""
Lógica del módulo LEADS. Traduce las medidas DAX del PBI a Python.
Columnas calculadas replicadas:
- Cantidad_Veces -> 1ª aparición de un teléfono = lead único
- Ultima_Etiqueta / ESTADO/OBJECION -> limpieza de cached_label_list
- Tipo_Programa / Sede -> clasificación por dsc_programa
Medidas replicadas:
- Leads_Totales_Pauta_Unico, Leads_Procesados_Pauta_Unicos,
Leads_Procesados_Contactados_Unicos, % Procesados, % Contactados,
Total_Matriculas, Cant_inscritos_Mes, Ocupabilidad,
Cursos (Inicios / Suspendidos / Ya Iniciados), Matrículas por día,
tabla Estado/Objeción.
"""
from datetime import datetime, date
# Etiquetas de sistema que se eliminan para hallar el estado/objeción real (igual que el DAX)
ETIQUETAS_SISTEMA = {
"atención_humana", "negociación", "supervisor", "grupo_arequipa",
"grupo_trujillo", "sin_respuesta",
}
META_POR_TIPO = {
"PROGRAMAS TEAC": 22, "PROGRAMAS TERC": 22,
"PROVINCIA TEAC": 18, "PROVINCIA TERC": 18,
"SEMINARIOS": 15,
}
# ── Helpers de fecha ────────────────────────────────────────────
def _to_date(v):
if v is None:
return None
if isinstance(v, datetime):
return v.date()
if isinstance(v, date):
return v
s = str(v)[:10]
for fmt in ("%Y-%m-%d", "%d-%m-%Y", "%d/%m/%Y"):
try:
return datetime.strptime(s, fmt).date()
except Exception:
pass
return None
def _en_periodo(d, ano, mes, dia):
"""True si la fecha cae en el filtro (ano/mes/dia; 'TODOS' = sin filtrar)."""
if d is None:
return False
if ano not in ("TODOS", None) and d.year != int(ano):
return False
if mes not in ("TODOS", None) and d.month != int(mes):
return False
if dia not in ("TODOS", None) and d.day != int(dia):
return False
return True
# ── Clasificadores (igual que columnas calculadas DAX) ──────────
def clasificar_tipo_programa(dsc_programa):
up = str(dsc_programa or "").upper()
otros = ["CERTIFICACIÓN", "CERTIFICACION", "CURSO A MEDIDA", "MASTERCLASS",
"TALLER DE REFRIGERACIÓN DOM", "GESTIÓN DE VENTA", "GESTION DE VENTA"]
for o in otros:
if o.upper() in up:
return "OTROS"
return None # el tipo PROGRAMAS/PROVINCIA se arma con sede+programa abajo
def clasificar_sede(dsc_programa):
up = str(dsc_programa or "").upper()
if "AREQUIPA" in up: return "AREQUIPA"
if "TRUJILLO" in up: return "TRUJILLO"
if "PIURA" in up: return "PIURA"
return "LIMA"
def sede_por_codigo_map():
"""{codigo: SEDE} tomado del mapa de campañas (la sede que le corresponde a
cada código, SIN reasignar por asesor). Usado por el filtro SEDE de leads,
para que coincida con 'sede del código' (no con sede_act)."""
from data_manager_v2 import CAMPANIAS
out = {}
for fila in CAMPANIAS:
cod = str(fila[2]).strip() # índice 2 = codigo
sede = str(fila[3]).strip().upper() # índice 3 = sede
if cod:
out[cod] = sede
return out
# ── Grupo de PROGRAMA para el filtro: TEAC / TERC / SEMINARIOS / OTROS ──
def grupo_programa_curso(dsc_programa):
"""Reduce el tipo detallado a 4 grupos para el filtro PROGRAMA (cursos/matrículas).
TEAC = PROGRAMAS TEAC + PROVINCIA TEAC
TERC = PROGRAMAS TERC + PROVINCIA TERC
SEMINARIOS = SEMINARIOS
OTROS = OTROS + CARRERA"""
tp = tipo_programa_curso(dsc_programa)
if tp in ("PROGRAMAS TEAC", "PROVINCIA TEAC"):
return "TEAC"
if tp in ("PROGRAMAS TERC", "PROVINCIA TERC"):
return "TERC"
if tp == "SEMINARIOS":
return "SEMINARIOS"
return "OTROS" # OTROS + CARRERA
def grupo_programa_lead(cargo):
"""Grupo de programa para un LEAD, según su 'cargo' (del mapa de campañas).
TEAC → TEAC, TERC → TERC, resto (VRF/CO2/DIPLOMADO/etc.) → SEMINARIOS."""
c = str(cargo or "").strip().upper()
if c == "TEAC":
return "TEAC"
if c == "TERC":
return "TERC"
return "SEMINARIOS"
def grupo_tipo_cohorte(tp):
"""Reduce 'PROGRAMAS TEAC'/'PROVINCIA TEAC'/... a los 3 grupos de la tabla por Sede:
TEAC = PROGRAMAS/PROVINCIA TEAC, TERC = PROGRAMAS/PROVINCIA TERC, resto = SEMINARIOS."""
t = str(tp or "").upper()
if "TEAC" in t:
return "TEAC"
if "TERC" in t:
return "TERC"
return "SEMINARIOS"
# Réplica del DAX Sede_Act: la sede del lead se decide por el ASESOR;
# si el asesor no está en la lista, usa la sede de la campaña.
_SEDE_POR_ASESOR = {
"almendra peralta": "Arequipa",
"juan carlos aguilar": "Piura",
"diego lázaro": "Trujillo",
"diego lazaro": "Trujillo",
"verónica la rosa": "Lima",
"veronica la rosa": "Lima",
"dayana balabarca": "Lima",
"milagros vargas": "Lima",
"carmen montoya": "Lima",
"diana chávez": "Lima",
"diana chavez": "Lima",
"copito rivera": "Lima",
}
def sede_act(asesor, sede_campania):
return _SEDE_POR_ASESOR.get(str(asesor or "").strip().lower(), sede_campania)
def tipo_programa_cohorte(sede, programa_cat):
"""Igual al DAX 'Tipo Programa': combina sede + (TEAC/TERC) -> categoría."""
s = str(sede or "").upper()
p = str(programa_cat or "").upper()
if s == "LIMA" and p == "TEAC": return "PROGRAMAS TEAC"
if s == "LIMA" and p == "TERC": return "PROGRAMAS TERC"
if s in ("TRUJILLO", "PIURA", "AREQUIPA") and p == "TEAC": return "PROVINCIA TEAC"
if s in ("TRUJILLO", "PIURA", "AREQUIPA") and p == "TERC": return "PROVINCIA TERC"
return "SEMINARIOS"
def tipo_programa_curso(dsc_programa):
"""Réplica EXACTA del DAX Tipo_Programa (columna calculada de Fact_SQL_Base_Cursos).
Usa CONTAINSSTRING en el mismo ORDEN que el PBI (el orden importa)."""
p = str(dsc_programa or "")
def has(s): # CONTAINSSTRING es sensible a may/min en DAX; comparamos tal cual
return s in p
# 1) OTROS
if (has("CERTIFICACIÓN") or has("CURSO A MEDIDA") or has("MASTERCLASS")
or has("TALLER DE REFRIGERACIÓN DOM")
or has("GESTIÓN DE VENTA DE EQUIPOS DE AIRE ACONDICIONADO Y EQUIPOS DE REFRIGERACIÓN")):
return "OTROS"
if (has("CERTIFICACION FUNDAMENTOS DE CHILLER MODULAR INVERT- LG")
or has("PIURA - CERTIFICACIÓN MIDEA: TECNOLOGIA INVERTER")
or has("AREQUIPA - CERTIFICACIÓN MIDEA - TECNOLOGÍA INVERTER")
or has("CERTIFICACIÓN: OPERACIÓN Y MANTENIMIENTO DE REFRIGERADORES MIDEA")
or has("TRUJILLO - CERTIFICACIÓN MIDEA: TECNOLOGÍA INVERTER")
or has("CERTIFICACIÓN MIDEA: AIRE ACOND INVERTER (INTROD., FUNC., INST. Y MANT.)")):
return "OTROS"
# 2) CARRERA
if has("CARRERA TECNICA DE AIRE ACONDICIONADO Y REFRIGERACION"):
return "CARRERA"
# 3) PROVINCIA TEAC
if (has("TRUJILLO - TECNICO ESPECIALISTA EN AIRE ACONDICIONADO")
or has("PIURA - TECNICO ESPECIALISTA EN AIRE ACONDICIONADO")
or has("AREQUIPA - TECNICO ESPECIALISTA EN AIRE ACONDICIONADO")
or has("TRUJILLO - VIRTUAL TECNICO ESPECIALISTA EN AIRE ACONDICIONADO")):
return "PROVINCIA TEAC"
# 4) PROVINCIA TERC
if (has("AREQUIPA - TECNICO ESPECIALISTA EN REFRIGERACION DOMÉSTICA Y COMERCIAL")
or has("PIURA - TECNICO ESPECIALISTA EN REFRIGERACION DOMÉSTICA Y COMERCIAL")
or has("TRUJILLO - TECNICO ESPECIALISTA EN REFRIGERACION DOMÉSTICA Y COMERCIAL")):
return "PROVINCIA TERC"
# 5) SEMINARIOS
if (has("CO2") or has("CERTIF") or has("SEM:") or has("MASTERCLASS") or has("DISEÑO")
or has("DIPLOMADO") or has("SEM.") or has("DUCTOS") or has("SEMINARIO") or has("SEMINARIOS")):
return "SEMINARIOS"
# 6) PROGRAMAS TEAC (Lima)
if (has("MANTENIMIENTO EN AIRE ACONDICIONADO")
or has("TECNICO ESPECIALISTA EN AIRE ACONDICIONADO")
or has("TALLER - TECNICO ESPECIALISTA EN AIRE ACONDICIONADO")
or has("CONTINUIDAD-TECNICO ESPECIALISTA EN AA (4M)")
or has("VIRTUAL TECNICO ESPECIALISTA EN AIRE ACONDICIONADO")):
return "PROGRAMAS TEAC"
# 7) PROGRAMAS TERC (Lima)
if (has("INSTALACION EN REFRIGERACION COMERCIAL")
or has("MANTENIMIENTO EN REFRIGERACIÓN COMERCIAL")
or has("CONTINUIDAD-TECNICO ESPECIALISTA EN REFRIGERACIÓN COMERCIAL (4M)")
or has("TECNICO ESPECIALISTA EN REFRIGERACION COMERCIAL")):
return "PROGRAMAS TERC"
return "SEMINARIOS"
def meta_curso(dsc_programa):
"""Meta por curso según Tipo_Programa (igual que el DAX Meta_Curso):
PROGRAMAS TEAC/TERC=22, PROVINCIA TEAC/TERC=18, resto=15."""
tp = tipo_programa_curso(dsc_programa)
if tp in ("PROGRAMAS TEAC", "PROGRAMAS TERC"):
return 22
if tp in ("PROVINCIA TEAC", "PROVINCIA TERC"):
return 18
return 15
# ── ESTADO/OBJECION (réplica EXACTA del DAX: cadena de SUBSTITUTE) ──
# El DAX quita comas y espacios (pega todas las etiquetas), elimina las de
# sistema, y luego reduce combinaciones concatenadas a un estado final.
_SUST = [
("atención_humana", ""), ("negociación", ""), ("supervisor", ""),
("grupo_arequipa", ""), ("grupo_trujillo", ""), ("sin_respuesta", ""),
("interesadovendido", "vendido"), ("próximo_inicio", "proxima_fecha"),
("inicio", ""), ("aprobación", ""), ("negociacion", ""), ("no_acepta", ""),
("pendiente", ""),
("revisando_informaciónsólo_consulta", "sólo_consulta"),
("contacto_iniciadovendido", "vendido"),
("revisando_informacióninteresado", "interesado"),
("revisando_informaciónvendido", "vendido"),
("sólo_consultainteresado", "interesado"),
("revisando_informaciónno_califica", "no_califica"),
("interesadopor_pagar", "por_pagar"),
("revisando_informaciónprecio_elevado", "precio_elevado"),
("proxima_fechavendido", "vendido"),
("sólo_consultavendido", "vendido"),
("vendidointeresado", "vendido"),
("revisando_informaciónproxima_fecha", "proxima_fecha"),
("contacto_iniciadosólo_consulta", "sólo_consulta"),
]
def estado_objecion(etiquetas):
"""Réplica del DAX ESTADO/OBJECION (versión de 28 sustituciones del PBI)."""
if etiquetas is None:
return "no trabajado"
s = str(etiquetas).replace(",", "").replace(" ", "")
for buscar, reemplazar in _SUST:
s = s.replace(buscar, reemplazar)
s = s.strip()
return s if s else "no trabajado"
# ── Ultima_Etiqueta (réplica EXACTA del DAX, usada para "Total Contactados") ──
_ULTIMA_NO_TRABAJADO = {
"", "(en blanco)", "aprobación", "atencion humana", "atención humana",
"grupo arequipa", "grupo trujillo", "importacion masiva", "inicio",
"negociacion", "sin respuesta", "pendiente",
}
def ultima_etiqueta(etiquetas):
if etiquetas is None or str(etiquetas).strip() == "":
return "NO TRABAJADO"
partes = str(etiquetas).split(", ")
limpia = partes[-1].replace("_", " ").strip()
if limpia in _ULTIMA_NO_TRABAJADO:
return "NO TRABAJADO"
return limpia if limpia else "NO TRABAJADO"
# ════════════════════════════════════════════════════════════════
# PROCESAMIENTO DE LEADS (calcula Cantidad_Veces y agrupa)
# ════════════════════════════════════════════════════════════════
def procesar_leads(filas_chatwoot):
"""Marca el lead único (1ª vez de cada teléfono) y arma estructura limpia.
Ordenamos por fecha para que la 1ª aparición de cada teléfono sea la 'única'."""
# Ordenar por fecha ascendente (el query puede venir ordenado por id)
filas_chatwoot = sorted(filas_chatwoot, key=lambda f: (_to_date(f.get("fecha_creada")) or date.min))
# Teléfonos de prueba a excluir (igual que el PBI: 924374783)
EXCLUIR = {"924374783"}
vistos = set()
out = []
for f in filas_chatwoot:
tel = str(f.get("telefono") or "").strip()
if not tel or tel in EXCLUIR:
continue
es_unico = tel not in vistos
vistos.add(tel)
fecha = _to_date(f.get("fecha_creada"))
asesor = (f.get("asesor") or "").strip()
sede_campania = (f.get("sede") or "").strip() or "SIN SEDE"
sede = sede_act(asesor, sede_campania) # réplica DAX Sede_Act (reasigna por asesor)
cargo = (f.get("programa") or "").strip() # TEAC / TERC / VRF / etc. (del mapa campañas)
out.append({
"telefono": tel,
"asesor": asesor,
"fecha": fecha,
"estado": estado_objecion(f.get("etiquetas")),
"ultima": ultima_etiqueta(f.get("etiquetas")),
"sede": sede,
"cargo": cargo, # TEAC / TERC / VRF / CO2 / etc.
"tipo_programa": tipo_programa_cohorte(sede, cargo), # PROGRAMAS/PROVINCIA/SEMINARIOS
"codigo": (f.get("codigo") or "").strip() or "-", # código de campaña
"es_unico": es_unico,
})
return out
# ════════════════════════════════════════════════════════════════
# KPIs DEL MÓDULO LEADS
# ════════════════════════════════════════════════════════════════
def kpis_leads(leads, cursos, matriculas, ano, mes, dia):
# Leads en el periodo (solo únicos = Cantidad_Veces == 1)
leads_periodo = [l for l in leads if l["es_unico"] and _en_periodo(l["fecha"], ano, mes, dia)]
recibidos = len(leads_periodo)
procesados = sum(1 for l in leads_periodo if l["asesor"])
contactados = sum(1 for l in leads_periodo if l["asesor"] and l["ultima"] != "NO TRABAJADO")
pct_procesados = (procesados / recibidos) if recibidos > 0 else 0.0
pct_contactados = (contactados / procesados) if procesados > 0 else 0.0
# ── Cursos del periodo (por fecha de inicio) ──
cursos_periodo = [c for c in cursos if _en_periodo(_to_date(c.get("fch_inicio")), ano, mes, dia)]
cursos_programados = len(cursos_periodo)
suspendidos = sum(1 for c in cursos_periodo if str(c.get("cod_estado", "")).strip().upper() == "SUS")
hoy = date.today()
iniciados = sum(1 for c in cursos_periodo
if str(c.get("cod_estado", "")).strip().upper() != "SUS"
and (_to_date(c.get("fch_inicio")) or hoy) <= hoy)
# ── Total matriculados (= medida Matriculas_Totales del PBI) ──
# Cuenta matrículas ALU/PRE cuya FECHA DE MATRÍCULA cae en el mes,
# excluyendo 2 vendedoras. El Calendario del PBI va por fch_matricula.
VENDEDORES_EXCLUIDOS = {"CALDERON S. LISSA GENA", "CRUZ G. FIORELLA MELISSA"}
total_matriculados = sum(
1 for m in matriculas
if str(m.get("estado_matricula", "")).strip().upper() in ("ALU", "PRE")
and str(m.get("dsc_vendedor", "")).strip() not in VENDEDORES_EXCLUIDOS
and _en_periodo(_to_date(m.get("fch_matricula")), ano, mes, dia)
)
# Ocupabilidad (PBI): SUM(Inscritos) / SUM(Meta).
# Excluye cursos suspendidos (SUS) y num_indice excluidos manualmente.
# (Ver FUTUROS_CAMBIOS.md: mover esta lista a GitHub/Supabase)
CURSOS_EXCLUIDOS = {"1154", "1121"}
cursos_ocup = [c for c in cursos_periodo
if str(c.get("cod_estado", "")).strip().upper() != "SUS"
and str(c.get("num_indice", "")).strip() not in CURSOS_EXCLUIDOS]
sum_meta = sum(meta_curso(c.get("dsc_programa")) for c in cursos_ocup)
sum_inscritos_cursos = sum(int(c.get("Inscritos_Totales", 0) or 0) for c in cursos_ocup)
# ── Matrículas en los cursos del mes ──
# Matrículas ALU/PRE que: (1) se hicieron en el mes (fch_matricula) Y
# (2) son de un curso que INICIA en el mes (dsc_promocion = dsc_det_programa
# de los cursos del periodo). Réplica de Cant_inscritos_Mes del PBI.
promos_periodo = {str(c.get("dsc_det_programa", "")).strip()
for c in cursos_periodo if str(c.get("dsc_det_programa", "")).strip()}
mats_mes = [m for m in matriculas
if str(m.get("dsc_promocion", "")).strip() in promos_periodo
and str(m.get("estado_matricula", "")).strip().upper() in ("ALU", "PRE")
and _en_periodo(_to_date(m.get("fch_matricula")), ano, mes, dia)]
matriculas_mes = len({str(m.get("num_matricula")) for m in mats_mes})
ocupabilidad = (sum_inscritos_cursos / sum_meta) if sum_meta > 0 else 0.0
return {
"leads_recibidos": recibidos,
"leads_procesados": procesados,
"leads_contactados": contactados,
"pct_procesados": round(pct_procesados * 100, 2),
"pct_contactados": round(pct_contactados * 100, 2),
"total_matriculados": total_matriculados,
"matriculas_mes": matriculas_mes,
"ocupabilidad": round(ocupabilidad * 100, 2),
"cursos_programados": cursos_programados,
"cursos_reprogramados": 0, # requiere SharePoint (fase 2)
"cursos_suspendidos": suspendidos,
"cursos_iniciados": iniciados,
}
# ── Tabla Estado/Objeción (agrupa por Ultima_Etiqueta + desglose por asesor) ──
def tabla_estado_objecion(leads, ano, mes, dia):
leads_periodo = [l for l in leads if l["es_unico"]
and l["asesor"] and _en_periodo(l["fecha"], ano, mes, dia)]
agg = {} # estado -> total
por_asesor = {} # estado -> {asesor: cantidad}
telefonos = {} # (estado, asesor) -> [telefonos]
for l in leads_periodo:
est = l["ultima"] or "NO TRABAJADO"
ase = l["asesor"] or "SIN ASESOR"
agg[est] = agg.get(est, 0) + 1
por_asesor.setdefault(est, {})
por_asesor[est][ase] = por_asesor[est].get(ase, 0) + 1
telefonos.setdefault((est, ase), []).append(l["telefono"])
filas = []
for estado in sorted(agg.keys(), key=lambda x: -agg[x]): # mayor a menor por cantidad
asesores = sorted(por_asesor[estado].items(), key=lambda x: -x[1])
filas.append({
"estado": estado,
"cantidad": agg[estado],
"asesores": [
{"asesor": a, "cantidad": c,
"telefonos": sorted(telefonos.get((estado, a), []))}
for a, c in asesores
],
})
total = sum(agg.values())
return {"filas": filas, "total": total}
# ── Matrículas por día (gráfico de línea) ──
# Cuenta TODAS las matrículas del día (misma base que Total Matriculados):
# estado ALU/PRE, excluyendo las 2 vendedoras, por día de fch_matricula.
def matriculas_por_dia(matriculas, cursos, ano, mes, dia):
VENDEDORES_EXCLUIDOS = {"CALDERON S. LISSA GENA", "CRUZ G. FIORELLA MELISSA"}
conteo = {} # dia -> total
por_tipo = {} # dia -> {tipo_programa: cantidad}
for m in matriculas:
if str(m.get("estado_matricula", "")).strip().upper() not in ("ALU", "PRE"):
continue
if str(m.get("dsc_vendedor", "")).strip() in VENDEDORES_EXCLUIDOS:
continue
d = _to_date(m.get("fch_matricula"))
if not _en_periodo(d, ano, mes, dia):
continue
conteo[d.day] = conteo.get(d.day, 0) + 1
tp = tipo_programa_curso(m.get("dsc_programa")) # TEAC/TERC/PROVINCIA.../SEMINARIOS/OTROS
por_tipo.setdefault(d.day, {})
por_tipo[d.day][tp] = por_tipo[d.day].get(tp, 0) + 1
def _detalle(day):
items = sorted(por_tipo.get(day, {}).items(), key=lambda x: -x[1])
return [{"tipo": t, "cantidad": c} for t, c in items]
import calendar
if mes not in ("TODOS", None) and ano not in ("TODOS", None):
ndias = calendar.monthrange(int(ano), int(mes))[1]
return [{"dia": d, "cantidad": conteo.get(d, 0), "detalle": _detalle(d)}
for d in range(1, ndias + 1)]
return [{"dia": d, "cantidad": conteo[d], "detalle": _detalle(d)}
for d in sorted(conteo.keys())]
# ── Gráfico "Leads por Programa": serie diaria Totales vs Procesados ──
def leads_por_dia(leads, ano, mes, dia):
lp = [l for l in leads if l["es_unico"] and _en_periodo(l["fecha"], ano, mes, dia)]
tot = {} # dia -> totales
proc = {} # dia -> procesados (con asesor)
for l in lp:
d = l["fecha"].day
tot[d] = tot.get(d, 0) + 1
if l["asesor"]:
proc[d] = proc.get(d, 0) + 1
import calendar
if mes not in ("TODOS", None) and ano not in ("TODOS", None):
ndias = calendar.monthrange(int(ano), int(mes))[1]
dias = range(1, ndias + 1)
else:
dias = sorted(set(list(tot.keys()) + list(proc.keys())))
return [{"dia": d, "totales": tot.get(d, 0), "procesados": proc.get(d, 0)} for d in dias]
# ── Tabla por Sede_Act -> Tipo_Programa con 4 medidas (cohorte de leads) ──
def tabla_pauta(leads, matriculas, ano, mes, dia, importe_por_pauta_periodo=None,
resultados_por_pauta_periodo=None, campanias_sede_cargo=None):
importe_por_pauta_periodo = importe_por_pauta_periodo or {}
resultados_por_pauta_periodo = resultados_por_pauta_periodo or {}
campanias_sede_cargo = campanias_sede_cargo or {}
# 1) Leads únicos del periodo con su teléfono, fecha, sede y tipo
lp = [l for l in leads if l["es_unico"] and _en_periodo(l["fecha"], ano, mes, dia)]
# mapa teléfono -> fecha del lead (para "matrícula posterior al lead")
fecha_lead = {}
for l in lp:
fecha_lead.setdefault(l["telefono"], l["fecha"])
# 2) Matrículas que cruzan por teléfono y son POSTERIORES a la fecha del lead
# (réplica de Matriculas_Cohorte_Lead e Inversion_x_Pauta_Cohorte)
mat_por_tel = {} # telefono -> {"mats": set(num_matricula), "inv": float}
for m in matriculas:
tel = str(m.get("dsc_telefono_1") or "").strip()
if tel not in fecha_lead:
continue
fm = _to_date(m.get("fch_matricula"))
fl = fecha_lead[tel]
if not fm or not fl or not (fm > fl):
continue
# INV_NETA_FINAL: si DOL, ×3.34
try: inv = float(m.get("INV_NETA", 0) or 0)
except: inv = 0.0
if str(m.get("cod_moneda", "")).strip().upper() == "DOL":
inv *= 3.34
e = mat_por_tel.setdefault(tel, {"mats": set(), "inv": 0.0})
e["mats"].add(str(m.get("num_matricula")))
e["inv"] += inv
# 3) Agrupar por Sede -> Tipo_Programa -> Código
def _nuevo():
return {"recibidos": 0, "procesados": 0, "mats": set(), "inv": 0.0}
grupos = {} # sede -> tipo -> codigo -> métricas
for l in lp:
sede = l["sede"]; tp = grupo_tipo_cohorte(l["tipo_programa"]); cod = l.get("codigo", "-"); tel = l["telefono"]
g = grupos.setdefault(sede, {}).setdefault(tp, {}).setdefault(cod, _nuevo())
g["recibidos"] += 1
if l["asesor"]:
g["procesados"] += 1
if tel in mat_por_tel:
g["mats"] |= mat_por_tel[tel]["mats"]
g["inv"] += mat_por_tel[tel]["inv"]
def _fila(d):
return {"recibidos": d["recibidos"], "procesados": d["procesados"],
"matriculas": len(d["mats"]), "inversion": round(d["inv"], 0)}
# IMPORTE y RESULTADOS por PAUTA -> se asignan a su (sede, tipo) de CAMPANIAS,
# una sola vez por pauta (no por sede del lead), para NO duplicar.
# imp_st[(sede,tipo)][cod] = importe ; res_st[(sede,tipo)][cod] = resultados
imp_st = {}
res_st = {}
for cod, imp_val in importe_por_pauta_periodo.items():
info = campanias_sede_cargo.get(str(cod).strip())
if not info:
continue
se = str(info.get("sede") or "").upper()
tp = grupo_tipo_cohorte(tipo_programa_cohorte(se, info.get("cargo")))
imp_st.setdefault((se, tp), {})[str(cod).strip()] = float(imp_val or 0.0)
for cod, res_val in resultados_por_pauta_periodo.items():
info = campanias_sede_cargo.get(str(cod).strip())
if not info:
continue
se = str(info.get("sede") or "").upper()
tp = grupo_tipo_cohorte(tipo_programa_cohorte(se, info.get("cargo")))
res_st.setdefault((se, tp), {})[str(cod).strip()] = int(res_val or 0)
filas = []
for sede in sorted(grupos.keys()):
subfilas = []
s_rec = s_proc = 0; s_mats = set(); s_inv = 0.0; s_imp = 0.0; s_res = 0
for tp in sorted(grupos[sede].keys()):
cods = grupos[sede][tp]
imp_map = imp_st.get((sede.upper(), tp), {}) # importe por pauta de esta sede/tipo
res_map = res_st.get((sede.upper(), tp), {})
# métricas del tipo (sumando sus códigos)
t_rec = t_proc = 0; t_mats = set(); t_inv = 0.0; t_imp = 0.0; t_res = 0
codigos = []
for cod in sorted(cods.keys()):
d = cods[cod]
imp_cod = float(imp_map.get(str(cod).strip(), 0.0))
res_cod = int(res_map.get(str(cod).strip(), 0))
codigos.append({"codigo": cod, **_fila(d), "importe": round(imp_cod, 2),
"resultados": res_cod})
t_rec += d["recibidos"]; t_proc += d["procesados"]
t_mats |= d["mats"]; t_inv += d["inv"]; t_imp += imp_cod; t_res += res_cod
subfilas.append({
"tipo": tp, "recibidos": t_rec, "procesados": t_proc,
"matriculas": len(t_mats), "inversion": round(t_inv, 0),
"importe": round(t_imp, 2), "resultados": t_res, "codigos": codigos,
})
s_rec += t_rec; s_proc += t_proc; s_mats |= t_mats; s_inv += t_inv; s_imp += t_imp; s_res += t_res
filas.append({
"sede": sede, "recibidos": s_rec, "procesados": s_proc,
"matriculas": len(s_mats), "inversion": round(s_inv, 0),
"importe": round(s_imp, 2), "resultados": s_res, "subfilas": subfilas,
})
# Totales
t_rec = sum(f["recibidos"] for f in filas)
t_proc = sum(f["procesados"] for f in filas)
t_inv = round(sum(f["inversion"] for f in filas), 0)
# Total de importe/resultados: suma de TODAS las pautas que estan en campanias
# (una vez cada una), aunque no tengan fila con leads. Asi el total no se pierde.
t_imp = round(sum(float(v or 0.0) for cod, v in importe_por_pauta_periodo.items()
if str(cod).strip() in campanias_sede_cargo), 2)
t_res = sum(int(v or 0) for cod, v in resultados_por_pauta_periodo.items()
if str(cod).strip() in campanias_sede_cargo)
# matrículas total: unión global
all_mats = set()
for tel, e in mat_por_tel.items():
all_mats |= e["mats"]
return {"filas": filas, "total": {"recibidos": t_rec, "procesados": t_proc,
"matriculas": len(all_mats), "inversion": t_inv,
"importe": t_imp, "resultados": t_res}}
# ── Personalizado: replica la columna Fact_SQL_Base_Cursos[Personalizado] (M) ──
# Base = dsc_det_programa, limpiando saltos de línea, luego cadena de reemplazos.
_PERS_REEMPLAZOS = [
("TECNICO ESPECIALISTA EN AIRE ACONDICIONADO", "TEAC"),
("TECNICO ESPECIALISTA EN REFRIGERACION COMERCIAL", "TERC"),
("TECNICO ESPECIALISTA EN REFRIGERACION DOMÉSTICA Y COMERCIAL", "TERC"),
("CARRERA TECNICA DE AIRE ACONDICIONADO Y REFRIGERACION ", "CARRERA"),
("VIRTUAL SEMINARIO SUPERVISION DE OBRAS EN AIRE ACONDICIONADO", "SUPERVISION DE OBRAS"),
("SEMINARIO APLICACIÓN DE VARIADORES DE FRECUENCIA EN SISTEMAS HVAC", "VARIADORES DE FRECUENCIA"),
("VIRTUAL - SISTEMAS DE REFRIGERACION CON CO2 - FASE SUBCRITICA Y TRANSCRITICA", "REFRIGERACION CON CO2"),
("VIRTUAL SEMINARIO DISEÑO DE CAMARAS DE REFRIGERACION CON SISTEMAS CON FREON", "DISEÑO DE CAMARAS"),
("VIRTUAL SEM. DETERMINACIÓN DE CAPACIDAD DE EQUIPOS DE AIRE ACONDICIONADO- CARGAS TÉRMICAS -(A/A)", "CARGAS TERMICAS"),
("VIRTUAL SEMINARIO DIBUJO TECNICO Y DISEÑO ASISTIDO POR COMPUTADORA (CAD) PARA HVAC -(A/A)", "DIBUJO TECNICO (CAD)"),
("VIRTUAL SEMINARIO: SISTEMAS DE REFRIGERACIÓN INDUSTRIAL POR AMONIACO (NH3)", "AMONIACO (NH3)"),
("SEMINARIO VIRTUAL METRADO , COSTEO Y PRESUPUESTOS DE HVAC (AA)", "METRADO, COSTEO Y PRESUPUESTOS"),
("CARRERA TECNICA LIMA GESTION DE EMPRESAS", "GESTION DE EMPRESAS"),
("VIRTUAL SEMINARIO DISEÑO DE CHILLERS PARA PROCESOS INDUSTRIALES DE REFRIGERACIÓN", "DISEÑO DE CHILLERS"),
("VIRTUAL SEMINARIO PRACTICO VENTILACION DE SOTANO Y PREZURIZACION DE ESCALERA -(A/A)", "VENTILACION DE SOTANO"),
("SEMINARIO USO DE SOFTWARE EN CÁLCULOS DE AIRE ACONDICIONADO", "SOFTWARE EN CALCULOS DE A/A"),
("DISEÑO DE SISTEMAS DE AIRE ACONDICIONADO", "DISEÑO DE SISTEMAS DE A/A"),
("SEMINARIO INSTALACIÓN DE SISTEMAS DE A/A DE VOLUMEN VARIABLE VRV/VRF - SAB", "VOLUMEN VARIABLE VRF"),
# Seminarios VRF por sede y CO2 → nombre corto (la frecuencia se conserva al final)
("PIURA - SEMINARIO INSTALACIÓN DE SISTEMAS DE A/A DE VOLUMEN VARIABLE - VRF", "PIURA - VRF"),
("AREQUIPA - SEMINARIO INSTALACIÓN DE SISTEMAS DE A/A DE VOLUMEN VARIABLE - VRF", "AREQUIPA - VRF"),
("TRUJILLO - SEMINARIO INSTALACIÓN DE SISTEMAS DE A/A DE VOLUMEN VARIABLE - VRF", "TRUJILLO - VRF"),
("VIRTUAL - DISEÑO Y OPERACIÓN DE SISTEMAS DE REFRIGERACIÓN CON CO2 EN FASE SUBCRITICA Y TRANSCRITICA", "CO2"),
# Reemplazos sobre Nombre_Programa_Detalle (bloque ductos y otros)
("VIRTUAL - DIMENSIONAMIENTO DE", ""),
("METÁLICOS EN AIRE ACONDICIONADO Y VENTILACIÓN", ""),
("MASTERCLASS: PROGRAMACIÓN DE CONTROLADORES PARA REFRIGERACIÓN", "PROGRAMACION DE CONTROLADORES"),
("DIPLOMADO INTERNACIONAL DE AIRE ACONDICIONADO", "DIPLOMADO DE A/A"),
# Reemplazos finales (sedes/carreras/masterclass)
("AREQUIPA - CARRERA TÉCNICA DE AIRE ACONDICIONADO Y REFRIGERACIÓN - TAR", "AREQUIPA - CARRERA - TARDE"),
("PIURA - CARRERA TÉCNICA DE AIRE ACONDICIONADO Y REFRIGERACIÓN - TAR", "PIURA - CARRERA - TARDE"),
("TRUJILLO - CARRERA TÉCNICA DE AIRE ACONDICIONADO Y REFRIGERACIÓN - TAR", "TRUJILLO - CARRERA - TARDE"),
("AREQUIPA - CARRERA TÉCNICA DE AIRE ACONDICIONADO Y REFRIGERACIÓN - MAN", "AREQUIPA - CARRERA - MAÑANA"),
("PIURA - CARRERA TÉCNICA DE AIRE ACONDICIONADO Y REFRIGERACIÓN - MAN", "PIURA - CARRERA - MAÑANA"),
("TRUJILLO - CARRERA TÉCNICA DE AIRE ACONDICIONADO Y REFRIGERACIÓN - MAN", "TRUJILLO - CARRERA - MAÑANA"),
("GESTIÓN DE VENTA DE EQUIPOS DE AIRE ACONDICIONADO Y EQUIPOS DE REFRIGERACIÓN - NOC", "GESTIÓN DE VENTA - NOC"),
("MASTERCLASS SELECCIÓN E INSTALACIÓN DE TARJETAS ELECTRÓNICAS UNIVERSALES EN EQUIPOS DE A/A .INVERTER - MAN", "MASTERCLASS TARJETAS UNIVERSALES - MAN"),
("MASTERCLASS: SISTEMAS DE A/A CON VRF - SAB", "MASTERCLASS: A/A CON VRF - SAB"),
("GESTIÓN DE VENTA DE EQUIPOS DE AIRE ACONDICIONADO Y EQUIPOS DE REFRIGERACIÓN - TAR", "GESTIÓN DE VENTA - TAR"),
]
def personalizado_curso(dsc_programa, cod_frecuencia=""):
"""Replica Fact_SQL_Base_Cursos[Personalizado] del PBI:
base = dsc_programa & ' - ' & cod_frecuencia (NO dsc_det_programa)."""
s = f"{str(dsc_programa or '')} - {str(cod_frecuencia or '')}"
# Text.Clean + reemplazar saltos de línea por espacio, luego Trim
s = s.replace("\r", " ").replace("\n", " ")
s = "".join(ch for ch in s if ch >= " " or ch == " ") # Text.Clean (quita control)
s = s.strip()
for buscar, reemplazo in _PERS_REEMPLAZOS:
s = s.replace(buscar, reemplazo)
# colapsar espacios múltiples que pudieran quedar de reemplazos vacíos
s = " ".join(s.split())
return s
# ── Matriz por Curso (num_indice): Personalizado + métricas de leads por pauta ──
# NOTA: las métricas de leads cruzan por el CÓDIGO DE PAUTA del curso, que viene de
# SharePoint (no disponible aún). Por eso salen en 0 hasta conectar esa fuente.
# Ver FUTUROS_CAMBIOS.md punto 5. Lo que sí se calcula: Personalizado y Fecha Inicio.
def matriz_cursos(cursos, leads, ano, mes, dia, pauta_map=None,
campanias_map=None, cartera_all=None, cartera_asig=None,
mat_por_indice=None, cartera_tels=None, cartera_origen=None,
importe_por_pauta=None, importe_por_pauta_periodo=None,
cartera_canal=None, conjunto_por_pauta=None, pautas_por_indice=None):
pauta_map = pauta_map or {}
pautas_por_indice = pautas_por_indice or {} # {num_indice: [pautas]} (muchos-a-muchos)
campanias_map = campanias_map or {}
cartera_all = cartera_all or {}
cartera_asig = cartera_asig or {}
mat_por_indice = mat_por_indice or {}
cartera_tels = cartera_tels or set()
cartera_origen = cartera_origen or {}
importe_por_pauta = importe_por_pauta or {}
importe_por_pauta_periodo = importe_por_pauta_periodo or {}
conjunto_por_pauta = conjunto_por_pauta or {} # {pauta: [conjuntos de anuncios]}
# cartera_canal = {"PAUTA": {"all","asig","tels"}, "OTROS": {...}} para el desglose por canal
cartera_canal = cartera_canal or {}
CURSOS_EXCLUIDOS = {"1121", "1154"}
def _excluir(c):
ni = str(c.get("num_indice", ""))
if ni in CURSOS_EXCLUIDOS:
return True
info = pauta_map.get(ni)
if not info:
return False # sin info en Supabase → no se excluye
# Excluir SUSPENDIDO o contar = NO
return info.get("contar") == "NO" or info.get("estado") == "SUSPENDIDO"
cursos_periodo = [c for c in cursos
if _en_periodo(_to_date(c.get("fch_inicio")), ano, mes, dia)
and not _excluir(c)]
# Leads únicos agrupados por codigo (= Pauta). Históricos y del mes (filtro fecha).
leads_unicos = [l for l in leads if l.get("es_unico")]
hist_por_cod = {} # codigo → set teléfonos
hist_ases_cod = {} # codigo → set teléfonos con asesor
mes_por_cod = {} # codigo → set teléfonos (en periodo filtrado)
mes_ases_cod = {} # codigo → set teléfonos con asesor (en periodo)
for l in leads_unicos:
cod = l.get("codigo", "-")
tel = l["telefono"]
hist_por_cod.setdefault(cod, set()).add(tel)
if l.get("asesor"):
hist_ases_cod.setdefault(cod, set()).add(tel)
if _en_periodo(l["fecha"], ano, mes, dia):
mes_por_cod.setdefault(cod, set()).add(tel)
if l.get("asesor"):
mes_ases_cod.setdefault(cod, set()).add(tel)
# Construir filas con sus medidas
pre = []
for c in sorted(cursos_periodo, key=lambda x: (_to_date(x.get("fch_inicio")) or date.min)):
nombre = personalizado_curso(c.get("dsc_programa"), c.get("cod_frecuencia"))
fch = _to_date(c.get("fch_inicio"))
ni = str(c.get("num_indice", "")).strip()
# LISTA de pautas del num_indice (muchos-a-muchos). Si no hay en la tabla nueva,
# usa la de basebi (compatibilidad).
cods = pautas_por_indice.get(ni)
if not cods:
_info = pauta_map.get(ni)
cods = [_info.get("pauta")] if _info and _info.get("pauta") else []
cods = [str(x).strip() for x in cods if x]
# Leads = union de telefonos de TODAS las pautas (sin duplicar)
def _union(mapa):
s = set()
for cd in cods:
s |= mapa.get(cd, set())
return len(s)
nuevos = _union(hist_por_cod)
nuevos_a = _union(hist_ases_cod)
nuevos_m = _union(mes_por_cod)
nuevos_ma = _union(mes_ases_cod)
pre.append({
"num_indice": ni,
"personalizado": nombre,
"fch": fch,
"pauta": cods[0] if cods else None, # pauta principal (para mostrar)
"pautas": cods, # todas las pautas (para sumar)
"fecha_inicio": fch.strftime("%d/%m/%Y") if fch else "",
"leads_nuevos": nuevos,
"leads_nuevos_asesor": nuevos_a,
"leads_nuevos_mes": nuevos_m,
"leads_nuevos_mes_asesor": nuevos_ma,
})
# Índice HISTÓRICO completo (TODOS los cursos, sin filtro de fecha) por
# personalizado → lista de (fecha, leads_nuevos). Para el acumulado.
hist_cursos = {}
for c in cursos:
if _excluir(c):
continue
nom = personalizado_curso(c.get("dsc_programa"), c.get("cod_frecuencia"))
f = _to_date(c.get("fch_inicio"))
info = pauta_map.get(str(c.get("num_indice", "")))
cd = info.get("pauta") if info else None
nv = len(hist_por_cod.get(cd, ())) if cd else 0
hist_cursos.setdefault(nom, []).append((f, nv))
# Leads_Acumulados_Historico: suma de leads_nuevos de TODOS los cursos del
# MISMO personalizado con fecha <= la del curso actual (histórico completo).
filas = []
for r in pre:
acum = sum(nv for (f, nv) in hist_cursos.get(r["personalizado"], [])
if f and r["fch"] and f <= r["fch"])
# CARTERA TOTAL: teléfonos únicos de cartera_junta con la misma SEDE+PROGRAMA
# del curso (según su pauta → campanias) cuya fecha_creada <= fch_inicio del curso.
# cartera_total = todos; cartera_total_asig = solo con asesor asignado.
cartera_total = 0
cartera_total_asig = 0
cod = r.get("pauta")
cods = r.get("pautas") or ([cod] if cod else [])
if cods and r["fch"]:
# Cartera = union de telefonos de la sede+programa de TODAS las pautas del num_indice
tels_all = {}; tels_asig = {}
for cd in cods:
sp = campanias_map.get(str(cd).upper())
if not sp:
continue
for tel, fx in cartera_all.get(sp, {}).items():
if fx and (tel not in tels_all or fx < tels_all[tel]):
tels_all[tel] = fx
for tel, fx in cartera_asig.get(sp, {}).items():
if fx and (tel not in tels_asig or fx < tels_asig[tel]):
tels_asig[tel] = fx
cartera_total = sum(1 for fx in tels_all.values() if fx <= r["fch"])
cartera_total_asig = sum(1 for fx in tels_asig.values() if fx <= r["fch"])
# MATRÍCULAS NO IDENTIFICADAS: matrículas de este curso (num_indice) cuyo teléfono
# (tel_1, o tel_2 si el 1 está vacío) NO aparece en cartera_junta. Sin filtro de fecha.
_ni = r["num_indice"]
_ni = _ni[:-2] if _ni.endswith(".0") else _ni
_mats = mat_por_indice.get(_ni, []) # lista de (telefono, fecha_matricula)
_tels_no_iden = [ph for (ph, fm) in _mats if ph not in cartera_tels]
matriculas_no_iden = len(_tels_no_iden)
# MATRICULAS LEADS NUEVOS: matrícula hecha dentro de 45 días DESPUÉS del origen
# del lead (registro es_origen=SI de ese teléfono en la cartera).
mat_leads_nuevos = 0
mat_leads_antiguos = 0
_tels_nuevos = []
_tels_antiguos = []
for (ph, fm) in _mats:
if not ph or not fm:
continue
forig = cartera_origen.get(ph)
if forig is None:
continue
dias = (fm - forig).days
if 0 <= dias <= 45:
mat_leads_nuevos += 1
_tels_nuevos.append(ph)
else:
# >45 días, o matrícula anterior al origen del lead (dias<0) → Antiguo
mat_leads_antiguos += 1
_tels_antiguos.append(ph)
# IMPORTE PAUTA: gasto de Meta Ads sumando TODAS las pautas del num_indice.
importe_pauta = round(sum(float(importe_por_pauta.get(str(cd), 0.0)) for cd in cods), 2)
# IMPORTE PAUTA EN EL MES: igual, pero solo del periodo filtrado.
importe_pauta_mes = round(sum(float(importe_por_pauta_periodo.get(str(cd), 0.0)) for cd in cods), 2)
# ── SUBFILAS por CANAL (PAUTA / OTROS) ── mismo criterio que la fila padre
# (SEDE+PROGRAMA vía campanias, misma fecha) + filtro de canal desde cartera.
def _subfila(clave):
cc = cartera_canal.get(clave, {})
c_all = cc.get("all", {}); c_asig = cc.get("asig", {}); c_tels = cc.get("tels", set())
ct = ct_asig = 0
l_rec = l_proc = 0
l_rec_mes = l_proc_mes = 0
if cod and r["fch"]:
sp = campanias_map.get(str(cod).upper())
if sp:
fa = c_all.get(sp, {})
fg = c_asig.get(sp, {})
# Cartera Total/Asig del canal: fecha_creada <= inicio del curso (igual que padre)
ct = sum(1 for fx in fa.values() if fx and fx <= r["fch"])
ct_asig = sum(1 for fx in fg.values() if fx and fx <= r["fch"])
# L. Recibidos/Procesados del canal = misma cartera del canal (mismo corte)
l_rec = ct
l_proc = ct_asig
# "del Mes" = fecha_creada en el periodo filtrado
l_rec_mes = sum(1 for fx in fa.values() if _en_periodo(fx, ano, mes, dia))
l_proc_mes = sum(1 for fx in fg.values() if _en_periodo(fx, ano, mes, dia))
# Matrículas del curso, clasificadas por canal:
# - NO identificadas (tel NO en cartera) -> todas a OTROS.
# - identificadas -> al canal de su telefono (c_tels), regla 45 dias.
mt_noiden = mt_nuevos = mt_antiguos = 0
for (ph, fm) in _mats:
if not ph:
continue
en_cartera = ph in cartera_tels
if not en_cartera:
# no identificada: solo cuenta en la subfila OTROS
if clave == "OTROS":
mt_noiden += 1
continue
# identificada: solo cuenta si su tel pertenece a ESTE canal
if ph not in c_tels:
continue
forig = cartera_origen.get(ph)
if fm and forig:
d = (fm - forig).days
if 0 <= d <= 45: mt_nuevos += 1
else: mt_antiguos += 1
return {"canal": clave, "cartera_total": ct, "cartera_total_asig": ct_asig,
"leads_nuevos": l_rec, "leads_nuevos_asesor": l_proc,
"leads_nuevos_mes": l_rec_mes, "leads_nuevos_mes_asesor": l_proc_mes,
"matriculas_no_iden": mt_noiden, "mat_leads_nuevos": mt_nuevos,
"mat_leads_antiguos": mt_antiguos}
subfilas = [_subfila("PAUTA"), _subfila("WEB"), _subfila("OTROS")] if cartera_canal else []
filas.append({
"num_indice": r["num_indice"],
"personalizado": r["personalizado"],
"fecha_inicio": r["fecha_inicio"],
"pauta": r.get("pauta"),
"importe_pauta": importe_pauta,
"importe_pauta_mes": importe_pauta_mes,
"cartera_total": cartera_total,
"cartera_total_asig": cartera_total_asig,
"matriculas_no_iden": matriculas_no_iden,
"matriculas_no_iden_tels": _tels_no_iden,
"mat_leads_nuevos": mat_leads_nuevos,
"mat_leads_antiguos": mat_leads_antiguos,
"mat_leads_nuevos_tels": _tels_nuevos,
"mat_leads_antiguos_tels": _tels_antiguos,
"leads_acumulados": acum,
"leads_nuevos": r["leads_nuevos"],
"leads_nuevos_asesor": r["leads_nuevos_asesor"],
"leads_nuevos_mes": r["leads_nuevos_mes"],
"leads_nuevos_mes_asesor": r["leads_nuevos_mes_asesor"],
"subfilas_canal": subfilas,
"conjuntos": [cj for cd in cods for cj in conjunto_por_pauta.get(str(cd).strip(), [])],
"contar": (pauta_map.get(str(r["num_indice"]).strip()) or {}).get("contar") or "SI",
})
return {"filas": filas}

View File

@@ -1,483 +1,290 @@
# backend/main.py # backend/main.py
""" """API REST del Dashboard de LEADS (FastAPI)."""
API REST del Dashboard — FastAPI. from fastapi import FastAPI, Query, HTTPException, Body
Reutiliza la lógica de negocio existente en modules/ y core/.
Cada endpoint devuelve JSON; el frontend (React) lo consume.
"""
from fastapi import FastAPI, Query, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from datetime import datetime from datetime import datetime
import uvicorn import uvicorn
import os
from dotenv import load_dotenv
# Cargar variables de entorno desde .env
load_dotenv()
import services import services
from cache_manager import start_background_refresh, cache_invalidate, cache_stats from cache_manager import start_background_refresh, cache_stats
app = FastAPI(title="Dashboard API", version="1.0") app = FastAPI(title="Dashboard Leads API", version="1.0")
# Orígenes CORS configurables
cors_env = os.getenv("CORS_ORIGINS", "*")
origins = [o.strip() for o in cors_env.split(",") if o.strip() and o.strip() != "*"]
# CORS — permite que el frontend React (otro puerto) consuma la API
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], # en red local está bien; en producción restringir allow_origins=origins if origins else ["*"],
allow_origin_regex=".*",
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
# ── Arranque: precarga + refresco en segundo plano ─────────────────────────
@app.on_event("startup") @app.on_event("startup")
def _startup(): def _startup():
print("[startup] Precargando datos iniciales...") # 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: try:
services.precargar_todo() services.precargar_todo()
print("[startup] Precarga completa.") print("[startup] Precarga completa.")
except Exception as e: except Exception as e:
print(f"[startup] Precarga falló: {e}") print(f"[startup] Precarga falló: {e}")
# Refresco cada 4 min en segundo plano (TTL del caché es 5 min) 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) start_background_refresh(services.refrescar_todo, interval=900)
# ── Salud / utilidades ─────────────────────────────────────────────────────
@app.get("/api/health") @app.get("/api/health")
def health(): def health():
return {"status": "ok", "hora": datetime.now().isoformat()} return {"status": "ok", "hora": datetime.now().isoformat()}
@app.get("/api/cache/stats") @app.get("/api/cache/stats")
def cache_estadisticas(): def stats():
return cache_stats() return cache_stats()
@app.post("/api/cache/refresh") @app.post("/api/cache/refresh")
def cache_refresh(): def refresh():
services.refrescar_todo() services.refrescar_todo()
return {"status": "refrescado"} return {"status": "refrescado"}
@app.post("/api/cache/refresh-manual") @app.get("/api/leads/filtros")
def cache_refresh_manual(clave: str = None): def get_filtros():
"""Botón 'Actualizar': re-lee las bases para la vista actual (clave) al instante, try:
y recachea el resto en segundo plano.""" return services.opciones_filtros()
return services.refrescar_manual(clave) except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/ultima-actualizacion") @app.get("/api/leads")
def api_ultima_actualizacion(): def get_leads(
return services.ultima_actualizacion() ano: str = Query("TODOS"),
mes: str = Query("TODOS"),
dia: str = Query("TODOS"),
@app.post("/api/precarga/modulo")
def api_precarga_modulo(modulo: str, ano: int):
"""Calienta en 2º plano la tabla principal del módulo para el año indicado."""
return services.precargar_modulo(modulo, ano)
@app.get("/api/precarga/estado")
def api_precarga_estado(modulo: str):
"""Progreso de la precarga (para el aviso 'ya cargó todo')."""
return services.precarga_estado(modulo)
@app.get("/api/periodo-actual")
def periodo_actual():
dm = services.get_dm()
return {"ano": dm.get_current_year(), "mes": dm.get_current_month()}
# ── OCUPABILIDAD ───────────────────────────────────────────────────────────
@app.get("/api/ocupabilidad")
def get_ocupabilidad(
ano: int = Query(...),
mes: int = Query(...),
sede: str = Query("TODOS"),
programa: str = Query("TODOS"), programa: str = Query("TODOS"),
):
try:
return services.ocupabilidad(ano, mes, sede, programa)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/ocupabilidad/alumnos")
def get_ocupabilidad_alumnos(num_indice: str = Query(...)):
try:
return services.ocupabilidad_alumnos(num_indice)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ── VENTAS ─────────────────────────────────────────────────────────────────
@app.get("/api/ventas")
def get_ventas(ano: int = Query(...), mes: int = Query(...),
sede: str = Query("TODOS"), programa: str = Query("TODOS"),
con_overrides: bool = Query(False)):
try:
return services.ventas(ano, mes, sede, programa, con_overrides)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/ventas/detalle")
def get_ventas_detalle(
vendedor: str = Query(...),
ano: int = Query(...),
mes: int = Query(...),
tipo: str = Query("Venta Inscritos"),
sede: str = Query("TODOS"), sede: str = Query("TODOS"),
programa: str = Query("TODOS"),
): ):
try: try:
return services.ventas_detalle(vendedor, ano, mes, tipo, sede, programa) return services.leads_dashboard(ano, mes, dia, programa, sede)
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
# ── COBRANZA ─────────────────────────────────────────────────────────────── @app.get("/api/vendedores")
@app.get("/api/cobranza") def get_vendedores(
def get_cobranza( ano: str = Query("TODOS"),
ano: int = Query(...), mes: str = Query("TODOS"),
mes: int = Query(...), dia: str = Query("TODOS"),
sectorista: str = Query("TODOS"),
agrupacion: str = Query("SEDE"),
sede_filtro: str = Query("TODOS"),
prog_filtro: str = Query("TODOS"),
venc_hasta: str = Query("TODOS"),
): ):
try: try:
return services.cobranza(ano, mes, sectorista, agrupacion, sede_filtro, prog_filtro, venc_hasta) return services.vendedores_dashboard(ano, mes, dia)
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/cobranza/detalle") @app.get("/api/roas")
def get_cobranza_detalle( def get_roas(
grupo: str = Query(...), ano: str = Query("TODOS"),
ano: int = Query(...), mes: str = Query("TODOS"),
mes: int = Query(...), dia: str = Query("TODOS"),
sectorista: str = Query("TODOS"),
agrupacion: str = Query("PROGRAMA"),
prog_filtro: str = Query("TODOS"),
sede_filtro: str = Query("TODOS"),
venc_hasta: str = Query("TODOS"),
): ):
try: try:
return services.cobranza_detalle(grupo, ano, mes, sectorista, agrupacion, prog_filtro, sede_filtro, venc_hasta) return services.roas_dashboard(ano, mes, dia)
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
# ── RENTABILIDAD ─────────────────────────────────────────────────────────── @app.get("/api/conjuntos-sin-sede")
@app.get("/api/rentabilidad") def get_conjuntos_sin_sede():
def get_rentabilidad( try:
ano: int = Query(...), return services.conjuntos_sin_sede()
mes: int = Query(...), except Exception as e:
sede: str = Query("TODOS"), raise HTTPException(status_code=500, detail=str(e))
programa: str = Query("TODOS"),
@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: try:
return services.rentabilidad(ano, mes, sede, programa) return services.otros_general(ano, mes, dia)
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/rentabilidad/detalle") @app.get("/api/conjuntos-sin-pauta")
def get_rentabilidad_detalle( def get_conjuntos_sin_pauta():
programa: str = Query(...),
ano: int = Query(...),
mes: int = Query(...),
):
try: try:
return services.rentabilidad_detalle(programa, ano, mes) return {"conjuntos": services.conjuntos_sin_pauta()}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/rentabilidad/costos") @app.get("/api/programas-ocultos")
def get_rentabilidad_costos( def get_programas_ocultos():
programa: str = Query(...),
ano: int = Query(...),
mes: int = Query(...),
):
try: try:
return services.rentabilidad_costos(programa, ano, mes) return services.programas_ocultos()
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
# ── SALDO PENDIENTE ──────────────────────────────────────────────────────── @app.post("/api/programas-ocultos/encender")
@app.get("/api/saldo-pendiente") def post_encender_programas(body: dict = Body(...)):
def get_saldo_pendiente(tipo_cuota: str = Query("1° Cuota")):
try: try:
return services.saldo_pendiente(tipo_cuota) nis = body.get("num_indices") or []
return services.encender_programas(nis)
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
# ── GUARDAR COSTOS ───────────────────────────────────────────────────────── @app.get("/api/programas-disponibles")
from fastapi import Body def get_programas_disponibles():
@app.post("/api/rentabilidad/costos/guardar")
def post_guardar_costos(payload: dict = Body(...)):
try: try:
num_indice = payload.get("num_indice", "") return services.programas_disponibles()
costos_inicial = payload.get("inicial", {}) except Exception as e:
costos_actual = payload.get("actual", {}) raise HTTPException(status_code=500, detail=str(e))
ok = services.guardar_costos(num_indice, costos_inicial, costos_actual)
if ok:
return {"status": "ok"} @app.get("/api/leyenda-anuncios")
raise HTTPException(status_code=500, detail="No se pudo guardar (verifica Supabase)") 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: except HTTPException:
raise raise
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/cobranza/clasificar-sede") @app.get("/api/ultima-actualizacion")
def post_clasificar_sede(payload: dict = Body(...)): def get_ultima_actualizacion():
try: try:
nombres = payload.get("programas", []) return services.ultima_actualizacion()
return {"mapa": services.clasificar_programas(nombres)}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/cobranza/clasificar-full")
def post_clasificar_full(payload: dict = Body(...)):
"""Devuelve {programa: {sede, filtro}} usando el JSON REPLACE_META."""
try:
nombres = payload.get("programas", [])
return {"mapa": services.clasificar_programas_full(nombres)}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/debug/supabase-inicios") @app.get("/api/leads/alertas")
def debug_supabase_inicios(ano: int, mes: int): def get_alertas():
"""Diagnóstico: muestra qué hay en Supabase y por qué (no) se inyecta el rojo.""" """Valores de programa/sede en cartera_junta que NO están en el diccionario."""
try: try:
dm = services.get_dm() return services.alertas()
# 1) ¿Supabase conectado?
conectado = dm.supabase_client is not None
error_conexion = getattr(dm, "supabase_error", None)
# 2) Fechas guardadas en Supabase
fechas = dm.obtener_fechas_originales()
# 3) Índices que SQL trae este mes
crudos = dm.ejecutar_consulta_cursos(str(ano), str(mes))
indices_sql = sorted({str(f.get("num_indice","")).strip() for f in (crudos or [])})
# 4) Para cada fecha en Supabase, calcular su mes y si coincide con el filtrado
analisis = []
for idx, info in fechas.items():
fecha_orig = info.get("fecha","") if isinstance(info, dict) else str(info)
mes_orig = ""
if "/" in fecha_orig:
p = fecha_orig.split("/")
if len(p) >= 2: mes_orig = str(int(p[1]))
elif "-" in fecha_orig:
p = fecha_orig.split("-")
if len(p) >= 2: mes_orig = str(int(p[1]))
analisis.append({
"num_indice": idx,
"fecha_supabase": fecha_orig,
"mes_detectado": mes_orig,
"mes_filtrado": str(int(mes)),
"coincide_mes": mes_orig == str(int(mes)),
"esta_en_sql_este_mes": idx in indices_sql,
"deberia_salir_rojo": (mes_orig == str(int(mes))) and (idx not in indices_sql),
})
return {
"supabase_conectado": conectado,
"error_conexion": error_conexion,
"total_fechas_en_supabase": len(fechas),
"indices_en_sql_este_mes": indices_sql,
"analisis": analisis,
}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/cobranza/detalle-todos") @app.get("/api/debug/cursos")
def get_cobranza_detalle_todos(ano: int, mes: int, sectorista: str = "TODOS"): def debug_cursos(ano: str = "2026", mes: str = "1"):
"""Diagnóstico: lista cursos del periodo con inscritos, tipo y meta."""
try: try:
return services.cobranza_detalle_todos(ano, mes, sectorista) return services.debug_cursos(ano, mes)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ── USUARIOS ───────────────────────────────────────────────────────────────
@app.get("/api/alumno/estado")
def get_alumno_estado(matricula: str):
try:
return services.alumno_estado_obtener(matricula)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/alumnos/por-retirar")
def get_alumnos_por_retirar():
try:
return services.alumnos_por_retirar()
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/alumno/estado/guardar")
def post_alumno_estado(payload: dict = Body(...)):
try:
return services.alumno_estado_guardar(
payload.get("matricula",""),
payload.get("estado","EN CURSO"),
payload.get("observaciones",[]),
payload.get("usuario"))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/usuarios")
def get_usuarios():
try:
return services.usuarios_listar()
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/usuarios/crear")
def post_usuario_crear(payload: dict = Body(...)):
try:
return services.usuarios_crear(
payload.get("email",""), payload.get("password",""),
payload.get("nombre",""), payload.get("rol","COBRANZA"))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/usuarios/rol")
def post_usuario_rol(payload: dict = Body(...)):
try:
return services.usuarios_actualizar_rol(payload.get("id",""), payload.get("rol",""))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/usuarios/eliminar")
def post_usuario_eliminar(payload: dict = Body(...)):
try:
return services.usuarios_eliminar(payload.get("id",""))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ── VENDEDORES MANUALES (Comisiones) ───────────────────────────────────────
@app.get("/api/comisiones/vendedores")
def get_vend_manuales(ano: int, mes: int):
try:
return services.vendedores_manuales_listar(ano, mes)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/comisiones/vendedores/crear")
def post_vend_manual_crear(payload: dict = Body(...)):
try:
return services.vendedores_manuales_crear(
payload.get("nombre",""), payload.get("descripcion",""),
payload.get("fch_emision",""), payload.get("monto",0),
payload.get("ano"), payload.get("mes"))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/comisiones/vendedores/eliminar")
def post_vend_manual_eliminar(payload: dict = Body(...)):
try:
return services.vendedores_manuales_eliminar(payload.get("id",""))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/comisiones/override/guardar")
def post_com_override_guardar(payload: dict = Body(...)):
try:
return services.comisiones_override_guardar(payload.get("registros", []))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/comisiones/override/restaurar")
def post_com_override_restaurar(payload: dict = Body(...)):
try:
return services.comisiones_override_restaurar(payload.get("num_matricula",""))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/comisiones/vendedores/actualizar")
def post_vend_manual_actualizar(payload: dict = Body(...)):
try:
return services.vendedores_manuales_actualizar(
payload.get("id",""), payload.get("descripcion",""),
payload.get("fch_emision",""), payload.get("monto",0))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/comisiones/vendedores/lote")
def post_vend_manual_lote(payload: dict = Body(...)):
try:
return services.vendedores_manuales_guardar_lote(
payload.get("nombre",""), payload.get("ano"), payload.get("mes"),
payload.get("filas", []))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/comisiones/detalle-todos")
def get_comisiones_detalle_todos(ano: int, mes: int):
try:
return services.comisiones_detalle_todos(ano, mes)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/comisiones/config")
def get_comisiones_config(ano: int = Query(...), mes: int = Query(...)):
try:
return services.comisiones_config_listar(ano, mes)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/comisiones/config/guardar")
def post_comisiones_config(payload: dict = Body(...)):
try:
return services.comisiones_config_guardar(payload.get("ano"), payload.get("mes"), payload.get("filas", []))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ── ASESORES (Chatwoot) ────────────────────────────────────────────────────
CHATWOOT_URL = "https://gestor.escueladerefrigeracion.edu.pe"
ACCESS_TOKEN = "4anazHvZnvKLtup8biu5Zuoh"
ACCOUNT_ID = "1"
CHATWOOT_HEADERS = {"api_access_token": ACCESS_TOKEN, "Content-Type": "application/json"}
@app.get("/api/asesores")
def get_asesores():
import requests
try:
url = f"{CHATWOOT_URL}/api/v1/accounts/{ACCOUNT_ID}/agents"
r = requests.get(url, headers=CHATWOOT_HEADERS, timeout=10)
r.raise_for_status()
return {"agentes": r.json()}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/asesores/estado")
def set_asesor_estado(agent_id: int = Query(...), online: bool = Query(...)):
import requests, time
url = f"{CHATWOOT_URL}/api/v1/accounts/{ACCOUNT_ID}/agents/{agent_id}"
estado = "online" if online else "offline"
try:
requests.patch(url, headers=CHATWOOT_HEADERS, json={"auto_offline": False}, timeout=10)
time.sleep(0.5)
r = requests.patch(url, headers=CHATWOOT_HEADERS,
json={"availability_status": estado, "availability": estado}, timeout=10)
r.raise_for_status()
return {"status": "ok", "estado": estado}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
if __name__ == "__main__": if __name__ == "__main__":
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=False) uvicorn.run("main:app", host="0.0.0.0", port=8001, reload=False)

View File

@@ -1,86 +0,0 @@
# modules/asesores/processor.py
import requests
import time
class AsesoresProcessor:
def __init__(self):
# Tus credenciales maestras de Chatwoot
self.chatwoot_url = "https://gestor.escueladerefrigeracion.edu.pe"
self.access_token = "4anazHvZnvKLtup8biu5Zuoh"
self.account_id = "1"
def obtener_agentes_chatwoot(self):
url = f"{self.chatwoot_url}/api/v1/accounts/{self.account_id}/agents"
headers = {
"api_access_token": self.access_token,
"Content-Type": "application/json"
}
try:
response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status()
agentes = response.json()
# =================================================================
# 🕵️‍♂️ ESCÁNER DEBUG (Se imprimirá en tu consola negra)
# =================================================================
print("\n" + "="*60)
print("🕵️‍♂️ ESCÁNER: REVISANDO 'DESCONEXIÓN AUTOMÁTICA'")
print("="*60)
for ag in agentes:
nombre = ag.get('available_name') or ag.get('name', 'Desconocido')
estado = ag.get('availability_status', 'offline')
auto_offline = ag.get('auto_offline', 'Desconocido')
print(f"👤 {nombre} | Estado: {estado} | Radar: {auto_offline}")
print("="*60 + "\n")
return agentes
except Exception:
return []
# =========================================================================
# MOTOR DE ACTUALIZACIÓN (EL COMBO DE 2 GOLPES)
# =========================================================================
def cambiar_estado_agente(self, agent_id, is_online):
url = f"{self.chatwoot_url}/api/v1/accounts/{self.account_id}/agents/{agent_id}"
headers = {
"api_access_token": self.access_token,
"Content-Type": "application/json"
}
estado_str = "online" if is_online else "offline"
try:
# -----------------------------------------------------------------
# GOLPE 1: Desactivar el botón automático primero
# -----------------------------------------------------------------
payload_radar = {
"auto_offline": False
}
requests.patch(url, headers=headers, json=payload_radar, timeout=10)
# Le damos 0.5 segundos a la base de datos de Chatwoot para que
# asimile que el radar de este usuario acaba de ser destruido.
time.sleep(0.5)
# -----------------------------------------------------------------
# GOLPE 2: El código viejo y confiable para cambiar el estado
# -----------------------------------------------------------------
payload_estado = {
"availability_status": estado_str,
"availability": estado_str
}
response = requests.patch(url, headers=headers, json=payload_estado, timeout=10)
if response.status_code == 500:
return False, "Error interno del servidor Chatwoot."
response.raise_for_status()
# ⏳ Le damos 1.5 segundos para que le avise a la computadora
# del asesor que su estado acaba de cambiar en pantalla.
time.sleep(1.5)
return True, "Orden ejecutada con el combo de 2 pasos"
except Exception as e:
return False, f"Fallo de red: {str(e)}"

View File

@@ -1,231 +0,0 @@
# modules/cobranza/logic.py
import pandas as pd
from .processor import CobranzaProcessor
class CobranzaLogic:
def __init__(self, data_manager):
self.data_manager = data_manager
self.processor = CobranzaProcessor(data_manager)
def obtener_lista_sectoristas(self, ano, mes):
return self.processor.obtener_lista_sectoristas(ano, mes)
def obtener_datos_tabla(self, ano, mes, sectorista="TODOS", agrupacion="SEDE",
sede_filtro="TODOS", prog_filtro="TODOS", venc_hasta=None):
datos_brutos = self.processor.obtener_datos_procesados(ano, mes, sectorista, agrupacion,
sede_filtro, prog_filtro, venc_hasta)
sheet_data = []
t_cta_ant = t_cta_cur = t_cta_tot = t_cob_ant = t_cob_cur = t_cob_tot = t_saldo = 0.0
for d in datos_brutos:
grupo = d.get('GRUPO', '')
frecuencia = d.get('FRECUENCIA', '-')
num_cuota = d.get('NUM_CUOTA', '-')
fecha_venc = d.get('FCH_VENC_MODA', '-')
cta_ant = float(d.get('CTA_COB_ANT', 0.0))
cta_cur = float(d.get('CTA_COB_MES_CURSO', 0.0))
cta_tot = float(d.get('TOTAL_CTA_COB', 0.0))
cob_ant = float(d.get('COB_ANT', 0.0))
r1 = float(d.get('RATIO_1', 0.0))
cob_cur = float(d.get('COB_MES_CURSO', 0.0))
r2 = float(d.get('RATIO_2', 0.0))
cob_tot = float(d.get('TOTAL_COBRADO', 0.0))
r3 = float(d.get('RATIO_3', 0.0))
saldo = float(d.get('SALDO', 0.0))
opciones = d.get('OPCIONES', ' ≡ ▼ ')
t_cta_ant += cta_ant
t_cta_cur += cta_cur
t_cta_tot += cta_tot
t_cob_ant += cob_ant
t_cob_cur += cob_cur
t_cob_tot += cob_tot
t_saldo += saldo
avance_ant = f"{r1:.0f}%" if cta_ant > 0 else ""
avance_cur = f"{r2:.0f}%" if cta_cur > 0 else ""
avance_tot = f"{r3:.0f}%" if cta_tot > 0 else ""
if agrupacion == "PROGRAMA":
fila = [
grupo, frecuencia, num_cuota, fecha_venc,
f"S/ {cta_ant:,.0f}", f"S/ {cob_ant:,.0f}", avance_ant,
f"S/ {cta_cur:,.0f}", f"S/ {cob_cur:,.0f}", avance_cur,
f"S/ {cta_tot:,.0f}", f"S/ {cob_tot:,.0f}", avance_tot,
f"S/ {saldo:,.0f}", opciones
]
else:
fila = [
grupo,
f"S/ {cta_ant:,.0f}", f"S/ {cob_ant:,.0f}", avance_ant,
f"S/ {cta_cur:,.0f}", f"S/ {cob_cur:,.0f}", avance_cur,
f"S/ {cta_tot:,.0f}", f"S/ {cob_tot:,.0f}", avance_tot,
f"S/ {saldo:,.0f}", opciones
]
sheet_data.append(fila)
r_tot_1 = (t_cob_ant / t_cta_ant) * 100 if t_cta_ant > 0 else 0.0
r_tot_2 = (t_cob_cur / t_cta_cur) * 100 if t_cta_cur > 0 else 0.0
r_tot_3 = (t_cob_tot / t_cta_tot) * 100 if t_cta_tot > 0 else 0.0
if agrupacion == "PROGRAMA":
fila_total = [
"TOTAL GENERAL", "-", "-", "-",
f"S/ {t_cta_ant:,.0f}", f"S/ {t_cob_ant:,.0f}", f"{r_tot_1:.0f}%",
f"S/ {t_cta_cur:,.0f}", f"S/ {t_cob_cur:,.0f}", f"{r_tot_2:.0f}%",
f"S/ {t_cta_tot:,.0f}", f"S/ {t_cob_tot:,.0f}", f"{r_tot_3:.0f}%",
f"S/ {t_saldo:,.0f}", ""
]
else:
fila_total = [
"TOTAL GENERAL",
f"S/ {t_cta_ant:,.0f}", f"S/ {t_cob_ant:,.0f}", f"{r_tot_1:.0f}%",
f"S/ {t_cta_cur:,.0f}", f"S/ {t_cob_cur:,.0f}", f"{r_tot_2:.0f}%",
f"S/ {t_cta_tot:,.0f}", f"S/ {t_cob_tot:,.0f}", f"{r_tot_3:.0f}%",
f"S/ {t_saldo:,.0f}", ""
]
sheet_data.append(fila_total)
return sheet_data
def obtener_detalle_programa_formateado(self, ano, mes, sectorista, programa, venc_hasta=None):
datos_brutos = self.processor.obtener_detalle_programa(ano, mes, sectorista, programa, venc_hasta)
sheet_data = []
t_cta_ant = t_cta_cur = t_cta_tot = t_cob_ant = t_cob_cur = t_cob_tot = t_saldo = 0.0
for d in datos_brutos:
mat = d.get('MATRICULA', '')
alumno = d.get('ALUMNO', '')
num_cuota = d.get('NUM_CUOTA', '-')
fch_venc = d.get('FCH_VENC', '-')
programa = d.get('PROGRAMA', '-') # programa detallado del alumno
cta_ant = float(d.get('CTA_COB_ANT', 0.0))
cta_cur = float(d.get('CTA_COB_MES_CURSO', 0.0))
cta_tot = float(d.get('TOTAL_CTA_COB', 0.0))
cob_ant = float(d.get('COB_ANT', 0.0))
cob_cur = float(d.get('COB_MES_CURSO', 0.0))
cob_tot = float(d.get('TOTAL_COBRADO', 0.0))
saldo = float(d.get('SALDO', 0.0))
t_cta_ant += cta_ant; t_cta_cur += cta_cur; t_cta_tot += cta_tot
t_cob_ant += cob_ant; t_cob_cur += cob_cur; t_cob_tot += cob_tot; t_saldo += saldo
r1 = (cob_ant / cta_ant) * 100 if cta_ant > 0 else 0.0
r2 = (cob_cur / cta_cur) * 100 if cta_cur > 0 else 0.0
r3 = (cob_tot / cta_tot) * 100 if cta_tot > 0 else 0.0
avance_ant = f"{r1:.0f}%" if cta_ant > 0 else ""
avance_cur = f"{r2:.0f}%" if cta_cur > 0 else ""
avance_tot = f"{r3:.0f}%" if cta_tot > 0 else ""
fila = [
mat, alumno, num_cuota, fch_venc,
f"S/ {cta_ant:,.0f}", f"S/ {cob_ant:,.0f}", avance_ant,
f"S/ {cta_cur:,.0f}", f"S/ {cob_cur:,.0f}", avance_cur,
f"S/ {cta_tot:,.0f}", f"S/ {cob_tot:,.0f}", avance_tot,
f"S/ {saldo:,.0f}",
programa # índice [14]: PROGRAMA (extra, al final; no altera columnas previas)
]
sheet_data.append(fila)
r_tot_1 = (t_cob_ant / t_cta_ant) * 100 if t_cta_ant > 0 else 0.0
r_tot_2 = (t_cob_cur / t_cta_cur) * 100 if t_cta_cur > 0 else 0.0
r_tot_3 = (t_cob_tot / t_cta_tot) * 100 if t_cta_tot > 0 else 0.0
fila_total = [
"TOTAL", "GENERAL", "-", "-",
f"S/ {t_cta_ant:,.0f}", f"S/ {t_cob_ant:,.0f}", f"{r_tot_1:.0f}%",
f"S/ {t_cta_cur:,.0f}", f"S/ {t_cob_cur:,.0f}", f"{r_tot_2:.0f}%",
f"S/ {t_cta_tot:,.0f}", f"S/ {t_cob_tot:,.0f}", f"{r_tot_3:.0f}%",
f"S/ {t_saldo:,.0f}",
"" # índice [14]: PROGRAMA vacío en la fila total
]
sheet_data.append(fila_total)
return sheet_data
def exportar_detalle_excel(self, programa, headers, datos):
if not datos: raise ValueError("No hay datos para exportar")
prog_limpio = "".join([c if c.isalnum() else "_" for c in str(programa)])[:30]
archivo = f"Detalle_Cobranza_{prog_limpio}.xlsx"
df = pd.DataFrame(datos, columns=[h.replace('\n', ' ') for h in headers])
try:
with pd.ExcelWriter(archivo, engine='openpyxl') as writer:
df.to_excel(writer, index=False, sheet_name='Detalle')
except:
df.to_excel(archivo, index=False)
return archivo
def exportar_reporte_global(self, ano, mes, sectorista):
datos_brutos = self.processor.obtener_detalle_programa(ano, mes, sectorista, "TODOS")
if not datos_brutos: raise ValueError("No hay datos para exportar en este mes.")
filas_excel = []
for d in datos_brutos:
cta_ant = float(d.get('CTA_COB_ANT', 0.0))
cob_ant = float(d.get('COB_ANT', 0.0))
cta_cur = float(d.get('CTA_COB_MES_CURSO', 0.0))
cob_cur = float(d.get('COB_MES_CURSO', 0.0))
cta_tot = float(d.get('TOTAL_CTA_COB', 0.0))
cob_tot = float(d.get('TOTAL_COBRADO', 0.0))
filas_excel.append({
"ALUMNO": d.get('ALUMNO', ''),
"PROGRAMA": d.get('PROGRAMA', '-'),
"FRECUENCIA": d.get('FRECUENCIA', '-'),
"NUM CUOTA": d.get('NUM_CUOTA', '-'),
"FCH VENCIMIENTO": d.get('FCH_VENC', '-'),
"CUENTA PENDIENTE": cta_ant,
"COBRADO PENDIENTE": cob_ant,
"AVANCE % ": (cob_ant / cta_ant) if cta_ant > 0 else 0.0,
"CUENTA EN CURSO": cta_cur,
"COBRADO EN CURSO": cob_cur,
"AVANCE % ": (cob_cur / cta_cur) if cta_cur > 0 else 0.0,
"TOTAL CUENTA": cta_tot,
"TOTAL COBRADO": cob_tot,
"AVANCE %": (cob_tot / cta_tot) if cta_tot > 0 else 0.0,
"SALDO": float(d.get('SALDO', 0.0))
})
df = pd.DataFrame(filas_excel)
archivo = f"Reporte_Cobranza_Global_{ano}_{mes}.xlsx"
try:
with pd.ExcelWriter(archivo, engine='openpyxl') as writer:
df.to_excel(writer, index=False, sheet_name='Reporte Global')
worksheet = writer.sheets['Reporte Global']
from openpyxl.styles import PatternFill, Font, Alignment
fill_header = PatternFill(start_color="12345F", end_color="12345F", fill_type="solid")
font_header = Font(color="FFFFFF", bold=True)
for cell in worksheet[1]:
cell.fill = fill_header
cell.font = font_header
cell.alignment = Alignment(horizontal="center", vertical="center")
for row in range(2, len(filas_excel) + 2):
for col in [6, 7, 9, 10, 12, 13, 15]:
worksheet.cell(row=row, column=col).number_format = '"S/" #,##0.00'
for col in [8, 11, 14]:
worksheet.cell(row=row, column=col).number_format = '0%'
# FCH VENCIMIENTO (col 5): forzar TEXTO para que Excel no reinterprete
# la fecha ni le reste un día por zona horaria.
celda_fecha = worksheet.cell(row=row, column=5)
celda_fecha.value = str(celda_fecha.value)
celda_fecha.number_format = '@'
for col in worksheet.columns:
max_len = 0
col_letter = col[0].column_letter
for cell in col:
if cell.value:
max_len = max(max_len, len(str(cell.value)))
worksheet.column_dimensions[col_letter].width = min(max_len + 2, 40)
except Exception:
df.to_excel(archivo, index=False)
return archivo

View File

@@ -1,824 +0,0 @@
# modules/cobranza/processor.py
from datetime import datetime
import requests
import unicodedata
class CobranzaProcessor:
def __init__(self, data_manager):
self.data_manager = data_manager
self.config_sedes = self._cargar_json_sedes()
# ── Caché de consultas SQL ────────────────────────────────────────
self._cache_cronograma = None
self._cache_facturas = None
self._cache_ano = None
def _obtener_datos_cached(self, ano):
"""Devuelve cronograma y facturas usando caché si el año coincide."""
if self._cache_ano == ano and self._cache_cronograma is not None:
print(f"[CACHE] Reutilizando datos en caché para año {ano}")
return self._cache_cronograma, self._cache_facturas
print(f"[CACHE] Consultando BD para año {ano}...")
cronograma = self.data_manager.ejecutar_consulta_cronograma_cobranza(ano)
facturas = self.data_manager.ejecutar_consulta_facturas_cobranza(ano)
self._cache_cronograma = cronograma
self._cache_facturas = facturas
self._cache_ano = ano
return cronograma, facturas
def invalidar_cache(self):
"""Fuerza recarga desde BD en la siguiente consulta."""
self._cache_cronograma = None
self._cache_facturas = None
self._cache_ano = None
print("🧹 [CACHE] Memoria limpiada por actualización automática. La próxima consulta irá directo a la BD.")
# =========================================================================
# CARGA DE CONFIGURACIÓN (sede.json)
# =========================================================================
def _cargar_json_sedes(self):
datos = None
try:
url = getattr(self.data_manager, 'github_sede_url', None)
if url:
response = requests.get(url)
response.raise_for_status()
datos = response.json()
except Exception as e:
pass
if datos is None:
datos = {
"clasificacion_sedes": {
"AREQUIPA": {"patrones": ["AREQUIPA"]},
"PIURA": {"patrones": ["PIURA"]},
"TRUJILLO": {"patrones": ["TRUJILLO"]}
},
"clasificacion_default": {"sede": "LIMA"}
}
self._reemplazos_nombre = datos.get("reemplazos_nombre", [])
return datos
# =========================================================================
# REEMPLAZOS DE NOMBRE DE PROGRAMA
# =========================================================================
@staticmethod
def _normalizar(texto):
texto = unicodedata.normalize('NFD', str(texto))
return texto.encode('ascii', 'ignore').decode('utf-8')
def _aplicar_reemplazos_nombre(self, texto):
if not texto or not self._reemplazos_nombre:
return texto
resultado = texto
for entrada in self._reemplazos_nombre:
buscar = str(entrada.get("buscar", "")).strip()
reemplazar = str(entrada.get("reemplazar", "")).strip()
if not buscar:
continue
buscar_norm = self._normalizar(buscar).upper()
idx = self._normalizar(resultado).upper().find(buscar_norm)
while idx != -1:
resultado = resultado[:idx] + reemplazar + resultado[idx + len(buscar):]
idx = self._normalizar(resultado).upper().find(buscar_norm, idx + len(reemplazar))
return resultado
# =========================================================================
# CLASIFICACIÓN DE SEDE
# =========================================================================
def clasificar_sede(self, dsc_programa):
if not dsc_programa:
return self.config_sedes.get("clasificacion_default", {}).get("sede", "LIMA")
dsc_prog_upper = str(dsc_programa).upper()
sedes = self.config_sedes.get("clasificacion_sedes", {})
for nombre_sede, datos in sedes.items():
for patron in datos.get("patrones", []):
if patron.upper() in dsc_prog_upper:
return nombre_sede
return self.config_sedes.get("clasificacion_default", {}).get("sede", "LIMA")
# =========================================================================
# UTILIDADES
# =========================================================================
def parse_fecha(self, fecha_raw):
try:
if isinstance(fecha_raw, datetime): return fecha_raw
if not fecha_raw: return None
s_fecha = str(fecha_raw).strip()
if len(s_fecha) == 10 and s_fecha[2] == '-':
return datetime.strptime(s_fecha, '%d-%m-%Y')
if ' ' in s_fecha:
return datetime.strptime(s_fecha.split('.')[0], '%Y-%m-%d %H:%M:%S')
return datetime.strptime(s_fecha, '%Y-%m-%d')
except:
return None
def parse_corte_venc(self, venc_hasta):
"""Convierte el corte del filtro (dd/mm/yyyy del calendario, o ISO) a datetime.
Devuelve None si no aplica (TODOS/vacío)."""
if venc_hasta in (None, "", "TODOS", "NONE"): return None
s = str(venc_hasta).strip()
try:
if '/' in s: return datetime.strptime(s, '%d/%m/%Y')
except: pass
return self.parse_fecha(s)
def obtener_fechas_filtro(self, ano, mes_numero):
ano_int = int(ano)
mes_int = int(mes_numero)
fecha_filtro = datetime(ano_int, mes_int, 1)
if mes_int == 12:
fecha_filtro_siguiente = datetime(ano_int + 1, 1, 1)
else:
fecha_filtro_siguiente = datetime(ano_int, mes_int + 1, 1)
return fecha_filtro, fecha_filtro_siguiente
def calcular_importe_final(self, fila):
try: imp_total = float(fila.get('imp_total', 0) or 0)
except: imp_total = 0.0
try: imp_dscto = float(fila.get('imp_dscto', 0) or 0)
except: imp_dscto = 0.0
return imp_total - imp_dscto
def evaluar_ctaxcob_suma(self, fila, importe_final, fecha_filtro, fecha_filtro_siguiente, hoy, debug_stats):
sectorista = str(fila.get('dsc_sectorista', ''))
if sectorista == " , " or sectorista.strip() == ",":
return 0.0, 0.0
try: num_cuota = int(fila.get('num_cuota', 0))
except: num_cuota = 0
if not (num_cuota > 1):
debug_stats['falla_cuota'] += 1
return 0.0, 0.0
fch_vencimiento = self.parse_fecha(fila.get('fch_vencimiento'))
if not fch_vencimiento:
debug_stats['falla_vencimiento'] += 1
return 0.0, 0.0
es_anterior = fch_vencimiento < fecha_filtro
es_curso = (fch_vencimiento >= fecha_filtro) and (fch_vencimiento < fecha_filtro_siguiente)
if not (es_anterior or es_curso):
debug_stats['falla_vencimiento'] += 1
return 0.0, 0.0
fch_inicio = self.parse_fecha(fila.get('fch_inicio'))
if not fch_inicio or not (fch_inicio <= hoy):
debug_stats['falla_inicio'] += 1
return 0.0, 0.0
estado = str(fila.get('estado_matricula', '')).strip().upper()
if estado in ["ALU", "PRE"]:
pass
elif estado == "RET":
fch_retiro = self.parse_fecha(fila.get('fch_retiro'))
if not fch_retiro or not (fch_retiro >= fecha_filtro_siguiente):
debug_stats['falla_estado'] += 1
return 0.0, 0.0
else:
debug_stats['falla_estado'] += 1
return 0.0, 0.0
importe_convertido = importe_final
moneda = str(fila.get('cod_moneda', '')).strip().upper()
if moneda == 'DOL':
try: tc = float(fila.get('imp_tc', 1.0) or 1.0)
except: tc = 1.0
importe_convertido *= tc
debug_stats['pasa_todo'] += 1
if es_anterior: return importe_convertido, 0.0
if es_curso: return 0.0, importe_convertido
return 0.0, 0.0
def evaluar_ctaxcob_resta(self, fila_factura, fecha_filtro, fecha_filtro_siguiente, hoy, debug_stats, tc_correcto):
sectorista = str(fila_factura.get('dsc_sectorista', ''))
if sectorista == " , " or sectorista.strip() == ",":
return 0.0, 0.0, 0.0, 0.0
try: num_cuota = int(fila_factura.get('num_cuota', 0))
except: num_cuota = 0
if not (num_cuota > 1):
debug_stats['falla_cuota'] += 1
return 0.0, 0.0, 0.0, 0.0
cod_estado = str(fila_factura.get('cod_estado', '')).strip().upper()
if cod_estado != "CAN":
debug_stats['falla_estado_can'] += 1
return 0.0, 0.0, 0.0, 0.0
flg_nc = str(fila_factura.get('flg_nc', '')).strip().upper()
if flg_nc == "SI":
debug_stats['falla_nc'] += 1
return 0.0, 0.0, 0.0, 0.0
fch_venc_cuota = self.parse_fecha(fila_factura.get('fch_vencimiento_cuota'))
if not fch_venc_cuota:
debug_stats['falla_venc_cuota'] += 1
return 0.0, 0.0, 0.0, 0.0
es_anterior = fch_venc_cuota < fecha_filtro
es_curso = (fch_venc_cuota >= fecha_filtro) and (fch_venc_cuota < fecha_filtro_siguiente)
if not (es_anterior or es_curso):
debug_stats['falla_venc_cuota'] += 1
return 0.0, 0.0, 0.0, 0.0
fch_cancelacion = self.parse_fecha(fila_factura.get('fch_cancelacion'))
if not fch_cancelacion:
debug_stats['falla_cancelacion'] += 1
return 0.0, 0.0, 0.0, 0.0
pasa_anterior = es_anterior and (fch_cancelacion < fecha_filtro)
pasa_curso = es_curso and (fch_cancelacion < fecha_filtro)
pasa_cobrado_ant = es_anterior and (fch_cancelacion >= fecha_filtro) and (fch_cancelacion < fecha_filtro_siguiente)
pasa_cobrado_curso = es_curso and (fch_cancelacion >= fecha_filtro) and (fch_cancelacion < fecha_filtro_siguiente)
if not (pasa_anterior or pasa_curso or pasa_cobrado_ant or pasa_cobrado_curso):
debug_stats['falla_cancelacion'] += 1
return 0.0, 0.0, 0.0, 0.0
fch_inicio = self.parse_fecha(fila_factura.get('fch_inicio'))
if not fch_inicio or not (fch_inicio <= hoy):
debug_stats['falla_inicio'] += 1
return 0.0, 0.0, 0.0, 0.0
estado_mat = str(fila_factura.get('estado_matricula', '')).strip().upper()
if estado_mat in ["ALU", "PRE"]: pass
elif estado_mat == "RET":
fch_retiro = self.parse_fecha(fila_factura.get('fch_retiro'))
if not fch_retiro or not (fch_retiro >= fecha_filtro_siguiente):
debug_stats['falla_estado_mat'] += 1
return 0.0, 0.0, 0.0, 0.0
else:
debug_stats['falla_estado_mat'] += 1
return 0.0, 0.0, 0.0, 0.0
try: imp_emitido = float(fila_factura.get('imp_emitido', 0) or 0)
except: imp_emitido = 0.0
imp_emitido_convertido = imp_emitido
moneda = str(fila_factura.get('cod_moneda', '')).strip().upper()
if moneda == 'DOL':
imp_emitido_convertido *= tc_correcto
debug_stats['pasa_todo'] += 1
val_resta_ant = imp_emitido_convertido if pasa_anterior else 0.0
val_resta_cur = imp_emitido_convertido if pasa_curso else 0.0
val_cobrado_ant = imp_emitido_convertido if pasa_cobrado_ant else 0.0
val_cobrado_curso = imp_emitido_convertido if pasa_cobrado_curso else 0.0
return val_resta_ant, val_resta_cur, val_cobrado_ant, val_cobrado_curso
# =========================================================================
# LISTA DE SECTORISTAS
# =========================================================================
def obtener_lista_sectoristas(self, ano, mes):
fecha_filtro, fecha_filtro_siguiente = self.obtener_fechas_filtro(ano, mes)
hoy = datetime.now()
try:
cronograma_raw = self.data_manager.ejecutar_consulta_cronograma_cobranza(ano)
facturas_raw = self.data_manager.ejecutar_consulta_facturas_cobranza(ano)
except Exception: return ["TODOS"]
sectoristas_neto = {}
stats_suma = {'falla_cuota': 0, 'falla_vencimiento': 0, 'falla_inicio': 0, 'falla_estado': 0, 'pasa_todo': 0}
stats_resta = {'falla_cuota': 0, 'falla_estado_can': 0, 'falla_nc': 0, 'falla_cancelacion': 0, 'falla_venc_cuota': 0, 'falla_inicio': 0, 'falla_estado_mat': 0, 'falla_treatas': 0, 'pasa_todo': 0}
indices_cronograma = set()
mapa_tc = {}
mapa_sectorista = {}
for fila in cronograma_raw:
sec_str = str(fila.get('dsc_sectorista', ''))
sec_clean = sec_str.strip()
if sec_clean in ["", ",", "None"] or sec_str == " , ": continue
idx = str(fila.get('num_indice', '')).strip()
if idx:
indices_cronograma.add(idx)
mapa_sectorista[idx] = sec_clean
mat = str(fila.get('num_matricula', '')).strip()
if mat and mat not in mapa_tc:
try: tc = float(fila.get('imp_tc', 1.0) or 1.0)
except: tc = 1.0
mapa_tc[mat] = tc
for fila in cronograma_raw:
idx = str(fila.get('num_indice', '')).strip()
if idx not in indices_cronograma: continue
sec_clean = mapa_sectorista[idx]
importe_final = self.calcular_importe_final(fila)
suma_ant, suma_cur = self.evaluar_ctaxcob_suma(fila, importe_final, fecha_filtro, fecha_filtro_siguiente, hoy, stats_suma)
if sec_clean not in sectoristas_neto: sectoristas_neto[sec_clean] = 0.0
sectoristas_neto[sec_clean] += (suma_ant + suma_cur)
for fila in facturas_raw:
idx_factura = str(fila.get('num_indice', '')).strip()
if idx_factura not in indices_cronograma: continue
sec_clean = mapa_sectorista.get(idx_factura, "")
if not sec_clean: continue
mat_factura = str(fila.get('num_matricula', '')).strip()
tc_correcto = mapa_tc.get(mat_factura, 1.0)
resta_ant, resta_cur, _, _ = self.evaluar_ctaxcob_resta(
fila, fecha_filtro, fecha_filtro_siguiente, hoy, stats_resta, tc_correcto
)
if sec_clean in sectoristas_neto: sectoristas_neto[sec_clean] -= (resta_ant + resta_cur)
lista_final = []
for sec, total_cta_x_cob in sectoristas_neto.items():
if round(total_cta_x_cob, 2) > 0: lista_final.append(sec)
lista_final = sorted(lista_final)
lista_final.insert(0, "TODOS")
return lista_final
# =========================================================================
# DATOS PROCESADOS (TABLA PRINCIPAL CON VISTA DE ASESORES)
# =========================================================================
def _meta_full(self):
"""JSON completo REPLACE_META (con clasificacion_filtro_programa)."""
md = getattr(self.data_manager, "meta_data", None)
if isinstance(md, dict) and md:
return md
return self.config_sedes or {}
def clasificar_filtro_programa(self, dsc_programa):
"""TEAC/TERC/SEMINARIOS/OTROS desde clasificacion_filtro_programa (REPLACE_META).
Respeta el orden del JSON (primer patrón que coincide gana)."""
up = str(dsc_programa or "").upper()
data = self._meta_full().get("clasificacion_filtro_programa", {})
for cat, cfg in data.items():
if cat == "DEFAULT":
continue
for pat in cfg.get("patrones", []):
if str(pat).upper() in up:
return cat
return data.get("DEFAULT", "OTROS")
def clasificar_sede_meta(self, dsc_programa):
"""Sede desde clasificacion_sedes del REPLACE_META (LIMA por defecto)."""
up = str(dsc_programa or "").upper()
data = self._meta_full().get("clasificacion_sedes", {})
for sede, cfg in data.items():
if sede == "DEFAULT":
continue
for pat in cfg.get("patrones", []):
if str(pat).upper() in up:
return sede
return data.get("DEFAULT", "LIMA")
def obtener_datos_procesados(self, ano, mes, sectorista_filtro="TODOS", agrupacion="SEDE",
sede_filtro="TODOS", prog_filtro="TODOS", venc_hasta=None):
fecha_filtro, fecha_filtro_siguiente = self.obtener_fechas_filtro(ano, mes)
hoy = datetime.now()
# Filtro adicional por F. Vencimiento: solo cuotas con vencimiento <= venc_hasta.
# Las cuotas sin fecha de vencimiento se incluyen SIEMPRE.
venc_corte = self.parse_corte_venc(venc_hasta)
datos_agrupados = {}
frecuencias_cuota = {}
frecuencias_fecha_venc = {}
mapa_frecuencia = {}
alumnos_tracking = {}
try:
cronograma_raw, facturas_raw = self._obtener_datos_cached(ano)
except Exception as e:
print(f"Error crítico: {e}")
return []
stats_suma = {'falla_cuota': 0, 'falla_vencimiento': 0, 'falla_inicio': 0, 'falla_estado': 0, 'pasa_todo': 0}
stats_resta = {'falla_cuota': 0, 'falla_estado_can': 0, 'falla_nc': 0, 'falla_cancelacion': 0, 'falla_venc_cuota': 0, 'falla_inicio': 0, 'falla_estado_mat': 0, 'falla_treatas': 0, 'pasa_todo': 0}
indices_cronograma = set()
mapa_tc = {}
mapa_programa = {}
mapa_sectorista = {} # <-- NUEVO MAPA PARA CORREGIR ASESORES
for fila in cronograma_raw:
sectorista_str = str(fila.get('dsc_sectorista', ''))
sectorista_clean = sectorista_str.strip()
idx = str(fila.get('num_indice', '')).strip()
if idx:
# Guardamos el asesor real del cronograma para usarlo en las facturas
mapa_sectorista[idx] = sectorista_clean if sectorista_clean and sectorista_clean not in [",", "None"] else "SIN ASESOR"
if sectorista_clean in [",", "", "None"] or sectorista_str == " , ": continue
if sectorista_filtro != "TODOS" and sectorista_clean != sectorista_filtro: continue
if idx: indices_cronograma.add(idx)
mat = str(fila.get('num_matricula', '')).strip()
if mat:
if mat not in mapa_tc:
try: tc = float(fila.get('imp_tc', 1.0) or 1.0)
except: tc = 1.0
mapa_tc[mat] = tc
if mat not in mapa_programa:
prog = str(fila.get('dsc_det_programa', '')).strip()
mapa_programa[mat] = prog if (prog and prog != "None") else "SIN PROGRAMA"
for fila in cronograma_raw:
idx = str(fila.get('num_indice', '')).strip()
if idx not in indices_cronograma: continue
mat = str(fila.get('num_matricula', '')).strip()
# Filtro por SEDE y PROGRAMA (REPLACE_META) — se aplica en cualquier
# agrupación cuando se pide (usado sobre todo en ASESOR).
_prog = fila.get('dsc_programa', '')
if str(sede_filtro).upper() not in ("TODOS", "", "NONE"):
if str(self.clasificar_sede_meta(_prog)).upper() != str(sede_filtro).upper():
continue
if str(prog_filtro).upper() not in ("TODOS", "", "NONE"):
if str(self.clasificar_filtro_programa(_prog)).upper() != str(prog_filtro).upper():
continue
# Filtro por F. Vencimiento (<=). Sin fecha => se incluye siempre.
if venc_corte is not None:
_fv = self.parse_fecha(fila.get('fch_vencimiento'))
if _fv is not None and _fv > venc_corte:
continue
if agrupacion == "SEDE":
llave = self.clasificar_sede(fila.get('dsc_programa', ''))
elif agrupacion == "ASESOR":
llave = mapa_sectorista.get(idx, "SIN ASESOR")
else:
llave = str(fila.get('dsc_det_programa', '')).strip()
if not llave or llave == "None": llave = "SIN PROGRAMA"
if llave not in datos_agrupados:
datos_agrupados[llave] = {"Suma_Ant": 0.0, "Suma_Cur": 0.0, "Resta_Ant": 0.0, "Resta_Cur": 0.0, "Cob_Ant": 0.0, "Cob_Cur": 0.0}
importe_final = self.calcular_importe_final(fila)
suma_ant, suma_cur = self.evaluar_ctaxcob_suma(fila, importe_final, fecha_filtro, fecha_filtro_siguiente, hoy, stats_suma)
datos_agrupados[llave]["Suma_Ant"] += suma_ant
datos_agrupados[llave]["Suma_Cur"] += suma_cur
clave_mat = (mat, llave)
if clave_mat not in alumnos_tracking:
alumnos_tracking[clave_mat] = {"suma": 0.0, "resta": 0.0, "cobrado": 0.0, "cuota_final": None, "fecha_final": None}
alumnos_tracking[clave_mat]["suma"] += (suma_ant + suma_cur)
if agrupacion == "PROGRAMA":
if llave not in mapa_frecuencia:
frec_val = str(fila.get('cod_frecuencia', '-')).strip()
mapa_frecuencia[llave] = frec_val if frec_val and frec_val != 'None' and frec_val != '' else '-'
fch_venc_raw = fila.get('fch_vencimiento')
fch_venc_dt = self.parse_fecha(fch_venc_raw)
if fch_venc_dt and (fecha_filtro <= fch_venc_dt < fecha_filtro_siguiente):
try:
n_cuota = int(fila.get('num_cuota', -1))
if n_cuota >= 0: alumnos_tracking[clave_mat]["cuota_final"] = n_cuota
except: pass
try:
alumnos_tracking[clave_mat]["fecha_final"] = fch_venc_dt.strftime('%d/%m/%Y')
except: pass
for fila in facturas_raw:
idx_factura = str(fila.get('num_indice', '')).strip()
if idx_factura not in indices_cronograma: continue
mat_factura = str(fila.get('num_matricula', '')).strip()
tc_correcto = mapa_tc.get(mat_factura, 1.0)
# Mismo filtro SEDE/PROGRAMA (REPLACE_META) que en el cronograma.
_progF = fila.get('dsc_programa', '')
if str(sede_filtro).upper() not in ("TODOS", "", "NONE"):
if str(self.clasificar_sede_meta(_progF)).upper() != str(sede_filtro).upper():
continue
if str(prog_filtro).upper() not in ("TODOS", "", "NONE"):
if str(self.clasificar_filtro_programa(_progF)).upper() != str(prog_filtro).upper():
continue
# Filtro por F. Vencimiento (<=). En facturas la fecha es fch_vencimiento_cuota.
# Sin fecha => se incluye siempre.
if venc_corte is not None:
_fvF = self.parse_fecha(fila.get('fch_vencimiento_cuota'))
if _fvF is not None and _fvF > venc_corte:
continue
if agrupacion == "SEDE":
llave = self.clasificar_sede(fila.get('dsc_programa', ''))
elif agrupacion == "ASESOR":
# AQUÍ ESTÁ LA MAGIA: usa el asesor guardado desde el cronograma
llave = mapa_sectorista.get(idx_factura, "SIN ASESOR")
else:
llave = mapa_programa.get(mat_factura, "")
if not llave:
llave = str(fila.get('dsc_det_programa', '')).strip()
if not llave or llave == "None": llave = "SIN PROGRAMA"
if llave not in datos_agrupados:
datos_agrupados[llave] = {"Suma_Ant": 0.0, "Suma_Cur": 0.0, "Resta_Ant": 0.0, "Resta_Cur": 0.0, "Cob_Ant": 0.0, "Cob_Cur": 0.0}
resta_ant, resta_cur, cobrado_ant, cobrado_curso = self.evaluar_ctaxcob_resta(
fila, fecha_filtro, fecha_filtro_siguiente, hoy, stats_resta, tc_correcto
)
datos_agrupados[llave]["Resta_Ant"] += resta_ant
datos_agrupados[llave]["Resta_Cur"] += resta_cur
datos_agrupados[llave]["Cob_Ant"] += cobrado_ant
datos_agrupados[llave]["Cob_Cur"] += cobrado_curso
clave_mat = (mat_factura, llave)
if clave_mat in alumnos_tracking:
alumnos_tracking[clave_mat]["resta"] += (resta_ant + resta_cur)
alumnos_tracking[clave_mat]["cobrado"] += (cobrado_ant + cobrado_curso)
for (mat, llave), track in alumnos_tracking.items():
saldo_alumno = (track["suma"] - track["resta"]) - track["cobrado"]
if round(saldo_alumno, 2) > 0:
c = track["cuota_final"]
f = track["fecha_final"]
if c is not None:
if llave not in frecuencias_cuota: frecuencias_cuota[llave] = {}
frecuencias_cuota[llave][c] = frecuencias_cuota[llave].get(c, 0) + 1
if f is not None:
if llave not in frecuencias_fecha_venc: frecuencias_fecha_venc[llave] = {}
frecuencias_fecha_venc[llave][f] = frecuencias_fecha_venc[llave].get(f, 0) + 1
datos_limpios = []
llaves_ordenadas = sorted(datos_agrupados.keys())
asesores_validos = []
if agrupacion == "ASESOR":
asesores_validos = [s for s in self.obtener_lista_sectoristas(ano, mes) if s != "TODOS"]
if agrupacion == "SEDE":
orden_sedes = ["LIMA", "AREQUIPA", "TRUJILLO", "PIURA"]
llaves_ordenadas = [s for s in orden_sedes if s in datos_agrupados] + [s for s in llaves_ordenadas if s not in orden_sedes]
for llave in llaves_ordenadas:
if agrupacion == "ASESOR" and llave not in asesores_validos:
continue
totales = datos_agrupados[llave]
cta_cob_ant = totales["Suma_Ant"] - totales["Resta_Ant"]
cta_cob_curso = totales["Suma_Cur"] - totales["Resta_Cur"]
total_cta = cta_cob_ant + cta_cob_curso
cobrado_meses_ant = totales["Cob_Ant"]
cobrado_mes_curso = totales["Cob_Cur"]
total_cobrado_sede = cobrado_meses_ant + cobrado_mes_curso
if round(total_cta, 2) <= 0 and round(total_cobrado_sede, 2) <= 0: continue
ratio_1 = (cobrado_meses_ant / cta_cob_ant) * 100 if cta_cob_ant > 0 else 0.0
ratio_2 = (cobrado_mes_curso / cta_cob_curso) * 100 if cta_cob_curso > 0 else 0.0
ratio_3 = (total_cobrado_sede / total_cta) * 100 if total_cta > 0 else 0.0
saldo_sede = total_cta - total_cobrado_sede
frec_str = "-"
moda_cuota = "-"
moda_fecha = "-"
if agrupacion == "PROGRAMA":
frec_str = mapa_frecuencia.get(llave, "-")
if llave in frecuencias_cuota and frecuencias_cuota[llave]:
moda_cuota = str(max(frecuencias_cuota[llave], key=frecuencias_cuota[llave].get))
if llave in frecuencias_fecha_venc and frecuencias_fecha_venc[llave]:
moda_fecha = str(max(frecuencias_fecha_venc[llave], key=frecuencias_fecha_venc[llave].get))
if agrupacion == "PROGRAMA":
grupo_display = self._aplicar_reemplazos_nombre(llave)
else:
grupo_display = llave
datos_limpios.append({
'GRUPO': grupo_display,
'GRUPO_ORIGINAL': llave,
'FRECUENCIA': frec_str,
'NUM_CUOTA': moda_cuota,
'FCH_VENC_MODA': moda_fecha,
'CTA_COB_ANT': cta_cob_ant, 'CTA_COB_MES_CURSO': cta_cob_curso, 'TOTAL_CTA_COB': total_cta,
'COB_ANT': cobrado_meses_ant, 'RATIO_1': ratio_1, 'COB_MES_CURSO': cobrado_mes_curso, 'RATIO_2': ratio_2,
'TOTAL_COBRADO': total_cobrado_sede, 'RATIO_3': ratio_3, 'SALDO': saldo_sede, 'OPCIONES': " ≡ ▼ "
})
if agrupacion == "PROGRAMA":
def orden_personalizado_programa(x):
fecha_str = x['FCH_VENC_MODA']
if fecha_str == "-":
return (0, datetime.min, x['GRUPO'])
else:
try:
dt = datetime.strptime(fecha_str, '%d/%m/%Y')
return (1, dt, x['GRUPO'])
except:
return (1, datetime.max, x['GRUPO'])
return sorted(datos_limpios, key=orden_personalizado_programa)
return datos_limpios
# =========================================================================
# DESGLOSE POR ALUMNO/MATRÍCULA
# =========================================================================
def obtener_detalle_programa(self, ano, mes, sectorista_filtro, programa_nombre, venc_hasta=None):
fecha_filtro, fecha_filtro_siguiente = self.obtener_fechas_filtro(ano, mes)
hoy = datetime.now()
# Filtro por F. Vencimiento (<=). Sin fecha => se incluye siempre.
venc_corte = self.parse_corte_venc(venc_hasta)
try:
cronograma_raw, facturas_raw = self._obtener_datos_cached(ano)
except Exception: return []
stats_suma = {'falla_cuota': 0, 'falla_vencimiento': 0, 'falla_inicio': 0, 'falla_estado': 0, 'pasa_todo': 0}
stats_resta = {'falla_cuota': 0, 'falla_estado_can': 0, 'falla_nc': 0, 'falla_cancelacion': 0, 'falla_venc_cuota': 0, 'falla_inicio': 0, 'falla_estado_mat': 0, 'falla_treatas': 0, 'pasa_todo': 0}
indices_cronograma = set()
mat_validas = set()
mapa_tc = {}
mapa_programa_frec = {}
for fila in cronograma_raw:
sectorista_str = str(fila.get('dsc_sectorista', '')).strip()
if sectorista_str in [",", ""] or fila.get('dsc_sectorista') == " , ": continue
if sectorista_filtro != "TODOS" and sectorista_str != sectorista_filtro: continue
prog = str(fila.get('dsc_det_programa', '')).strip()
if not prog or prog == "None": prog = "SIN PROGRAMA"
if programa_nombre != "TODOS":
if self._aplicar_reemplazos_nombre(prog) != programa_nombre: continue
idx = str(fila.get('num_indice', '')).strip()
if idx: indices_cronograma.add(idx)
mat = str(fila.get('num_matricula', '')).strip()
if mat:
mat_validas.add(mat)
if mat not in mapa_tc:
try: tc = float(fila.get('imp_tc', 1.0) or 1.0)
except: tc = 1.0
mapa_tc[mat] = tc
if mat not in mapa_programa_frec:
prog_bd = str(fila.get('dsc_det_programa', '')).strip()
frec_bd = str(fila.get('cod_frecuencia', '')).strip()
mapa_programa_frec[mat] = {
"prog": prog_bd if prog_bd and prog_bd != "None" else "-",
"frec": frec_bd if frec_bd and frec_bd != "None" else "-"
}
mapa_alumnos = {}
if mat_validas:
try:
conn = self.data_manager.get_connection()
cursor = conn.cursor()
lista_mats = list(mat_validas)
for i in range(0, len(lista_mats), 1000):
chunk = lista_mats[i:i+1000]
chunk_str = ",".join([f"'{m}'" for m in chunk])
sql = f"""
SELECT sgeca_matricula.num_matricula,
sgema_alumno.dsc_apellido_paterno + ' ' + sgema_alumno.dsc_apellido_materno + ', ' + sgema_alumno.dsc_nombres AS dsc_alumno
FROM sgeca_matricula
INNER JOIN sgema_alumno ON sgeca_matricula.cod_alumno = sgema_alumno.cod_alumno
WHERE sgeca_matricula.num_matricula IN ({chunk_str})
"""
cursor.execute(sql)
for row in cursor.fetchall():
num_mat_bd = str(row[0]).strip()
nombre_bd = str(row[1]).strip()
mapa_alumnos[num_mat_bd] = nombre_bd
conn.close()
except Exception as e:
print(f"Error consultando nombres de alumnos en BD: {e}")
datos_agrupados = {}
for fila in cronograma_raw:
idx = str(fila.get('num_indice', '')).strip()
if idx not in indices_cronograma: continue
mat = str(fila.get('num_matricula', '')).strip()
if mat not in datos_agrupados:
prog_info = mapa_programa_frec.get(mat, {"prog": "-", "frec": "-"})
datos_agrupados[mat] = {
"Alumno": mapa_alumnos.get(mat, "SIN NOMBRE"),
"Programa": prog_info["prog"],
"Frecuencia": prog_info["frec"],
"Suma_Ant": 0.0, "Suma_Cur": 0.0, "Resta_Ant": 0.0, "Resta_Cur": 0.0, "Cob_Ant": 0.0, "Cob_Cur": 0.0,
"Cuota_Mes": "-", "Fch_Venc_Mes": "-"
}
fch_venc_raw = fila.get('fch_vencimiento')
fch_venc_dt = self.parse_fecha(fch_venc_raw)
# Filtro por F. Vencimiento (<=). Sin fecha => se incluye siempre.
if venc_corte is not None and fch_venc_dt is not None and fch_venc_dt > venc_corte:
continue
if fch_venc_dt and (fecha_filtro <= fch_venc_dt < fecha_filtro_siguiente):
try: datos_agrupados[mat]["Cuota_Mes"] = str(int(fila.get('num_cuota', -1)))
except: pass
datos_agrupados[mat]["Fch_Venc_Mes"] = fch_venc_dt.strftime('%d/%m/%Y')
importe_final = self.calcular_importe_final(fila)
suma_ant, suma_cur = self.evaluar_ctaxcob_suma(fila, importe_final, fecha_filtro, fecha_filtro_siguiente, hoy, stats_suma)
datos_agrupados[mat]["Suma_Ant"] += suma_ant
datos_agrupados[mat]["Suma_Cur"] += suma_cur
for fila in facturas_raw:
idx_factura = str(fila.get('num_indice', '')).strip()
if idx_factura not in indices_cronograma: continue
mat_factura = str(fila.get('num_matricula', '')).strip()
tc_correcto = mapa_tc.get(mat_factura, 1.0)
# Filtro por F. Vencimiento (<=). En facturas la fecha es fch_vencimiento_cuota.
# Sin fecha => se incluye siempre.
if venc_corte is not None:
_fvD = self.parse_fecha(fila.get('fch_vencimiento_cuota'))
if _fvD is not None and _fvD > venc_corte:
continue
if mat_factura not in datos_agrupados:
prog_info = mapa_programa_frec.get(mat_factura, {"prog": "-", "frec": "-"})
datos_agrupados[mat_factura] = {
"Alumno": mapa_alumnos.get(mat_factura, "SIN NOMBRE"),
"Programa": prog_info["prog"],
"Frecuencia": prog_info["frec"],
"Suma_Ant": 0.0, "Suma_Cur": 0.0, "Resta_Ant": 0.0, "Resta_Cur": 0.0, "Cob_Ant": 0.0, "Cob_Cur": 0.0,
"Cuota_Mes": "-", "Fch_Venc_Mes": "-"
}
resta_ant, resta_cur, cobrado_ant, cobrado_curso = self.evaluar_ctaxcob_resta(
fila, fecha_filtro, fecha_filtro_siguiente, hoy, stats_resta, tc_correcto
)
datos_agrupados[mat_factura]["Resta_Ant"] += resta_ant
datos_agrupados[mat_factura]["Resta_Cur"] += resta_cur
datos_agrupados[mat_factura]["Cob_Ant"] += cobrado_ant
datos_agrupados[mat_factura]["Cob_Cur"] += cobrado_curso
datos_limpios = []
for mat, totales in datos_agrupados.items():
cta_cob_ant = totales["Suma_Ant"] - totales["Resta_Ant"]
cta_cob_curso = totales["Suma_Cur"] - totales["Resta_Cur"]
total_cta = cta_cob_ant + cta_cob_curso
cob_ant = totales["Cob_Ant"]
cob_cur = totales["Cob_Cur"]
total_cob = cob_ant + cob_cur
saldo = total_cta - total_cob
if round(total_cta, 2) <= 0 and round(total_cob, 2) <= 0: continue
cuota_final = totales["Cuota_Mes"]
fecha_final = totales["Fch_Venc_Mes"]
if round(saldo, 2) <= 0:
cuota_final = "-"
fecha_final = "-"
datos_limpios.append({
'MATRICULA': mat, 'ALUMNO': totales["Alumno"],
'PROGRAMA': totales["Programa"], 'FRECUENCIA': totales["Frecuencia"],
'NUM_CUOTA': cuota_final,
'FCH_VENC': fecha_final,
'CTA_COB_ANT': cta_cob_ant, 'CTA_COB_MES_CURSO': cta_cob_curso, 'TOTAL_CTA_COB': total_cta,
'COB_ANT': cob_ant, 'COB_MES_CURSO': cob_cur, 'TOTAL_COBRADO': total_cob, 'SALDO': saldo
})
def orden_personalizado(x):
fecha_str = x['FCH_VENC']
if fecha_str == "-":
return (0, datetime.min, x['ALUMNO'])
else:
try:
dt = datetime.strptime(fecha_str, '%d/%m/%Y')
return (1, dt, x['ALUMNO'])
except:
return (1, datetime.max, x['ALUMNO'])
return sorted(datos_limpios, key=orden_personalizado)

View File

@@ -1,234 +0,0 @@
# modules/ocupabilidad/logic.py
import pandas as pd
from .processor import CursoProcessor
class AnalizadorCursos:
"""Analizador de cursos - Lógica de cálculo y estadísticas"""
def __init__(self, data_manager):
self.data_manager = data_manager
self.curso_processor = CursoProcessor(self.data_manager)
self.columnas = self.curso_processor.columnas
def obtener_sedes(self):
"""Devuelve la lista de sedes para el nuevo filtro"""
return ["LIMA", "AREQUIPA", "PIURA", "TRUJILLO"]
def identificar_sede(self, dsc_programa):
"""Identifica la sede basada en el nombre del programa y el JSON de GitHub"""
if not dsc_programa: return "LIMA"
dsc_prog_upper = str(dsc_programa).upper()
sedes_data = self.data_manager.meta_data.get("clasificacion_sedes", {})
for sede, data in sedes_data.items():
if sede == "DEFAULT": continue
for patron in data.get("patrones", []):
if patron.upper() in dsc_prog_upper: return sede
return sedes_data.get("DEFAULT", "LIMA")
def obtener_filtros_programa(self):
"""Devuelve la lista de programas para el nuevo filtro combinado"""
return ["SEMINARIOS", "OTROS", "TEAC", "TERC"]
def identificar_filtro_programa(self, dsc_programa):
"""Identifica la categoría del programa para el filtro de la UI"""
if not dsc_programa: return "OTROS"
dsc_prog_upper = str(dsc_programa).upper()
filtro_data = self.data_manager.meta_data.get("clasificacion_filtro_programa", {})
for categoria, data in filtro_data.items():
if categoria == "DEFAULT": continue
for patron in data.get("patrones", []):
if patron.upper() in dsc_prog_upper: return categoria
return filtro_data.get("DEFAULT", "OTROS")
def get_current_month(self):
return self.data_manager.get_current_month()
def get_current_year(self):
return self.data_manager.get_current_year()
def actualizar_configuracion(self):
print("🔄 Actualización automática de configuración...")
self.data_manager.cargar_toda_configuracion()
# --- AGREGADO EL PARÁMETRO "TIPO" ---
def obtener_datos_procesados(self, ano, mes, sede="TODOS", filtro_prog="TODOS"):
# Sincronizamos el estado del toggle UI con el procesador
self.curso_processor.mostrar_reprogramados = getattr(self, 'mostrar_reprogramados', True)
datos = self.curso_processor.obtener_datos_procesados(ano, mes)
programas_disponibles = set()
if datos:
for d in datos:
prog = str(d.get('programa_frecuencia', d.get('dsc_programa', '')))
# Inyectamos variables para el filtro UI
d['Sede'] = self.identificar_sede(prog)
d['Filtro_Programa'] = self.identificar_filtro_programa(prog)
if sede == "TODOS" or d['Sede'] == sede:
programas_disponibles.add(d['Filtro_Programa'])
# Ordenar lista dinámica
orden_deseado = ["SEMINARIOS", "OTROS", "TEAC", "TERC"]
self.programas_disponibles_filtro = [p for p in orden_deseado if p in programas_disponibles]
# NO auto-corregir: si el programa no existe en la sede/mes, se respeta el
# filtro y la tabla queda vacia (antes cambiaba a TODOS y mostraba todo).
self.filtro_corregido = None
if datos and (sede != "TODOS" or filtro_prog != "TODOS"):
datos_filtrados = []
for d in datos:
cumple_sede = (sede == "TODOS" or d.get('Sede') == sede)
cumple_prog = (filtro_prog == "TODOS" or d.get('Filtro_Programa') == filtro_prog)
if cumple_sede and cumple_prog:
datos_filtrados.append(d)
return datos_filtrados
return datos
def formatear_datos_para_tabla(self, datos):
if not datos:
return [], {}
sheet_data = []
totales = {
'mes': 0, 'retirados': 0, 'totales': 0,
'activos': 0, 'pc': 0, 'continuidad': 0, 'meta': 0,
'descuento': 0
}
for registro in datos:
fila = []
for col in self.columnas:
if col != 'Avance_Inscritos':
valor = registro.get(col, '')
fila.append(str(valor))
self._acumular_totales(totales, col, registro)
else:
total_insc = registro.get('Inscritos_Totales', 0)
meta = registro.get('Meta_Curso', 0)
if total_insc == "-" or meta == "-":
fila.append("-")
else:
porcentaje = self.calcular_porcentaje_avance(total_insc, meta)
fila.append(self.crear_barra_texto_color(porcentaje))
sheet_data.append(fila)
fila_totales = self.crear_fila_totales(totales)
sheet_data.append(fila_totales)
return sheet_data, totales
def _acumular_totales(self, totales, col, registro):
val = registro.get(col, 0)
if val == "-": return # Ignoramos los guiones para que no rompa la suma
if col == 'Descuento':
totales['descuento'] += val
elif col == 'Inscritos_Mes':
totales['mes'] += val
elif col == 'Retirados':
totales['retirados'] += val
elif col == 'Inscritos_Totales':
totales['totales'] += val
elif col == 'Inscritos_Activos':
totales['activos'] += val
elif col == 'Inscritos_PC':
totales['pc'] += val
elif col == 'Inscritos_Continuidad':
totales['continuidad'] += val
elif col == 'Meta_Curso':
totales['meta'] += val
def crear_fila_totales(self, totales):
porcentaje = self.calcular_porcentaje_avance(totales['totales'], totales['meta'])
# REORGANIZADO SEGÚN NUEVO ORDEN DE COLUMNAS SOLICITADO:
# [0]Prog, [1]Fch, [2]Dias, [3]Mes, [4]Total, [5]Retirados, [6]Activos, [7]PC, [8]Refriperu, [9]Cont, [10]Meta, [11]Avance
return [
"TOTAL GENERAL", # PROGRAMA
"", # FECHA INICIO
"", # DIAS PARA INICIO
str(totales['mes']), # INSCRITOS MES
str(totales['totales']), # TOTAL INSCRITOS
str(totales['retirados']), # RETIRADOS
str(totales['activos']), # INSCRITOS EN CURSO
str(totales['pc']), # INSCRITOS P.C
str(totales['descuento']), # INSCRITOS REFRIPERU (Descuento)
str(totales['continuidad']), # INSCRITOS CONTINUIDAD
str(totales['meta']), # META
self.crear_barra_texto_color(porcentaje) # AVANCE INSCRITOS
]
def calcular_porcentaje_avance(self, activos, meta):
return (activos / meta * 100) if meta > 0 else 0
def crear_barra_texto_color(self, porcentaje):
longitud = 10
llenas = int((min(porcentaje, 100) / 100) * longitud)
vacias = longitud - llenas
barra = '█' * llenas + '░' * vacias
porc_text = f"{porcentaje:.1f}%"
espacios = " " * (6 - len(porc_text))
return f"{porc_text}{espacios}{barra}"
def get_intervalo_actualizacion(self):
return self.data_manager.config_data.get(
'config_general', {}
).get('auto_update_minutos', 5) * 60000
def exportar_a_excel(self, datos, ano, mes):
df = pd.DataFrame(datos)
df_exportar = df[self.columnas]
archivo = f"cursos_{ano}_{mes}.xlsx"
df_exportar.to_excel(archivo, index=False)
return archivo
def calcular_metricas_generales(self, datos):
if not datos: return {}
# Filtramos las filas fantasmas para no romper la matemática de Pandas
datos_validos = [d for d in datos if d.get('dias_para_inicio') != 'REPROGRAMADO']
if not datos_validos: return {}
df = pd.DataFrame(datos_validos)
metricas = {
'total_cursos': len(datos), # Mostramos el total real de filas (incluyendo fantasmas)
'total_inscritos': df['Inscritos_Activos'].sum(),
'total_meta': df['Meta_Curso'].sum(),
'porcentaje_avance_general': (df['Inscritos_Activos'].sum() / df['Meta_Curso'].sum() * 100) if df['Meta_Curso'].sum() > 0 else 0,
'cursos_sobre_meta': len(df[df['Inscritos_Activos'] >= df['Meta_Curso']]),
'cursos_bajo_meta': len(df[df['Inscritos_Activos'] < df['Meta_Curso']]),
'total_inscritos_pc': df['Inscritos_PC'].sum()
}
return metricas
def obtener_top_programas(self, datos, top_n=5):
if not datos: return []
# Filtramos las filas fantasmas
datos_validos = [d for d in datos if d.get('dias_para_inicio') != 'REPROGRAMADO']
if not datos_validos: return []
df = pd.DataFrame(datos_validos)
df['Porcentaje_Avance'] = df.apply(
lambda x: self.calcular_porcentaje_avance(x['Inscritos_Activos'], x['Meta_Curso']),
axis=1
)
top_programas = df.nlargest(top_n, 'Porcentaje_Avance')[
['programa_frecuencia', 'Inscritos_Activos', 'Meta_Curso', 'Porcentaje_Avance']
].to_dict('records')
return top_programas
def calcular_tendencias_mensuales(self, ano):
tendencias = {}
for mes in range(1, 13):
try:
datos_mes = self.obtener_datos_procesados(str(ano), str(mes))
if datos_mes:
metricas = self.calcular_metricas_generales(datos_mes)
tendencias[mes] = metricas
except Exception as e:
print(f"Error procesando mes {mes}: {e}")
continue
return tendencias

View File

@@ -1,677 +0,0 @@
# modules/ocupabilidad/processor.py
from datetime import datetime
import unicodedata
class CursoProcessor:
"""Procesador de datos de cursos - Aplica transformaciones y cálculos"""
def __init__(self, data_manager):
self.data_manager = data_manager
# --- COLUMNAS REORDENADAS Y REORGANIZADAS ---
self.columnas = [
'programa_frecuencia',
'fch_inicio',
'dias_para_inicio',
'Inscritos_Mes',
'Inscritos_Totales',
'Retirados',
'Inscritos_Activos',
'Inscritos_PC',
'Descuento', # Esto es 'INSCRITOS REFRIPERU'
'Inscritos_Continuidad',
'Meta_Curso',
'Avance_Inscritos'
]
def obtener_datos_procesados(self, ano, mes):
try:
# 1. Cargar datos básicos
datos_matriculas = self.data_manager.cargar_datos_matriculas(ano, mes)
datos_raw_matriculas = self.data_manager.ejecutar_consulta_matriculados_detalle(ano, mes)
datos_originales = self.data_manager.ejecutar_consulta_cursos(ano, mes)
# --- NUEVO: EL GUARDIÁN DE FECHAS (SUPABASE) ---
fechas_historicas = self.data_manager.obtener_fechas_originales()
indices_en_sql = set()
for fila in datos_originales:
indice = str(fila.get('num_indice', '')).strip()
fecha_actual = fila.get('fch_inicio')
programa = fila.get('dsc_programa', '')
if indice and fecha_actual:
indices_en_sql.add(indice) # Registramos que sí vino en SQL este mes
if indice not in fechas_historicas:
fecha_str = fecha_actual.strftime('%d/%m/%Y') if isinstance(fecha_actual, datetime) else str(fecha_actual)
exito = self.data_manager.guardar_fecha_original(indice, programa, fecha_str)
if exito:
fechas_historicas[indice] = {'fecha': fecha_str, 'programa': programa}
# -----------------------------------------------
# 2. CONTINUIDAD
lista_dnis = []
if datos_raw_matriculas:
for alumno in datos_raw_matriculas:
dni = alumno.get('dsc_documento')
if dni: lista_dnis.append(dni)
datos_historial = self.data_manager.consultar_historial_continuidad(lista_dnis)
# 3. Procesar todo
datos_procesados = self.aplicar_personalizaciones(
datos_originales,
datos_matriculas,
datos_raw_matriculas,
datos_historial,
ano, mes
)
# --- INYECTAR CURSOS REPROGRAMADOS (FILAS FANTASMAS) ---
if getattr(self, 'mostrar_reprogramados', True):
for idx, info in fechas_historicas.items():
if idx not in indices_en_sql: # Si el curso NO vino este mes desde SQL...
# Aseguramos compatibilidad si guardaste data vieja como texto o la nueva como diccionario
if isinstance(info, dict):
fecha_orig = info.get('fecha', '')
prog_orig = info.get('programa', 'CURSO REPROGRAMADO')
else:
fecha_orig = str(info)
prog_orig = 'CURSO REPROGRAMADO'
# Verificamos si la fecha original correspondía a ESTE mes Y AÑO filtrado
mes_orig = ""
ano_orig = ""
if "/" in fecha_orig:
partes = fecha_orig.split("/")
if len(partes) >= 2: mes_orig = str(int(partes[1]))
if len(partes) >= 3: ano_orig = str(int(partes[2]))
elif "-" in fecha_orig:
partes = fecha_orig.split("-")
if len(partes) >= 2: mes_orig = str(int(partes[1]))
if len(partes) >= 3: ano_orig = str(int(partes[2]))
# Mes debe coincidir; y si la fecha tiene año, el año también debe coincidir.
# Si la fecha NO tiene año (formato viejo dd/mm), no se inyecta para evitar
# mostrarla en años equivocados.
coincide_mes = (mes_orig == str(int(mes)))
coincide_ano = (ano_orig == str(int(ano))) if ano_orig else False
if coincide_mes and coincide_ano:
# Inyectamos la fila falsa para alertar en pantalla
datos_procesados.append({
'num_indice': idx,
'programa_frecuencia': prog_orig,
'dsc_programa': prog_orig,
'fch_inicio': fecha_orig,
'dias_para_inicio': 'REPROGRAMADO',
'Inscritos_Mes': "-",
'Inscritos_Totales': "-",
'Retirados': "-",
'Inscritos_Activos': "-",
'Inscritos_PC': "-",
'Descuento': "-",
'Inscritos_Continuidad': "-",
'Meta_Curso': "-",
'Avance_Inscritos': "-"
})
# -----------------------------------------------
## --- ORDENAR: NORMALES POR FECHA, REPROGRAMADOS POR SEDE ---
def parse_fecha_orden(fecha_str):
if not fecha_str or fecha_str == "-":
return datetime.max
s = str(fecha_str).replace("/", "-").strip()
try:
if len(s) == 5:
return datetime.strptime(f"{s}-{ano}", "%d-%m-%Y")
if len(s) >= 10:
return datetime.strptime(s[:10], "%d-%m-%Y")
except:
pass
return datetime.max
def obtener_orden_sede(programa):
prog_upper = str(programa).upper()
if 'LIMA' in prog_upper: return 1
if 'PIURA' in prog_upper: return 2
if 'TRUJILLO' in prog_upper: return 3
if 'AREQUIPA' in prog_upper: return 4
return 5
def logica_ordenamiento(x):
if x.get('dias_para_inicio') == 'REPROGRAMADO':
# Reprogramados: Van al final (1), ordenados por sede, luego alfabéticamente
return (1, obtener_orden_sede(x.get('dsc_programa', '')), x.get('dsc_programa', ''))
else:
# Normales: Van al inicio (0), ordenados por fecha
return (0, 0, parse_fecha_orden(x.get('fch_inicio', '')))
# Aplicamos el ordenamiento inteligente
datos_procesados.sort(key=logica_ordenamiento)
return datos_procesados
except Exception as e:
print(f"❌ Error obteniendo datos procesados: {e}")
return []
# =========================================================================
# DETALLE DE ALUMNOS DE UN CURSO (para el popup "Ver")
# =========================================================================
def obtener_alumnos_curso(self, num_indice):
"""Alumnos matriculados de un curso (num_indice), TODOS menos ANU.
Devuelve nombre + cod_estado (ALU/PRE/RET) + curso anterior TEAC/TERC (si existe)."""
try:
idx = str(num_indice).strip()
conn = self.data_manager.get_connection()
cursor = conn.cursor()
# 1) Alumnos del curso actual (nombre, estado, dni, fecha de inicio del curso)
sql = """
SELECT
sgeca_matricula.num_matricula,
sgeca_matricula.cod_estado AS cod_estado,
sgema_alumno.dsc_documento AS dni,
sgema_alumno.dsc_apellido_paterno + ' ' + sgema_alumno.dsc_apellido_materno
+ ', ' + sgema_alumno.dsc_nombres AS dsc_alumno,
rp.fch_inicio AS fch_inicio_actual
FROM sgeca_matricula
INNER JOIN sgema_alumno ON sgeca_matricula.cod_alumno = sgema_alumno.cod_alumno
LEFT JOIN sgede_RP_programa rp
ON sgeca_matricula.cod_periodo = rp.cod_detalle
AND sgeca_matricula.cod_programa = rp.cod_programa
AND sgeca_matricula.num_indice = rp.num_indice
WHERE sgeca_matricula.num_indice = ?
AND sgeca_matricula.cod_localidad LIKE 'SCENT'
AND sgeca_matricula.cod_estado NOT IN ('ANU')
ORDER BY dsc_alumno
"""
cursor.execute(sql, idx)
filas = []
dnis = []
for row in cursor.fetchall():
mat = str(row[0]).strip()
if mat.endswith('.0'): mat = mat[:-2]
dni = str(row[2]).strip() if row[2] else ""
filas.append({
"matricula": mat,
"estado": str(row[1]).strip().upper(),
"dni": dni,
"alumno": str(row[3]).strip(),
"fch_inicio_actual": self.parse_fecha(row[4]),
"curso_anterior": "-",
"estado_anterior": "-",
})
if dni:
dnis.append(dni)
# 2) Historial de esos alumnos (cursos TEAC/TERC, no ANU, no SUS)
historial = {} # dni -> lista de {programa, fecha, estado}
if dnis:
dnis_unicos = list(set(dnis))
for i in range(0, len(dnis_unicos), 1000):
chunk = dnis_unicos[i:i+1000]
ph = ",".join(["?"] * len(chunk))
sql_hist = f"""
SELECT a.dsc_documento,
rp.dsc_det_programa AS programa_detallado,
p.dsc_programa AS programa_general,
rp.fch_inicio, m.cod_estado
FROM sgeca_matricula m
INNER JOIN sgema_alumno a ON m.cod_alumno = a.cod_alumno
INNER JOIN sgeca_programa p ON m.cod_programa = p.cod_programa
INNER JOIN sgede_RP_programa rp
ON m.cod_periodo = rp.cod_detalle
AND m.cod_programa = rp.cod_programa
AND m.num_indice = rp.num_indice
WHERE a.dsc_documento IN ({ph})
AND m.cod_localidad LIKE 'SCENT'
AND m.cod_estado IN ('ALU','PRE','RET')
AND rp.cod_estado <> 'SUS'
"""
cursor.execute(sql_hist, *chunk)
for r in cursor.fetchall():
d = str(r[0]).strip()
det = str(r[1]).strip() if r[1] else ""
gen = str(r[2]).strip() if r[2] else ""
historial.setdefault(d, []).append({
"programa": det if det and det != "None" else gen, # detallado (como Cobranza)
"programa_clasif": gen, # general, para clasificar TEAC/TERC
"fecha": self.parse_fecha(r[3]),
"estado": str(r[4]).strip().upper(),
})
conn.close()
# 3) Para cada alumno, buscar el ÚLTIMO curso anterior TEAC/TERC
for f in filas:
dni = f["dni"]
fch_act = f["fch_inicio_actual"]
if not dni or not fch_act or dni not in historial:
continue
candidatos = []
for h in historial[dni]:
if not h["fecha"]:
continue
if not (h["fecha"] < fch_act): # debe ser anterior
continue
cat = self.obtener_categoria_programa(h.get("programa_clasif") or h["programa"])
if cat not in ("TEAC", "TERC"): # solo TEAC/TERC
continue
candidatos.append(h)
if candidatos:
ultimo = max(candidatos, key=lambda x: x["fecha"])
f["curso_anterior"] = ultimo["programa"]
f["estado_anterior"] = ultimo["estado"]
# limpiar campos internos
for f in filas:
f.pop("dni", None)
f.pop("fch_inicio_actual", None)
return filas
except Exception as e:
print(f"❌ Error obtener_alumnos_curso: {e}")
return []
def normalizar_texto(self, texto):
if not texto: return ""
texto = str(texto).upper().strip()
texto = unicodedata.normalize('NFD', texto)
texto = texto.encode('ascii', 'ignore').decode("utf-8")
return texto
def obtener_categoria_programa(self, nombre_programa):
try:
nombre_limpio = self.normalizar_texto(nombre_programa)
clasificaciones = self.data_manager.meta_data.get('clasificacion_programas', {})
for categoria, config in clasificaciones.items():
patrones = config.get('patrones', [])
for patron in patrones:
patron_limpio = self.normalizar_texto(patron)
if patron_limpio in nombre_limpio:
return categoria
return "OTROS"
except:
return "OTROS"
def aplicar_personalizaciones(self, datos_originales, datos_matriculas, datos_raw_matriculas, datos_historial, ano_filtro, mes_filtro):
print("🎯 Aplicando personalizaciones...")
cursos_personalizados = self.data_manager.config_data.get('cursos_personalizados', {})
datos_filtrados = []
try:
ano_target = int(ano_filtro)
mes_target = int(mes_filtro)
except ValueError:
ano_target = 0
mes_target = 0
# 1. FILTRADO
for curso in datos_originales:
num_indice = str(curso.get('num_indice', ''))
if num_indice in cursos_personalizados:
personalizacion = cursos_personalizados[num_indice]
if 'fch_inicio' in personalizacion:
curso['fch_inicio'] = personalizacion['fch_inicio']
if 'flg_activo' in personalizacion:
curso['flg_activo'] = personalizacion['flg_activo']
if 'dsc_programa' in personalizacion:
curso['dsc_programa'] = personalizacion['dsc_programa']
if 'dsc_det_programa' in personalizacion:
curso['dsc_det_programa'] = personalizacion['dsc_det_programa']
if curso.get('flg_activo', '') == 'NO':
continue
fch_valida = True
try:
raw_fecha = curso.get('fch_inicio')
if raw_fecha:
f_obj = None
s_fecha = str(raw_fecha).strip()
if len(s_fecha) == 10 and s_fecha[2] == '-' and s_fecha[5] == '-':
f_obj = datetime.strptime(s_fecha, '%d-%m-%Y')
elif '-' in s_fecha:
if ' ' in s_fecha:
f_obj = datetime.strptime(s_fecha.split('.')[0], '%Y-%m-%d %H:%M:%S')
else:
f_obj = datetime.strptime(s_fecha, '%Y-%m-%d')
if f_obj:
if f_obj.year != ano_target or f_obj.month != mes_target:
fch_valida = False
except Exception:
pass
if fch_valida:
datos_filtrados.append(curso)
# 2. TRANSFORMACIONES
datos_filtrados = self.aplicar_reemplazos_programas(datos_filtrados)
datos_filtrados = self.concatenar_programa_frecuencia(datos_filtrados)
for curso in datos_filtrados:
if 'fch_inicio' in curso:
curso['fch_inicio'] = self.formatear_fecha(curso['fch_inicio'])
# 3. DATOS CALCULADOS CON PRIORIDAD (REFRIPERU > CONTINUIDAD)
datos_filtrados, alumnos_refriperu = self.agregar_descuento(datos_filtrados, datos_raw_matriculas)
datos_filtrados = self.agregar_continuidad(datos_filtrados, datos_raw_matriculas, datos_historial, alumnos_refriperu)
datos_filtrados = self.calcular_dias_para_inicio_cursos(datos_filtrados)
datos_filtrados = self.agregar_inscritos_mes(datos_filtrados, datos_matriculas)
datos_filtrados = self.agregar_inscritos_pc(datos_filtrados, datos_raw_matriculas)
datos_filtrados = self.agregar_meta_curso(datos_filtrados)
datos_filtrados = self.agregar_avance_inscritos(datos_filtrados)
return datos_filtrados
def aplicar_reemplazos_programas(self, datos):
if 'reemplazos_programas' in self.data_manager.replace_data:
reemplazos = self.data_manager.replace_data['reemplazos_programas']
for curso in datos:
programa_original = curso.get('dsc_programa', '')
if programa_original in reemplazos:
curso['dsc_programa'] = reemplazos[programa_original]
return datos
def concatenar_programa_frecuencia(self, datos):
for curso in datos:
programa = curso.get('dsc_programa', '')
frecuencia = curso.get('cod_frecuencia', '')
if programa and frecuencia:
curso['programa_frecuencia'] = f"{programa} - {frecuencia}"
elif programa:
curso['programa_frecuencia'] = programa
elif frecuencia:
curso['programa_frecuencia'] = frecuencia
else:
curso['programa_frecuencia'] = ""
return datos
def formatear_fecha(self, fecha_str):
try:
if not fecha_str: return ""
if isinstance(fecha_str, str) and len(fecha_str) == 10 and fecha_str[2] == '-': return fecha_str
if '.' in str(fecha_str):
return datetime.strptime(str(fecha_str), '%Y-%m-%d %H:%M:%S.%f').strftime('%d-%m-%Y')
return datetime.strptime(str(fecha_str), '%Y-%m-%d %H:%M:%S').strftime('%d-%m-%Y')
except: return str(fecha_str)
def identificar_linea_carrera(self, nombre_programa):
categoria = self.obtener_categoria_programa(nombre_programa)
# --- AQUÍ ESTÁ EL CAMBIO ---
# Si es cualquiera de estas 5, las unimos bajo una misma "Línea Universal" de compatibilidad
if categoria in ["AREQUIPA", "TRUJILLO", "PIURA", "TEAC", "TERC"]:
return "CARRERA_COMPATIBLE"
# Si es GESTION, MASTERCLASS, SEMINARIOS, etc., retorna None (las sigue ignorando)
return None
def parse_fecha(self, fecha_raw):
try:
if isinstance(fecha_raw, datetime): return fecha_raw
if not fecha_raw: return None
s_fecha = str(fecha_raw).strip()
if len(s_fecha) == 10 and s_fecha[2] == '-':
return datetime.strptime(s_fecha, '%d-%m-%Y')
if ' ' in s_fecha:
return datetime.strptime(s_fecha.split('.')[0], '%Y-%m-%d %H:%M:%S')
return datetime.strptime(s_fecha, '%Y-%m-%d')
except:
return None
# =========================================================================
# ❄️ REFRIPERU / DESCUENTO (ALTA PRIORIDAD)
# =========================================================================
def agregar_descuento(self, datos, datos_raw_matriculas):
print("\n💰 Calculando REFRIPERU (Descuentos)...")
print("="*80)
print(f"{'DNI':<12} | {'NOMBRE ALUMNO':<35} | {'PROGRAMA':<20} | {'PAGO':<10} | {'ZONA/MOTIVO'}")
print("-" * 80)
conteo_por_curso = {}
alumnos_refriperu = set()
correcciones_descuento = self.data_manager.config_data.get('correcciones_descuento', {})
if datos_raw_matriculas:
for alumno in datos_raw_matriculas:
try:
estado = str(alumno.get('estado_matricula', '')).strip().upper()
if estado not in ['ALU', 'PRE']: continue
num_indice = str(alumno.get('num_indice', ''))
dni = str(alumno.get('dsc_documento', 'SD')).strip()
nombre_alumno = str(alumno.get('nombre_alumno', 'SIN NOMBRE')).strip()
mat_id = str(alumno.get('num_matricula', '')).strip()
if mat_id.endswith('.0'): mat_id = mat_id[:-2]
if mat_id in correcciones_descuento:
accion = str(correcciones_descuento[mat_id]).upper().strip()
if accion == "SI":
conteo_por_curso[num_indice] = conteo_por_curso.get(num_indice, 0) + 1
alumnos_refriperu.add(mat_id)
print(f"{dni:<12} | {nombre_alumno[:35]:<35} | {'---':<20} | {'---':<10} | MANUAL (JSON)")
continue
elif accion == "NO":
continue
nombre_programa = str(alumno.get('dsc_programa', ''))
cod_moneda = str(alumno.get('cod_moneda', 'SOL')).strip().upper()
try:
inv_neta = float(alumno.get('INV_NETA', 0) or 0)
imp_tc = float(alumno.get('imp_tc', 1) or 1)
if imp_tc <= 0: imp_tc = 1.0
except:
inv_neta = 0.0; imp_tc = 1.0
if cod_moneda == 'DOL':
inv_neta_final = inv_neta * imp_tc
else:
inv_neta_final = inv_neta
inv_neta_final = round(inv_neta_final, 2)
categoria_json = self.obtener_categoria_programa(nombre_programa)
es_descuento = False
motivo_debug = ""
if categoria_json in ["AREQUIPA", "TRUJILLO", "PIURA"]:
if 1200 <= inv_neta_final <= 1700:
es_descuento = True
motivo_debug = f"{categoria_json} (Rango Prov)"
elif categoria_json in ["TEAC", "TERC"]:
if 1400 <= inv_neta_final <= 1900:
es_descuento = True
motivo_debug = f"{categoria_json} (Rango Lima)"
if es_descuento:
conteo_por_curso[num_indice] = conteo_por_curso.get(num_indice, 0) + 1
alumnos_refriperu.add(mat_id)
print(f"{dni:<12} | {nombre_alumno[:35]:<35} | {nombre_programa[:20]:<20} | S/{inv_neta_final:<8} | {motivo_debug}")
except Exception:
continue
for curso in datos:
idx = str(curso.get('num_indice', ''))
curso['Descuento'] = conteo_por_curso.get(idx, 0)
return datos, alumnos_refriperu
# =========================================================================
# 🔄 CONTINUIDAD (BAJA PRIORIDAD)
# =========================================================================
def agregar_continuidad(self, datos, datos_raw_matriculas, datos_historial, alumnos_refriperu=None):
if alumnos_refriperu is None:
alumnos_refriperu = set()
print("🔄 Calculando CONTINUIDAD...")
print("="*80)
print(f"{'DNI':<12} | {'NOMBRE ALUMNO':<35} | {'PROGRAMA ACTUAL':<30} | {'MOTIVO/ORIGEN'}")
print("-" * 80)
conteo_por_curso = {}
correcciones_continuidad = self.data_manager.config_data.get('correcciones_continuidad', {})
MIN_DIAS_DIFERENCIA = 60
if datos_raw_matriculas and datos_historial:
alumnos_por_curso = {}
for alu in datos_raw_matriculas:
idx = str(alu.get('num_indice', ''))
if idx not in alumnos_por_curso: alumnos_por_curso[idx] = []
alumnos_por_curso[idx].append(alu)
for curso in datos:
num_indice = str(curso.get('num_indice', ''))
nombre_curso_actual = str(curso.get('dsc_programa', ''))
fecha_inicio_actual = self.parse_fecha(curso.get('fch_inicio'))
# Identifica si el curso actual pertenece a la "Línea Universal" de 5 categorías
linea_actual = self.identificar_linea_carrera(nombre_curso_actual)
if not linea_actual or not fecha_inicio_actual: continue
lista_alumnos = alumnos_por_curso.get(num_indice, [])
contador_fieles = 0
for alumno in lista_alumnos:
estado = str(alumno.get('estado_matricula', '')).strip().upper()
if estado not in ['ALU', 'PRE']: continue
mat_id = str(alumno.get('num_matricula', '')).strip()
if mat_id.endswith('.0'): mat_id = mat_id[:-2]
if mat_id in alumnos_refriperu:
continue
dni = str(alumno.get('dsc_documento', 'SD')).strip()
nombre_alumno = str(alumno.get('nombre_alumno', 'SIN NOMBRE')).strip()
es_manual_si = False
if mat_id in correcciones_continuidad:
accion = str(correcciones_continuidad[mat_id]).upper().strip()
if accion == "SI": es_manual_si = True
elif accion == "NO": continue
es_fiel = False
motivo = ""
if es_manual_si:
es_fiel = True
motivo = "MANUAL (JSON)"
elif dni and dni in datos_historial:
historial_alumno = datos_historial[dni]
for antecedente in historial_alumno:
nombre_pasado = str(antecedente.get('programa', ''))
fecha_pasada = antecedente.get('fecha')
if not fecha_pasada: continue
if not (fecha_pasada < fecha_inicio_actual): continue
dias_diff = (fecha_inicio_actual - fecha_pasada).days
if dias_diff < MIN_DIAS_DIFERENCIA: continue
# Revisa si el curso pasado también es de esa misma línea universal
linea_pasada = self.identificar_linea_carrera(nombre_pasado)
# Si ambos devuelven "CARRERA_COMPATIBLE", entonces hacen match
if linea_pasada == linea_actual:
es_fiel = True
motivo = f"Viene de: {nombre_pasado}"
break
if es_fiel:
contador_fieles += 1
print(f"{dni:<12} | {nombre_alumno[:35]:<35} | {nombre_curso_actual[:30]:<30} | {motivo}")
conteo_por_curso[num_indice] = contador_fieles
for curso in datos:
idx = str(curso.get('num_indice', ''))
curso['Inscritos_Continuidad'] = conteo_por_curso.get(idx, 0)
return datos
def calcular_dias_para_inicio(self, fecha_str, cod_estado):
try:
if cod_estado == 'SUS': return "SUSPENDIDO"
if not fecha_str: return ""
if isinstance(fecha_str, str) and len(fecha_str) == 10 and fecha_str[2] == '-':
fecha_curso = datetime.strptime(fecha_str, '%d-%m-%Y')
else:
fecha_curso = datetime.strptime(str(fecha_str), '%Y-%m-%d %H:%M:%S')
hoy = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
fecha_curso = fecha_curso.replace(hour=0, minute=0, second=0, microsecond=0)
diferencia = (fecha_curso - hoy).days
if diferencia < 0: return "INICIADO"
return str(diferencia) if diferencia != 0 else "0"
except: return ""
def calcular_dias_para_inicio_cursos(self, datos):
for curso in datos:
curso['dias_para_inicio'] = self.calcular_dias_para_inicio(curso.get('fch_inicio'), curso.get('cod_estado'))
return datos
def agregar_inscritos_mes(self, datos, datos_matriculas):
dict_norm = {str(k): v for k, v in datos_matriculas.items()}
for curso in datos:
num_indice = str(curso.get('num_indice', ''))
curso['Inscritos_Mes'] = dict_norm.get(num_indice, 0)
return datos
def agregar_inscritos_pc(self, datos, datos_raw_matriculas):
conteo_pc = {}
if datos_raw_matriculas:
for m in datos_raw_matriculas:
try:
est = str(m.get('estado_matricula', '')).strip()
nid = str(m.get('num_indice', ''))
sm = float(m.get('imp_saldo_matricula', 0) or 0)
sc1 = float(m.get('imp_saldo_cuota1', 0) or 0)
if est in ['ALU', 'PRE'] and sm < 1 and sc1 < 1:
conteo_pc[nid] = conteo_pc.get(nid, 0) + 1
except: continue
for curso in datos:
curso['Inscritos_PC'] = conteo_pc.get(str(curso.get('num_indice', '')), 0)
curso['Retirados'] = int(curso.get('Inscritos_Retirados', 0)) # <--- NUEVA EXTRACCIÓN
return datos
def calcular_meta_curso(self, nombre_programa, cod_estado):
try:
if cod_estado == 'SUS': return 0
categoria = self.obtener_categoria_programa(nombre_programa)
clasificaciones = self.data_manager.meta_data.get('clasificacion_programas', {})
if categoria in clasificaciones:
return clasificaciones[categoria].get('valor', 0)
return self.data_manager.meta_data.get('clasificacion_default', {}).get('valor', 15)
except: return 0
def agregar_meta_curso(self, datos):
print("🎯 Calculando META_CURSO...")
for curso in datos:
curso['Meta_Curso'] = self.calcular_meta_curso(curso.get('dsc_programa', ''), curso.get('cod_estado', ''))
return datos
def calcular_avance_inscritos(self, inscritos_activos, meta_curso):
try:
if meta_curso > 0:
porcentaje = (inscritos_activos / meta_curso) * 100
return f"{porcentaje:.1f}%"
else: return "0%"
except: return "0%"
def agregar_avance_inscritos(self, datos):
print("📊 Calculando AVANCE_INSCRITOS...")
for curso in datos:
curso['Avance_Inscritos'] = self.calcular_avance_inscritos(curso.get('Inscritos_Totales', 0), curso.get('Meta_Curso', 0))
return datos

View File

@@ -1,946 +0,0 @@
# modules/rentabilidad/logic.py
import pandas as pd
import requests
import re
from datetime import datetime
from .processor import RentabilidadProcessor
class RentabilidadLogic:
"""Controlador lógico de Rentabilidad - Estructura la tabla y gestiona variables de clasificación"""
def __init__(self, data_manager):
self.data_manager = data_manager
self.processor = RentabilidadProcessor(self.data_manager)
def obtener_categorias(self):
"""Devuelve la lista simplificada y agrupada para el filtro del Dashboard"""
return ["AREQUIPA", "TRUJILLO", "PIURA", "PROGRAMAS", "SEMINARIOS", "OTROS"]
def obtener_sedes(self):
"""Devuelve la lista de sedes para el nuevo filtro"""
return ["LIMA", "AREQUIPA", "PIURA", "TRUJILLO"]
def identificar_sede(self, dsc_programa):
"""Identifica la sede basada en el nombre del programa y el JSON de GitHub"""
if not dsc_programa: return "LIMA"
dsc_prog_upper = str(dsc_programa).upper()
sedes_data = self.data_manager.meta_data.get("clasificacion_sedes", {})
for sede, data in sedes_data.items():
if sede == "DEFAULT": continue
for patron in data.get("patrones", []):
if patron.upper() in dsc_prog_upper:
return sede
return sedes_data.get("DEFAULT", "LIMA")
def obtener_filtros_programa(self):
"""Devuelve la lista de programas para el nuevo filtro combinado"""
return ["SEMINARIOS", "OTROS", "TEAC", "TERC"]
def identificar_filtro_programa(self, dsc_programa):
"""Identifica la categoría del programa para el filtro de la UI"""
if not dsc_programa: return "OTROS"
dsc_prog_upper = str(dsc_programa).upper()
filtro_data = self.data_manager.meta_data.get("clasificacion_filtro_programa", {})
for categoria, data in filtro_data.items():
if categoria == "DEFAULT": continue
for patron in data.get("patrones", []):
if patron.upper() in dsc_prog_upper:
return categoria
return filtro_data.get("DEFAULT", "OTROS")
def clasificar_programa(self, dsc_programa):
if not dsc_programa: return "OTROS"
dsc_prog_upper = str(dsc_programa).upper()
meta_data = self.data_manager.meta_data
clasificaciones = meta_data.get("clasificacion_programas", {})
for clase, data in clasificaciones.items():
for patron in data.get("patrones", []):
if patron.upper() in dsc_prog_upper:
return clase
return meta_data.get("clasificacion_default", {}).get("categoria", "SEMINARIOS")
def identificar_linea_carrera(self, nombre_programa):
"""Identifica si el programa es de la familia de Carreras Técnicas"""
categoria = self.clasificar_programa(nombre_programa)
if categoria in ["AREQUIPA", "TRUJILLO", "PIURA", "TEAC", "TERC"]:
return "CARRERA_COMPATIBLE"
return None
def parse_fecha(self, fecha_raw):
"""Convierte diferentes formatos de fecha a objeto datetime"""
try:
if isinstance(fecha_raw, datetime): return fecha_raw
if not fecha_raw: return None
s_fecha = str(fecha_raw).strip()
if len(s_fecha) == 10 and s_fecha[2] == '-':
return datetime.strptime(s_fecha, '%d-%m-%Y')
if ' ' in s_fecha:
return datetime.strptime(s_fecha.split('.')[0], '%Y-%m-%d %H:%M:%S')
return datetime.strptime(s_fecha, '%Y-%m-%d')
except:
return None
def calcular_descuento(self, clasificacion, inv_neta):
try: inv_neta = float(inv_neta)
except: inv_neta = 0.0
if clasificacion in ["TEAC", "TERC"]: return 3499.0 - inv_neta
elif clasificacion in ["AREQUIPA", "PIURA", "TRUJILLO"]: return 2839.0 - inv_neta
elif clasificacion == "CARRERA": return 4299.0 - inv_neta
else: return None
def calcular_estado_descuento(self, clasificacion, descuento):
if descuento is None: return ""
es_provincia = clasificacion in ["TRUJILLO", "AREQUIPA", "PIURA"]
tope = 1000.0 if es_provincia else 800.0
if descuento > tope or descuento < 0: return "NO"
return "SI"
# ========================================================================
# MOTOR DINÁMICO DE COSTOS - BÚSQUEDA INTELIGENTE
# ========================================================================
# ========================================================================
# MOTOR DINÁMICO DE COSTOS - BÚSQUEDA INTELIGENTE
# ========================================================================
def _obtener_costo_segun_categoria(self, diccionario_costo, categoria, dsc_programa):
"""Busca el costo inteligente. Si es 'otros', busca similitud de palabras."""
cat_lower = str(categoria).lower()
# 1. Agrupamos los técnicos dentro de 'programas'
if cat_lower in ["teac", "terc"]:
cat_lower = "programas"
# 2. EL EMBUDO: Si la categoría no existe en el diccionario (ej. seminarios, masterclass, etc.),
# la forzamos a que caiga siempre en la bolsa de "otros"
if cat_lower not in diccionario_costo:
cat_lower = "otros"
# 3. Buscamos el valor
if cat_lower in diccionario_costo:
valor = diccionario_costo[cat_lower]
# Si el valor es un bloque de similitudes (Como sucede ahora con "otros")
if isinstance(valor, dict):
dsc_upper = str(dsc_programa).upper()
# Buscamos coincidencias con el nombre completo del curso
for patron, monto in valor.items():
if patron != "DEFAULT" and patron.upper() in dsc_upper:
return float(monto)
# Si lee todo el diccionario y no encuentra coincidencia, usamos el DEFAULT
return float(valor.get("DEFAULT", 0))
else:
# Si es un número directo (ej. piura, trujillo, carrera)
return float(valor)
return 0.0
# ========================================================================
def obtener_datos_procesados(self, ano, mes, sede="TODOS", filtro_prog="TODOS"):
"""Obtiene datos, inyecta Categoria_Programa, Sede, Filtro_Programa y calcula promedios."""
datos = self.processor.obtener_datos_procesados(ano, mes)
programas_disponibles = set()
if datos:
for d in datos:
prog = str(d.get('programa_frecuencia', d.get('dsc_programa', '')))
d['Categoria_Programa'] = self.clasificar_programa(prog)
d['Sede'] = self.identificar_sede(prog)
d['Filtro_Programa'] = self.identificar_filtro_programa(prog)
# Recolectar qué programas existen REALMENTE en la Sede seleccionada
if sede == "TODOS" or d['Sede'] == sede:
programas_disponibles.add(d['Filtro_Programa'])
# Ordenar para el Dropdown en la UI
orden_deseado = ["SEMINARIOS", "OTROS", "TEAC", "TERC"]
self.programas_disponibles_filtro = [p for p in orden_deseado if p in programas_disponibles]
# NO auto-corregir: se respeta el filtro y la tabla queda vacia si no hay.
self.filtro_corregido = None
if datos and (sede != "TODOS" or filtro_prog != "TODOS"):
datos_filtrados = []
for d in datos:
cumple_sede = (sede == "TODOS" or d.get('Sede') == sede)
cumple_prog = (filtro_prog == "TODOS" or d.get('Filtro_Programa') == filtro_prog)
if cumple_sede and cumple_prog:
datos_filtrados.append(d)
datos = datos_filtrados
if not datos: return []
sql_matriculas = self._get_sql_matriculas_modificado()
sql_cuotas = self._get_sql_cuotas()
matriculados_raw = []
cuotas_raw = []
if sql_matriculas and sql_cuotas:
try:
conn = self.data_manager.get_connection()
cursor = conn.cursor()
try:
cursor.execute(sql_matriculas, ano, mes)
cols_mat = [c[0] for c in cursor.description]
matriculados_raw = [dict(zip(cols_mat, row)) for row in cursor.fetchall()]
cursor.execute(sql_cuotas, ano, mes)
cols_cuo = [c[0] for c in cursor.description]
cuotas_raw = [dict(zip(cols_cuo, row)) for row in cursor.fetchall()]
finally:
conn.close()
except Exception as e:
print(f"❌ Error en consultas DAX: {e}")
promedios_dax = self._calcular_promedios_dax(matriculados_raw, cuotas_raw)
for d in datos:
idx = str(d.get('num_indice', '')).strip()
if idx.endswith('.0'): idx = idx[:-2]
if idx in promedios_dax:
d['Promedio_Cuota'] = promedios_dax[idx]['promedio']
d['Suma_Dax_Real'] = promedios_dax[idx]['suma_real']
d['Conteo_Dax_Real'] = promedios_dax[idx]['conteo_real']
d['Promedio_Desc_E'] = promedios_dax[idx]['promedio_desc_e']
d['Suma_Desc_E_Real'] = promedios_dax[idx]['suma_desc_e_real']
d['Conteo_Desc_E_Real'] = promedios_dax[idx]['conteo_desc_e_real']
d['Valor_Venta'] = promedios_dax[idx]['suma_valor_venta']
d['Valor_Venta_Actual'] = promedios_dax[idx]['suma_valor_venta_actual']
else:
d['Promedio_Cuota'] = 0.0
d['Suma_Dax_Real'] = 0.0
d['Conteo_Dax_Real'] = 0
d['Promedio_Desc_E'] = 0.0
d['Suma_Desc_E_Real'] = 0.0
d['Conteo_Desc_E_Real'] = 0
d['Valor_Venta'] = 0.0
d['Valor_Venta_Actual'] = 0.0
return datos
def _calcular_promedios_dax(self, matriculados, cuotas):
alumnos_dict = {}
agrupado_indices = {}
lista_mirar_cuotas = ["AREQUIPA", "TRUJILLO", "PIURA", "TEAC", "TERC"]
correcciones_mat = self.data_manager.config_data.get('correcciones_matriculas', {})
correcciones_inv_neta = self.data_manager.config_data.get('Actualizar_INV_NETA', {})
for m in matriculados:
estado_mat = str(m.get('estado_matricula', '')).strip().upper()
if estado_mat not in ['ALU', 'PRE', 'RET']: continue
num_matricula = str(m.get('num_matricula', '')).strip()
if num_matricula.endswith('.0'): num_matricula = num_matricula[:-2]
num_indice = str(m.get('num_indice', '')).strip()
if num_indice.endswith('.0'): num_indice = num_indice[:-2]
prog = str(m.get('dsc_programa', ''))
try: inv_neta = float(m.get('INV_NETA', 0.0))
except: inv_neta = 0.0
tc_override = None
if num_matricula in correcciones_mat:
if "imp_tc" in correcciones_mat[num_matricula]:
tc_override = float(correcciones_mat[num_matricula]["imp_tc"])
# TC del COMPROBANTE — único TC para todo (INV_NETA + cuotas)
if tc_override is not None:
imp_tc_comp = tc_override
else:
try: imp_tc_comp = float(m.get('imp_tc', 0) or 0)
except: imp_tc_comp = 0
if imp_tc_comp < 2: imp_tc_comp = 3.45 # TC por defecto
# CORRECCIÓN MANUAL DE INV_NETA (siempre en SOL, sin multiplicar)
if num_matricula in correcciones_inv_neta:
try: inv_neta = float(correcciones_inv_neta[num_matricula])
except: pass
else:
cod_moneda_mat = str(m.get('cod_moneda', '')).strip().upper()
if cod_moneda_mat == "DOL":
inv_neta = inv_neta * imp_tc_comp
clase = self.clasificar_programa(prog)
descuento = self.calcular_descuento(clase, inv_neta)
estado_desc = self.calcular_estado_descuento(clase, descuento)
alumnos_dict[num_matricula] = {
'num_indice': num_indice,
'clasificacion': clase,
'programa': prog,
'estado_descuento': estado_desc,
'inv_neta': inv_neta,
'estado_mat': estado_mat,
'monto_real_cuotas': 0.0,
'imp_tc_comprobante': imp_tc_comp
}
if num_indice not in agrupado_indices:
agrupado_indices[num_indice] = {
'suma': 0.0, 'conteo': 0,
'suma_desc_e': 0.0, 'conteo_desc_e': 0,
'suma_valor_venta': 0.0,
'suma_valor_venta_actual': 0.0
}
agrupado_indices[num_indice]['suma_valor_venta'] += inv_neta
# ALU y PRE: suma INV_NETA completa
if estado_mat in ['ALU', 'PRE']:
agrupado_indices[num_indice]['suma_valor_venta_actual'] += inv_neta
# RET: suma solo lo que ya pagó
elif estado_mat == 'RET':
try: imp_pagado = float(m.get('imp_total_pagado', 0) or 0)
except: imp_pagado = 0.0
agrupado_indices[num_indice]['suma_valor_venta_actual'] += imp_pagado
if clase not in lista_mirar_cuotas:
agrupado_indices[num_indice]['suma'] += inv_neta
agrupado_indices[num_indice]['conteo'] += 1
for c in cuotas:
num_matricula = str(c.get('num_matricula', '')).strip()
if num_matricula.endswith('.0'): num_matricula = num_matricula[:-2]
if num_matricula not in alumnos_dict: continue
alumno = alumnos_dict[num_matricula]
num_idx = alumno['num_indice']
if alumno['clasificacion'] in lista_mirar_cuotas:
try: num_cuota = int(c.get('num_cuota', 0))
except: num_cuota = 0
t_val = c.get('imp_total')
d_val = c.get('imp_dscto')
try: t_monto = float(t_val) if t_val is not None else 0.0
except: t_monto = 0.0
try: d_monto = float(d_val) if d_val is not None else 0.0
except: d_monto = 0.0
imp_cuota = t_monto - d_monto
cod_moneda_cuo = str(c.get('cod_moneda', '')).strip().upper()
if cod_moneda_cuo == "DOL":
# USAR el TC del COMPROBANTE (ya validado con fallback 3.45)
tc_cuo = alumno['imp_tc_comprobante']
imp_cuota = imp_cuota * tc_cuo
cumple_cuota_normal = (alumno['estado_descuento'] == "SI" and num_cuota > 0 and imp_cuota < 1000)
cumple_desc_especial = (alumno['estado_descuento'] == "SI" and num_cuota > 0)
if cumple_cuota_normal:
agrupado_indices[num_idx]['suma'] += imp_cuota
agrupado_indices[num_idx]['conteo'] += 1
if cumple_desc_especial:
alumno['monto_real_cuotas'] += imp_cuota
for mat_id, alumno in alumnos_dict.items():
idx = alumno['num_indice']
clase = alumno['clasificacion']
estado_desc = alumno['estado_descuento']
if clase in lista_mirar_cuotas:
monto_real = alumno['monto_real_cuotas']
else:
monto_real = alumno['inv_neta']
valor_final = 0.0
if monto_real >= 1900 and estado_desc == "SI":
if clase in ["TEAC", "TERC"]:
desc_calc = 3400 - monto_real
elif clase in ["AREQUIPA", "PIURA", "TRUJILLO"]:
desc_calc = 2740 - monto_real
else:
desc_calc = 0
resta = desc_calc - 200
valor_final = min(max(resta, 0.0), 200.0)
agrupado_indices[idx]['suma_desc_e'] += valor_final
agrupado_indices[idx]['conteo_desc_e'] += 1
promedios = {}
for idx, totales in agrupado_indices.items():
suma_total = totales['suma']
conteo_total = totales['conteo']
suma_desc_e = totales['suma_desc_e']
conteo_desc_e = totales['conteo_desc_e']
promedios[idx] = {
'promedio': suma_total / conteo_total if conteo_total > 0 else 0.0,
'suma_real': suma_total,
'conteo_real': conteo_total,
'promedio_desc_e': suma_desc_e / conteo_desc_e if conteo_desc_e > 0 else 0.0,
'suma_desc_e_real': suma_desc_e,
'conteo_desc_e_real': conteo_desc_e,
'suma_valor_venta': totales['suma_valor_venta'],
'suma_valor_venta_actual': totales['suma_valor_venta_actual'] # <-- NUEVO
}
return promedios
def formatear_datos_para_tabla(self, datos):
"""Estructura las filas finales para tksheet (14 columnas)"""
if not datos: return []
cfg_costos = self.data_manager.costos_data
if not cfg_costos:
cfg_costos = {
"epp": {"piura": 28, "trujillo": 28, "arequipa": 28, "programas": 28, "carrera": 28, "otros": 0},
"certificado": {"piura": 7, "trujillo": 7, "arequipa": 7, "programas": 7, "carrera": 7, "otros": 0},
"consumibles": {"piura": 676, "trujillo": 676, "arequipa": 676, "programas": 614, "carrera": 614, "otros": 0},
"marketing": {"piura": 2839, "trujillo": 2839, "arequipa": 2839, "programas": 3499, "carrera": 3499, "otros": {"VRV/VRF": 4500, "INDUSTRIAL POR AMONIACO (NH3)": 1000, "VIRTUAL SEMINARIO DISEÑO DE CHILLERS": 900, "DIPLOMADO INTERNACIONAL": 1300, "SEMINARIO VIRTUAL METRADO , COSTEO": 800, "VIRTUAL SEMINARIO DISEÑO DE CAMARAS": 1000, "DEFAULT": 0}},
"docente": {"piura": 5115, "trujillo": 5115, "arequipa": 6500, "programas": 4560, "carrera": 4560, "otros": {"VRV/VRF": 4500, "INDUSTRIAL POR AMONIACO (NH3)": 4000, "VIRTUAL SEMINARIO DISEÑO DE CHILLERS": 3000, "DIPLOMADO INTERNACIONAL": 7000, "SEMINARIO VIRTUAL METRADO , COSTEO": 2000, "VIRTUAL SEMINARIO DISEÑO DE CAMARAS": 4000, "DEFAULT": 0}}
}
# CARGA OVERRIDES DESDE SUPABASE (una sola vez por render)
overrides_supabase = self.data_manager.cargar_overrides_costos()
sheet_data = []
tot_total = tot_retirados = tot_curso = 0
tot_venta = 0.0
tot_venta_actual = 0.0
tot_costo = 0.0
tot_costo_actual = 0.0
bolsa_plata_global = 0.0
bolsa_recibos_global = 0
bolsa_desc_e_plata_global = 0.0
bolsa_desc_e_recibos_global = 0
for d in datos:
try: total_inscritos = int(d.get('Inscritos_Totales', 0))
except: total_inscritos = 0
try: retirados = int(d.get('Retirados', 0))
except: retirados = 0
try: en_curso = int(d.get('Inscritos_En_Curso', d.get('Inscritos_Activos', 0)))
except: en_curso = 0
p_cuota = float(d.get('Promedio_Cuota', 0.0))
p_desc = float(d.get('Promedio_Desc_E', 0.0))
venta = float(d.get('Valor_Venta', 0.0))
venta_actual = float(d.get('Valor_Venta_Actual', 0.0))
conteo_cuota_real = int(d.get('Conteo_Dax_Real', 0))
str_cuota_tabla = f"S/ {p_cuota:,.0f}" if conteo_cuota_real > 0 else ""
conteo_desc_e = int(d.get('Conteo_Desc_E_Real', 0))
str_desc_e_tabla = f"S/ {p_desc:,.0f}" if conteo_desc_e > 0 else ""
cat = d.get('Categoria_Programa', 'OTROS')
nombre_prog_crudo = d.get('dsc_programa', '')
# Identificador de curso para buscar override
num_idx_curso = str(d.get('num_indice', '')).strip()
if num_idx_curso.endswith('.0'): num_idx_curso = num_idx_curso[:-2]
ov_curso = overrides_supabase.get(num_idx_curso, {})
ov_inicial = ov_curso.get('inicial', {})
ov_actual = ov_curso.get('actual', {})
# ============= COSTO INICIAL (con override si existe) =============
def _costo_inicial(key_supa, key_cfg, multiplicar):
val = ov_inicial.get(key_supa)
if val is not None:
return float(val) # Supabase manda directo, sin multiplicar
base = self._obtener_costo_segun_categoria(cfg_costos.get(key_cfg, {}), cat, nombre_prog_crudo)
return total_inscritos * base if multiplicar else base
costo_epp = _costo_inicial('epp', 'epp', True)
costo_cert = _costo_inicial('certificado', 'certificado', True)
costo_cons = _costo_inicial('consumibles', 'consumibles', True)
costo_mkt = _costo_inicial('marketing', 'marketing', False)
costo_doc = _costo_inicial('docente', 'docente', False)
valor_costo_inicial = costo_epp + costo_cert + costo_cons + costo_mkt + costo_doc
# ============= COSTO ACTUAL (con override si existe) =============
def _costo_actual(key_supa, key_cfg, multiplicador):
val = ov_actual.get(key_supa)
if val is not None:
return float(val) # Supabase manda directo, sin multiplicar
base = self._obtener_costo_segun_categoria(cfg_costos.get(key_cfg, {}), cat, nombre_prog_crudo)
return multiplicador * base if multiplicador is not None else base
costo_epp_actual = _costo_actual('epp', 'epp', total_inscritos)
costo_cert_actual = _costo_actual('certificado', 'certificado', en_curso)
costo_cons_actual = _costo_actual('consumibles', 'consumibles', total_inscritos)
costo_mkt_actual = _costo_actual('marketing', 'marketing', None)
costo_doc_actual = _costo_actual('docente', 'docente', None)
costo_actual = costo_epp_actual + costo_cert_actual + costo_cons_actual + costo_mkt_actual + costo_doc_actual
# MARGEN BRUTO %
if venta > 0:
margen_bruto = 1 - (valor_costo_inicial / venta)
str_margen = f"{margen_bruto * 100:,.1f}%"
else:
str_margen = ""
# MARGEN BRUTO ACTUAL %
if venta_actual > 0:
margen_bruto_actual = 1 - (costo_actual / venta_actual)
str_margen_actual = f"{margen_bruto_actual * 100:,.1f}%"
else:
str_margen_actual = ""
tot_total += total_inscritos
tot_retirados += retirados
tot_curso += en_curso
tot_venta += venta
tot_venta_actual += venta_actual
tot_costo += valor_costo_inicial
tot_costo_actual += costo_actual
bolsa_plata_global += float(d.get('Suma_Dax_Real', 0.0))
bolsa_recibos_global += int(d.get('Conteo_Dax_Real', 0))
bolsa_desc_e_plata_global += float(d.get('Suma_Desc_E_Real', 0.0))
bolsa_desc_e_recibos_global += int(d.get('Conteo_Desc_E_Real', 0))
nombre_prog = d.get('programa_frecuencia', d.get('dsc_programa', ''))
fila = [
nombre_prog,
d.get('fch_inicio', ''),
total_inscritos,
retirados,
en_curso,
str_cuota_tabla,
str_desc_e_tabla,
f"S/ {venta:,.0f}",
f"S/ {valor_costo_inicial:,.0f}",
str_margen,
f"S/ {venta_actual:,.0f}",
f"S/ {costo_actual:,.0f}",
str_margen_actual,
" ≡ ▼ "
]
sheet_data.append(fila)
promedio_total_final = (bolsa_plata_global / bolsa_recibos_global) if bolsa_recibos_global > 0 else 0.0
promedio_desc_e_final = (bolsa_desc_e_plata_global / bolsa_desc_e_recibos_global) if bolsa_desc_e_recibos_global > 0 else 0.0
str_total_cuota = f"S/ {promedio_total_final:,.0f}" if bolsa_recibos_global > 0 else ""
str_total_desc_e = f"S/ {promedio_desc_e_final:,.0f}" if bolsa_desc_e_recibos_global > 0 else ""
margen_total = 1 - (tot_costo / tot_venta) if tot_venta > 0 else 0
margen_total_actual = 1 - (tot_costo_actual / tot_venta_actual) if tot_venta_actual > 0 else 0
fila_total = [
"TOTAL GENERAL", "",
tot_total,
tot_retirados,
tot_curso,
str_total_cuota,
str_total_desc_e,
f"S/ {tot_venta:,.0f}",
f"S/ {tot_costo:,.0f}",
f"{margen_total * 100:,.1f}%",
f"S/ {tot_venta_actual:,.0f}",
f"S/ {tot_costo_actual:,.0f}",
f"{margen_total_actual * 100:,.1f}%",
""
]
sheet_data.append(fila_total)
return sheet_data
def exportar_detalle_alumnos_excel(self, programa, headers, datos):
if not datos:
raise ValueError("No hay datos para exportar")
prog_limpio = "".join([c if c.isalnum() else "_" for c in str(programa)])[:40]
archivo = f"Detalle_Alumnos_{prog_limpio}.xlsx"
df = pd.DataFrame(datos, columns=headers)
try:
with pd.ExcelWriter(archivo, engine='openpyxl') as writer:
df.to_excel(writer, index=False, sheet_name='Detalle_Alumnos')
worksheet = writer.sheets['Detalle_Alumnos']
try:
from openpyxl.styles import PatternFill, Font, Border, Side, Alignment
fill_header = PatternFill(start_color="12345F", end_color="12345F", fill_type="solid")
font_header = Font(color="FFFFFF", bold=True)
thin_border = Border(
left=Side(style='thin', color="DDDDDD"), right=Side(style='thin', color="DDDDDD"),
top=Side(style='thin', color="DDDDDD"), bottom=Side(style='thin', color="DDDDDD")
)
for cell in worksheet[1]:
cell.fill = fill_header
cell.font = font_header
cell.border = thin_border
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
for row in worksheet.iter_rows(min_row=2):
for cell in row:
cell.border = thin_border
cell.alignment = Alignment(vertical="center")
for col in worksheet.columns:
max_len = 0
col_letter = col[0].column_letter
for cell in col:
if cell.value:
max_len = max(max_len, len(str(cell.value)))
worksheet.column_dimensions[col_letter].width = min(max_len + 3, 50)
except Exception: pass
except Exception as e:
df.to_excel(archivo, index=False)
return archivo
def obtener_detalle_programa(self, programa_target, ano, mes_numero):
prog_t = str(programa_target).strip().upper()
sql_matriculas = self._get_sql_matriculas_modificado()
sql_cuotas = self._get_sql_cuotas()
matriculados_raw = []
cuotas_raw = []
if sql_matriculas and sql_cuotas:
try:
conn = self.data_manager.get_connection()
cursor = conn.cursor()
try:
cursor.execute(sql_matriculas, ano, mes_numero)
cols_mat = [c[0] for c in cursor.description]
matriculados_raw = [dict(zip(cols_mat, row)) for row in cursor.fetchall()]
cursor.execute(sql_cuotas, ano, mes_numero)
cols_cuo = [c[0] for c in cursor.description]
cuotas_raw = [dict(zip(cols_cuo, row)) for row in cursor.fetchall()]
finally:
conn.close()
except Exception as e:
print(f"❌ Error en consultas detalle: {e}")
cuotas_por_mat = {}
for c in cuotas_raw:
m_id = str(c.get('num_matricula', '')).strip()
if m_id.endswith('.0'): m_id = m_id[:-2]
if m_id not in cuotas_por_mat: cuotas_por_mat[m_id] = []
cuotas_por_mat[m_id].append(c)
cursos = self.processor.obtener_datos_procesados(ano, mes_numero)
indices_target = []
fecha_inicio_actual = None
linea_actual = self.identificar_linea_carrera(prog_t)
for c in cursos:
nombre = str(c.get('programa_frecuencia', c.get('dsc_programa', ''))).strip().upper()
if nombre == prog_t:
idx = str(c.get('num_indice', '')).strip()
if idx.endswith('.0'): idx = idx[:-2]
indices_target.append(idx)
if not fecha_inicio_actual:
fecha_inicio_actual = self.parse_fecha(c.get('fch_inicio'))
lista_dnis = []
for m in matriculados_raw:
dni = m.get('dsc_documento')
if dni: lista_dnis.append(str(dni).strip())
try: datos_historial = self.data_manager.consultar_historial_continuidad(lista_dnis)
except: datos_historial = {}
correcciones_desc = self.data_manager.config_data.get('correcciones_descuento', {})
correcciones_cont = self.data_manager.config_data.get('correcciones_continuidad', {})
correcciones_mat = self.data_manager.config_data.get('correcciones_matriculas', {})
correcciones_inv_neta = self.data_manager.config_data.get('Actualizar_INV_NETA', {})
lista_mirar_cuotas = ["AREQUIPA", "TRUJILLO", "PIURA", "TEAC", "TERC"]
acum_inv_neta_global = 0.0
acum_suma_cuotas_global = 0.0
acum_cont_cuotas_global = 0
acum_suma_desc_e_global = 0.0
acum_cont_desc_e_global = 0
alumnos_lista = []
for m in matriculados_raw:
estado_mat = str(m.get('estado_matricula', '')).strip().upper()
if estado_mat not in ['ALU', 'PRE', 'RET']: continue
idx_mat = str(m.get('num_indice', '')).strip()
if idx_mat.endswith('.0'): idx_mat = idx_mat[:-2]
nombre_prog_crudo = str(m.get('dsc_programa', '')).strip().upper()
if (idx_mat in indices_target) or (prog_t in nombre_prog_crudo) or (nombre_prog_crudo in prog_t):
vendedor = str(m.get('dsc_vendedor', 'SIN VENDEDOR')).strip()
if vendedor == "None" or not vendedor: vendedor = "SIN VENDEDOR"
alumno_nombre = str(m.get('nombre_alumno', m.get('dsc_alumno', 'SIN NOMBRE'))).strip()
if alumno_nombre == "None" or not alumno_nombre: alumno_nombre = "SIN NOMBRE"
mat_id = str(m.get('num_matricula', '')).strip()
if mat_id.endswith('.0'): mat_id = mat_id[:-2]
dni = str(m.get('dsc_documento', '')).strip()
try: inv_neta_raw = float(m.get('INV_NETA', 0.0))
except: inv_neta_raw = 0.0
cod_moneda = str(m.get('cod_moneda', 'SOL')).strip().upper()
try: tc = float(m.get('imp_tc', 0) or 0)
except: tc = 0
if tc < 2: tc = 3.45 # TC por defecto si comprobante inválido
fch_canc_raw = m.get('fch_cancelacion_cuota1', '')
if mat_id in correcciones_mat:
datos_corregidos = correcciones_mat[mat_id]
if "imp_tc" in datos_corregidos: tc = float(datos_corregidos["imp_tc"])
if "fch_cancelacion_cuota1" in datos_corregidos: fch_canc_raw = datos_corregidos["fch_cancelacion_cuota1"]
# CORRECCIÓN MANUAL DE INV_NETA (siempre en SOL, sin multiplicar)
if mat_id in correcciones_inv_neta:
try: inv_neta_soles = float(correcciones_inv_neta[mat_id])
except: inv_neta_soles = inv_neta_raw
elif cod_moneda == "DOL":
inv_neta_soles = inv_neta_raw * tc
else:
inv_neta_soles = inv_neta_raw
inv_neta_soles = round(inv_neta_soles, 2)
acum_inv_neta_global += inv_neta_soles
fch_mat = m.get('fch_matricula', '')
fecha_mat_limpia = ""
if fch_mat:
try:
if hasattr(fch_mat, 'strftime'): fecha_mat_limpia = fch_mat.strftime('%d/%m/%Y')
else:
tmp = str(fch_mat)[:10]
if len(tmp) == 10 and tmp[4] == '-': p = tmp.split('-'); fecha_mat_limpia = f"{p[2]}/{p[1]}/{p[0]}"
else: fecha_mat_limpia = tmp
except: fecha_mat_limpia = str(fch_mat)[:10]
fch_canc_limpia = ""
if fch_canc_raw and str(fch_canc_raw).strip() != "None":
try:
if hasattr(fch_canc_raw, 'strftime'): fch_canc_limpia = fch_canc_raw.strftime('%d/%m/%Y')
else:
tmp = str(fch_canc_raw)[:10]
if len(tmp) == 10 and tmp[4] == '-': p = tmp.split('-'); fch_canc_limpia = f"{p[2]}/{p[1]}/{p[0]}"
else: fch_canc_limpia = tmp
except: fch_canc_limpia = str(fch_canc_raw)[:10]
clase_alumno = self.clasificar_programa(nombre_prog_crudo)
es_refriperu = False
if mat_id in correcciones_desc:
if str(correcciones_desc[mat_id]).upper() == "SI": es_refriperu = True
elif str(correcciones_desc[mat_id]).upper() == "NO": es_refriperu = False
else:
if clase_alumno in ["AREQUIPA", "TRUJILLO", "PIURA"] and 1200 <= inv_neta_soles <= 1700: es_refriperu = True
elif clase_alumno in ["TEAC", "TERC"] and 1400 <= inv_neta_soles <= 1900: es_refriperu = True
tipo_final = "NUEVO"
if es_refriperu: tipo_final = "REFRIPERU"
else:
es_cont = False
if mat_id in correcciones_cont:
if str(correcciones_cont[mat_id]).upper() == "SI": es_cont = True
elif str(correcciones_cont[mat_id]).upper() == "NO": es_cont = False
else:
if fecha_inicio_actual and linea_actual and (dni in datos_historial):
for antecedente in datos_historial[dni]:
nombre_pasado = str(antecedente.get('programa', ''))
fecha_pasada = antecedente.get('fecha')
if not fecha_pasada: continue
if not (fecha_pasada < fecha_inicio_actual): continue
if (fecha_inicio_actual - fecha_pasada).days < 60: continue
if self.identificar_linea_carrera(nombre_pasado) == linea_actual:
es_cont = True; break
if es_cont: tipo_final = "CONTINUIDAD"
descuento = self.calcular_descuento(clase_alumno, inv_neta_soles)
estado_desc = self.calcular_estado_descuento(clase_alumno, descuento)
str_cuota_val = ""
monto_eval = 0.0
if clase_alumno in lista_mirar_cuotas:
suma_c = 0.0; cont_c = 0; monto_real_c = 0.0
for c_dict in cuotas_por_mat.get(mat_id, []):
try: num_c = int(c_dict.get('num_cuota', 0))
except: num_c = 0
t_val = c_dict.get('imp_total'); d_val = c_dict.get('imp_dscto')
try: t_monto = float(t_val) if t_val is not None else 0.0
except: t_monto = 0.0
try: d_monto = float(d_val) if d_val is not None else 0.0
except: d_monto = 0.0
imp_c = t_monto - d_monto
tc_over = None
if mat_id in correcciones_mat and "imp_tc" in correcciones_mat[mat_id]:
tc_over = float(correcciones_mat[mat_id]["imp_tc"])
cod_m_c = str(c_dict.get('cod_moneda', '')).strip().upper()
if cod_m_c == "DOL":
# USAR TC del COMPROBANTE (no del cronograma)
tc_c = tc # tc ya está validado arriba (línea 654)
imp_c *= tc_c
if estado_desc == "SI" and num_c > 0 and imp_c < 1000:
suma_c += imp_c; cont_c += 1
if estado_desc == "SI" and num_c > 0:
monto_real_c += imp_c
if cont_c > 0:
str_cuota_val = f"S/ {suma_c / cont_c:,.0f}"
monto_eval = monto_real_c
acum_suma_cuotas_global += suma_c
acum_cont_cuotas_global += cont_c
else:
str_cuota_val = f"S/ {inv_neta_soles:,.0f}"
monto_eval = inv_neta_soles
acum_suma_cuotas_global += inv_neta_soles
acum_cont_cuotas_global += 1
str_desc_e_val = ""
if monto_eval >= 1900 and estado_desc == "SI":
if clase_alumno in ["TEAC", "TERC"]: d_calc = 3400 - monto_eval
elif clase_alumno in ["AREQUIPA", "PIURA", "TRUJILLO"]: d_calc = 2740 - monto_eval
else: d_calc = 0
desc_e_val = min(max(d_calc - 200, 0.0), 200.0)
str_desc_e_val = f"S/ {desc_e_val:,.0f}"
acum_suma_desc_e_global += desc_e_val
acum_cont_desc_e_global += 1
alumnos_lista.append([
vendedor, alumno_nombre, fecha_mat_limpia, fch_canc_limpia,
tipo_final, str_cuota_val, str_desc_e_val, f"S/ {inv_neta_soles:,.0f}",
estado_mat # columna oculta para detectar RET
])
alumnos_lista.sort(key=lambda x: x[1])
prom_gral_cuota = (acum_suma_cuotas_global / acum_cont_cuotas_global) if acum_cont_cuotas_global > 0 else 0.0
prom_gral_desc = (acum_suma_desc_e_global / acum_cont_desc_e_global) if acum_cont_desc_e_global > 0 else 0.0
str_gral_cuota = f"S/ {prom_gral_cuota:,.0f}" if acum_cont_cuotas_global > 0 else ""
str_gral_desc = f"S/ {prom_gral_desc:,.0f}" if acum_cont_desc_e_global > 0 else ""
alumnos_lista.append([
"TOTAL GENERAL", "", "", "", "",
str_gral_cuota, str_gral_desc, f"S/ {acum_inv_neta_global:,.0f}",
"" # columna oculta extra para TOTAL
])
return alumnos_lista
def exportar_a_excel(self, datos, ano, mes):
if not datos: raise ValueError("No hay datos para exportar")
df = pd.DataFrame(datos)
archivo = f"reporte_rentabilidad_{ano}_{mes}.xlsx"
df.to_excel(archivo, index=False)
return archivo
def _get_sql_matriculas_modificado(self):
if not hasattr(self, '_sql_matriculas_cache') or not self._sql_matriculas_cache:
sql = self.data_manager.query_matriculas_sql
if not sql: return ""
sql = re.sub(
r'YEAR\(\s*sgeca_matricula\.fch_matricula\s*\)',
'YEAR(sgede_RP_programa.fch_inicio)',
sql, flags=re.IGNORECASE
)
sql = re.sub(
r'MONTH\(\s*sgeca_matricula\.fch_matricula\s*\)',
'MONTH(sgede_RP_programa.fch_inicio)',
sql, flags=re.IGNORECASE
)
self._sql_matriculas_cache = sql
return self._sql_matriculas_cache
def _get_sql_cuotas(self):
if not hasattr(self, '_sql_cuotas_cache') or not self._sql_cuotas_cache:
try:
res = requests.get(self.data_manager.github_cuota_url)
self._sql_cuotas_cache = res.text
except:
self._sql_cuotas_cache = ""
return self._sql_cuotas_cache
def obtener_datos_costos_programa(self, programa, ano, mes):
"""Devuelve el desglose de costos e ingresos para el modal Editar"""
datos = self.obtener_datos_procesados(ano, mes)
if not datos: return None
curso = None
for d in datos:
nombre = d.get('programa_frecuencia', d.get('dsc_programa', ''))
if str(nombre).strip() == str(programa).strip():
curso = d; break
if not curso: return None
cfg_costos = self.data_manager.costos_data
if not cfg_costos: return None
# OVERRIDES desde Supabase
num_idx = str(curso.get('num_indice', '')).strip()
if num_idx.endswith('.0'): num_idx = num_idx[:-2]
overrides = self.data_manager.cargar_overrides_costos()
ov_curso = overrides.get(num_idx, {})
ov_inicial = ov_curso.get('inicial', {})
ov_actual = ov_curso.get('actual', {})
cat = curso.get('Categoria_Programa', 'OTROS')
prog_crudo = curso.get('dsc_programa', '')
try: total_inscritos = int(curso.get('Inscritos_Totales', 0))
except: total_inscritos = 0
try: en_curso = int(curso.get('Inscritos_En_Curso', curso.get('Inscritos_Activos', 0)))
except: en_curso = 0
epp_u = self._obtener_costo_segun_categoria(cfg_costos.get("epp", {}), cat, prog_crudo)
cert_u = self._obtener_costo_segun_categoria(cfg_costos.get("certificado", {}), cat, prog_crudo)
cons_u = self._obtener_costo_segun_categoria(cfg_costos.get("consumibles", {}), cat, prog_crudo)
mkt = self._obtener_costo_segun_categoria(cfg_costos.get("marketing", {}), cat, prog_crudo)
doc = self._obtener_costo_segun_categoria(cfg_costos.get("docente", {}), cat, prog_crudo)
# INICIAL
epp_i = float(ov_inicial['epp']) if ov_inicial.get('epp') is not None else total_inscritos * epp_u
cert_i = float(ov_inicial['certificado']) if ov_inicial.get('certificado') is not None else total_inscritos * cert_u
cons_i = float(ov_inicial['consumibles']) if ov_inicial.get('consumibles') is not None else total_inscritos * cons_u
mkt_i = float(ov_inicial['marketing']) if ov_inicial.get('marketing') is not None else mkt
doc_i = float(ov_inicial['docente']) if ov_inicial.get('docente') is not None else doc
costo_i = epp_i + cert_i + cons_i + mkt_i + doc_i
# ACTUAL
epp_a = float(ov_actual['epp']) if ov_actual.get('epp') is not None else total_inscritos * epp_u
cert_a = float(ov_actual['certificado']) if ov_actual.get('certificado') is not None else en_curso * cert_u
cons_a = float(ov_actual['consumibles']) if ov_actual.get('consumibles') is not None else total_inscritos * cons_u
mkt_a = float(ov_actual['marketing']) if ov_actual.get('marketing') is not None else mkt
doc_a = float(ov_actual['docente']) if ov_actual.get('docente') is not None else doc
costo_a = epp_a + cert_a + cons_a + mkt_a + doc_a
vi = float(curso.get('Valor_Venta', 0.0))
va = float(curso.get('Valor_Venta_Actual', 0.0))
mbi = (1 - costo_i / vi) * 100 if vi > 0 else 0.0
mba = (1 - costo_a / va) * 100 if va > 0 else 0.0
return {
'num_indice': num_idx,
'venta_inicial': vi, 'costo_inicial': costo_i, 'mb_inicial': mbi,
'venta_actual': va, 'costo_actual': costo_a, 'mb_actual': mba,
'epp_inicial': epp_i, 'cert_inicial': cert_i, 'cons_inicial': cons_i,
'mkt_inicial': mkt_i, 'doc_inicial': doc_i,
'epp_actual': epp_a, 'cert_actual': cert_a, 'cons_actual': cons_a,
'mkt_actual': mkt_a, 'doc_actual': doc_a,
# Lo que ya estaba en Supabase, para detectar qué fue editado
'ov_inicial': ov_inicial,
'ov_actual': ov_actual,
}

View File

@@ -1,290 +0,0 @@
# modules/rentabilidad/processor.py
from datetime import datetime
import unicodedata
class RentabilidadProcessor:
"""Procesador de datos para Rentabilidad - Hereda la lógica estricta de Ocupabilidad"""
def __init__(self, data_manager):
self.data_manager = data_manager
# Columnas exclusivas de rentabilidad
self.columnas = [
'programa_frecuencia',
'fch_inicio',
'Descuento', # INSCRITOS REFRIPERU
'Inscritos_Continuidad', # INSCRITOS CONTINUIDAD
'Inscritos_Nuevos', # Calculado en Logic
'Inscritos_Totales', # TOTAL INSCRITOS
'Inscritos_PC', # INSCRITOS P.C
'Retirados', # INSCRITOS RETIRADOS
'Inscritos_En_Curso', # INSCRITOS ACTIVOS
'Promedio_Cuota', # Pendiente (0)
'Promedio_Desc_E', # Pendiente (0)
'Valor_Venta', # Pendiente (0)
'Opciones' # Fijo "en cu"
]
def obtener_datos_procesados(self, ano, mes):
try:
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=3) as executor:
fut_cursos = executor.submit(self.data_manager.ejecutar_consulta_cursos, ano, mes)
fut_raw = executor.submit(self.data_manager.ejecutar_consulta_matriculados_detalle, ano, mes)
fut_matriculas = executor.submit(self.data_manager.cargar_datos_matriculas, ano, mes)
datos_originales = fut_cursos.result()
datos_raw_matriculas = fut_raw.result()
datos_matriculas = fut_matriculas.result()
lista_dnis = []
if datos_raw_matriculas:
for alumno in datos_raw_matriculas:
dni = alumno.get('dsc_documento')
if dni: lista_dnis.append(dni)
datos_historial = self.data_manager.consultar_historial_continuidad(lista_dnis)
datos_procesados = self.aplicar_personalizaciones(
datos_originales, datos_matriculas, datos_raw_matriculas, datos_historial, ano, mes
)
return datos_procesados
except Exception as e:
print(f"❌ Error obteniendo datos rentabilidad: {e}")
return []
def normalizar_texto(self, texto):
if not texto: return ""
texto = str(texto).upper().strip()
texto = unicodedata.normalize('NFD', texto)
return texto.encode('ascii', 'ignore').decode("utf-8")
def obtener_categoria_programa(self, nombre_programa):
try:
nombre_limpio = self.normalizar_texto(nombre_programa)
clasificaciones = self.data_manager.meta_data.get('clasificacion_programas', {})
for categoria, config in clasificaciones.items():
patrones = config.get('patrones', [])
for patron in patrones:
if self.normalizar_texto(patron) in nombre_limpio:
return categoria
return "OTROS"
except: return "OTROS"
def aplicar_personalizaciones(self, datos_originales, datos_matriculas, datos_raw_matriculas, datos_historial, ano_filtro, mes_filtro):
print("💰 Procesando variables de Rentabilidad...")
cursos_personalizados = self.data_manager.config_data.get('cursos_personalizados', {})
datos_filtrados = []
try: ano_target, mes_target = int(ano_filtro), int(mes_filtro)
except: ano_target, mes_target = 0, 0
# FILTRADO Y ACTUALIZADOR EN VIVO
for curso in datos_originales:
num_indice = str(curso.get('num_indice', ''))
if num_indice in cursos_personalizados:
curso.update(cursos_personalizados[num_indice])
if curso.get('flg_activo', '') == 'NO': continue
# Si el curso está suspendido, lo ignoramos y no se muestra en Rentabilidad
if str(curso.get('cod_estado', '')).strip().upper() == 'SUS': continue
fch_valida = True
try:
raw_fecha = curso.get('fch_inicio')
if raw_fecha:
s_fecha = str(raw_fecha).strip()
if len(s_fecha) == 10 and s_fecha[2] == '-': f_obj = datetime.strptime(s_fecha, '%d-%m-%Y')
elif ' ' in s_fecha: f_obj = datetime.strptime(s_fecha.split('.')[0], '%Y-%m-%d %H:%M:%S')
else: f_obj = datetime.strptime(s_fecha, '%Y-%m-%d')
if f_obj and (f_obj.year != ano_target or f_obj.month != mes_target):
fch_valida = False
except: pass
if fch_valida: datos_filtrados.append(curso)
datos_filtrados = self.aplicar_reemplazos_programas(datos_filtrados)
datos_filtrados = self.concatenar_programa_frecuencia(datos_filtrados)
for c in datos_filtrados: c['fch_inicio'] = self.formatear_fecha(c.get('fch_inicio', ''))
# CÁLCULOS CENTRALES
datos_filtrados, alumnos_refriperu = self.agregar_descuento(datos_filtrados, datos_raw_matriculas)
datos_filtrados = self.agregar_continuidad(datos_filtrados, datos_raw_matriculas, datos_historial, alumnos_refriperu)
datos_filtrados = self.agregar_inscritos_mes(datos_filtrados, datos_matriculas)
datos_filtrados = self.agregar_inscritos_pc(datos_filtrados, datos_raw_matriculas)
# EXTRACCIÓN REAL DE BD Y VALORES PENDIENTES
for c in datos_filtrados:
c['Retirados'] = int(c.get('Inscritos_Retirados', 0)) # <--- AHORA JALA LA DATA REAL DEL SQL
c['Promedio_Cuota'] = 0.0
c['Promedio_Desc_E'] = 0.0
c['Valor_Venta'] = 0.0
c['Opciones'] = "en cu"
return datos_filtrados
def aplicar_reemplazos_programas(self, datos):
reemplazos = self.data_manager.replace_data.get('reemplazos_programas', {})
for curso in datos:
prog = curso.get('dsc_programa', '')
if prog in reemplazos: curso['dsc_programa'] = reemplazos[prog]
return datos
def concatenar_programa_frecuencia(self, datos):
for c in datos:
p, f = c.get('dsc_programa', ''), c.get('cod_frecuencia', '')
c['programa_frecuencia'] = f"{p} - {f}" if p and f else p or f or ""
return datos
def formatear_fecha(self, fecha_str):
try:
if not fecha_str: return ""
if len(str(fecha_str)) == 10 and str(fecha_str)[2] == '-': return str(fecha_str)
if '.' in str(fecha_str): return datetime.strptime(str(fecha_str), '%Y-%m-%d %H:%M:%S.%f').strftime('%d-%m-%Y')
return datetime.strptime(str(fecha_str), '%Y-%m-%d %H:%M:%S').strftime('%d-%m-%Y')
except: return str(fecha_str)
def identificar_linea_carrera(self, nombre_programa):
categoria = self.obtener_categoria_programa(nombre_programa)
if categoria in ["AREQUIPA", "TRUJILLO", "PIURA", "TEAC", "TERC"]:
return "CARRERA_COMPATIBLE"
return None
def parse_fecha(self, fecha_raw):
try:
if isinstance(fecha_raw, datetime): return fecha_raw
if not fecha_raw: return None
s_fecha = str(fecha_raw).strip()
if len(s_fecha) == 10 and s_fecha[2] == '-': return datetime.strptime(s_fecha, '%d-%m-%Y')
if ' ' in s_fecha: return datetime.strptime(s_fecha.split('.')[0], '%Y-%m-%d %H:%M:%S')
return datetime.strptime(s_fecha, '%Y-%m-%d')
except: return None
# Lógica estricta de Refriperu
def agregar_descuento(self, datos, datos_raw_matriculas):
conteo_por_curso = {}
alumnos_refriperu = set()
correcciones_descuento = self.data_manager.config_data.get('correcciones_descuento', {})
if datos_raw_matriculas:
for alumno in datos_raw_matriculas:
try:
if str(alumno.get('estado_matricula', '')).strip().upper() not in ['ALU', 'PRE']: continue
num_indice = str(alumno.get('num_indice', ''))
mat_id = str(alumno.get('num_matricula', '')).strip()
if mat_id.endswith('.0'): mat_id = mat_id[:-2]
if mat_id in correcciones_descuento:
if str(correcciones_descuento[mat_id]).upper().strip() == "SI":
conteo_por_curso[num_indice] = conteo_por_curso.get(num_indice, 0) + 1
alumnos_refriperu.add(mat_id)
continue
elif str(correcciones_descuento[mat_id]).upper().strip() == "NO": continue
nombre_programa = str(alumno.get('dsc_programa', ''))
cod_moneda = str(alumno.get('cod_moneda', 'SOL')).strip().upper()
try:
inv_neta = float(alumno.get('INV_NETA', 0) or 0)
imp_tc = float(alumno.get('imp_tc', 0) or 0)
if imp_tc <= 0: imp_tc = 3.45 # TC por defecto si no hay comprobante
except: inv_neta, imp_tc = 0.0, 3.45
inv_neta_final = round(inv_neta * imp_tc if cod_moneda == 'DOL' else inv_neta, 2)
categoria_json = self.obtener_categoria_programa(nombre_programa)
es_descuento = False
if categoria_json in ["AREQUIPA", "TRUJILLO", "PIURA"] and 1200 <= inv_neta_final <= 1700: es_descuento = True
elif categoria_json in ["TEAC", "TERC"] and 1400 <= inv_neta_final <= 1900: es_descuento = True
if es_descuento:
conteo_por_curso[num_indice] = conteo_por_curso.get(num_indice, 0) + 1
alumnos_refriperu.add(mat_id)
except: continue
for curso in datos: curso['Descuento'] = conteo_por_curso.get(str(curso.get('num_indice', '')), 0)
return datos, alumnos_refriperu
# Lógica estricta de Continuidad
def agregar_continuidad(self, datos, datos_raw_matriculas, datos_historial, alumnos_refriperu):
conteo_por_curso = {}
correcciones_continuidad = self.data_manager.config_data.get('correcciones_continuidad', {})
if datos_raw_matriculas and datos_historial:
# PRE-ÍNDICE: {dni: [(linea_carrera, fecha), ...]}
# Calculamos identificar_linea_carrera UNA sola vez por programa del historial
historial_indexado = {}
for dni, antecedentes in datos_historial.items():
lineas = []
for ant in antecedentes:
linea = self.identificar_linea_carrera(str(ant.get('programa', '')))
fecha = ant.get('fecha')
if linea and fecha:
lineas.append((linea, fecha))
if lineas:
historial_indexado[dni] = lineas
alumnos_por_curso = {}
for alu in datos_raw_matriculas:
idx = str(alu.get('num_indice', ''))
if idx not in alumnos_por_curso: alumnos_por_curso[idx] = []
alumnos_por_curso[idx].append(alu)
for curso in datos:
num_indice = str(curso.get('num_indice', ''))
fecha_inicio_actual = self.parse_fecha(curso.get('fch_inicio'))
linea_actual = self.identificar_linea_carrera(str(curso.get('dsc_programa', '')))
if not linea_actual or not fecha_inicio_actual: continue
contador_fieles = 0
for alumno in alumnos_por_curso.get(num_indice, []):
if str(alumno.get('estado_matricula', '')).strip().upper() not in ['ALU', 'PRE']: continue
mat_id = str(alumno.get('num_matricula', '')).strip()
if mat_id.endswith('.0'): mat_id = mat_id[:-2]
if mat_id in alumnos_refriperu: continue
dni = str(alumno.get('dsc_documento', 'SD')).strip()
es_fiel = False
if mat_id in correcciones_continuidad:
accion = str(correcciones_continuidad[mat_id]).upper().strip()
if accion == "SI": es_fiel = True
elif accion == "NO": continue
if not es_fiel and dni in historial_indexado:
for linea_pasada, fecha_pasada in historial_indexado[dni]:
if fecha_pasada >= fecha_inicio_actual: continue
if (fecha_inicio_actual - fecha_pasada).days < 60: continue
if linea_pasada == linea_actual:
es_fiel = True
break
if es_fiel: contador_fieles += 1
conteo_por_curso[num_indice] = contador_fieles
for curso in datos: curso['Inscritos_Continuidad'] = conteo_por_curso.get(str(curso.get('num_indice', '')), 0)
return datos
def agregar_inscritos_mes(self, datos, datos_matriculas):
dict_norm = {str(k): v for k, v in datos_matriculas.items()}
for curso in datos: curso['Inscritos_Mes'] = dict_norm.get(str(curso.get('num_indice', '')), 0)
return datos
def agregar_inscritos_pc(self, datos, datos_raw_matriculas):
conteo_pc = {}
if datos_raw_matriculas:
for m in datos_raw_matriculas:
try:
if str(m.get('estado_matricula', '')).strip() in ['ALU', 'PRE'] and float(m.get('imp_saldo_matricula', 0) or 0) < 1 and float(m.get('imp_saldo_cuota1', 0) or 0) < 1:
nid = str(m.get('num_indice', ''))
conteo_pc[nid] = conteo_pc.get(nid, 0) + 1
except: continue
for curso in datos: curso['Inscritos_PC'] = conteo_pc.get(str(curso.get('num_indice', '')), 0)
return datos

View File

@@ -1,193 +0,0 @@
# modules/saldo_pendiente/logic.py
from .processor import SaldoProcessor
from datetime import datetime
class SaldoLogic:
"""
Controlador lógico optimizado.
1. Filtro Global Manual (lista_saldo_pendiente).
2. Filtro REFRIPERU (Precio Neto < 1900/1000 o Etiquetas).
3. Filtros de Negocio (Vendedor, Fecha, Estado).
"""
def __init__(self, data_manager):
self.data_manager = data_manager
self.processor = SaldoProcessor(self.data_manager)
def obtener_saldos_consolidados(self, tipo_cuota):
# 1. Recargar Configuración (Para leer tu nueva lista en vivo)
try: self.data_manager.cargar_toda_configuracion()
except: pass
all_debtors = []
years_to_scan = [2025, 2026]
print(f"⚡ [Logic] Iniciando carga para {tipo_cuota}...")
for year in years_to_scan:
try:
# Usamos la consulta anual optimizada
raw_year_data = self.data_manager.ejecutar_consulta_saldos_anual(str(year))
if raw_year_data:
datos_procesados = self._procesar_lote_local(raw_year_data, tipo_cuota)
all_debtors.extend(datos_procesados)
except AttributeError:
print("⚠️ Error: DataManager no actualizado.")
continue
# Ordenar alfabéticamente
all_debtors.sort(key=lambda x: (x.get('VENDEDOR', '') or "ZZZ", x.get('ALUMNO', '') or ""))
print(f"✅ Carga Finalizada. Total en tabla: {len(all_debtors)}")
return all_debtors
def _procesar_lote_local(self, raw_data, tipo_cuota):
datos_limpios = []
# --- Configuración ---
ahora = datetime.now()
ano_actual = ahora.year
mes_actual = ahora.month
config = self.data_manager.config_data
# 1. CARGAMOS TUS LISTAS DEL JSON
correcciones_refri = config.get("correcciones_descuento", {})
exclusion_manual_global = config.get("lista_saldo_pendiente", {}) # <--- TU NUEVA LISTA
# 2. Lista Vendedores Permitidos
lista_raw = config.get("lista_pendientes", [])
if not lista_raw:
lista_raw = ["AGUILAR U. JUAN CARLOS", "CHAVEZ P. DIANA", "HUAMAN C. ALONSO AGUSTIN",
"LA ROSA C. VERONICA ASTRID", "LAZARO Q. DIEGO ARTURO",
"MONTOYA D. CARMEN ISABEL", "PERALTA C. ALMENDRA LUCIA"]
lista_permitidos = set([str(v).strip().upper() for v in lista_raw])
# 3. Mapeo de columnas
mapa_cols = {
"1° Cuota": ("imp_saldo_cuota1", "fch_venc_cuota1"),
"2° Cuota": ("imp_saldo_cuota2", "fch_venc_cuota2"),
"3° Cuota": ("imp_saldo_cuota3", "fch_venc_cuota3"),
"4° Cuota": ("imp_saldo_cuota4", "fch_venc_cuota4"),
"5° Cuota": ("imp_saldo_cuota5", "fch_venc_cuota5"),
}
col_saldo, col_venc = mapa_cols.get(tipo_cuota, (None, None))
if not col_saldo: return []
for row in raw_data:
# Identificadores
num_mat = str(row.get('num_matricula', '')).split('.')[0].strip()
# =================================================================
# 🛑 FILTRO 0: EXCLUSIÓN GLOBAL MANUAL (TU NUEVO PEDIDO)
# =================================================================
# Si la matrícula está en "lista_saldo_pendiente" con valor "NO", ADIÓS.
if num_mat in exclusion_manual_global:
val_excl = str(exclusion_manual_global[num_mat]).strip().upper()
if val_excl == "NO":
continue # Se salta inmediatamente, no importa nada más.
# =================================================================
# 🛑 FILTRO 1: DETECCIÓN DE REFRIPERU (PRECIO NETO / ETIQUETAS)
# =================================================================
es_refriperu = False
# A) Manual (correcciones_descuento)
if num_mat in correcciones_refri:
val = str(correcciones_refri[num_mat]).strip().upper()
if val == "SI": es_refriperu = True
elif val == "NO": es_refriperu = False # Forzamos a cobrar
else:
# B) Automático (Etiquetas)
dsc_beca = str(row.get('dsc_beca', '')).strip().upper()
dsc_prog = str(row.get('dsc_programa', '')).strip().upper()
dsc_prom = str(row.get('dsc_promocion', '')).strip().upper()
if 'REFRIPERU' in dsc_beca or '100%' in dsc_beca or 'BECA' in dsc_beca:
es_refriperu = True
elif 'REFRIPERU' in dsc_prog or 'REFRIPERU' in dsc_prom:
es_refriperu = True
# C) Automático por PRECIO FINAL (INV_NETA)
if not es_refriperu:
try:
# Usamos INV_NETA (Precio Real)
inv_neta = float(row.get('INV_NETA', 0))
except:
inv_neta = 0.0
if inv_neta > 0:
# Rango Técnicos/Especialistas
if ("TECNICO" in dsc_prog or "ESPECIALISTA" in dsc_prog or "TEAC" in dsc_prog):
if inv_neta < 1900: es_refriperu = True # ej: 1799
# Rango Gestión/Ventas
elif "GESTION" in dsc_prog or "VENTA" in dsc_prog:
if inv_neta < 1000: es_refriperu = True
if es_refriperu:
continue
# =================================================================
# 🛑 FILTROS ESTÁNDAR (VENDEDOR, ESTADO, FECHA)
# =================================================================
# Vendedor
vendedor = str(row.get('dsc_vendedor', '')).strip().upper()
if lista_permitidos:
encontrado = False
if vendedor in lista_permitidos: encontrado = True
else:
for p in lista_permitidos:
if p.replace(" ","") in vendedor.replace(" ",""):
encontrado = True; break
if not encontrado: continue
# Estado
estado = str(row.get('estado_matricula', '')).strip().upper()
if estado not in ('ALU', 'PRE'): continue
# Fecha Futura (Solo 2da cuota en adelante)
if tipo_cuota != "1° Cuota":
fch_raw = row.get(col_venc)
if fch_raw:
try:
if isinstance(fch_raw, str): f_obj = datetime.strptime(fch_raw[:10], '%Y-%m-%d')
else: f_obj = fch_raw
if f_obj.year > ano_actual: continue
elif f_obj.year == ano_actual and f_obj.month > mes_actual: continue
except: pass
# Saldo Positivo
try: val = float(row.get(col_saldo, 0))
except: val = 0.0
if val <= 0.1: continue
# --- 4. Construcción de Fila ---
def fmt(v):
if not v: return ""
if isinstance(v, str): return v[:10]
if isinstance(v, datetime): return v.strftime('%d-%m-%Y')
return str(v)
fila = {
'MATRICULA': row.get('num_matricula'),
'VENDEDOR': row.get('dsc_vendedor'),
'ALUMNO': row.get('dsc_alumno'),
'PROGRAMA': row.get('dsc_promocion'),
'FECHA INICIO': fmt(row.get('fch_inicio')),
'FECHA MATR.': fmt(row.get('fch_matricula')),
'SALDO MAT.': float(row.get('imp_saldo_matricula', 0)),
f'SALDO {tipo_cuota.upper()}': val,
'VENCIMIENTO': fmt(row.get(col_venc)),
'INV. NETA': float(row.get('INV_NETA', 0)),
'DNI': row.get('dsc_documento', ''),
'CELULAR': row.get('dsc_telefono_1', '')
}
datos_limpios.append(fila)
return datos_limpios
# Métodos legacy
def obtener_saldos(self, ano, mes, tipo_cuota): return self.processor.obtener_datos_procesados(ano, mes, tipo_cuota)
def get_current_year(self): return self.data_manager.get_current_year()
def get_current_month(self): return self.data_manager.get_current_month()

View File

@@ -1,162 +0,0 @@
# modules/saldo_pendiente/processor.py
from datetime import datetime
class SaldoProcessor:
"""
Procesador de lógica de negocio para Saldos Pendientes.
Filtra estrictamente por estado ALU/PRE, saldos positivos,
vendedores permitidos y FECHA DE VENCIMIENTO (No mostrar futuro).
"""
def __init__(self, data_manager):
self.data_manager = data_manager
def obtener_datos_procesados(self, ano, mes, tipo_cuota):
"""
Recupera datos crudos y aplica los filtros de negocio.
"""
# 1. Traer la data cruda desde DataManager
raw_data = self.data_manager.ejecutar_consulta_saldos_pendientes(ano, mes)
datos_limpios = []
# 2. Obtener fecha actual para saber qué es "futuro"
ahora = datetime.now()
ano_actual = ahora.year
mes_actual = ahora.month
# 3. Cargar la "Lista Blanca" de vendedores desde el JSON
config = self.data_manager.config_data
lista_raw = config.get("lista_pendientes", [])
# RESPALDO DE EMERGENCIA
if not lista_raw:
lista_raw = [
"AGUILAR U. JUAN CARLOS",
"CHAVEZ P. DIANA",
"HUAMAN C. ALONSO AGUSTIN",
"LA ROSA C. VERONICA ASTRID",
"LAZARO Q. DIEGO ARTURO",
"MONTOYA D. CARMEN ISABEL",
"PERALTA C. ALMENDRA LUCIA"
]
# Normalizamos la lista
lista_permitidos = set([str(v).strip().upper() for v in lista_raw])
# 4. Mapeo de columnas según la selección
mapa_columnas = {
"1° Cuota": ("imp_saldo_cuota1", "fch_venc_cuota1"),
"2° Cuota": ("imp_saldo_cuota2", "fch_venc_cuota2"),
"3° Cuota": ("imp_saldo_cuota3", "fch_venc_cuota3"),
}
col_saldo_target, col_venc_target = mapa_columnas.get(tipo_cuota, (None, None))
if not col_saldo_target:
return []
for row in raw_data:
# =================================================================
# 🛑 FILTRO 1: VENDEDOR PERMITIDO
# =================================================================
vendedor_actual = str(row.get('dsc_vendedor', '')).strip().upper()
if lista_permitidos:
if vendedor_actual not in lista_permitidos:
# Búsqueda parcial por si hay errores de espacios
encontrado = False
for permitido in lista_permitidos:
v_norm = vendedor_actual.replace(" ", "")
p_norm = permitido.replace(" ", "")
if p_norm in v_norm:
encontrado = True
break
if not encontrado:
continue
# =================================================================
# 🛑 FILTRO 2: ESTADO (ALU/PRE)
# =================================================================
estado = str(row.get('estado_matricula', '')).strip().upper()
if estado not in ('ALU', 'PRE'):
continue
# =================================================================
# 🛑 FILTRO 3: FECHA DE VENCIMIENTO (NO MOSTRAR FUTURO)
# =================================================================
# Este filtro aplica PRINCIPALMENTE para 2° Cuota en adelante.
# (Aunque la lógica es válida para todas, la 1° suele ser inmediata).
if tipo_cuota != "1° Cuota":
fch_venc_raw = row.get(col_venc_target)
es_futuro = False
if fch_venc_raw:
try:
# Convertir a objeto fecha si es texto
if isinstance(fch_venc_raw, str):
# Asumimos formato SQL YYYY-MM-DD
f_obj = datetime.strptime(fch_venc_raw[:10], '%Y-%m-%d')
else:
f_obj = fch_venc_raw # Ya es datetime
venc_ano = f_obj.year
venc_mes = f_obj.month
# LÓGICA DE TIEMPO:
# Si el año de vencimiento es mayor al actual -> ES FUTURO
if venc_ano > ano_actual:
es_futuro = True
# Si es el mismo año, pero el mes es mayor al actual -> ES FUTURO
elif venc_ano == ano_actual and venc_mes > mes_actual:
es_futuro = True
except Exception as e:
# Si falla la fecha, asumimos que no es futuro para no ocultar por error
pass
# Si la cuota vence en el futuro (ej: Marzo cuando estamos en Febrero), LA OCULTAMOS.
if es_futuro:
continue
# =================================================================
# 🛑 FILTRO 4: SALDO > 0
# =================================================================
try:
val = row.get(col_saldo_target, 0)
saldo_a_mostrar = float(val) if val is not None else 0.0
except:
saldo_a_mostrar = 0.0
if saldo_a_mostrar <= 0.1:
continue
# --- FORMATEO PARA VISUALIZACIÓN ---
def format_date(val):
if not val: return ""
if isinstance(val, str): return val[:10]
if isinstance(val, datetime): return val.strftime('%d-%m-%Y')
return str(val)
f_inicio = format_date(row.get('fch_inicio'))
f_matr = format_date(row.get('fch_matricula'))
f_vencimiento = format_date(row.get(col_venc_target))
# --- CONSTRUCCIÓN DE LA FILA FINAL ---
fila = {
'MATRICULA': row.get('num_matricula'),
'VENDEDOR': row.get('dsc_vendedor'),
'ALUMNO': row.get('dsc_alumno'),
'PROGRAMA': row.get('dsc_promocion'),
'FECHA INICIO': f_inicio,
'FECHA MATR.': f_matr,
'SALDO MAT.': float(row.get('imp_saldo_matricula', 0)),
f'SALDO {tipo_cuota.upper()}': saldo_a_mostrar,
'VENCIMIENTO': f_vencimiento, # Aquí se verá la fecha (ej: 14-02-2026)
'INV. NETA': float(row.get('INV_NETA', 0)),
}
datos_limpios.append(fila)
return datos_limpios

View File

@@ -1,767 +0,0 @@
# modules/ventas/logic.py
import pandas as pd
from datetime import datetime
import requests
import re
class VentasLogic:
"""Clase que maneja la lógica de negocio y procesamiento de datos para Ventas"""
def __init__(self, data_manager):
self.data_manager = data_manager
self._config_sedes = None
def _clasificar_sede_comisiones(self, dsc_programa):
"""Clasifica la sede usando EXACTAMENTE la misma lógica de Cobranza (sede.json)."""
try:
if not hasattr(self, '_cob_processor') or self._cob_processor is None:
from modules.cobranza.processor import CobranzaProcessor
self._cob_processor = CobranzaProcessor(self.data_manager)
return self._cob_processor.clasificar_sede(dsc_programa)
except Exception:
prog_up = str(dsc_programa or "").upper()
for s in ("AREQUIPA", "PIURA", "TRUJILLO"):
if s in prog_up:
return s
return "LIMA"
def buscar_todos_matriculados(self):
"""Devuelve TODOS los matriculados (todos los años/meses) para el buscador.
Ejecuta la query de matrículas quitando el filtro de YEAR/MONTH de fch_matricula.
Retorna filas con el mismo formato que el detalle (para reusar columnas)."""
import re as _re
sql_base = self.data_manager.query_matriculas_sql
if not sql_base:
return []
# Quitar las condiciones de año y mes de fch_matricula
sql = _re.sub(r"AND\s+YEAR\(\s*sgeca_matricula\.fch_matricula\s*\)\s*=\s*\?", "", sql_base, flags=_re.IGNORECASE)
sql = _re.sub(r"AND\s+MONTH\(\s*sgeca_matricula\.fch_matricula\s*\)\s*=\s*\?", "", sql, flags=_re.IGNORECASE)
# Por si están sin AND (primera condición)
sql = _re.sub(r"YEAR\(\s*sgeca_matricula\.fch_matricula\s*\)\s*=\s*\?", "1=1", sql, flags=_re.IGNORECASE)
sql = _re.sub(r"MONTH\(\s*sgeca_matricula\.fch_matricula\s*\)\s*=\s*\?", "1=1", sql, flags=_re.IGNORECASE)
# El buscador debe traer TODOS los matriculados excepto ANU (incluye SUS, no pagados, etc.)
# El buscador muestra TODOS los matriculados, incluso ANU
sql = _re.sub(r"AND\s+sgeca_matricula\.cod_estado\s+NOT\s+IN\s*\([^)]*\)", "", sql, flags=_re.IGNORECASE)
sql = _re.sub(r"NOT\s+IN\s*\(\s*'ANU'\s*,\s*'SUS'\s*\)", "NOT IN ('ZZZ')", sql, flags=_re.IGNORECASE)
sql = _re.sub(r"NOT\s+IN\s*\(\s*'ANU'\s*,\s*'SUS'\s*,\s*'RET'\s*\)", "NOT IN ('ZZZ')", sql, flags=_re.IGNORECASE)
conn = self.data_manager.get_connection()
cursor = conn.cursor()
cursor.execute(sql) # sin parámetros de fecha
columns = [c[0] for c in cursor.description]
datos = [dict(zip(columns, row)) for row in cursor.fetchall()]
# Traer fecha de cancelación de la cuota 1 de TODAS las matrículas (cualquier refinanciamiento)
fechas_canc = {}
try:
cursor.execute("""
SELECT num_matricula, MAX(fch_cancelacion)
FROM sgede_cronograma_matricula
WHERE num_cuota = 1 AND fch_cancelacion IS NOT NULL
GROUP BY num_matricula
""")
for r in cursor.fetchall():
k = str(r[0]).strip()
if k.endswith('.0'): k = k[:-2]
fechas_canc[k] = r[1]
except Exception:
pass
conn.close()
filas = []
for d in datos:
raw_mat = d.get('num_matricula')
mat_id = ""
if raw_mat is not None:
v = str(raw_mat).strip()
if v.endswith('.0'): v = v[:-2]
mat_id = v
alumno = d.get('dsc_alumno', 'SIN NOMBRE')
# MOSTRAR: detallado (dsc_promocion = sgede_RP_programa.dsc_det_programa)
programa = (d.get('dsc_promocion') or d.get('dsc_det_programa') or d.get('dsc_programa') or '')
# CLASIFICAR sede: prioriza dsc_programa
prog_clasificacion = (d.get('dsc_programa') or d.get('dsc_det_programa') or d.get('dsc_promocion') or '')
vendedor = d.get('dsc_vendedor', 'SIN VENDEDOR') or 'SIN VENDEDOR'
inv = d.get('INV_NETA', 0) or 0
saldo_mat = d.get('imp_saldo_matricula', 0) or 0
saldo_c1 = d.get('imp_saldo_cuota1', 0) or 0
def _fdate(v):
if not v or str(v).strip() in ("","None"): return ""
if hasattr(v,'strftime'): return v.strftime('%d/%m/%Y')
s = str(v)[:10]
if len(s)==10 and s[4]=='-': p=s.split('-'); return f"{p[2]}/{p[1]}/{p[0]}"
return s
f_mat = _fdate(d.get('fch_matricula'))
f_ini = _fdate(d.get('fch_inicio'))
f_canc = _fdate(fechas_canc.get(mat_id))
sede = self._clasificar_sede_comisiones(prog_clasificacion)
# mismo layout que el detalle: 16 columnas
fila = [
vendedor, alumno, f_mat, f_canc, "", "", "",
f"S/ {float(inv):,.0f}", programa, f_ini,
f"S/ {float(saldo_mat):,.0f}", f"S/ {float(saldo_c1):,.0f}",
"", "-", sede, mat_id,
]
filas.append(fila)
return filas
def obtener_datos_brutos(self, ano, mes_numero):
datos = self.data_manager.ejecutar_consulta_ventas(str(ano), str(mes_numero))
return datos or []
def obtener_datos_brutos_filtrado(self, ano, mes_numero, sede="TODOS", programa="TODOS"):
datos = self.data_manager.ejecutar_consulta_ventas(str(ano), str(mes_numero), sede, programa)
return datos or []
# =========================================================================
# FUNCIONES AUXILIARES
# =========================================================================
def clasificar_programa(self, dsc_programa):
if not dsc_programa: return "OTROS"
dsc_prog_upper = str(dsc_programa).upper()
meta_data = self.data_manager.meta_data
clasificaciones = meta_data.get("clasificacion_programas", {})
for clase, data in clasificaciones.items():
for patron in data.get("patrones", []):
if patron.upper() in dsc_prog_upper:
return clase
return meta_data.get("clasificacion_default", {}).get("categoria", "SEMINARIOS")
def identificar_linea_carrera(self, nombre_programa):
categoria = self.clasificar_programa(nombre_programa)
if categoria in ["AREQUIPA", "TRUJILLO", "PIURA", "TEAC", "TERC", "CARRERA"]:
return "CARRERA_COMPATIBLE"
return None
def parse_fecha(self, fecha_raw):
try:
if isinstance(fecha_raw, datetime): return fecha_raw
if not fecha_raw: return None
s_fecha = str(fecha_raw).strip()
if len(s_fecha) == 10 and s_fecha[2] == '-':
return datetime.strptime(s_fecha, '%d-%m-%Y')
if ' ' in s_fecha:
return datetime.strptime(s_fecha.split('.')[0], '%Y-%m-%d %H:%M:%S')
return datetime.strptime(s_fecha, '%Y-%m-%d')
except:
return None
# =========================================================================
# OBTENCIÓN DE DETALLE POR VENDEDOR (LÓGICA PURA)
# =========================================================================
def obtener_detalle_vendedor(self, vendedor, ano, mes_numero):
try:
key_mes = f"{int(mes_numero):02d}-{ano}"
lista_raw = self.data_manager.historico_pendientes.get(key_mes, [])
set_vip_actual = set()
lista_vip_str = []
for item in lista_raw:
try:
val = int(item)
set_vip_actual.add(val)
lista_vip_str.append(str(val))
except: pass
# Lista PURA de BASE_PENDIENTES (sin overrides). Venta Pendientes usa SOLO esto.
set_lista_pendientes = set(set_vip_actual)
# Incluir matrículas con override de fecha (igual que ejecutar_consulta_ventas)
# Incluir matrículas con override de fecha (igual que ejecutar_consulta_ventas)
_fov_vip = getattr(self.data_manager, '_fecha_canc_overrides', None) or {}
for mk in _fov_vip.keys():
try:
val = int(float(str(mk)))
set_vip_actual.add(val)
if str(val) not in lista_vip_str:
lista_vip_str.append(str(val))
except: pass
correcciones_mat = self.data_manager.config_data.get("correcciones_matriculas", {})
correcciones_desc = self.data_manager.config_data.get('correcciones_descuento', {})
correcciones_cont = self.data_manager.config_data.get('correcciones_continuidad', {})
sql_base = self.data_manager.query_matriculas_sql
if not sql_base:
return {"Venta Inscritos": [], "Venta P.C": [], "Venta Pendientes": []}
sql_in_clause = "(-1)"
if lista_vip_str:
sql_in_clause = "(" + ",".join(lista_vip_str) + ")"
sql_modificado = re.sub(
r"YEAR\(\s*sgeca_matricula\.fch_matricula\s*\)\s*=\s*\?",
r"( (YEAR(sgeca_matricula.fch_matricula) = ?",
sql_base, flags=re.IGNORECASE
)
sql_modificado = re.sub(
r"MONTH\(\s*sgeca_matricula\.fch_matricula\s*\)\s*=\s*\?",
rf"""MONTH(sgeca_matricula.fch_matricula) = ?) OR sgeca_matricula.num_matricula IN {sql_in_clause}
OR EXISTS (SELECT 1 FROM sgede_cronograma_matricula crono
WHERE crono.cod_localidad = sgeca_matricula.cod_localidad
AND crono.num_matricula = sgeca_matricula.num_matricula
AND crono.num_cuota = 1 AND crono.fch_cancelacion IS NOT NULL
AND YEAR(crono.fch_cancelacion) = ? AND MONTH(crono.fch_cancelacion) = ?) )""",
sql_modificado, flags=re.IGNORECASE
)
# Permitir matrículas VIP/override aunque estén ANU/SUS (igual que la tabla principal)
if lista_vip_str:
sql_modificado = re.sub(
r"sgeca_matricula\.cod_estado\s+NOT\s+IN\s*\(\s*'ANU'\s*,\s*'SUS'\s*\)",
rf"(sgeca_matricula.cod_estado NOT IN ('ANU', 'SUS') OR sgeca_matricula.num_matricula IN {sql_in_clause})",
sql_modificado, flags=re.IGNORECASE
)
conn = self.data_manager.get_connection()
cursor = conn.cursor()
cursor.execute(sql_modificado, ano, mes_numero, ano, mes_numero)
columns = [column[0] for column in cursor.description]
datos_raw = [dict(zip(columns, row)) for row in cursor.fetchall()]
# DEDUP por num_matricula: el LEFT JOIN con RP_programa puede duplicar filas
# (una con fch_cancelacion y otra sin). Nos quedamos con UNA sola por matrícula,
# priorizando la que TENGA fecha de cancelación (verdad del pago).
_dedup = {}
for _d in datos_raw:
_m = str(_d.get('num_matricula', '')).strip()
if _m.endswith('.0'): _m = _m[:-2]
_prev = _dedup.get(_m)
_tiene_f = bool(_d.get('fch_cancelacion_cuota1'))
if _prev is None:
_dedup[_m] = _d
elif _tiene_f and not bool(_prev.get('fch_cancelacion_cuota1')):
_dedup[_m] = _d
datos_raw = list(_dedup.values())
# 🔥 DESCARGA DEL CRONOGRAMA COMPLETO (SIN IMPORTAR EL MES) 🔥
lista_mats = list(set([str(d.get('num_matricula')).replace('.0','').strip() for d in datos_raw if d.get('num_matricula')]))
cuotas_raw = []
if lista_mats:
mats_str_q = ",".join([f"'{m}'" for m in lista_mats])
try:
res_cuo = requests.get(self.data_manager.github_cuota_url)
sql_cuotas_base = res_cuo.text
# Convertimos las validaciones de Año y Mes en "1=1" para que no esconda los cronogramas de otros meses
sql_cuotas_base = re.sub(r"YEAR\s*\([^)]+\)\s*=\s*\?", "1=1", sql_cuotas_base, flags=re.IGNORECASE)
sql_cuotas_base = re.sub(r"MONTH\s*\([^)]+\)\s*=\s*\?", "1=1", sql_cuotas_base, flags=re.IGNORECASE)
sql_cuotas_base = re.sub(r"ORDER\s+BY\s+.*$", "", sql_cuotas_base, flags=re.IGNORECASE | re.DOTALL)
sql_final_cuotas = f"SELECT * FROM ({sql_cuotas_base}) AS sub_cuotas WHERE num_matricula IN ({mats_str_q})"
cursor.execute(sql_final_cuotas)
cols_cuo = [c[0] for c in cursor.description]
cuotas_raw = [dict(zip(cols_cuo, row)) for row in cursor.fetchall()]
except Exception as e:
print(f"Error DAX Cuotas: {e}")
try: cursos_raw = self.data_manager.ejecutar_consulta_cursos(ano, mes_numero)
except: cursos_raw = []
conn.close()
# Agrupar Cuotas
cuotas_por_mat = {}
for c in cuotas_raw:
m_id = str(c.get('num_matricula', '')).strip()
if m_id.endswith('.0'): m_id = m_id[:-2]
if m_id not in cuotas_por_mat: cuotas_por_mat[m_id] = []
cuotas_por_mat[m_id].append(c)
fecha_inicio_prog = {}
for c in cursos_raw:
prog = str(c.get('dsc_programa', '')).strip().upper()
if prog not in fecha_inicio_prog:
fecha_inicio_prog[prog] = self.parse_fecha(c.get('fch_inicio'))
lista_dnis = [str(d.get('dsc_documento', '')).strip() for d in datos_raw if d.get('dsc_documento')]
try: datos_historial = self.data_manager.consultar_historial_continuidad(lista_dnis)
except: datos_historial = {}
listas_datos = {
"Venta Inscritos": {"filas": [], "s_cuota": 0.0, "c_cuota": 0, "s_desc": 0.0, "c_desc": 0, "s_neta": 0.0},
"Venta P.C": {"filas": [], "s_cuota": 0.0, "c_cuota": 0, "s_desc": 0.0, "c_desc": 0, "s_neta": 0.0},
"Venta Pendientes":{"filas": [], "s_cuota": 0.0, "c_cuota": 0, "s_desc": 0.0, "c_desc": 0, "s_neta": 0.0}
}
vendedor_target = str(vendedor).upper().replace(".", "").replace(",", "")
vendedor_target = " ".join(vendedor_target.split())
traer_todos = (vendedor_target == "__TODOS__")
for d in datos_raw:
vend_name = str(d.get('dsc_vendedor', 'SIN VENDEDOR')).upper().replace(".", "").replace(",", "")
vend_name_clean = " ".join(vend_name.split())
if not traer_todos and vendedor_target not in vend_name_clean and vend_name_clean not in vendedor_target:
continue
estado = str(d.get('estado_matricula', '')).strip().upper()
# Permitir ANU/otros si tiene override de fecha; si no, solo ALU/PRE/RET
_rm = d.get('num_matricula')
_mk = ""
if _rm is not None:
_mk = str(_rm).strip()
if _mk.endswith('.0'): _mk = _mk[:-2]
_fov_chk = getattr(self.data_manager, '_fecha_canc_overrides', None) or {}
if estado not in ['ALU', 'PRE', 'RET'] and _mk not in _fov_chk: continue
raw_mat = d.get('num_matricula')
mat_id = ""
matricula_int = -1
if raw_mat is not None:
val_str = str(raw_mat).strip()
if val_str.endswith('.0'): val_str = val_str[:-2]
mat_id = val_str
try: matricula_int = int(float(val_str))
except: pass
dni = str(d.get('dsc_documento', '')).strip()
alumno_nombre = d.get('dsc_alumno', 'SIN NOMBRE')
nombre_prog_crudo = str(d.get('dsc_programa', '')).strip().upper()
inv_neta_raw = float(d.get('INV_NETA', 0.0))
cod_moneda = str(d.get('cod_moneda', 'SOL')).strip().upper()
# Buscar imp_tc desde el CRONOGRAMA (no del comprobante)
imp_tc = 0
cuotas_alumno = cuotas_por_mat.get(mat_id, [])
for c_aux in cuotas_alumno:
try:
tc_aux = float(c_aux.get('imp_tc', 0) or 0)
if tc_aux >= 2: # Tomar el primer TC válido del cronograma
imp_tc = tc_aux
break
except: continue
if imp_tc < 2: imp_tc = 3.45 # TC por defecto si no hay cronograma o es inválido
saldo_mat = float(d.get('imp_saldo_matricula', 0.0))
saldo_c1 = float(d.get('imp_saldo_cuota1', 0.0))
fch_canc_raw = d.get('fch_cancelacion_cuota1')
fch_canc_real_sql = d.get('fch_cancelacion_cuota1') # SQL puro, sin override
fch_mat = d.get('fch_matricula')
# Override de fecha cancelación 1 (Supabase) → prioridad para clasificación + cuenta como pagado
_fov = getattr(self.data_manager, '_fecha_canc_overrides', None) or {}
_mat_key = str(mat_id).strip()
if _mat_key.endswith('.0'): _mat_key = _mat_key[:-2]
tiene_override_fecha = _mat_key in _fov
fecha_vaciada_override = False # override "borrar" → tiene prioridad sobre todo
if tiene_override_fecha:
s = str(_fov[_mat_key]).strip()
if s == "__VACIO__":
# Fecha borrada a propósito → anula la del SQL (no cuenta como pagado)
fch_canc_raw = None
tiene_override_fecha = False
fecha_vaciada_override = True
else:
# Normalizar dd/mm/yyyy o d/m/yyyy → yyyy-mm-dd
if '/' in s:
p = s.split('/')
if len(p) == 3:
s = f"{p[2]}-{int(p[1]):02d}-{int(p[0]):02d}"
fch_canc_raw = s
if mat_id in correcciones_mat:
datos_corregidos = correcciones_mat[mat_id]
if "imp_tc" in datos_corregidos: imp_tc = float(datos_corregidos["imp_tc"])
if "imp_saldo_cuota1" in datos_corregidos: saldo_c1 = float(datos_corregidos["imp_saldo_cuota1"])
if "imp_saldo_matricula" in datos_corregidos: saldo_mat = float(datos_corregidos["imp_saldo_matricula"])
# Supabase (vaciado) tiene prioridad: la corrección manual NO restaura la fecha
if "fch_cancelacion_cuota1" in datos_corregidos and not fecha_vaciada_override:
fch_canc_raw = datos_corregidos["fch_cancelacion_cuota1"]
# Prioridad TC: Supabase (mes) > corrección > cronograma
_tc_ov = getattr(self.data_manager, '_tc_override_mes', None)
if _tc_ov: imp_tc = float(_tc_ov)
if cod_moneda == 'DOL': inv_neta_soles = round(inv_neta_raw * imp_tc, 2)
else: inv_neta_soles = round(inv_neta_raw, 2)
row_year, row_month = -1, -1
fecha_mat_limpia = ""
if fch_mat:
try:
if hasattr(fch_mat, 'strftime'):
row_year, row_month = fch_mat.year, fch_mat.month
fecha_mat_limpia = fch_mat.strftime('%d/%m/%Y')
else:
f_obj = datetime.strptime(str(fch_mat)[:10], '%Y-%m-%d')
row_year, row_month = f_obj.year, f_obj.month
fecha_mat_limpia = f_obj.strftime('%d/%m/%Y')
except: fecha_mat_limpia = str(fch_mat)[:10]
fch_canc_limpia = ""
if fch_canc_raw and str(fch_canc_raw).strip() != "None":
try:
if hasattr(fch_canc_raw, 'strftime'): fch_canc_limpia = fch_canc_raw.strftime('%d/%m/%Y')
else:
tmp = str(fch_canc_raw)[:10]
if len(tmp) == 10 and tmp[4] == '-': p = tmp.split('-'); fch_canc_limpia = f"{p[2]}/{p[1]}/{p[0]}"
else: fch_canc_limpia = tmp
except: fch_canc_limpia = str(fch_canc_raw)[:10]
clase_alumno = self.clasificar_programa(nombre_prog_crudo)
linea_actual = self.identificar_linea_carrera(nombre_prog_crudo)
fecha_inicio_actual = fecha_inicio_prog.get(nombre_prog_crudo)
es_refriperu = False
if mat_id in correcciones_desc:
if str(correcciones_desc[mat_id]).upper() == "SI": es_refriperu = True
elif str(correcciones_desc[mat_id]).upper() == "NO": es_refriperu = False
else:
if clase_alumno in ["AREQUIPA", "TRUJILLO", "PIURA"] and 1200 <= inv_neta_soles <= 1700: es_refriperu = True
elif clase_alumno in ["TEAC", "TERC", "CARRERA"] and 1400 <= inv_neta_soles <= 1900: es_refriperu = True
tipo_final = "NUEVO"
if es_refriperu: tipo_final = "REFRIPERU"
else:
es_cont = False
if mat_id in correcciones_cont:
if str(correcciones_cont[mat_id]).upper() == "SI": es_cont = True
elif str(correcciones_cont[mat_id]).upper() == "NO": es_cont = False
else:
if fecha_inicio_actual and linea_actual and (dni in datos_historial):
for antecedente in datos_historial[dni]:
nombre_pasado = str(antecedente.get('programa', ''))
fecha_pasada = antecedente.get('fecha')
if not fecha_pasada: continue
if not (fecha_pasada < fecha_inicio_actual): continue
dias_diff = (fecha_inicio_actual - fecha_pasada).days
if dias_diff < 60: continue
linea_pasada = self.identificar_linea_carrera(nombre_pasado)
if linea_pasada == linea_actual:
es_cont = True
break
if es_cont: tipo_final = "CONTINUIDAD"
# ===============================================================
# 🔥 MATEMÁTICA PURA EXACTAMENTE COMO LA PEDISTE 🔥
# ===============================================================
suma_cuotas = 0.0
mis_cuotas = cuotas_por_mat.get(mat_id, [])
for c_dict in mis_cuotas:
try: num_c = int(c_dict.get('num_cuota', 0))
except: num_c = 0
# REGLA: Si el número de cuota es mayor a 0, sumar (imp_total - imp_dscto)
if num_c > 0:
t_val = c_dict.get('imp_total', 0)
d_val = c_dict.get('imp_dscto', 0)
try: t_monto = float(t_val) if t_val is not None else 0.0
except: t_monto = 0.0
try: d_monto = float(d_val) if d_val is not None else 0.0
except: d_monto = 0.0
imp_c = t_monto - d_monto
# Conversión si es Dólares
tc_over = None
if mat_id in correcciones_mat and "imp_tc" in correcciones_mat[mat_id]:
tc_over = float(correcciones_mat[mat_id]["imp_tc"])
cod_m_c = str(c_dict.get('cod_moneda', 'SOL')).strip().upper()
if cod_m_c == "DOL":
if tc_over is not None: tc_c = tc_over
else:
try: tc_c = float(c_dict.get('imp_tc', 0) or 0)
except: tc_c = 0
if tc_c < 2: tc_c = 3.45 # TC por defecto si es inválido
imp_c *= tc_c
suma_cuotas += imp_c
# ===============================================================
# DIVISIÓN SEGÚN CLASIFICACIÓN
# ===============================================================
if suma_cuotas > 0:
if clase_alumno in ["TEAC", "TERC", "CARRERA"]:
valor_cuota_final = suma_cuotas / 5.0
elif clase_alumno in ["AREQUIPA", "TRUJILLO", "PIURA"]:
valor_cuota_final = suma_cuotas / 6.0
else:
valor_cuota_final = suma_cuotas
str_cuota_val = f"S/ {valor_cuota_final:,.0f}"
else:
valor_cuota_final = 0.0
# Si no tiene cuotas mayores a 0, se queda en blanco limpio
str_cuota_val = ""
# ===============================================================
# LÓGICA DESC. ESPECIAL (Sobre la suma pura)
# ===============================================================
desc_e_raw = 0.0
if suma_cuotas >= 1900:
if clase_alumno in ["TEAC", "TERC", "CARRERA"]: d_calc = 3400 - suma_cuotas
elif clase_alumno in ["AREQUIPA", "PIURA", "TRUJILLO"]: d_calc = 2740 - suma_cuotas
else: d_calc = 0
if d_calc > 0:
desc_e_raw = min(max(d_calc - 200, 0.0), 200.0)
str_desc_e_val = f"S/ {desc_e_raw:,.0f}" if desc_e_raw > 0 else ""
# ==========================================
# DISTRIBUCIÓN EN LAS 3 LISTAS
# ==========================================
vendedor_real = d.get('dsc_vendedor', 'SIN VENDEDOR')
if not vendedor_real: vendedor_real = 'SIN VENDEDOR'
# Campos extra para el popup de Comisiones (mismo alumno / num_indice)
# NOTA: la query trae el programa DETALLADO con alias 'dsc_promocion'
# (sgede_RP_programa.dsc_det_programa AS dsc_promocion).
# MOSTRAR: prioriza el detallado (dsc_promocion)
det_programa = (d.get('dsc_promocion') or d.get('dsc_det_programa')
or d.get('dsc_programa') or '')
# CLASIFICAR (sede/filtro): prioriza dsc_programa
prog_clasificacion = (d.get('dsc_programa') or d.get('dsc_det_programa')
or d.get('dsc_promocion') or '')
fch_ini_raw = d.get('fch_inicio', '')
fch_ini_limpia = ""
if fch_ini_raw and str(fch_ini_raw).strip() != "None":
try:
if hasattr(fch_ini_raw, 'strftime'): fch_ini_limpia = fch_ini_raw.strftime('%d/%m/%Y')
else:
t = str(fch_ini_raw)[:10]
if len(t) == 10 and t[4] == '-':
p = t.split('-'); fch_ini_limpia = f"{p[2]}/{p[1]}/{p[0]}"
else: fch_ini_limpia = t
except: fch_ini_limpia = str(fch_ini_raw)[:10]
str_saldo_mat = f"S/ {saldo_mat:,.0f}" if saldo_mat else "S/ 0"
str_saldo_c1 = f"S/ {saldo_c1:,.0f}" if saldo_c1 else "S/ 0"
# ── Cálculos extra para Comisiones ──────────────────────────
# DÍAS ANTICIPACIÓN = Fecha Inicio - Fecha Cancelación 1 (en días)
dias_anticipacion = "-"
try:
def _to_date(v):
if not v or str(v).strip() in ("", "None"): return None
if hasattr(v, 'year'): return datetime(v.year, v.month, v.day)
s = str(v)[:10]
if len(s) == 10 and s[4] == '-': return datetime.strptime(s, '%Y-%m-%d')
if len(s) == 10 and s[2] == '/':
p = s.split('/'); return datetime(int(p[2]), int(p[1]), int(p[0]))
return None
di = _to_date(fch_ini_raw)
dc = _to_date(fch_canc_raw)
if di and dc:
dias_anticipacion = str((di - dc).days)
except Exception:
dias_anticipacion = "-"
# TIPO PROGRAMA y sede (clasificación tipo Cobranza vía sede.json)
sede_alumno = self._clasificar_sede_comisiones(prog_clasificacion or nombre_prog_crudo)
tipo_programa = sede_alumno # según sede.json (LIMA/AREQUIPA/PIURA/TRUJILLO)
# VALOR CUOTA ADICIONAL = Promedio cuota - 640 (solo si sede = LIMA, mínimo 0)
if sede_alumno == "LIMA" and valor_cuota_final > 0:
valor_adicional = valor_cuota_final - 640
if valor_adicional < 0:
valor_adicional = 0
str_valor_adicional = f"S/ {valor_adicional:,.0f}"
else:
str_valor_adicional = "-"
fila_final = [
vendedor_real, alumno_nombre, fecha_mat_limpia, fch_canc_limpia,
tipo_final, str_cuota_val, str_desc_e_val, f"S/ {inv_neta_soles:,.0f}",
# Extra para Comisiones (índices 8+): programa, fch inicio, saldos
det_programa, fch_ini_limpia, str_saldo_mat, str_saldo_c1,
# Nuevos (índices 12+): días anticipación, valor cuota adicional, tipo programa
dias_anticipacion, str_valor_adicional, tipo_programa,
# Índice [15]: num_matricula (clave para overrides de Comisiones)
mat_id,
# Índice [16]: nombre para CLASIFICAR (prioriza dsc_programa). Oculto en UI.
prog_clasificacion
]
es_venta_del_mes = (str(row_year) == str(ano)) and (str(row_month) == str(mes_numero))
saldos_ok = (saldo_mat == 0 and saldo_c1 == 0)
if tiene_override_fecha:
saldos_ok = True
def agregar_a_lista(nombre_lista):
listas_datos[nombre_lista]["filas"].append(fila_final)
listas_datos[nombre_lista]["s_neta"] += inv_neta_soles
if valor_cuota_final > 0:
listas_datos[nombre_lista]["s_cuota"] += valor_cuota_final
listas_datos[nombre_lista]["c_cuota"] += 1
if desc_e_raw > 0:
listas_datos[nombre_lista]["s_desc"] += desc_e_raw
listas_datos[nombre_lista]["c_desc"] += 1
# Sacamos la validación de fecha de cancelación AFUERA para que sirva a ambas listas
# Sacamos la validación de fecha de cancelación AFUERA para que sirva a ambas listas
pago_en_fecha = False
if fch_canc_raw and str(fch_canc_raw).strip() != "None":
try:
if isinstance(fch_canc_raw, str):
f_obj = datetime.strptime(fch_canc_raw[:10], '%Y-%m-%d')
f_ano, f_mes = f_obj.year, f_obj.month
else:
f_ano, f_mes = fch_canc_raw.year, fch_canc_raw.month
if str(f_ano) == str(ano) and str(f_mes) == str(mes_numero):
pago_en_fecha = True
except: pass
# ¿Matrícula de un mes ANTERIOR al filtro?
matricula_mes_pasado = False
try:
ym_mat = int(row_year) * 100 + int(row_month)
ym_filtro = int(ano) * 100 + int(mes_numero)
matricula_mes_pasado = ym_mat < ym_filtro
except: pass
if es_venta_del_mes:
agregar_a_lista("Venta Inscritos")
if saldos_ok and pago_en_fecha:
agregar_a_lista("Venta P.C")
# VENTA PENDIENTES: SOLO de tu lista BASE_PENDIENTES y que pagaron su 1ª
# cuota EN EL MES/AÑO DEL FILTRO. Usa la FECHA REAL DEL SQL (sin override).
_pago_mes_real = False
if fch_canc_real_sql and str(fch_canc_real_sql).strip() != "None":
try:
if isinstance(fch_canc_real_sql, str):
_fo = datetime.strptime(fch_canc_real_sql[:10], '%Y-%m-%d')
_fa, _fm = _fo.year, _fo.month
else:
_fa, _fm = fch_canc_real_sql.year, fch_canc_real_sql.month
if int(_fa) == int(ano) and int(_fm) == int(mes_numero):
_pago_mes_real = True
except: pass
if matricula_int in set_lista_pendientes and _pago_mes_real:
agregar_a_lista("Venta Pendientes")
# ==========================================
# ORDENAR Y ARMAR TOTAL GENERAL
# ==========================================
resultado_final = {}
for k, dict_data in listas_datos.items():
dict_data["filas"].sort(key=lambda x: x[1])
p_cuota = (dict_data["s_cuota"] / dict_data["c_cuota"]) if dict_data["c_cuota"] > 0 else 0.0
p_desc = (dict_data["s_desc"] / dict_data["c_desc"]) if dict_data["c_desc"] > 0 else 0.0
str_gral_cuota = f"S/ {p_cuota:,.0f}" if dict_data["c_cuota"] > 0 else ""
str_gral_desc = f"S/ {p_desc:,.0f}" if dict_data["c_desc"] > 0 else ""
dict_data["filas"].append([
"TOTAL GENERAL", "", "", "", "",
str_gral_cuota, str_gral_desc, f"S/ {dict_data['s_neta']:,.0f}"
])
resultado_final[k] = dict_data["filas"]
return resultado_final
except Exception as e:
print(f"Error procesando alumnos para modal Ventas: {e}")
return {"Venta Inscritos": [], "Venta P.C": [], "Venta Pendientes": []}
def formatear_datos_para_tabla(self, datos):
if not datos:
return []
sheet_data = []
total_monto = total_pc = total_pendientes = total_avance_pc_total = 0.0
total_cantidad = total_cantidad_pc = total_cantidad_pendientes = 0
for registro in datos:
vendedor = registro.get('VENDEDOR', 'SIN VENDEDOR')
monto = float(registro.get('MONTO', 0.0))
cantidad = int(registro.get('CANTIDAD', 0))
ventas_pc = float(registro.get('VENTAS_PC', 0.0))
cantidad_pc = int(registro.get('INSCRITOS_PC', 0))
pendientes = float(registro.get('PENDIENTES', 0.0))
cant_pendientes = int(registro.get('INSCRITOS_PENDIENTES', 0))
avance_pc_total = ventas_pc + pendientes
total_monto += monto; total_cantidad += cantidad; total_pc += ventas_pc
total_cantidad_pc += cantidad_pc; total_pendientes += pendientes
total_cantidad_pendientes += cant_pendientes; total_avance_pc_total += avance_pc_total
sheet_data.append([
vendedor, cantidad, f"S/ {monto:,.0f}", cantidad_pc, f"S/ {ventas_pc:,.0f}",
cant_pendientes, f"S/ {pendientes:,.0f}", f"S/ {avance_pc_total:,.0f}",
" ≡ ▼ "
])
sheet_data.append([
"TOTAL GENERAL", total_cantidad, f"S/ {total_monto:,.0f}",
total_cantidad_pc, f"S/ {total_pc:,.0f}", total_cantidad_pendientes,
f"S/ {total_pendientes:,.0f}", f"S/ {total_avance_pc_total:,.0f}",
""
])
return sheet_data
def exportar_detalle_alumnos_excel(self, vendedor, lista_nombre, headers, datos):
if not datos: raise ValueError("No hay datos para exportar")
vend_limpio = "".join([c if c.isalnum() else "_" for c in str(vendedor)])[:30]
lista_limpia = "".join([c if c.isalnum() else "_" for c in str(lista_nombre)])
archivo = f"Ventas_{vend_limpio}_{lista_limpia}.xlsx"
df = pd.DataFrame(datos, columns=headers)
try:
with pd.ExcelWriter(archivo, engine='openpyxl') as writer:
df.to_excel(writer, index=False, sheet_name='Detalle_Ventas')
worksheet = writer.sheets['Detalle_Ventas']
try:
from openpyxl.styles import PatternFill, Font, Border, Side, Alignment
fill_header = PatternFill(start_color="12345F", end_color="12345F", fill_type="solid")
font_header = Font(color="FFFFFF", bold=True)
thin_border = Border(left=Side(style='thin', color="DDDDDD"), right=Side(style='thin', color="DDDDDD"),
top=Side(style='thin', color="DDDDDD"), bottom=Side(style='thin', color="DDDDDD"))
for cell in worksheet[1]:
cell.fill = fill_header
cell.font = font_header
cell.border = thin_border
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
for row in worksheet.iter_rows(min_row=2):
for cell in row:
cell.border = thin_border
cell.alignment = Alignment(vertical="center")
for col in worksheet.columns:
max_len = 0
col_letter = col[0].column_letter
for cell in col:
if cell.value: max_len = max(max_len, len(str(cell.value)))
worksheet.column_dimensions[col_letter].width = min(max_len + 3, 50)
except: pass
except: df.to_excel(archivo, index=False)
return archivo
def exportar_a_excel(self, datos, ano, nombre_mes):
if not datos: raise ValueError("No hay datos para exportar")
datos_export = []
for d in datos:
nuevo_d = dict(d)
nuevo_d['AVANCE_PC_TOTAL'] = float(d.get('VENTAS_PC', 0)) + float(d.get('PENDIENTES', 0))
datos_export.append(nuevo_d)
df = pd.DataFrame(datos_export)
archivo = f"ventas_{ano}_{nombre_mes}.xlsx"
df.to_excel(archivo, index=False)
return archivo

View File

@@ -1,10 +1,6 @@
fastapi==0.115.0 fastapi
uvicorn[standard]==0.30.6 uvicorn
pyodbc==5.1.0 python-dotenv
requests==2.32.3 pyodbc
pandas==2.2.2 psycopg2-binary
openpyxl==3.1.5 requests
python-dotenv==1.0.1
supabase==2.15.1
httpx==0.28.1
pytz==2024.1

File diff suppressed because it is too large Load Diff

View File

@@ -1,29 +1,38 @@
# ============================================================
# Dashboard Leads - despliegue con Docker Compose
#
# IP Pública: 161.132.96.129 | IP Privada: 192.168.1.107
#
# Mapeo de puertos en el Host / Nginx Reverse Proxy:
# dashboardv1.escueladerefrigeracion.edu.pe -> host:3002 (frontend nginx)
# dashboardapi.escueladerefrigeracion.edu.pe -> host:8001 (backend uvicorn)
# ============================================================
services: services:
# 1. EL BACKEND (FastAPI - Python)
backend: backend:
build: build:
context: ./backend context: ./backend
environment: dockerfile: Dockerfile
- SQL_SERVER=191.98.134.80 restart: unless-stopped
- SQL_DATABASE=BDUS_CK000040_0001 ports:
- SQL_USERNAME=ASEBASTIAN - "8001:8001"
- SQL_PASSWORD=24MY36$$z>&Uf # Escapado correctamente para Docker ($$) expose:
- PG_HOST=191.98.134.81 - "8001"
- PG_DATABASE=chatwoot_production healthcheck:
- PG_USER=postgres test: ["CMD-SHELL", "python -c \"import urllib.request;urllib.request.urlopen('http://127.0.0.1:8001/api/health')\" || exit 1"]
- PG_PASSWORD=2165$$%sd3%DFG # <--- ¡CORREGIDO AQUÍ! Añadido el doble $$ para Docker interval: 30s
- PG_PORT=5432 timeout: 10s
- SUPABASE_URL=https://ogzjtkxnfswpbmnbhnjd.supabase.co retries: 5
- SUPABASE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9nemp0a3huZnN3cGJtbmJobmpkIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc3NjYzNDk4MywiZXhwIjoyMDkyMjEwOTgzfQ.D7XB6GIs8UvI97lcMdf6Y6-8ON2ENhh0DOaiMro2f9Y start_period: 60s
- GITHUB_BASE=https://raw.githubusercontent.com/aron1798/prueba1/main/DASHBOARD
# 2. EL FRONTEND (Vite - React)
frontend: frontend:
build: build:
context: ./frontend context: ./frontend
args: dockerfile: Dockerfile
- VITE_SUPABASE_URL=https://ogzjtkxnfswpbmnbhnjd.supabase.co restart: unless-stopped
- VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9nemp0a3huZnN3cGJtbmJobmpkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzY2MzQ5ODMsImV4cCI6MjA5MjIxMDk4M30.OEMjCJOXHBgAHPxtncQUOVC8iDjW40CnT5pczU-gz0U ports:
- VITE_API_URL=https://dashboardapi.escueladerefrigeracion.edu.pe - "3002:80"
expose:
- "80"
depends_on: depends_on:
- backend - backend

6
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
dist
.env.local
npm-debug.log*
Dockerfile
.dockerignore

View File

@@ -1,5 +1,4 @@
# Credenciales públicas de Supabase para el login (frontend) # URL base del backend FastAPI que consume el frontend.
# La URL es la misma de tu proyecto. # Local: http://localhost:8001
# La ANON KEY es la PÚBLICA (anon/public), NO la service_role. # Despliegue: https://dashboardapi.escueladerefrigeracion.edu.pe
VITE_SUPABASE_URL=https://ogzjtkxnfswpbmnbhnjd.supabase.co VITE_API_URL=http://localhost:8001
VITE_SUPABASE_ANON_KEY=pega_aqui_tu_anon_public_key

View File

@@ -1 +0,0 @@
VITE_API_URL=https://dashboardapi.escueladerefrigeracion.edu.pe

4
frontend/.gitignore vendored
View File

@@ -1,4 +0,0 @@
node_modules/
dist/
*.log
.env

View File

@@ -1,27 +1,20 @@
# Usamos Node.js para compilar el proyecto # ── Etapa 1: build del frontend con Vite ──
FROM node:18-alpine FROM node:20-alpine AS build
# Creamos la carpeta de trabajo
WORKDIR /app WORKDIR /app
# Declaramos que recibiremos estos argumentos durante el build COPY package.json package-lock.json* ./
ARG VITE_SUPABASE_URL RUN npm ci --no-audit --no-fund || npm install --no-audit --no-fund
ARG VITE_SUPABASE_ANON_KEY
ARG VITE_API_URL
# Los convertimos en variables de entorno para que Vite los vea al compilar
ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL
ENV VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY
ENV VITE_API_URL=$VITE_API_URL
# Copiamos los archivos de configuración e instalamos dependencias
COPY package*.json ./
RUN npm install
# Copiamos el código y construimos la web (ahora Vite sí verá las variables)
COPY . . COPY . .
RUN npm run build RUN npm run build
# Instalamos el servidor estático y lo ejecutamos # ── Etapa 2: servir el build con nginx ──
RUN npm install -g serve FROM nginx:alpine
CMD ["serve", "-s", "dist", "-l", "5173"]
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -1,29 +0,0 @@
# Frontend — Dashboard React
## Requisitos
- Node.js 18+ (descarga: https://nodejs.org)
## Instalación (una sola vez)
```
npm install
```
## Ejecutar
```
npm run dev
```
Abre: http://localhost:3000
⚠️ El backend FastAPI debe estar corriendo en http://localhost:8000
(en otra terminal: `py -3.12 main.py`)
## Estado
- ✅ Sidebar + navegación
- ✅ Ocupabilidad (conectado al backend)
- 🚧 Ventas, Cobranza, Rentabilidad, Saldo Pendiente, Asesores (próximas sesiones)
## Cambiar IP del backend
Si el backend corre en otra máquina, edita `src/lib/api.js` línea 3:
```js
const BASE_URL = "http://IP_DEL_SERVIDOR:8000";
```

View File

@@ -1,9 +1,9 @@
<!DOCTYPE html> <!doctype html>
<html lang="es"> <html lang="es">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dashboard — Escuela de Refrigeración</title> <title>Dashboard Leads — Escuela Refrigeración</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

37
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,37 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Compresion
gzip on;
gzip_types text/plain text/css application/json application/javascript
text/xml application/xml application/xml+rss text/javascript
image/svg+xml;
gzip_min_length 1024;
# Cache larga para los assets con hash en el nombre
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# SPA: cualquier ruta desconocida devuelve index.html
location / {
try_files $uri $uri/ /index.html;
}
# index.html nunca se cachea, para que los deploys se vean al instante
location = /index.html {
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
internal;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +1,20 @@
{ {
"name": "dashboard-frontend", "name": "dashboard-leads",
"private": true, "private": true,
"version": "1.0.0", "version": "1.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build --mode production", "build": "vite build",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@supabase/supabase-js": "^2.107.0", "react": "^18.2.0",
"react": "^18.3.1", "react-dom": "^18.2.0",
"react-dom": "^18.3.1", "recharts": "^2.12.0"
"xlsx": "^0.18.5",
"xlsx-js-style": "^1.2.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.2.0",
"vite": "^5.4.0" "vite": "^5.0.0"
} }
} }

19
frontend/public/50x.html Normal file
View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html>
<head>
<title>Error</title>
<style>
html { color-scheme: light dark; }
body { width: 35em; margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif; }
</style>
</head>
<body>
<h1>An error occurred.</h1>
<p>Sorry, the page you are looking for is currently unavailable.<br/>
Please try again later.</p>
<p>If you are the system administrator of this resource then you should check
the error log for details.</p>
<p><em>Faithfully yours, nginx.</em></p>
</body>
</html>

View File

@@ -1,67 +1,35 @@
// src/App.jsx import { useState } from "react";
import { useState, useEffect } from "react";
import { useAuth } from "./lib/auth";
import Sidebar from "./components/Sidebar"; import Sidebar from "./components/Sidebar";
import IndicadorActualizacion from "./components/IndicadorActualizacion"; import Leads from "./pages/Leads";
import Login from "./pages/Login"; import OtrosGeneral from "./pages/OtrosGeneral";
import Ocupabilidad from "./pages/Ocupabilidad"; import Vendedores from "./pages/Vendedores";
import Ventas from "./pages/Ventas"; import Roas from "./pages/Roas";
import Comisiones from "./pages/Comisiones"; import CampanitaAlertas from "./components/CampanitaAlertas";
import Cobranza from "./pages/Cobranza"; import ConfigModal from "./components/ConfigModal";
import Rentabilidad from "./pages/Rentabilidad"; import ErrorBoundary from "./components/ErrorBoundary";
import Asesores from "./pages/Asesores";
import Usuarios from "./pages/Usuarios";
export default function App() { export default function App() {
const { autenticado, cargando, permisos, puedeVer } = useAuth(); const [pagina, setPagina] = useState("leads");
const [pagina, setPagina] = useState(null); const [config, setConfig] = useState(false);
// Al entrar, seleccionar el primer módulo permitido
useEffect(() => {
if (autenticado && permisos.length > 0 && !pagina) {
setPagina(permisos[0]);
}
if (!autenticado) setPagina(null);
}, [autenticado, permisos, pagina]);
if (cargando) {
return (
<div style={{ minHeight:"100vh", display:"flex", alignItems:"center", justifyContent:"center", background:"#0f172a", color:"#fff" }}>
Cargando...
</div>
);
}
if (!autenticado) return <Login />;
function render() { function render() {
// Seguridad: si la página actual no está permitida, no renderizar
if (pagina && !puedeVer(pagina)) return <SinAcceso />;
switch (pagina) { switch (pagina) {
case "ocupabilidad": return <Ocupabilidad />; case "leads": return <Leads />;
case "ventas": return <Ventas />; case "otros": return <OtrosGeneral />;
case "comisiones": return <Comisiones />; case "vendedores": return <Vendedores />;
case "cobranza": return <Cobranza />; case "roas": return <Roas />;
case "rentabilidad": return <Rentabilidad />;
case "asesores": return <Asesores />;
case "usuarios": return <Usuarios />;
default: return null; default: return null;
} }
} }
return ( return (
<div className="app"> <div className="app">
<Sidebar active={pagina} onChange={setPagina} /> <Sidebar active={pagina} onChange={setPagina} onConfig={() => setConfig(true)} />
<main className="main">{render()}</main> <main className="main">
<IndicadorActualizacion /> <ErrorBoundary key={pagina}>{render()}</ErrorBoundary>
</div> </main>
); <CampanitaAlertas />
} {config && <ConfigModal onClose={() => setConfig(false)} />}
function SinAcceso() {
return (
<div style={{ padding:40, textAlign:"center", color:"#94a3b8" }}>
🔒 No tienes acceso a este módulo.
</div> </div>
); );
} }

View File

@@ -0,0 +1,92 @@
// Campanita de alertas: avisa de programas/sedes NO identificados en cartera_junta
// (valores que no están en el diccionario alias_normalizacion).
import { useState, useEffect } from "react";
import { api } from "../lib/api";
export default function CampanitaAlertas() {
const [data, setData] = useState({ programa: [], sede: [] });
const [abierto, setAbierto] = useState(false);
useEffect(() => {
let activo = true;
api.alertas()
.then((r) => { if (activo) setData({ programa: r.programa || [], sede: r.sede || [] }); })
.catch(() => { if (activo) setData({ programa: [], sede: [] }); });
return () => { activo = false; };
}, []);
const total = (data.programa?.length || 0) + (data.sede?.length || 0);
const hay = total > 0;
return (
<div style={{ position: "fixed", right: 18, top: 14, zIndex: 80 }}>
<button
onClick={() => setAbierto((v) => !v)}
title={hay ? `${total} valor(es) sin identificar` : "Sin alertas"}
style={{
position: "relative", width: 38, height: 38, borderRadius: 10,
border: "1px solid " + (hay ? "#f59e0b" : "#334155"),
background: hay ? "#78350f" : "rgba(15,23,42,0.9)",
color: hay ? "#fde68a" : "#94a3b8",
cursor: "pointer", fontSize: 18, lineHeight: 1,
display: "flex", alignItems: "center", justifyContent: "center",
boxShadow: "0 2px 8px rgba(0,0,0,0.25)",
}}
>
🔔
{hay && (
<span style={{
position: "absolute", top: -6, right: -6, minWidth: 18, height: 18,
padding: "0 4px", borderRadius: 999, background: "#dc2626", color: "#fff",
fontSize: 11, fontWeight: 700, display: "flex", alignItems: "center",
justifyContent: "center", border: "1px solid #fff",
}}>{total}</span>
)}
</button>
{abierto && (
<div style={{
position: "absolute", right: 0, top: 46, width: 320, maxHeight: 420,
overflowY: "auto", background: "#fff", color: "#0f172a",
border: "1px solid #e2e8f0", borderRadius: 12,
boxShadow: "0 8px 24px rgba(0,0,0,0.18)", padding: 14, fontSize: 13,
}}>
<div style={{ fontWeight: 700, marginBottom: 8 }}>🔔 Alertas de normalización</div>
{!hay ? (
<div style={{ color: "#059669", fontWeight: 600 }}>✓ Todo identificado, nada por revisar.</div>
) : (
<>
{(data.programa?.length > 0) && (
<div style={{ marginBottom: 10 }}>
<div style={{ fontWeight: 600, color: "#b45309", marginBottom: 4 }}>
Nuevo programa no identificado:
</div>
{data.programa.map((p, i) => (
<div key={i} style={{ padding: "3px 0", borderBottom: "1px solid #f1f5f9" }}>
<b>{p.valor || "(vacío)"}</b> <span style={{ color: "#94a3b8" }}>· {p.veces}</span>
</div>
))}
</div>
)}
{(data.sede?.length > 0) && (
<div>
<div style={{ fontWeight: 600, color: "#b45309", marginBottom: 4 }}>
Nueva sede no identificada:
</div>
{data.sede.map((s, i) => (
<div key={i} style={{ padding: "3px 0", borderBottom: "1px solid #f1f5f9" }}>
<b>{s.valor || "(vacío)"}</b> <span style={{ color: "#94a3b8" }}>· {s.veces}</span>
</div>
))}
</div>
)}
<div style={{ marginTop: 10, color: "#64748b", fontSize: 12 }}>
Agrégalos al diccionario (alias_normalizacion) para clasificarlos.
</div>
</>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,969 @@
import { useState, useEffect, useRef } from "react";
import { api } from "../lib/api";
// Desplegable con búsqueda (estilo Claude).
function ProgramaSelect({ valor, opciones = [], onSelect }) {
const [abierto, setAbierto] = useState(false);
const [q, setQ] = useState("");
const ref = useRef(null);
useEffect(() => {
function fuera(e) { if (ref.current && !ref.current.contains(e.target)) setAbierto(false); }
document.addEventListener("mousedown", fuera);
return () => document.removeEventListener("mousedown", fuera);
}, []);
const filtradas = opciones.filter((o) => o.label.toLowerCase().includes(q.toLowerCase()));
return (
<div ref={ref} style={{ position: "relative", width: "100%" }}>
<button onClick={() => setAbierto((v) => !v)}
style={{ width: "100%", textAlign: "left", padding: "7px 10px", border: "1px solid #cbd5e1",
borderRadius: 8, fontSize: 13, background: "#fff", cursor: "pointer",
color: valor ? "#334155" : "#94a3b8", fontFamily: "inherit",
display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{valor || "Seleccionar programa..."}
</span>
<span style={{ color: "#94a3b8", marginLeft: 6 }}>⌄</span>
</button>
{abierto && (
<div style={{ position: "absolute", top: "calc(100% + 6px)", left: 0, right: 0, background: "#fff",
border: "1px solid #e2e8f0", borderRadius: 12, boxShadow: "0 12px 34px rgba(0,0,0,0.18)",
zIndex: 100, overflow: "hidden" }}>
<div style={{ padding: 10, borderBottom: "1px solid #f1f5f9" }}>
<input autoFocus value={q} onChange={(e) => setQ(e.target.value)} placeholder="Buscar..."
style={{ width: "100%", padding: "8px 11px", border: "1px solid #cbd5e1", borderRadius: 8,
fontSize: 13, fontFamily: "inherit", outline: "none" }} />
</div>
<div style={{ maxHeight: 220, overflow: "auto" }}>
{filtradas.length === 0 ? (
<div style={{ padding: "12px 14px", fontSize: 13, color: "#94a3b8" }}>Sin opciones</div>
) : filtradas.map((o) => (
<div key={o.num_indice} style={{ padding: "9px 14px", fontSize: 13, color: "#334155", cursor: "pointer" }}
onMouseEnter={(e) => e.currentTarget.style.background = "#f1f5f9"}
onMouseLeave={(e) => e.currentTarget.style.background = "transparent"}
onMouseDown={(e) => e.preventDefault()}
onClick={() => { onSelect && onSelect(o); setAbierto(false); setQ(""); }}>
{o.label}
</div>
))}
</div>
</div>
)}
</div>
);
}
// Apartado "Sedes de Anuncios": conjuntos del CSV sin sede -> asignar sede manual.
// FASE 1: solo vista (dropdown de sede por fila). El guardar se agrega despues.
function SedesAnuncios() {
const [filas, setFilas] = useState([]);
const [cargando, setCargando] = useState(true);
const [sel, setSel] = useState({}); // conjunto -> sede elegida (decorativo por ahora)
const [selProg, setSelProg] = useState({}); // conjunto -> programa elegido (decorativo)
const [busca, setBusca] = useState("");
// Opciones fijas (no se pueden borrar) y opciones ampliables con "Agregar".
const SEDES_FIJAS = ["LIMA", "PIURA", "AREQUIPA", "TRUJILLO"];
const PROGS_FIJOS = ["TEAC", "TERC", "SEMINARIOS", "OTROS"];
const [sedes, setSedes] = useState(SEDES_FIJAS);
const [programas, setProgramas] = useState(PROGS_FIJOS);
const SEDES = sedes;
const PROGRAMAS = programas;
// Quita una opción creada (no fija): la saca del dropdown y de cualquier
// conjunto que la tenga asignada (se aplica al Guardar).
function quitarOpcion(tipo, val) {
if (tipo === "TIPO") {
if (SEDES_FIJAS.includes(val)) return;
setSedes((s) => s.filter((x) => x !== val));
setSel((cur) => {
const n = { ...cur };
Object.keys(n).forEach((k) => { if (n[k] === val) n[k] = ""; });
return n;
});
} else {
if (PROGS_FIJOS.includes(val)) return;
setProgramas((p) => p.filter((x) => x !== val));
setSelProg((cur) => {
const n = { ...cur };
Object.keys(n).forEach((k) => { if (n[k] === val) n[k] = ""; });
return n;
});
}
}
// Pop-up "Agregar"
const [popup, setPopup] = useState(false);
const [nvTipo, setNvTipo] = useState("TIPO"); // "TIPO" o "PROGRAMA"
const [nvValor, setNvValor] = useState("");
function agregar() {
const val = nvValor.trim().toUpperCase();
if (!val) return;
if (nvTipo === "TIPO") {
if (!sedes.includes(val)) setSedes((s) => [...s, val]);
} else {
if (!programas.includes(val)) setProgramas((p) => [...p, val]);
}
setNvValor(""); setPopup(false);
}
const [guardando, setGuardando] = useState(false);
const [msg, setMsg] = useState("");
useEffect(() => {
api.conjuntosSinSede()
.then((r) => {
const rows = r || [];
setFilas(rows);
// precargar sede/programa ya asignados + ampliar opciones si hay valores nuevos
const s0 = {}, p0 = {}; const sExtra = new Set(), pExtra = new Set();
rows.forEach((f) => {
if (f.sede) { s0[f.conjunto] = f.sede; sExtra.add(f.sede); }
if (f.programa) { p0[f.conjunto] = f.programa; pExtra.add(f.programa); }
});
setSel(s0); setSelProg(p0);
setSedes((cur) => [...cur, ...[...sExtra].filter((x) => !cur.includes(x))]);
// Carga los programas ya asignados (incluye los nuevos que crees, ej.
// BRANDING). El dashboard ahora SÍ reconoce cualquier programa asignado.
setProgramas((cur) => [...cur, ...[...pExtra].filter((x) => !cur.includes(x))]);
setCargando(false);
})
.catch(() => setCargando(false));
}, []);
async function guardar() {
setGuardando(true); setMsg("");
try {
const cambios = filas
.filter((f) => sel[f.conjunto] || selProg[f.conjunto])
.map((f) => ({ conjunto: f.conjunto, sede: sel[f.conjunto] || "", programa: selProg[f.conjunto] || "" }));
await api.guardarConjuntoSede(cambios);
setMsg("✓ Guardado. Se reflejará en ROAS.");
window.dispatchEvent(new Event("roas-actualizar")); // avisa al ROAS que recargue
window.dispatchEvent(new Event("datos-actualizar")); // avisa a TODOS los apartados
} catch (e) {
setMsg("Error al guardar");
}
setGuardando(false);
}
async function borrar(conjunto) {
// limpia la seleccion en pantalla y borra la asignacion en Supabase
setSel((s) => { const n = { ...s }; delete n[conjunto]; return n; });
setSelProg((s) => { const n = { ...s }; delete n[conjunto]; return n; });
try {
await api.borrarConjuntoSede(conjunto);
setMsg("✓ Asignación borrada.");
window.dispatchEvent(new Event("roas-actualizar")); // avisa al ROAS que recargue
window.dispatchEvent(new Event("datos-actualizar")); // avisa a TODOS los apartados
} catch (e) {
setMsg("Error al borrar");
}
}
const vis = filas.filter((f) => f.conjunto.toLowerCase().includes(busca.toLowerCase()));
return (
<>
<div style={{ padding: "14px 22px 8px" }}>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input value={busca} onChange={(e) => setBusca(e.target.value)}
placeholder="Buscar conjunto de anuncio..."
style={{ flex: 1, padding: "9px 12px", border: "1px solid #cbd5e1",
borderRadius: 9, fontSize: 13 }} />
<button onClick={() => setPopup(true)}
style={{ padding: "9px 16px", border: "none", background: "#1e3a5f", color: "#fff",
borderRadius: 9, fontSize: 13, fontWeight: 700, cursor: "pointer", whiteSpace: "nowrap" }}>
+ Agregar
</button>
</div>
<div style={{ fontSize: 12, color: "#64748b", marginTop: 8 }}>
Conjuntos de anuncios sin sede asignada. Elige la sede y el programa de cada uno.
</div>
</div>
{/* Pop-up Agregar nueva opción (Tipo o Programa) */}
{popup && (
<div onClick={() => setPopup(false)}
style={{ position: "fixed", inset: 0, background: "rgba(15,23,42,0.4)", zIndex: 60,
display: "flex", alignItems: "center", justifyContent: "center" }}>
<div onClick={(e) => e.stopPropagation()}
style={{ background: "#fff", borderRadius: 14, width: 360, padding: 22,
boxShadow: "0 20px 50px rgba(0,0,0,0.3)" }}>
<div style={{ fontSize: 16, fontWeight: 700, color: "#0f172a", marginBottom: 16 }}>
Agregar nueva opción
</div>
<label style={{ fontSize: 12, fontWeight: 600, color: "#64748b" }}>Clasificación</label>
<select value={nvTipo} onChange={(e) => setNvTipo(e.target.value)}
style={{ width: "100%", padding: "9px 10px", border: "1px solid #cbd5e1",
borderRadius: 8, fontSize: 14, marginTop: 5, marginBottom: 14 }}>
<option value="TIPO">Tipo (Sede)</option>
<option value="PROGRAMA">Programa</option>
</select>
<label style={{ fontSize: 12, fontWeight: 600, color: "#64748b" }}>Nombre</label>
<input value={nvValor} onChange={(e) => setNvValor(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && agregar()}
placeholder="Ej: CHICLAYO"
style={{ width: "100%", padding: "9px 10px", border: "1px solid #cbd5e1",
borderRadius: 8, fontSize: 14, marginTop: 5, marginBottom: 14 }} />
{/* Lista de opciones CREADAS (no fijas) para poder borrarlas */}
{(() => {
const esSede = nvTipo === "TIPO";
const lista = (esSede ? sedes : programas)
.filter((x) => !(esSede ? SEDES_FIJAS : PROGS_FIJOS).includes(x));
if (lista.length === 0) return null;
return (
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: "#64748b", marginBottom: 6 }}>
{esSede ? "Sedes creadas" : "Programas creados"} (clic en × para borrar)
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{lista.map((x) => (
<span key={x} style={{ display: "inline-flex", alignItems: "center", gap: 6,
background: "#f1f5f9", border: "1px solid #e2e8f0",
borderRadius: 999, padding: "4px 6px 4px 11px", fontSize: 12.5,
fontWeight: 600, color: "#334155" }}>
{x}
<button onClick={() => quitarOpcion(nvTipo, x)} title="Borrar"
style={{ width: 18, height: 18, borderRadius: "50%", border: "none",
background: "#fee2e2", color: "#ef4444", cursor: "pointer",
fontSize: 13, lineHeight: 1, display: "inline-flex",
alignItems: "center", justifyContent: "center" }}>×</button>
</span>
))}
</div>
</div>
);
})()}
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
<button onClick={() => setPopup(false)}
style={{ padding: "8px 16px", border: "1px solid #cbd5e1", background: "#fff",
color: "#64748b", borderRadius: 8, fontSize: 13, fontWeight: 600, cursor: "pointer" }}>
Cancelar
</button>
<button onClick={agregar}
style={{ padding: "8px 18px", border: "none", background: "#1e3a5f", color: "#fff",
borderRadius: 8, fontSize: 13, fontWeight: 700, cursor: "pointer" }}>
Guardar
</button>
</div>
</div>
</div>
)}
<div style={{ flex: 1, overflow: "hidden", padding: "0 22px 4px" }}>
{cargando ? (
<div style={{ padding: 40, textAlign: "center", color: "#94a3b8" }}>Cargando...</div>
) : (
<div style={{ height: "100%", border: "1px solid #e2e8f0", borderRadius: 12, overflow: "auto" }}>
<table style={{ width: "100%", borderCollapse: "separate", borderSpacing: 0, tableLayout: "fixed" }}>
<thead>
<tr>
<th style={{ textAlign: "center", padding: "11px 14px", position: "sticky", top: 0,
background: "#1e3a5f", color: "#fff", fontSize: 12.5, fontWeight: 700,
borderRight: "1px solid rgba(255,255,255,0.12)" }}>Conjunto de Anuncio</th>
<th style={{ textAlign: "center", padding: "11px 14px", position: "sticky", top: 0,
background: "#1e3a5f", color: "#fff", fontSize: 12.5, fontWeight: 700, width: 150,
borderRight: "1px solid rgba(255,255,255,0.12)" }}>Tipo</th>
<th style={{ textAlign: "center", padding: "11px 14px", position: "sticky", top: 0,
background: "#1e3a5f", color: "#fff", fontSize: 12.5, fontWeight: 700, width: 150,
borderRight: "1px solid rgba(255,255,255,0.12)" }}>Programa</th>
<th style={{ padding: "11px 8px", position: "sticky", top: 0,
background: "#1e3a5f", color: "#fff", fontSize: 12.5, fontWeight: 700, width: 48 }}></th>
</tr>
</thead>
<tbody>
{vis.map((f, i) => (
<tr key={f.conjunto} style={{ background: i % 2 ? "#f8fafc" : "#fff" }}>
<td style={{ padding: "10px 14px", fontSize: 13, color: "#1e293b",
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
borderRight: "1px solid #eef2f7" }}>{f.conjunto}</td>
<td style={{ padding: "10px 14px", borderRight: "1px solid #eef2f7" }}>
<select value={sel[f.conjunto] || ""}
onChange={(e) => setSel((s) => ({ ...s, [f.conjunto]: e.target.value }))}
style={{ width: "100%", padding: "6px 8px", border: "1px solid #cbd5e1",
borderRadius: 7, fontSize: 13 }}>
<option value="">— Sin asignar —</option>
{SEDES.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
</td>
<td style={{ padding: "10px 14px", borderRight: "1px solid #eef2f7" }}>
<select value={selProg[f.conjunto] || ""}
onChange={(e) => setSelProg((s) => ({ ...s, [f.conjunto]: e.target.value }))}
style={{ width: "100%", padding: "6px 8px", border: "1px solid #cbd5e1",
borderRadius: 7, fontSize: 13 }}>
<option value="">— Sin asignar —</option>
{PROGRAMAS.map((p) => <option key={p} value={p}>{p}</option>)}
</select>
</td>
<td style={{ padding: "10px 8px", textAlign: "center" }}>
{(sel[f.conjunto] || selProg[f.conjunto]) && (
<button onClick={() => borrar(f.conjunto)} title="Borrar asignación"
style={{ border: "none", background: "transparent", color: "#dc2626",
cursor: "pointer", fontSize: 16, fontWeight: 700, lineHeight: 1 }}>
×
</button>
)}
</td>
</tr>
))}
{vis.length === 0 && (
<tr><td colSpan={4} style={{ padding: 30, textAlign: "center", color: "#94a3b8", fontSize: 13 }}>
No hay conjuntos sin sede.</td></tr>
)}
</tbody>
</table>
</div>
)}
</div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center",
padding: "14px 22px", borderTop: "1px solid #e2e8f0" }}>
<span style={{ fontSize: 13, color: msg.startsWith("✓") ? "#047857" : (msg ? "#b91c1c" : "#94a3b8") }}>
{msg || `${vis.length} conjuntos sin sede`}
</span>
<button onClick={guardar} disabled={guardando}
style={{ padding: "9px 22px", border: "none", background: "#1e3a5f", color: "#fff",
borderRadius: 9, fontSize: 14, fontWeight: 700, cursor: guardando ? "wait" : "pointer" }}>
{guardando ? "Guardando..." : "Guardar"}
</button>
</div>
</>
);
}
// Apartado "Programas": lista de programas con contar=NO (ocultos), con switch para reactivar.
function ProgramasOcultos() {
const [filas, setFilas] = useState([]);
const [cargando, setCargando] = useState(true);
const [enc, setEnc] = useState({}); // num_indice -> true si se va a encender
const [guardando, setGuardando] = useState(false);
const [msg, setMsg] = useState("");
useEffect(() => {
api.programasOcultos()
.then((r) => { setFilas(r.filas || []); setCargando(false); })
.catch(() => setCargando(false));
}, []);
async function guardar() {
const nis = Object.entries(enc).filter(([, v]) => v).map(([ni]) => ni);
if (nis.length === 0) { setMsg("No hay programas para activar."); return; }
setGuardando(true); setMsg("");
try {
await api.encenderProgramas(nis);
setMsg("✓ Activados. Ya aparecen en la matriz.");
const r = await api.programasOcultos();
setFilas(r.filas || []); setEnc({});
window.dispatchEvent(new Event("datos-actualizar")); // avisa a TODOS los apartados
} catch (e) { setMsg("Error: " + e.message); }
finally { setGuardando(false); }
}
return (
<>
<div style={{ padding: "16px 22px 6px" }}>
<div style={{ fontSize: 13, color: "#64748b", lineHeight: 1.5 }}>
Aquí están los programas ocultos del <b>Detalle por Curso</b>. Enciende el interruptor
y guarda para que vuelvan a aparecer en la tabla del dashboard.
</div>
</div>
<div style={{ flex: 1, overflow: "auto", padding: "8px 22px" }}>
{cargando ? (
<div style={{ padding: 40, textAlign: "center", color: "#94a3b8" }}>Cargando...</div>
) : filas.length === 0 ? (
<div style={{ padding: 30, textAlign: "center", color: "#94a3b8", fontSize: 13 }}>
No hay programas ocultos.
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{filas.map((f) => {
const on = !!enc[f.num_indice];
return (
<div key={f.num_indice} style={{ display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "12px 14px", border: "1px solid #e2e8f0", borderRadius: 10, background: "#fff" }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 13.5, fontWeight: 600, color: "#0f172a" }}>{f.dsc_det_programa}</div>
<div style={{ fontSize: 12, color: "#94a3b8" }}>
Índice {f.num_indice}{f.fch_inicio ? ` · ${f.fch_inicio}` : ""}{f.pauta ? ` · Pauta ${f.pauta}` : ""}
</div>
</div>
<div onClick={() => setEnc((p) => ({ ...p, [f.num_indice]: !p[f.num_indice] }))}
style={{ width: 46, height: 26, borderRadius: 999, cursor: "pointer", flexShrink: 0,
background: on ? "#2563eb" : "#cbd5e1", position: "relative", transition: "background .15s" }}>
<div style={{ width: 20, height: 20, borderRadius: "50%", background: "#fff",
position: "absolute", top: 3, left: on ? 23 : 3, transition: "left .15s",
boxShadow: "0 1px 3px rgba(0,0,0,0.3)" }} />
</div>
</div>
);
})}
</div>
)}
</div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center",
padding: "14px 22px", borderTop: "1px solid #e2e8f0" }}>
<span style={{ fontSize: 13, color: msg.startsWith("✓") ? "#047857" : "#b91c1c" }}>{msg}</span>
<button onClick={guardar} disabled={guardando}
style={{ padding: "9px 22px", border: "none", background: "#1e3a5f", color: "#fff",
borderRadius: 9, fontSize: 14, fontWeight: 700, cursor: guardando ? "wait" : "pointer" }}>
{guardando ? "Guardando..." : "Guardar"}
</button>
</div>
</>
);
}
// ── Mini-apartado LEYENDA: tabla de campanias + Agregar (popup) + eliminar (visual) ──
function LeyendaCampanias() {
const CARGOS = ["TEAC", "TERC", "VRF", "CO2", "Seminarios", "Diseño de Chillers", "Diplomado REF", "CAD", "Amoniaco", "Cámaras", "Diseño de Sistemas", "Metrado y Costeo"];
const SEDES = ["Lima", "Piura", "Arequipa", "Trujillo"];
const ORIGENES = ["Pauta_wsp", "Web_Whatsapp"];
const DIAS = ["Domingo", "Sábado", "Semipresencial", "-"];
const [filas, setFilas] = useState([]);
const [cargando, setCargando] = useState(true);
const [busca, setBusca] = useState("");
const [popup, setPopup] = useState(false);
const [editandoId, setEditandoId] = useState(null); // id de la fila en edicion, o null = agregar
const [confirmar, setConfirmar] = useState(null); // fila a eliminar, o null
const [choques, setChoques] = useState(null); // [filas que chocan] o null (popup de aviso)
const [guardando, setGuardando] = useState(false);
const [msg, setMsg] = useState("");
const nuevaVacia = { frase_busqueda: "", cargo: "TEAC", codigo: "", sede: "Lima", dia: "-", origen: "Pauta_wsp", fecha_inicio: "", fecha_fin: "" };
const [nueva, setNueva] = useState(nuevaVacia);
function recargar() {
setCargando(true);
api.campanias().then((r) => { setFilas(r.filas || []); setCargando(false); })
.catch(() => setCargando(false));
}
useEffect(() => { recargar(); }, []);
const vis = filas.filter((f) =>
!busca || ((f.frase_busqueda || "") + (f.cargo || "") + (f.codigo || "") + (f.sede || "")).toLowerCase().includes(busca.toLowerCase()));
// Extrae los emojis de un texto (pictográficos). Devuelve un Set de emojis.
function emojisDe(txt) {
const s = new Set();
for (const ch of String(txt || "")) {
const cp = ch.codePointAt(0);
// rangos de emojis/pictogramas comunes
if ((cp >= 0x1F000 && cp <= 0x1FAFF) || (cp >= 0x2600 && cp <= 0x27BF) ||
(cp >= 0x2B00 && cp <= 0x2BFF) || cp === 0x2764 || (cp >= 0x2190 && cp <= 0x21FF)) {
s.add(ch);
}
}
return s;
}
// ¿los rangos [i1,f1] y [i2,f2] se solapan? (fechas en texto YYYY-MM-DD)
function fechasSolapan(i1, f1, i2, f2) {
if (!i1 || !f1 || !i2 || !f2) return false;
return i1 <= f2 && i2 <= f1;
}
// Devuelve las campañas existentes que chocan con "nueva":
// mismo emoji compartido Y fechas solapadas.
function detectarChoques() {
const emN = emojisDe(nueva.frase_busqueda);
if (emN.size === 0) return [];
const iN = (nueva.fecha_inicio || "").slice(0, 10);
const fN = (nueva.fecha_fin || "").slice(0, 10);
return filas.filter((f) => {
if (editandoId != null && f.id === editandoId) return false; // no chocar contra si misma
const emE = emojisDe(f.frase_busqueda);
const comparte = [...emN].some((e) => emE.has(e)); // ¿comparten algún emoji?
if (!comparte) return false;
return fechasSolapan(iN, fN, (f.fecha_inicio || "").slice(0, 10), (f.fecha_fin || "").slice(0, 10));
});
}
function abrirAgregar() {
setEditandoId(null); setNueva(nuevaVacia); setMsg(""); setPopup(true);
}
function abrirEditar(f) {
setEditandoId(f.id);
setNueva({
frase_busqueda: f.frase_busqueda || "", cargo: f.cargo || "TEAC",
codigo: f.codigo || "", sede: f.sede || "Lima", dia: f.dia || "-",
origen: f.origen || "Pauta_wsp",
fecha_inicio: (f.fecha_inicio || "").slice(0, 10),
fecha_fin: (f.fecha_fin || "").slice(0, 10),
});
setMsg(""); setPopup(true);
}
async function agregar() {
if (!nueva.frase_busqueda.trim()) { setMsg("Escribe la frase de búsqueda."); return; }
// Aviso de choque: mismo emoji + fechas solapadas. Si las fechas NO chocan, no avisa.
const ch = detectarChoques();
if (ch.length > 0) { setChoques(ch); return; }
await guardarCampania();
}
async function guardarCampania() {
setChoques(null);
setGuardando(true); setMsg("");
try {
if (editandoId != null) await api.editarCampania(editandoId, nueva);
else await api.agregarCampania(nueva);
setPopup(false); setNueva(nuevaVacia); setEditandoId(null);
recargar();
window.dispatchEvent(new Event("datos-actualizar"));
} catch (e) { setMsg("Error al guardar: " + e.message); }
finally { setGuardando(false); }
}
async function borrarConfirmado() {
const f = confirmar;
setConfirmar(null);
try {
await api.borrarCampania(f.id);
recargar();
window.dispatchEvent(new Event("datos-actualizar"));
} catch (e) { setMsg("Error al borrar: " + e.message); }
}
const inp = { width: "100%", height: 38, border: "1px solid #e2e8f0", borderRadius: 8,
padding: "0 11px", fontSize: 13, boxSizing: "border-box" };
const lab = { fontSize: 12.5, fontWeight: 600, color: "#475569", display: "block", marginBottom: 5 };
return (
<>
<div style={{ padding: "14px 22px 8px", display: "flex", gap: 10 }}>
<input value={busca} onChange={(e) => setBusca(e.target.value)}
placeholder="Buscar frase, cargo, código o sede..."
style={{ flex: 1, padding: "9px 12px", border: "1px solid #cbd5e1", borderRadius: 9, fontSize: 13 }} />
<button onClick={abrirAgregar}
style={{ padding: "0 16px", background: "#1e3a8a", color: "#fff", border: "none",
borderRadius: 9, fontSize: 13, fontWeight: 700, cursor: "pointer", whiteSpace: "nowrap" }}>
+ Agregar leyenda
</button>
</div>
<div style={{ flex: 1, overflow: "hidden", padding: "0 22px 12px" }}>
<div style={{ height: "100%", border: "1px solid #e2e8f0", borderRadius: 12, overflow: "auto" }}>
<table style={{ minWidth: 920, width: "100%", borderCollapse: "separate", borderSpacing: 0, fontSize: 12.5 }}>
<thead>
<tr>
{["Frase de búsqueda", "Cargo", "Código", "Sede", "Día", "Origen", "Inicio", "Fin", ""].map((h, i, arr) => (
<th key={i} style={{ position: "sticky", top: 0, zIndex: 20, background: "#1e3a5f", color: "#fff",
padding: "10px 12px", fontWeight: 600, textAlign: "center", whiteSpace: "nowrap",
borderRight: i < arr.length - 1 ? "1px solid rgba(255,255,255,0.12)" : "none" }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{cargando && (
<tr><td colSpan={9} style={{ padding: 40, textAlign: "center", color: "#94a3b8" }}>Cargando...</td></tr>
)}
{!cargando && vis.length === 0 && (
<tr><td colSpan={9} style={{ padding: 40, textAlign: "center", color: "#94a3b8" }}>Sin leyendas.</td></tr>
)}
{!cargando && vis.map((f) => (
<tr key={f.id}>
<td style={{ padding: "9px 12px", minWidth: 220, textAlign: "left", borderRight: "1px solid #eef2f7" }}>{f.frase_busqueda}</td>
<td style={{ padding: "9px 12px", whiteSpace: "nowrap", textAlign: "center", borderRight: "1px solid #eef2f7" }}>{f.cargo}</td>
<td style={{ padding: "9px 12px", fontWeight: 600, whiteSpace: "nowrap", textAlign: "center", borderRight: "1px solid #eef2f7" }}>{f.codigo}</td>
<td style={{ padding: "9px 12px", whiteSpace: "nowrap", textAlign: "center", borderRight: "1px solid #eef2f7" }}>{f.sede}</td>
<td style={{ padding: "9px 12px", whiteSpace: "nowrap", textAlign: "center", borderRight: "1px solid #eef2f7" }}>{f.dia}</td>
<td style={{ padding: "9px 12px", whiteSpace: "nowrap", textAlign: "center", borderRight: "1px solid #eef2f7" }}>{f.origen}</td>
<td style={{ padding: "9px 12px", whiteSpace: "nowrap", textAlign: "center", borderRight: "1px solid #eef2f7" }}>{f.fecha_inicio}</td>
<td style={{ padding: "9px 12px", whiteSpace: "nowrap", textAlign: "center", borderRight: "1px solid #eef2f7" }}>{f.fecha_fin}</td>
<td style={{ padding: "9px 12px", textAlign: "center", whiteSpace: "nowrap" }}>
<div style={{ display: "inline-flex", gap: 6, alignItems: "center" }}>
<button onClick={() => abrirEditar(f)} title="Editar leyenda"
onMouseEnter={(e) => { e.currentTarget.style.background = "#2563eb"; e.currentTarget.style.color = "#fff"; e.currentTarget.style.borderColor = "#2563eb"; }}
onMouseLeave={(e) => { e.currentTarget.style.background = "#eff6ff"; e.currentTarget.style.color = "#2563eb"; e.currentTarget.style.borderColor = "#bfdbfe"; }}
style={{ width: 28, height: 28, borderRadius: 8, background: "#eff6ff",
border: "1px solid #bfdbfe", color: "#2563eb", cursor: "pointer",
fontSize: 14, lineHeight: 1, display: "inline-flex",
alignItems: "center", justifyContent: "center",
transition: "all 0.15s ease" }}>✎</button>
<button onClick={() => setConfirmar(f)} title="Eliminar leyenda"
onMouseEnter={(e) => { e.currentTarget.style.background = "#ef4444"; e.currentTarget.style.color = "#fff"; e.currentTarget.style.borderColor = "#ef4444"; }}
onMouseLeave={(e) => { e.currentTarget.style.background = "#fef2f2"; e.currentTarget.style.color = "#ef4444"; e.currentTarget.style.borderColor = "#fecaca"; }}
style={{ width: 28, height: 28, borderRadius: 8, background: "#fef2f2",
border: "1px solid #fecaca", color: "#ef4444", cursor: "pointer",
fontSize: 16, fontWeight: 700, lineHeight: 1, display: "inline-flex",
alignItems: "center", justifyContent: "center",
transition: "all 0.15s ease" }}>×</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Popup Agregar leyenda */}
{popup && (
<div onClick={() => setPopup(false)}
style={{ position: "fixed", inset: 0, background: "rgba(15,23,42,0.45)",
display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1300 }}>
<div onClick={(e) => e.stopPropagation()}
style={{ background: "#fff", borderRadius: 14, width: 520, maxWidth: "94vw",
boxShadow: "0 20px 50px rgba(0,0,0,.25)", overflow: "hidden" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center",
padding: "16px 20px", borderBottom: "1px solid #eef2f7" }}>
<div style={{ fontSize: 16, fontWeight: 700, color: "#0f172a" }}>{editandoId != null ? "Editar leyenda" : "Agregar leyenda"}</div>
<button onClick={() => setPopup(false)}
style={{ background: "transparent", border: "none", fontSize: 20, color: "#94a3b8", cursor: "pointer" }}>×</button>
</div>
<div style={{ padding: "18px 20px", display: "flex", flexDirection: "column", gap: 14 }}>
<div>
<label style={lab}>Frase de búsqueda</label>
<input style={inp} value={nueva.frase_busqueda} placeholder="Ej. 🖥️ Estoy interesado en sus Seminarios"
onChange={(e) => setNueva({ ...nueva, frase_busqueda: e.target.value })} />
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
<div>
<label style={lab}>Cargo / programa</label>
<select style={{ ...inp, padding: "0 8px", background: "#fff" }} value={nueva.cargo}
onChange={(e) => setNueva({ ...nueva, cargo: e.target.value })}>
{CARGOS.map((c) => <option key={c}>{c}</option>)}
</select>
</div>
<div>
<label style={lab}>Código</label>
<input style={inp} value={nueva.codigo} placeholder="Ej. 74a"
onChange={(e) => setNueva({ ...nueva, codigo: e.target.value })} />
</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
<div>
<label style={lab}>Sede</label>
<select style={{ ...inp, padding: "0 8px", background: "#fff" }} value={nueva.sede}
onChange={(e) => setNueva({ ...nueva, sede: e.target.value })}>
{SEDES.map((s) => <option key={s}>{s}</option>)}
</select>
</div>
<div>
<label style={lab}>Origen</label>
<select style={{ ...inp, padding: "0 8px", background: "#fff" }} value={nueva.origen}
onChange={(e) => setNueva({ ...nueva, origen: e.target.value })}>
{ORIGENES.map((o) => <option key={o}>{o}</option>)}
</select>
</div>
</div>
<div>
<label style={lab}>Día / frecuencia</label>
<select style={{ ...inp, padding: "0 8px", background: "#fff" }} value={nueva.dia}
onChange={(e) => setNueva({ ...nueva, dia: e.target.value })}>
{DIAS.map((d) => <option key={d}>{d}</option>)}
</select>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
<div>
<label style={lab}>Fecha inicio</label>
<input type="date" style={inp} value={nueva.fecha_inicio}
onChange={(e) => setNueva({ ...nueva, fecha_inicio: e.target.value })} />
</div>
<div>
<label style={lab}>Fecha fin</label>
<input type="date" style={inp} value={nueva.fecha_fin}
onChange={(e) => setNueva({ ...nueva, fecha_fin: e.target.value })} />
</div>
</div>
</div>
<div style={{ display: "flex", justifyContent: "flex-end", gap: 10, padding: "14px 20px",
borderTop: "1px solid #eef2f7", background: "#f8fafc" }}>
<button onClick={() => setPopup(false)}
style={{ height: 38, padding: "0 16px", border: "1px solid #cbd5e1", background: "#fff",
borderRadius: 8, fontSize: 13, fontWeight: 600, color: "#475569", cursor: "pointer" }}>Cancelar</button>
<button onClick={agregar} disabled={guardando}
style={{ height: 38, padding: "0 18px", border: "none", background: "#1e3a8a",
borderRadius: 8, fontSize: 13, fontWeight: 700, color: "#fff",
cursor: guardando ? "default" : "pointer", opacity: guardando ? 0.6 : 1 }}>
{guardando ? "Guardando..." : (editandoId != null ? "Guardar cambios" : "Guardar leyenda")}
</button>
</div>
{msg && <div style={{ padding: "0 20px 14px", fontSize: 12.5,
color: msg.startsWith("Error") ? "#ef4444" : "#0f766e" }}>{msg}</div>}
</div>
</div>
)}
{/* Popup de confirmación de eliminación */}
{confirmar !== null && (
<div onClick={() => setConfirmar(null)}
style={{ position: "fixed", inset: 0, background: "rgba(15,23,42,0.45)",
display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1400 }}>
<div onClick={(e) => e.stopPropagation()}
style={{ background: "#fff", borderRadius: 14, width: 400, maxWidth: "92vw",
boxShadow: "0 20px 50px rgba(0,0,0,.25)", overflow: "hidden", textAlign: "center" }}>
<div style={{ padding: "26px 24px 8px" }}>
<div style={{ width: 52, height: 52, borderRadius: "50%", background: "#fef2f2",
display: "flex", alignItems: "center", justifyContent: "center",
margin: "0 auto 14px", fontSize: 26, color: "#ef4444" }}>🗑️</div>
<div style={{ fontSize: 16, fontWeight: 700, color: "#0f172a", marginBottom: 6 }}>
¿Eliminar esta leyenda?
</div>
<div style={{ fontSize: 13, color: "#64748b", lineHeight: 1.5 }}>
{confirmar && (
<>Se eliminará la pauta <b style={{ color: "#0f172a" }}>{confirmar.codigo}</b>
{" "}({confirmar.cargo} — {confirmar.sede}). Esta acción no se puede deshacer.</>
)}
</div>
</div>
<div style={{ display: "flex", gap: 10, padding: "18px 24px 22px" }}>
<button onClick={() => setConfirmar(null)}
style={{ flex: 1, height: 40, border: "1px solid #cbd5e1", background: "#fff",
borderRadius: 9, fontSize: 13.5, fontWeight: 600, color: "#475569", cursor: "pointer" }}>Cancelar</button>
<button onClick={borrarConfirmado}
style={{ flex: 1, height: 40, border: "none", background: "#ef4444",
borderRadius: 9, fontSize: 13.5, fontWeight: 700, color: "#fff", cursor: "pointer" }}>Sí, eliminar</button>
</div>
</div>
</div>
)}
{/* Popup de ADVERTENCIA de choque: mismo emoji + fechas solapadas */}
{choques !== null && (
<div onClick={() => setChoques(null)}
style={{ position: "fixed", inset: 0, background: "rgba(15,23,42,0.45)",
display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1500 }}>
<div onClick={(e) => e.stopPropagation()}
style={{ background: "#fff", borderRadius: 14, width: 460, maxWidth: "94vw",
boxShadow: "0 20px 50px rgba(0,0,0,.25)", overflow: "hidden" }}>
<div style={{ padding: "24px 24px 6px", textAlign: "center" }}>
<div style={{ width: 52, height: 52, borderRadius: "50%", background: "#fff7ed",
display: "flex", alignItems: "center", justifyContent: "center",
margin: "0 auto 14px", fontSize: 26 }}>⚠️</div>
<div style={{ fontSize: 16, fontWeight: 700, color: "#0f172a", marginBottom: 6 }}>
Esta leyenda podría duplicar leads
</div>
<div style={{ fontSize: 13, color: "#64748b", lineHeight: 1.5 }}>
La frase que vas a crear comparte emoji y fechas con {choques.length === 1 ? "esta pauta existente" : `estas ${choques.length} pautas existentes`}.
Un mismo mensaje podría contarse dos veces.
</div>
</div>
<div style={{ padding: "12px 20px", display: "flex", flexDirection: "column", gap: 8, maxHeight: 240, overflowY: "auto" }}>
{choques.map((c) => (
<div key={c.id} style={{ border: "1px solid #fed7aa", background: "#fffbeb",
borderRadius: 10, padding: "10px 12px" }}>
<div style={{ fontSize: 13, fontWeight: 700, color: "#0f172a", marginBottom: 3 }}>
Pauta {c.codigo} <span style={{ fontWeight: 500, color: "#92400e" }}>({c.cargo} — {c.sede})</span>
</div>
<div style={{ fontSize: 12.5, color: "#475569", marginBottom: 4 }}>“{c.frase_busqueda}”</div>
<div style={{ fontSize: 12, color: "#b45309", fontWeight: 600 }}>
Vigente: {c.fecha_inicio} → {c.fecha_fin}
</div>
</div>
))}
</div>
<div style={{ display: "flex", gap: 10, padding: "12px 24px 20px" }}>
<button onClick={() => setChoques(null)}
style={{ flex: 1, height: 40, border: "1px solid #cbd5e1", background: "#fff",
borderRadius: 9, fontSize: 13.5, fontWeight: 600, color: "#475569", cursor: "pointer" }}>Cancelar</button>
<button onClick={guardarCampania} disabled={guardando}
style={{ flex: 1, height: 40, border: "none", background: "#ea580c",
borderRadius: 9, fontSize: 13.5, fontWeight: 700, color: "#fff",
cursor: guardando ? "default" : "pointer", opacity: guardando ? 0.6 : 1 }}>
{guardando ? "Guardando..." : "Crear de todos modos"}
</button>
</div>
</div>
</div>
)}
</>
);
}
export default function ConfigModal({ onClose }) {
const [seccion, setSeccion] = useState("leyenda");
const [filas, setFilas] = useState([]);
const [cargando, setCargando] = useState(true);
const [guardando, setGuardando] = useState(false);
const [msg, setMsg] = useState("");
const [busca, setBusca] = useState("");
const [edit, setEdit] = useState({}); // conjunto -> pauta editada
const [editProg, setEditProg] = useState({}); // conjunto -> {num_indice, label} programa elegido
const [progOpc, setProgOpc] = useState([]); // opciones del desplegable de Programa
useEffect(() => {
api.leyendaAnuncios()
.then((r) => { setFilas(r.filas || []); setCargando(false); })
.catch(() => setCargando(false));
api.programasDisponibles()
.then((r) => setProgOpc(r.programas || []))
.catch(() => setProgOpc([]));
}, []);
const setPauta = (conjunto, val) => setEdit((p) => ({ ...p, [conjunto]: val }));
const valorPauta = (f) => (edit[f.conjunto] !== undefined ? edit[f.conjunto] : (f.pauta || ""));
// Programa mostrado: el elegido si hay, si no el que ya trae la fila
const valorPrograma = (f) => (editProg[f.conjunto] !== undefined ? editProg[f.conjunto].label : (f.programa || ""));
async function guardar() {
// Un cambio por conjunto que tenga pauta editada o programa elegido
const conjuntos = new Set([...Object.keys(edit), ...Object.keys(editProg)]);
const cambios = [];
for (const conjunto of conjuntos) {
const f = filas.find((x) => x.conjunto === conjunto) || {};
const pauta = (edit[conjunto] !== undefined ? edit[conjunto] : (f.pauta || "")).trim();
const prog = editProg[conjunto]; // {num_indice, label} o undefined
cambios.push({ conjunto, pauta, num_indice: prog ? prog.num_indice : null });
}
if (cambios.length === 0) { setMsg("No hay cambios."); return; }
setGuardando(true); setMsg("");
try {
await api.guardarLeyenda(cambios);
setMsg("✓ Guardado correctamente.");
const r = await api.leyendaAnuncios();
setFilas(r.filas || []); setEdit({}); setEditProg({});
window.dispatchEvent(new Event("datos-actualizar")); // avisa a TODOS los apartados
} catch (e) { setMsg("Error: " + e.message); }
finally { setGuardando(false); }
}
// Orden: 1) pauta Y programa, 2) pauta sin programa, 3) programa sin pauta, 4) nada
const _rango = (f) => {
const p = valorPauta(f).trim() !== "";
const g = (f.programa || "").trim() !== "";
if (p && g) return 0;
if (p && !g) return 1;
if (!p && g) return 2;
return 3;
};
const filtradas = filas
.filter((f) =>
!busca || f.conjunto.toLowerCase().includes(busca.toLowerCase())
|| String(f.pauta).includes(busca)
|| (f.programa || "").toLowerCase().includes(busca.toLowerCase()))
.sort((a, b) => (_rango(a) - _rango(b)) || a.conjunto.localeCompare(b.conjunto));
return (
<div onClick={onClose}
style={{ position: "fixed", inset: 0, background: "rgba(15,23,42,0.55)",
display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1200 }}>
<div onClick={(e) => e.stopPropagation()}
style={{ background: "#fff", borderRadius: 16, width: 1320, maxWidth: "96vw",
height: 680, maxHeight: "92vh", display: "flex", overflow: "hidden",
boxShadow: "0 24px 70px rgba(0,0,0,0.4)",
fontFamily: "system-ui, -apple-system, 'Segoe UI', sans-serif" }}>
{/* Sidebar del modal */}
<div style={{ width: 240, flexShrink: 0, background: "#f8fafc", borderRight: "1px solid #e2e8f0", padding: "18px 12px" }}>
<div style={{ fontSize: 12, fontWeight: 700, color: "#94a3b8", textTransform: "uppercase",
padding: "0 10px 10px" }}>Configuración</div>
<button onClick={() => setSeccion("leyenda")}
style={{ width: "100%", textAlign: "left", padding: "10px 12px", borderRadius: 8,
border: "none", cursor: "pointer", fontSize: 14, fontWeight: 600, marginBottom: 4,
whiteSpace: "nowrap",
background: seccion === "leyenda" ? "#e2e8f0" : "transparent", color: "#1e293b" }}>
Leyenda de Anuncios
</button>
<button onClick={() => setSeccion("programas")}
style={{ width: "100%", textAlign: "left", padding: "10px 12px", borderRadius: 8,
border: "none", cursor: "pointer", fontSize: 14, fontWeight: 600, marginBottom: 4,
whiteSpace: "nowrap",
background: seccion === "programas" ? "#e2e8f0" : "transparent", color: "#1e293b" }}>
Programas
</button>
<button onClick={() => setSeccion("sedes")}
style={{ width: "100%", textAlign: "left", padding: "10px 12px", borderRadius: 8,
border: "none", cursor: "pointer", fontSize: 14, fontWeight: 600, marginBottom: 4,
whiteSpace: "nowrap",
background: seccion === "sedes" ? "#e2e8f0" : "transparent", color: "#1e293b" }}>
Sedes de Anuncios
</button>
<button onClick={() => setSeccion("leyendas")}
style={{ width: "100%", textAlign: "left", padding: "10px 12px", borderRadius: 8,
border: "none", cursor: "pointer", fontSize: 14, fontWeight: 600, whiteSpace: "nowrap",
background: seccion === "leyendas" ? "#e2e8f0" : "transparent", color: "#1e293b" }}>
Leyenda
</button>
</div>
{/* Contenido */}
<div style={{ flex: 1, display: "flex", flexDirection: "column" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center",
padding: "16px 22px", borderBottom: "1px solid #e2e8f0" }}>
<div style={{ fontSize: 17, fontWeight: 700, color: "#0f172a" }}>
{seccion === "leyenda" ? "Leyenda de Anuncios"
: seccion === "programas" ? "Programas"
: seccion === "leyendas" ? "Leyenda"
: "Sedes de Anuncios"}
</div>
<button onClick={onClose}
style={{ background: "transparent", border: "none", fontSize: 24, cursor: "pointer", color: "#64748b" }}>×</button>
</div>
{seccion === "leyendas" ? (
<LeyendaCampanias />
) : seccion === "sedes" ? (
<SedesAnuncios />
) : seccion === "programas" ? (
<ProgramasOcultos />
) : (
<>
<div style={{ padding: "14px 22px 8px" }}>
<input value={busca} onChange={(e) => setBusca(e.target.value)}
placeholder="Buscar conjunto, pauta o programa..."
style={{ width: "100%", padding: "9px 12px", border: "1px solid #cbd5e1",
borderRadius: 9, fontSize: 13 }} />
</div>
<div style={{ flex: 1, overflow: "hidden", padding: "0 22px 4px" }}>
{cargando ? (
<div style={{ padding: 40, textAlign: "center", color: "#94a3b8" }}>Cargando...</div>
) : (
<div style={{ height: "100%", border: "1px solid #e2e8f0", borderRadius: 12,
overflow: "auto" }}>
<table style={{ width: "100%", borderCollapse: "separate", borderSpacing: 0,
tableLayout: "fixed" }}>
<colgroup>
<col style={{ width: "38%" }} />
<col style={{ width: 100 }} />
<col style={{ width: "auto" }} />
</colgroup>
<thead>
<tr>
<th style={{ position: "sticky", top: 0, zIndex: 20, background: "#1e3a5f", color: "#fff",
padding: "11px 14px", fontSize: 13, fontWeight: 600, textAlign: "left",
borderTopLeftRadius: 12 }}>Conjunto de Anuncios</th>
<th style={{ position: "sticky", top: 0, zIndex: 20, background: "#1e3a5f", color: "#fff",
padding: "11px 14px", fontSize: 13, fontWeight: 600, width: 110, textAlign: "left" }}>Pauta</th>
<th style={{ position: "sticky", top: 0, zIndex: 20, background: "#1e3a5f", color: "#fff",
padding: "11px 14px", fontSize: 13, fontWeight: 600, textAlign: "left", width: 340,
borderTopRightRadius: 12 }}>Programa</th>
</tr>
</thead>
<tbody>
{filtradas.map((f) => (
<tr key={f.conjunto}>
<td style={{ padding: "8px 14px", fontSize: 13, color: "#334155", textAlign: "left",
borderRight: "1px solid #eef2f7", wordBreak: "break-word" }}>{f.conjunto}</td>
<td style={{ padding: "8px 14px", borderRight: "1px solid #eef2f7", textAlign: "center" }}>
<input value={valorPauta(f)} onChange={(e) => setPauta(f.conjunto, e.target.value)}
style={{ width: 90, padding: "6px 8px", border: "1px solid #cbd5e1",
borderRadius: 6, fontSize: 13, textAlign: "left", fontFamily: "inherit",
background: edit[f.conjunto] !== undefined ? "#fffbeb" : "#fff" }} />
</td>
<td style={{ padding: "8px 14px", maxWidth: 0, position: "relative" }}>
<ProgramaSelect valor={valorPrograma(f)} opciones={progOpc}
onSelect={(o) => setEditProg((p) => ({ ...p, [f.conjunto]: o }))} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center",
padding: "14px 22px", borderTop: "1px solid #e2e8f0" }}>
<span style={{ fontSize: 13, color: msg.startsWith("✓") ? "#047857" : "#b91c1c" }}>{msg}</span>
<button onClick={guardar} disabled={guardando}
style={{ padding: "9px 22px", border: "none", background: "#1e3a5f", color: "#fff",
borderRadius: 9, fontSize: 14, fontWeight: 700, cursor: guardando ? "wait" : "pointer" }}>
{guardando ? "Guardando..." : "Guardar"}
</button>
</div>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { Component } from "react";
// Captura errores de render de sus hijos y muestra un mensaje en vez de
// dejar la pantalla en blanco. Botón para reintentar (re-monta el contenido).
export default class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { error: null };
}
static getDerivedStateFromError(error) {
return { error };
}
componentDidCatch(error, info) {
// Log para diagnóstico en consola del navegador
console.error("[ErrorBoundary]", error, info);
}
reintentar = () => this.setState({ error: null });
render() {
if (this.state.error) {
return (
<div style={{ padding: 40, display: "flex", flexDirection: "column",
alignItems: "center", justifyContent: "center", height: "100%",
textAlign: "center", color: "#475569" }}>
<div style={{ fontSize: 40, marginBottom: 12 }}>⚠️</div>
<div style={{ fontSize: 17, fontWeight: 700, color: "#0f172a", marginBottom: 6 }}>
Ocurrió un problema al mostrar esta sección
</div>
<div style={{ fontSize: 13.5, marginBottom: 18, maxWidth: 420, lineHeight: 1.5 }}>
El resto del dashboard sigue funcionando. Puedes reintentar o cambiar de apartado.
</div>
<button onClick={this.reintentar}
style={{ height: 40, padding: "0 20px", border: "none", background: "#2563eb",
borderRadius: 9, fontSize: 14, fontWeight: 700, color: "#fff", cursor: "pointer" }}>
Reintentar
</button>
</div>
);
}
return this.props.children;
}
}

View File

@@ -1,97 +0,0 @@
// src/components/IndicadorActualizacion.jsx
// Indicador (arriba-derecha) con la hora de la última actualización + botón de
// recarga. Al pulsar: re-lee las bases para la vista actual y recachea el resto.
import { useState, useEffect, useCallback } from "react";
import { api } from "../lib/api";
import { getVistaActual, emitirRefresco } from "../lib/vistaActual";
function formatearHora(iso) {
if (!iso) return "—";
try {
const d = new Date(iso);
return d.toLocaleString("es-PE", {
day: "2-digit", month: "2-digit",
hour: "2-digit", minute: "2-digit",
});
} catch {
return "—";
}
}
export default function IndicadorActualizacion() {
const [hora, setHora] = useState(null);
const [cargando, setCargando] = useState(false);
const consultar = useCallback(async () => {
try {
const r = await api.ultimaActualizacion();
setHora(r?.hora || null);
} catch { /* silencioso */ }
}, []);
useEffect(() => {
consultar();
const id = setInterval(consultar, 60000);
return () => clearInterval(id);
}, [consultar]);
async function recargar() {
if (cargando) return;
setCargando(true);
try {
const r = await api.cacheRefreshManual(getVistaActual());
setHora(r?.hora || null);
emitirRefresco();
} catch { /* silencioso */ }
finally { setCargando(false); }
}
return (
<>
<style>{`
@keyframes spin-actualiza { to { transform: rotate(360deg); } }
.ind-actualiza-btn { transition: color .15s, transform .15s; }
.ind-actualiza-btn:hover:not(:disabled) { color:#7dd3fc; transform: scale(1.15); }
.ind-actualiza-spin { animation: spin-actualiza .9s linear infinite; }
`}</style>
<div
style={{
position: "fixed", right: 16, top: 14, zIndex: 60,
display: "flex", alignItems: "center", gap: 8,
background: "rgba(15,23,42,0.9)",
color: cargando ? "#7dd3fc" : "#cbd5e1",
border: "1px solid " + (cargando ? "#0ea5e9" : "#334155"),
borderRadius: 20, padding: "6px 12px",
fontSize: 12, lineHeight: 1, fontWeight: 500,
boxShadow: "0 2px 8px rgba(0,0,0,0.28)",
backdropFilter: "blur(4px)",
userSelect: "none",
}}
title="Última actualización de las bases de datos"
>
<span style={{ opacity: cargando ? 1 : 0.85 }}>
{cargando ? "Actualizando…" : formatearHora(hora)}
</span>
<button
onClick={recargar}
disabled={cargando}
className={"ind-actualiza-btn" + (cargando ? " ind-actualiza-spin" : "")}
title={cargando ? "Actualizando datos…" : "Actualizar ahora"}
style={{
background: "transparent", border: "none",
cursor: cargando ? "default" : "pointer",
color: "#38bdf8", fontSize: 15, padding: 0, lineHeight: 1,
display: "flex", alignItems: "center",
}}
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.4"
strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
<path d="M21 3v6h-6" />
</svg>
</button>
</div>
</>
);
}

View File

@@ -1,34 +0,0 @@
// src/components/Modal.jsx
export default function Modal({ title, onClose, children, width = 900 }) {
return (
<div
onClick={onClose}
style={{
position: "fixed", inset: 0, background: "rgba(15,23,42,0.55)",
display: "flex", alignItems: "center", justifyContent: "center",
zIndex: 1000, padding: 20,
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
background: "#fff", borderRadius: 16, width: "100%", maxWidth: width,
maxHeight: "90vh", overflow: "auto", boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
}}
>
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
padding: "16px 22px", borderBottom: "1px solid #e2e8f0", position: "sticky",
top: 0, background: "#fff", zIndex: 2,
}}>
<h3 style={{ fontSize: 16, fontWeight: 700, color: "#1e40af" }}>{title}</h3>
<button onClick={onClose} style={{
border: "none", background: "#f1f5f9", borderRadius: 8, width: 32, height: 32,
cursor: "pointer", fontSize: 18, color: "#475569",
}}>×</button>
</div>
<div style={{ padding: 22 }}>{children}</div>
</div>
</div>
);
}

View File

@@ -1,26 +1,48 @@
// src/components/Sidebar.jsx import { useState, useEffect } from "react";
import { useAuth } from "../lib/auth"; import { api } from "../lib/api";
const MENU = [ const MENU = [
{ id: "ocupabilidad", ico: "📊", label: "Ocupabilidad" }, { id: "leads", ico: "🎯", label: "Leads" },
{ id: "ventas", ico: "💰", label: "Ventas" }, { id: "otros", ico: "📊", label: "Otros General" },
{ id: "comisiones", ico: "🧮", label: "Comisiones" }, { id: "vendedores", ico: "🧑‍💼", label: "Vendedores" },
{ id: "cobranza", ico: "📋", label: "Cobranza" }, { id: "roas", ico: "📈", label: "ROAS" },
{ id: "rentabilidad", ico: "📈", label: "Rentabilidad" },
{ id: "asesores", ico: "👥", label: "Asesores" },
{ id: "usuarios", ico: "🔐", label: "Usuarios" },
]; ];
export default function Sidebar({ active, onChange }) { export default function Sidebar({ active, onChange, onConfig }) {
const { perfil, permisos, logout } = useAuth(); const [menuUser, setMenuUser] = useState(false);
const visibles = MENU.filter((m) => permisos.includes(m.id)); 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 ( return (
<aside className="sidebar"> <aside className="sidebar">
<div className="sidebar-logo">Escuela <span>Refrigeración</span></div> <div className="sidebar-logo">Escuela <span>Refrigeración</span></div>
<nav className="nav" style={{ flex: 1 }}> <nav className="nav" style={{ flex: 1 }}>
{visibles.map((m) => ( {MENU.map((m) => (
<button <button
key={m.id} key={m.id}
className={`nav-item ${active === m.id ? "active" : ""}`} className={`nav-item ${active === m.id ? "active" : ""}`}
@@ -31,22 +53,59 @@ export default function Sidebar({ active, onChange }) {
))} ))}
</nav> </nav>
<div style={{ padding: 14, borderTop: "1px solid rgba(255,255,255,0.08)" }}> {/* Botón de actualizar manual + hora de última actualización */}
<div style={{ fontSize: 12, color: "#94a3b8", marginBottom: 2 }}> <div style={{ padding: "0 12px 8px" }}>
{perfil?.nombre || "Usuario"} <button onClick={actualizarTodo} disabled={actualizando}
title="Actualizar todos los datos ahora (sin esperar los 15 min)"
style={{ width: "100%", display: "flex", alignItems: "center", justifyContent: "center",
gap: 8, padding: "9px 12px", background: actualizando ? "#1e293b" : "#2563eb",
border: "none", borderRadius: 9, color: "#fff", fontSize: 12.5, fontWeight: 600,
cursor: actualizando ? "default" : "pointer", opacity: actualizando ? 0.75 : 1,
transition: "all .15s ease" }}>
<span style={{ display: "inline-block",
animation: actualizando ? "spin 1s linear infinite" : "none" }}>🔄</span>
{actualizando ? "Actualizando..." : "Actualizar ahora"}
</button>
{actualizando ? (
<div style={{ marginTop: 6, fontSize: 10, color: "#93c5fd", textAlign: "center" }}>
Actualizando en segundo plano...
</div> </div>
<div style={{ fontSize: 11, color: "#60a5fa", fontWeight: 600, marginBottom: 10 }}> ) : ultima ? (
{perfil?.rol || ""} <div style={{ marginTop: 6, fontSize: 10, color: "#64748b", textAlign: "center" }}>
Actualizado: {ultima}
</div> </div>
<button ) : null}
onClick={logout} </div>
style={{ <div style={{ position: "relative", borderTop: "1px solid rgba(255,255,255,0.08)", padding: 12 }}>
width: "100%", padding: "8px", border: "1px solid rgba(255,255,255,0.15)", {menuUser && (
borderRadius: 8, background: "transparent", color: "#cbd5e1", fontSize: 12, <div style={{ position: "absolute", bottom: 64, left: 12, right: 12, background: "#1e293b",
cursor: "pointer", fontWeight: 500, border: "1px solid rgba(255,255,255,0.12)", borderRadius: 10, overflow: "hidden",
}} boxShadow: "0 8px 24px rgba(0,0,0,0.4)" }}>
> <button onClick={() => { setMenuUser(false); onConfig && onConfig(); }}
🚪 Cerrar sesión style={{ width: "100%", textAlign: "left", padding: "11px 14px", background: "transparent",
border: "none", color: "#e2e8f0", cursor: "pointer", fontSize: 13,
display: "flex", alignItems: "center", gap: 9 }}>
⚙️ Configuración
</button>
<button onClick={() => setMenuUser(false)}
style={{ width: "100%", textAlign: "left", padding: "11px 14px", background: "transparent",
border: "none", borderTop: "1px solid rgba(255,255,255,0.08)", color: "#f87171",
cursor: "pointer", fontSize: 13, display: "flex", alignItems: "center", gap: 9 }}>
⎋ Cerrar sesión
</button>
</div>
)}
<button onClick={() => setMenuUser((v) => !v)}
style={{ width: "100%", display: "flex", alignItems: "center", gap: 10, background: "transparent",
border: "none", cursor: "pointer", padding: "6px 4px", color: "#e2e8f0" }}>
<div style={{ width: 34, height: 34, borderRadius: "50%", background: "#2563eb", color: "#fff",
display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 700,
fontSize: 13, flexShrink: 0 }}>AS</div>
<div style={{ flex: 1, textAlign: "left", lineHeight: 1.2 }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>Aron</div>
<div style={{ fontSize: 11, color: "#94a3b8" }}>RP ERP</div>
</div>
<span style={{ fontSize: 11, color: "#94a3b8" }}>{menuUser ? "▲" : "▼"}</span>
</button> </button>
</div> </div>
</aside> </aside>

View File

@@ -1,4 +1,3 @@
// src/components/UI.jsx
export function Loader({ text = "Cargando..." }) { export function Loader({ text = "Cargando..." }) {
return ( return (
<div className="loader-wrap"> <div className="loader-wrap">
@@ -12,34 +11,15 @@ export function ErrorBox({ msg }) {
return <div className="error-box">⚠️ {msg}</div>; return <div className="error-box">⚠️ {msg}</div>;
} }
export function colorSemaforo(pct) {
if (pct >= 80) return { bg: "#d1fae5", fg: "#065f46", fill: "#10b981" };
if (pct >= 60) return { bg: "#fef9c3", fg: "#854d0e", fill: "#f59e0b" };
return { bg: "#fee2e2", fg: "#991b1b", fill: "#ef4444" };
}
export function ProgressBar({ pct }) {
const c = colorSemaforo(pct);
const w = Math.min(Math.max(pct, 0), 100);
return (
<div className="pbar">
<span className="pct" style={{ color: c.fg }}>{pct.toFixed(1)}%</span>
<div className="track">
<div className="fill" style={{ width: `${w}%`, background: c.fill }} />
</div>
</div>
);
}
export function Filters({ children }) { export function Filters({ children }) {
return <div className="filters">{children}</div>; return <div className="filters">{children}</div>;
} }
export function Select({ label, value, options, onChange, width }) { export function Select({ label, value, options, onChange }) {
return ( return (
<div className="filter-group" style={width ? { width } : undefined}> <div className="filter-group">
{label && <label>{label}</label>} {label && <label>{label}</label>}
<select value={value} onChange={(e) => onChange(e.target.value)} style={width ? { width: "100%" } : undefined}> <select value={value} onChange={(e) => onChange(e.target.value)}>
{options.map((o) => { {options.map((o) => {
const val = typeof o === "object" ? o.value : o; const val = typeof o === "object" ? o.value : o;
const txt = typeof o === "object" ? o.label : o; const txt = typeof o === "object" ? o.label : o;

View File

@@ -1,86 +1,150 @@
// src/lib/api.js // Cliente del backend FastAPI de Leads.
// Cliente para el backend FastAPI. Cambia BASE_URL si el backend corre en otra IP. // 1) Si existe VITE_API_URL (archivo .env del frontend), se usa esa.
const BASE_URL = "http://localhost:8000"; // 2) Si no, se decide por el host desde el que se abrió la página:
// localhost -> backend local http://localhost:8001
// cualquier otro -> API publicada del servidor
// Así funciona en desarrollo y en el servidor sin necesitar ningún .env.
const API_LOCAL = "http://localhost:8001";
const API_SERVIDOR = "https://dashboardapi.escueladerefrigeracion.edu.pe";
async function get(path, params = {}) { function _baseUrlPorDefecto() {
if (typeof window === "undefined") return API_LOCAL;
const h = window.location.hostname;
if (h === "localhost" || h === "127.0.0.1" || h === "::1") {
return API_LOCAL;
}
const proto = window.location.protocol || "http:";
return `${proto}//dashboardapi.escueladerefrigeracion.edu.pe`;
}
const BASE_URL = (
import.meta.env.VITE_API_URL || _baseUrlPorDefecto()
).replace(/\/+$/, "");
// ── Cache en memoria (frontend) con TTL 15 min y "stale-while-revalidate" ──
// Guarda la respuesta de cada GET (por path+params). Al volver a pedir:
// - si hay dato vigente (< 15 min) -> lo devuelve al instante, SIN fetch.
// - si venció -> devuelve el viejo al instante Y refresca en 2º plano.
// Así, al cambiar de apartado y volver, NO se recarga; y a los 15 min se
// actualiza solo sin mostrar loader. Los POST invalidan el cache.
const TTL = 15 * 60 * 1000; // 15 minutos
const _cache = new Map(); // key -> { ts, data }
const _enVuelo = new Map(); // key -> Promise (evita fetch duplicado)
function _key(path, params) {
return path + "?" + new URLSearchParams(params).toString();
}
async function _fetch(path, params) {
const qs = new URLSearchParams(params).toString(); const qs = new URLSearchParams(params).toString();
const url = `${BASE_URL}${path}${qs ? "?" + qs : ""}`; const r = await fetch(`${BASE_URL}${path}${qs ? "?" + qs : ""}`);
const r = await fetch(url);
if (!r.ok) throw new Error(`Error ${r.status} en ${path}`); if (!r.ok) throw new Error(`Error ${r.status} en ${path}`);
return r.json(); return r.json();
} }
async function postJson(path, body) { // GET con cache. Devuelve SIEMPRE una promesa que resuelve rápido si hay cache.
function get(path, params = {}) {
const key = _key(path, params);
const hit = _cache.get(key);
const now = Date.now();
if (hit) {
// Hay algo cacheado -> devolverlo YA.
if (now - hit.ts >= TTL && !_enVuelo.has(key)) {
// Venció: refrescar en 2º plano (no bloquea, no muestra loader).
const p = _fetch(path, params)
.then((data) => { _cache.set(key, { ts: Date.now(), data }); return data; })
.finally(() => _enVuelo.delete(key));
_enVuelo.set(key, p);
}
return Promise.resolve(hit.data);
}
// No hay cache: si ya hay una petición en vuelo para esta key, reusarla.
if (_enVuelo.has(key)) return _enVuelo.get(key);
const p = _fetch(path, params)
.then((data) => { _cache.set(key, { ts: Date.now(), data }); return data; })
.finally(() => _enVuelo.delete(key));
_enVuelo.set(key, p);
return p;
}
// Invalida entradas del cache cuyo path empiece con alguno de los prefijos dados.
function invalidar(...prefijos) {
for (const k of _cache.keys()) {
if (prefijos.some((p) => k.startsWith(p))) _cache.delete(k);
}
}
// POST helper: hace el POST y luego invalida los caches afectados.
async function post(path, body, invalidarPrefijos = []) {
const r = await fetch(`${BASE_URL}${path}`, { const r = await fetch(`${BASE_URL}${path}`, {
method: "POST", method: "POST", headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
if (!r.ok) throw new Error(`Error ${r.status} en ${path}`); if (!r.ok) throw new Error(`Error al guardar en ${path}`);
return r.json(); const data = await r.json();
} if (invalidarPrefijos.length) invalidar(...invalidarPrefijos);
return data;
async function post(path, params = {}) {
const qs = new URLSearchParams(params).toString();
const url = `${BASE_URL}${path}${qs ? "?" + qs : ""}`;
const r = await fetch(url, { method: "POST" });
if (!r.ok) throw new Error(`Error ${r.status} en ${path}`);
return r.json();
} }
export const api = { export const api = {
periodoActual: () => get("/api/periodo-actual"), leadsFiltros: () => get("/api/leads/filtros"),
leads: (ano = "TODOS", mes = "TODOS", dia = "TODOS", programa = "TODOS", sede = "TODOS") =>
get("/api/leads", { ano, mes, dia, programa, sede }),
otrosGeneral: (ano = "TODOS", mes = "TODOS", dia = "TODOS") =>
get("/api/otros-general", { ano, mes, dia }),
vendedores: (ano = "TODOS", mes = "TODOS", dia = "TODOS") =>
get("/api/vendedores", { ano, mes, dia }),
roas: (ano = "TODOS", mes = "TODOS", dia = "TODOS") =>
get("/api/roas", { ano, mes, dia }),
conjuntosSinSede: () => get("/api/conjuntos-sin-sede"),
guardarConjuntoSede: (cambios) =>
post("/api/conjuntos-sin-sede/guardar", { cambios }, ["/api/conjuntos-sin-sede", "/api/roas"]),
borrarConjuntoSede: (conjunto) =>
post("/api/conjuntos-sin-sede/borrar", { conjunto }, ["/api/conjuntos-sin-sede", "/api/roas"]),
ultimaActualizacion: () => get("/api/ultima-actualizacion"), ultimaActualizacion: () => get("/api/ultima-actualizacion"),
cacheRefresh: () => post("/api/cache/refresh"), // Actualiza TODO manualmente (como si pasaran los 15 min), pero EN SEGUNDO
cacheRefreshManual: (clave) => post("/api/cache/refresh-manual", clave ? { clave } : {}), // PLANO: NO se toca el cache del cliente mientras el backend trabaja, así el
precargaModulo: (modulo, ano) => post("/api/precarga/modulo", { modulo, ano }), // usuario sigue viendo los datos viejos sin pantallas de carga durante los
precargaEstado: (modulo) => get("/api/precarga/estado", { modulo }), // ~29s. Recien cuando el backend responde OK se limpia el cache del cliente,
ocupabilidad: (ano, mes, sede = "TODOS", programa = "TODOS") => // para que a partir de ahi todo muestre los datos nuevos de inmediato.
get("/api/ocupabilidad", { ano, mes, sede, programa }), refrescarTodo: async () => {
ocupabilidadAlumnos: (num_indice) => const r = await fetch(`${BASE_URL}/api/cache/refresh`, { method: "POST" });
get("/api/ocupabilidad/alumnos", { num_indice }), if (!r.ok) throw new Error("Error al actualizar");
ventas: (ano, mes, sede = "TODOS", programa = "TODOS", con_overrides = false) => get("/api/ventas", { ano, mes, sede, programa, con_overrides }), await r.json(); // espera a que el backend TERMINE de rehacer todo
ventasDetalle: (vendedor, ano, mes, tipo, sede = "TODOS", programa = "TODOS") => _cache.clear(); // solo AHORA se invalida el cache del cliente
get("/api/ventas/detalle", { vendedor, ano, mes, tipo, sede, programa }), // Trae la hora de actualizacion FRESCA (sin cache) y la deja cacheada, para
cobranza: (ano, mes, sectorista = "TODOS", agrupacion = "SEDE", sede_filtro = "TODOS", prog_filtro = "TODOS", venc_hasta = "TODOS") => // que el sidebar muestre la hora del refresco manual recien hecho.
get("/api/cobranza", { ano, mes, sectorista, agrupacion, sede_filtro, prog_filtro, venc_hasta }), const hr = await _fetch("/api/ultima-actualizacion", {});
cobranzaDetalle: (grupo, ano, mes, sectorista = "TODOS", agrupacion = "PROGRAMA", prog_filtro = "TODOS", sede_filtro = "TODOS", venc_hasta = "TODOS") => _cache.set(_key("/api/ultima-actualizacion", {}), { ts: Date.now(), data: hr });
get("/api/cobranza/detalle", { grupo, ano, mes, sectorista, agrupacion, prog_filtro, sede_filtro, venc_hasta }), return hr; // { hora: "..." }
alumnoEstado: (matricula) => get("/api/alumno/estado", { matricula }), },
alumnosPorRetirar: () => get("/api/alumnos/por-retirar"), conjuntosSinPauta: () => get("/api/conjuntos-sin-pauta"),
alumnoEstadoGuardar: (matricula, estado, observaciones, usuario) => usoPauta: (pauta, excluir) => get("/api/pauta/uso", { pauta, excluir: excluir || "" }),
postJson("/api/alumno/estado/guardar", { matricula, estado, observaciones, usuario }), leyendaAnuncios: () => get("/api/leyenda-anuncios"),
rentabilidad: (ano, mes, sede = "TODOS", programa = "TODOS") => programasDisponibles: () => get("/api/programas-disponibles"),
get("/api/rentabilidad", { ano, mes, sede, programa }), programasOcultos: () => get("/api/programas-ocultos"),
rentabilidadDetalle: (programa, ano, mes) => encenderProgramas: (num_indices) =>
get("/api/rentabilidad/detalle", { programa, ano, mes }), post("/api/programas-ocultos/encender", { num_indices },
rentabilidadCostos: (programa, ano, mes) => ["/api/programas-ocultos", "/api/programas-disponibles", "/api/leads"]),
get("/api/rentabilidad/costos", { programa, ano, mes }), guardarLeyenda: (cambios) =>
saldoPendiente: (tipo_cuota) => get("/api/saldo-pendiente", { tipo_cuota }), post("/api/leyenda-anuncios/guardar", { cambios }, ["/api/leyenda-anuncios", "/api/roas"]),
asesores: () => get("/api/asesores"), guardarPauta: (num_indice, pauta, conjunto, contar) =>
asesorEstado: (agent_id, online) => post("/api/curso/guardar-pauta", { num_indice, pauta, conjunto, contar },
post("/api/asesores/estado", { agent_id, online }), ["/api/leads", "/api/conjuntos-sin-pauta", "/api/roas"]),
guardarCostos: (num_indice, inicial, actual) => alertas: () => get("/api/leads/alertas"),
postJson("/api/rentabilidad/costos/guardar", { num_indice, inicial, actual }), // Leyenda (campanias de Supabase). Al agregar/borrar, invalida TODOS los
clasificarSede: (programas) => // apartados que dependen de las leyendas para que el cambio se vea al instante.
postJson("/api/cobranza/clasificar-sede", { programas }), campanias: () => get("/api/campanias"),
clasificarFull: (programas) => agregarCampania: (datos) =>
postJson("/api/cobranza/clasificar-full", { programas }), post("/api/campanias/agregar", datos,
cobranzaDetalleTodos: (ano, mes, sectorista = "TODOS") => ["/api/campanias", "/api/leads", "/api/vendedores", "/api/roas", "/api/otros-general"]),
get("/api/cobranza/detalle-todos", { ano, mes, sectorista }), editarCampania: (id, datos) =>
usuariosListar: () => get("/api/usuarios"), post("/api/campanias/editar", { id, ...datos },
usuariosCrear: (email, password, nombre, rol) => ["/api/campanias", "/api/leads", "/api/vendedores", "/api/roas", "/api/otros-general"]),
postJson("/api/usuarios/crear", { email, password, nombre, rol }), borrarCampania: (id) =>
usuariosActualizarRol: (id, rol) => postJson("/api/usuarios/rol", { id, rol }), post("/api/campanias/borrar", { id },
usuariosEliminar: (id) => postJson("/api/usuarios/eliminar", { id }), ["/api/campanias", "/api/leads", "/api/vendedores", "/api/roas", "/api/otros-general"]),
vendedoresManualesListar: (ano, mes) => get("/api/comisiones/vendedores", { ano, mes }),
vendedoresManualesCrear: (datos) => postJson("/api/comisiones/vendedores/crear", datos),
vendedoresManualesEliminar: (id) => postJson("/api/comisiones/vendedores/eliminar", { id }),
vendedoresManualesActualizar: (datos) => postJson("/api/comisiones/vendedores/actualizar", datos),
vendedoresManualesLote: (datos) => postJson("/api/comisiones/vendedores/lote", datos),
comisionesOverrideGuardar: (registros) => postJson("/api/comisiones/override/guardar", { registros }),
comisionesOverrideRestaurar: (num_matricula) => postJson("/api/comisiones/override/restaurar", { num_matricula }),
comisionesDetalleTodos: (ano, mes) => get("/api/comisiones/detalle-todos", { ano, mes }),
comisionesConfig: (ano, mes) => get("/api/comisiones/config", { ano, mes }),
comisionesConfigGuardar: (datos) => postJson("/api/comisiones/config/guardar", datos),
}; };

View File

@@ -1,144 +0,0 @@
// src/lib/auth.jsx
import { createContext, useContext, useEffect, useState, useCallback } from "react";
import { supabase } from "./supabase";
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [session, setSession] = useState(null);
const [perfil, setPerfil] = useState(null);
const [permisos, setPermisos] = useState([]);
const [cargando, setCargando] = useState(true);
const cargarPerfil = useCallback(async (userId) => {
try {
const { data: perf, error: e1 } = await supabase
.from("perfiles")
.select("nombre, rol, activo")
.eq("id", userId)
.single();
if (e1 || !perf || perf.activo === false) {
setPerfil(null);
setPermisos([]);
return false;
}
const { data: perms } = await supabase
.from("permisos")
.select("modulo, puede_ver")
.eq("rol", perf.rol);
const modulos = (perms || [])
.filter((p) => p.puede_ver)
.map((p) => p.modulo);
setPerfil({ nombre: perf.nombre, rol: perf.rol });
setPermisos(modulos);
return true;
} catch (e) {
console.error("[auth] cargarPerfil falló:", e);
setPerfil(null);
setPermisos([]);
return false;
}
}, []);
useEffect(() => {
let activo = true;
let resuelto = false;
const terminar = () => {
if (activo) { setCargando(false); resuelto = true; sessionStorage.removeItem("auth_reload"); }
};
// Fuente principal: onAuthStateChange dispara INITIAL_SESSION al arrancar
// con la sesión persistida (renueva token solo). Maneja login/logout en vivo.
const { data: sub } = supabase.auth.onAuthStateChange(async (_evt, s) => {
try {
setSession(s);
if (s?.user) await cargarPerfil(s.user.id);
else { setPerfil(null); setPermisos([]); }
} catch (e) {
console.error("[auth] onAuthStateChange falló:", e);
} finally {
terminar();
}
});
// Respaldo: si el listener no disparó en 6s, intenta getSession una vez.
// Si trae sesión la usa; si no, NO fuerza login (deja que el listener resuelva).
const tRespaldo = setTimeout(async () => {
if (resuelto || !activo) return;
try {
const { data } = await supabase.auth.getSession();
const s = data?.session || null;
if (s?.user && activo) {
setSession(s);
await cargarPerfil(s.user.id);
}
} catch (e) {
console.error("[auth] getSession (respaldo) falló:", e);
} finally {
terminar();
}
}, 6000);
// Si a los 8s sigue sin resolver, recarga la página UNA vez (F5 automático)
// en vez de mostrar el login pegado. sessionStorage evita bucle.
const tSeguridad = setTimeout(() => {
if (!activo || resuelto) return;
if (!sessionStorage.getItem("auth_reload")) {
sessionStorage.setItem("auth_reload", "1");
window.location.reload();
} else {
setCargando(false);
}
}, 8000);
return () => {
activo = false;
clearTimeout(tRespaldo);
clearTimeout(tSeguridad);
sub?.subscription?.unsubscribe();
};
}, [cargarPerfil]);
const login = async (email, password) => {
const { data, error } = await supabase.auth.signInWithPassword({ email, password });
if (error) return { ok: false, msg: traducirError(error.message) };
const ok = await cargarPerfil(data.user.id);
if (!ok) {
await supabase.auth.signOut();
return { ok: false, msg: "Tu usuario no tiene un perfil asignado. Contacta al administrador." };
}
return { ok: true };
};
const logout = async () => {
await supabase.auth.signOut();
setPerfil(null);
setPermisos([]);
};
const value = {
session,
perfil,
permisos,
cargando,
login,
logout,
autenticado: !!session && !!perfil,
puedeVer: (modulo) => permisos.includes(modulo),
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth debe usarse dentro de AuthProvider");
return ctx;
}
function traducirError(msg) {
if (/invalid login credentials/i.test(msg)) return "Correo o contraseña incorrectos.";
if (/email not confirmed/i.test(msg)) return "El correo no está confirmado.";
return msg || "Error al iniciar sesión.";
}

View File

@@ -1,15 +0,0 @@
// src/lib/supabase.js
// Cliente de Supabase para autenticación (login).
// IMPORTANTE: aquí va la clave PÚBLICA (anon), nunca la secreta (service_role).
import { createClient } from "@supabase/supabase-js";
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || "";
const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY || "";
export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
auth: {
persistSession: true,
autoRefreshToken: true,
detectSessionInUrl: true,
},
});

View File

@@ -1,11 +1,10 @@
// src/lib/useColumnasAjustables.jsx // src/lib/useColumnasAjustables.jsx
// Hook reutilizable para columnas redimensionables (estilo Excel) en cualquier tabla. // Hook para columnas redimensionables (arrastrar el borde, estilo Excel).
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
export function useColumnasAjustables(anchosIniciales) { export function useColumnasAjustables(anchosIniciales) {
const [anchos, setAnchos] = useState(anchosIniciales); const [anchos, setAnchos] = useState(anchosIniciales);
// Si cambia el número de columnas (ej. Cobranza Sede↔Programa), reiniciar anchos
useEffect(() => { useEffect(() => {
if (anchos.length !== anchosIniciales.length) { if (anchos.length !== anchosIniciales.length) {
setAnchos(anchosIniciales); setAnchos(anchosIniciales);
@@ -29,7 +28,6 @@ export function useColumnasAjustables(anchosIniciales) {
document.addEventListener("mouseup", onUp); document.addEventListener("mouseup", onUp);
} }
// Devuelve los elementos <col> para el <colgroup> de la tabla
function ColGroup() { function ColGroup() {
return ( return (
<colgroup> <colgroup>
@@ -38,7 +36,6 @@ export function useColumnasAjustables(anchosIniciales) {
); );
} }
// Devuelve el divisor arrastrable para poner dentro de cada <th>
function Resizer({ index }) { function Resizer({ index }) {
return ( return (
<span <span
@@ -51,10 +48,6 @@ export function useColumnasAjustables(anchosIniciales) {
} }
const anchoTotal = anchos.reduce((a, b) => a + b, 0); const anchoTotal = anchos.reduce((a, b) => a + b, 0);
// width:100% → llena el contenedor desde el inicio (sin huecos a la derecha).
// minWidth:anchoTotal → si las columnas crecen más que el contenedor, aparece scroll.
// tableLayout:fixed + colgroup → encabezados y celdas alineados.
const tableProps = { const tableProps = {
style: { tableLayout: "fixed", width: "100%", minWidth: anchoTotal }, style: { tableLayout: "fixed", width: "100%", minWidth: anchoTotal },
}; };

View File

@@ -1,23 +0,0 @@
// src/lib/vistaActual.js
// Registro de la "vista actual" (clave de caché del backend) + bus de eventos
// para que la página visible se recargue tras el refresco manual.
let _clave = null;
export function setVistaActual(clave) {
_clave = clave || null;
}
export function getVistaActual() {
return _clave;
}
const EVENTO = "datos-refrescados";
export function emitirRefresco() {
window.dispatchEvent(new Event(EVENTO));
}
export function onRefrescar(cb) {
window.addEventListener(EVENTO, cb);
return () => window.removeEventListener(EVENTO, cb);
}

View File

@@ -1,13 +1,10 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import App from "./App"; import App from "./App";
import { AuthProvider } from "./lib/auth";
import "./styles.css"; import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")).render( ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode> <React.StrictMode>
<AuthProvider>
<App /> <App />
</AuthProvider>
</React.StrictMode> </React.StrictMode>
); );

View File

@@ -1,105 +0,0 @@
// src/pages/Asesores.jsx
import { useState, useEffect, useCallback } from "react";
import { api } from "../lib/api";
import { Loader, ErrorBox } from "../components/UI";
const ESTADO_INFO = {
online: { txt: "En Línea", color: "#10b981", bg: "#d1fae5", fg: "#065f46" },
busy: { txt: "Ocupado", color: "#f59e0b", bg: "#fef9c3", fg: "#854d0e" },
offline:{ txt: "Fuera de Línea", color: "#94a3b8", bg: "#f1f5f9", fg: "#475569" },
};
export default function Asesores() {
const [agentes, setAgentes] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [cambiando, setCambiando] = useState(null);
const cargar = useCallback(() => {
setLoading(true); setError(null);
api.asesores()
.then((res)=>{ setAgentes(res.agentes||[]); setLoading(false); })
.catch((e)=>{ setError(e.message); setLoading(false); });
}, []);
useEffect(() => { cargar(); }, [cargar]);
async function toggle(ag) {
const online = ag.availability_status !== "online";
setCambiando(ag.id);
try {
await api.asesorEstado(ag.id, online);
setAgentes((prev)=>prev.map((a)=>a.id===ag.id ? {...a, availability_status: online?"online":"offline"} : a));
} catch (e) { /* noop */ }
setCambiando(null);
}
async function todos(online) {
setCambiando("all");
for (const ag of agentes) {
try { await api.asesorEstado(ag.id, online); } catch (e) {}
}
setAgentes((prev)=>prev.map((a)=>({...a, availability_status: online?"online":"offline"})));
setCambiando(null);
}
const total = agentes.length;
const enLinea = agentes.filter((a)=>a.availability_status==="online").length;
const ocupados = agentes.filter((a)=>a.availability_status==="busy").length;
const offline = total - enLinea - ocupados;
return (
<div>
<h1 className="page-title">👥 Asesores</h1>
{loading ? <Loader text="Cargando asesores de Chatwoot..." /> :
error ? <ErrorBox msg={error} /> :
<>
<div className="kpis" style={{gridTemplateColumns:"repeat(4,1fr)"}}>
<div className="kpi"><div className="ico">👥</div><div className="label">Total Asesores</div><div className="value">{total}</div></div>
<div className="kpi" style={{background:"#d1fae5"}}><div className="ico">🟢</div><div className="label">En Línea</div><div className="value" style={{color:"#065f46"}}>{enLinea}</div></div>
<div className="kpi" style={{background:"#fef9c3"}}><div className="ico">🟡</div><div className="label">Ocupados</div><div className="value" style={{color:"#854d0e"}}>{ocupados}</div></div>
<div className="kpi" style={{background:"#fee2e2"}}><div className="ico">🔴</div><div className="label">Fuera de Línea</div><div className="value" style={{color:"#991b1b"}}>{offline}</div></div>
</div>
<div style={{display:"flex",gap:10,marginBottom:16}}>
<button className="btn btn-primary" disabled={cambiando==="all"} onClick={()=>todos(true)}>🟢 Encender todos</button>
<button className="btn btn-ghost" disabled={cambiando==="all"} onClick={()=>todos(false)}>🔴 Apagar todos</button>
<button className="btn btn-ghost" title="Refrescar" style={{fontSize:18}} onClick={cargar}>🔄</button>
</div>
<div className="table-wrap">
<table>
<thead><tr><th>ASESOR</th><th>EMAIL</th><th>ROL</th><th>ESTADO</th><th>ACCIÓN</th></tr></thead>
<tbody>
{agentes.map((ag)=>{
const est = ESTADO_INFO[ag.availability_status] || ESTADO_INFO.offline;
const isOnline = ag.availability_status==="online";
return (
<tr key={ag.id}>
<td className="col-name">{ag.name || ag.available_name || "—"}</td>
<td>{ag.email || "—"}</td>
<td>{ag.role || "agent"}</td>
<td>
<span style={{background:est.bg,color:est.fg,padding:"3px 10px",borderRadius:20,
fontSize:11,fontWeight:700}}>● {est.txt}</span>
</td>
<td>
<button className="btn" disabled={cambiando===ag.id}
style={{padding:"5px 12px",fontSize:11,
background:isOnline?"#fee2e2":"#d1fae5",
color:isOnline?"#991b1b":"#065f46"}}
onClick={()=>toggle(ag)}>
{cambiando===ag.id ? "..." : (isOnline?"Apagar":"Encender")}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>}
</div>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,764 @@
import { useState, useEffect, useMemo, Fragment } from "react";
import { api } from "../lib/api";
import { Loader, ErrorBox, Filters, Select } from "../components/UI";
import { useColumnasAjustables } from "../lib/useColumnasAjustables";
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LabelList,
AreaChart, Area, Legend, ComposedChart,
} from "recharts";
const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
export default function Leads() {
const hoy = new Date();
const [ano, setAno] = useState(String(hoy.getFullYear()));
const [mes, setMes] = useState(String(hoy.getMonth() + 1));
const [dia, setDia] = useState("TODOS");
const [tipoProg, setTipoProg] = useState("TODOS");
const [sede, setSede] = useState("TODOS");
const [filtros, setFiltros] = useState({ anos: [2026], tipos: [] });
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [expandido, setExpandido] = useState({}); // estado -> abierto
const [expAsesor, setExpAsesor] = useState({}); // "estado|asesor" -> abierto
const [expSede, setExpSede] = useState({}); // sede -> abierto (tabla pauta)
const [expTipo, setExpTipo] = useState({}); // "sede|tipo" -> abierto (códigos)
const [expMat, setExpMat] = useState({}); // num_indice -> abierto (subfilas canal)
const [modalCurso, setModalCurso] = useState(null); // curso seleccionado para el pop-up "Ver"
const [editando, setEditando] = useState(false); // modo edición del pop-up
const [editPauta, setEditPauta] = useState(""); // valor pauta en edición
const [editConj, setEditConj] = useState(""); // conjunto elegido
const [editContar, setEditContar] = useState("SI"); // switch contar SI/NO
const [avisoContar, setAvisoContar] = useState(false);// mostró la alerta al cambiar a NO
const [conjOpc, setConjOpc] = useState([]); // conjuntos sin pauta (desplegable)
const [guardando, setGuardando] = useState(false);
const [msgGuardar, setMsgGuardar] = useState("");
const [reload, setReload] = useState(0);
const [avisoUso, setAvisoUso] = useState(null); // {cursos, conjuntos} si la pauta ya se usa
// Vista de columnas de la matriz: TOTAL y/o ASIGNADO. Si ninguno → ambos (todo).
const [verTotal, setVerTotal] = useState(true);
const [verAsig, setVerAsig] = useState(true);
// Definición de columnas de la matriz. modo: "fija" | "total" | "asig".
// key = campo en la fila; label = encabezado; w = ancho; render opcional.
const _fmtMoney = (v) => v ? `$${Number(v).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : "—";
const COLS_MAT = [
{ key: "personalizado", label: "Programa", w: 340, modo: "fija", prog: true },
{ key: "fecha_inicio", label: "Fecha Inicio", w: 110, modo: "fija" },
{ key: "importe_pauta", label: "Importe Pauta", w: 120, modo: "fija", bold: true, render: (c) => _fmtMoney(c.importe_pauta) },
{ key: "importe_pauta_mes", label: "Importe Pauta en el Mes", w: 150, modo: "fija", bold: true, render: (c) => _fmtMoney(c.importe_pauta_mes) },
{ key: "cartera_total", label: "Cartera Total", w: 110, modo: "total" },
{ key: "cartera_total_asig", label: "Cartera Total Asig.", w: 130, modo: "asig", bold: true },
{ key: "leads_nuevos", label: "L. Recibidos", w: 120, modo: "total" },
{ key: "leads_nuevos_asesor", label: "L. Procesados", w: 130, modo: "asig" },
{ key: "leads_nuevos_mes", label: "L. Recibidos del Mes", w: 150, modo: "total" },
{ key: "leads_nuevos_mes_asesor", label: "L. Procesados del Mes", w: 160, modo: "asig" },
{ key: "matriculas_no_iden", label: "Matriculas no iden.", w: 140, modo: "fija", bold: true },
{ key: "mat_leads_nuevos", label: "Matriculas Leads Nuevos", w: 160, modo: "fija", bold: true },
{ key: "mat_leads_antiguos", label: "Matriculas Leads Antiguos", w: 170, modo: "fija", bold: true },
{ key: "__ver", label: "", w: 70, modo: "fija", ver: true },
];
// Si desmarcan ambos → mostrar todo (como si ambos activos)
const _t = verTotal || (!verTotal && !verAsig);
const _a = verAsig || (!verTotal && !verAsig);
const colsVisibles = COLS_MAT.filter(
(c) => c.modo === "fija" || (c.modo === "total" && _t) || (c.modo === "asig" && _a)
);
// Columnas redimensionables de la matriz (según columnas visibles)
const colsMat = useColumnasAjustables(colsVisibles.map((c) => c.w));
// Cargar opciones de filtros una vez
useEffect(() => {
api.leadsFiltros().then(setFiltros).catch(() => {});
}, []);
// Auto-refresco: cada 15 min vuelve a pedir datos frescos (sin que el usuario haga F5)
// y al recibir "datos-actualizar" (tras guardar en Config) recarga al instante.
useEffect(() => {
const id = setInterval(() => setReload((n) => n + 1), 15 * 60 * 1000);
const onActualizar = () => setReload((n) => n + 1);
window.addEventListener("datos-actualizar", onActualizar);
return () => { clearInterval(id); window.removeEventListener("datos-actualizar", onActualizar); };
}, []);
// Cargar dashboard cuando cambian los filtros (igual que el PBI)
useEffect(() => {
let activo = true;
// Mostrar spinner solo si aún no hay datos (primera carga). En recargas/auto-
// refresco, actualiza en silencio para no tapar la tabla.
if (!data) setLoading(true);
setError(null);
api.leads(ano, mes, dia, tipoProg, sede)
.then((res) => { if (activo) { setData(res); setLoading(false); } })
.catch((e) => { if (activo) { setError(e.message); setLoading(false); } });
return () => { activo = false; };
}, [ano, mes, dia, tipoProg, sede, reload]);
const k = data?.kpis;
const fmtPct = (v) => `${Number(v ?? 0).toFixed(2)} %`;
// Cierra el pop-up y resetea el modo edición (para que reabra limpio)
function cerrarModal() {
setModalCurso(null); setEditando(false); setAvisoUso(null);
setAvisoContar(false); setMsgGuardar("");
}
// Guarda la pauta/conjunto (llamado tras confirmar o si la pauta no está en uso)
async function hacerGuardado() {
setGuardando(true); setMsgGuardar(""); setAvisoUso(null);
try {
await api.guardarPauta(modalCurso.num_indice, editPauta.trim(), editConj || null, editContar);
setMsgGuardar("✓ Guardado. Refrescando datos...");
setTimeout(() => { setModalCurso(null); setEditando(false); setReload((n) => n + 1); }, 800);
} catch (e) {
setMsgGuardar("Error al guardar: " + e.message);
} finally { setGuardando(false); }
}
// opciones de filtros
const optAnos = useMemo(() => (filtros.anos || []).map(String), [filtros]);
const optMes = useMemo(() => [{ value: "TODOS", label: "TODOS" },
...MESES.map((m,i)=>({ value: String(i+1), label: m }))], []);
const optDia = useMemo(() => ["TODOS", ...Array.from({length:31}, (_,i)=>String(i+1))], []);
const optTipo = useMemo(() => ["TODOS", "TEAC", "TERC", "SEMINARIOS", "OTROS"], []);
const optSede = useMemo(() => ["TODOS", "LIMA", "AREQUIPA", "TRUJILLO", "PIURA"], []);
return (
<div>
<h1 className="page-title">🎯 Leads</h1>
<Filters>
<Select label="Programa" value={tipoProg} options={optTipo} onChange={setTipoProg} />
<Select label="Sede" value={sede} options={optSede} onChange={setSede} />
<Select label="Año" value={ano} options={optAnos} onChange={setAno} />
<Select label="Mes" value={mes} options={optMes} onChange={setMes} />
<Select label="Día" value={dia} options={optDia} onChange={setDia} />
</Filters>
{loading ? <Loader text="Cargando leads..." /> :
error ? <ErrorBox msg={error} /> :
!k ? <ErrorBox msg="Sin datos" /> :
<>
{/* ── FILA DE KPIs (4 tarjetas compuestas, como el PBI) ── */}
<div className="kpis">
{/* Tarjeta 1: Leads Recibidos / Procesados / % */}
<div className="kpi-multi">
<KpiRow ico="🧲" label="Leads Recibidos" value={k.leads_recibidos} />
<KpiRow ico="🛠️" label="Leads Procesados" value={k.leads_procesados} />
<KpiRow ico="↻" label="% Porcentaje Procesados" value={fmtPct(k.pct_procesados)} />
</div>
{/* Tarjeta 2: Procesados / Contactados / % */}
<div className="kpi-multi">
<KpiRow ico="🔧" label="Leads Procesados" value={k.leads_procesados} />
<KpiRow ico="📞" label="Total Contactados" value={k.leads_contactados} />
<KpiRow ico="↻" label="% Contactabilidad" value={fmtPct(k.pct_contactados)} />
</div>
{/* Tarjeta 3: Matriculados / Matrículas mes / Ocupabilidad */}
<div className="kpi-multi">
<KpiRow ico="🎓" label="Total matriculados" value={k.total_matriculados} />
<KpiRow ico="📅" label="Matrículas en los Cursos del Mes" value={k.matriculas_mes} />
<KpiRow ico="💱" label="Ocupabilidad en los Cursos del Mes" value={fmtPct(k.ocupabilidad)} />
</div>
{/* Tarjeta 4: Cursos programados / reprog / susp / iniciados */}
<div className="kpi-multi">
<KpiRow ico="📚" label="Cursos Programados" value={k.cursos_programados} />
<KpiRow ico="🔁" label="Cursos Reprogramados" value={k.cursos_reprogramados} />
<KpiRow ico="⛔" label="Cursos Suspendidos" value={k.cursos_suspendidos} />
<KpiRow ico="🚀" label="Cursos Iniciados" value={k.cursos_iniciados} />
</div>
</div>
{/* ── FILA INFERIOR: gráfico + tabla ── */}
<div style={{ display: "grid", gridTemplateColumns: "1.4fr 1fr", gap: 16, marginTop: 16 }}>
<div className="card">
<div className="card-title">Matrículas por Día</div>
<ResponsiveContainer width="100%" height={320}>
<ComposedChart data={data.matriculas_por_dia} margin={{ top: 24, right: 24, left: -6, bottom: 8 }}>
<defs>
<linearGradient id="gradMat" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3b82f6" stopOpacity={0.25} />
<stop offset="100%" stopColor="#3b82f6" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#eef2f7" vertical={false} />
<XAxis dataKey="dia" tick={{ fontSize: 10, fill: "#94a3b8" }} interval={0}
axisLine={{ stroke: "#e2e8f0" }} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: "#94a3b8" }} allowDecimals={false}
axisLine={false} tickLine={false} width={32} />
<Tooltip content={<TooltipMatriculas />} cursor={{ stroke: "#cbd5e1", strokeDasharray: "4 4" }} />
<Area type="monotone" dataKey="cantidad" stroke="none" fill="url(#gradMat)" />
<Line type="monotone" dataKey="cantidad" stroke="#2563eb" strokeWidth={2.5}
dot={{ r: 3, fill: "#fff", stroke: "#2563eb", strokeWidth: 2 }}
activeDot={{ r: 5, fill: "#2563eb", stroke: "#fff", strokeWidth: 2 }}>
<LabelList dataKey="cantidad" position="top" fontSize={10} fill="#64748b"
formatter={(v) => (v > 0 ? v : "")} />
</Line>
</ComposedChart>
</ResponsiveContainer>
</div>
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
<div className="table-wrap" style={{ border: "none", maxHeight: 360, overflowY: "auto" }}>
<table>
<thead>
<tr>
<th className="col-name" style={{ position: "sticky", top: 0, zIndex: 2 }}>Estado/Objeción</th>
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Leads Procesados</th>
</tr>
</thead>
<tbody>
{(data.estado_objecion?.filas || []).map((f) => {
const abierto = !!expandido[f.estado];
const tieneAsesores = (f.asesores || []).length > 0;
return (
<Fragment key={f.estado}>
<tr>
<td className="col-name">
{tieneAsesores && (
<button
onClick={() => setExpandido((p) => ({ ...p, [f.estado]: !p[f.estado] }))}
style={{ border: "none", background: "transparent", cursor: "pointer",
fontWeight: 700, marginRight: 6, color: "#2563eb", fontSize: 14 }}>
{abierto ? "−" : "+"}
</button>
)}
{f.estado}
</td>
<td>{f.cantidad}</td>
</tr>
{abierto && (f.asesores || []).map((a) => {
const keyA = f.estado + "|" + a.asesor;
const abiertoA = !!expAsesor[keyA];
const tieneTel = (a.telefonos || []).length > 0;
return (
<Fragment key={keyA}>
<tr style={{ background: "#f8fafc" }}>
<td className="col-name" style={{ paddingLeft: 30, color: "#475569", fontSize: 12 }}>
{tieneTel && (
<button
onClick={() => setExpAsesor((p) => ({ ...p, [keyA]: !p[keyA] }))}
style={{ border: "none", background: "transparent", cursor: "pointer",
fontWeight: 700, marginRight: 6, color: "#7c3aed", fontSize: 13 }}>
{abiertoA ? "−" : "+"}
</button>
)}
{a.asesor}
</td>
<td style={{ color: "#475569", fontSize: 12 }}>{a.cantidad}</td>
</tr>
{abiertoA && (a.telefonos || []).map((t, i) => (
<tr key={keyA + "-" + t + "-" + i} style={{ background: "#fcfdff" }}>
<td className="col-name" style={{ paddingLeft: 60, color: "#94a3b8", fontSize: 11 }}>📞 {t}</td>
<td></td>
</tr>
))}
</Fragment>
);
})}
</Fragment>
);
})}
<tr className="total-row">
<td className="col-name">Total</td>
<td>{data.estado_objecion.total}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
{/* ── FILA 2: Leads por Programa (área) + Tabla por Sede ── */}
<div style={{ display: "grid", gridTemplateColumns: "1.4fr 1fr", gap: 16, marginTop: 16 }}>
<div className="card">
<div className="card-title">Leads por Programa</div>
<ResponsiveContainer width="100%" height={320}>
<AreaChart data={data.leads_por_dia} margin={{ top: 10, right: 24, left: -6, bottom: 8 }}>
<defs>
<linearGradient id="gradTot" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#60a5fa" stopOpacity={0.55} />
<stop offset="100%" stopColor="#60a5fa" stopOpacity={0.05} />
</linearGradient>
<linearGradient id="gradProc" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#1e40af" stopOpacity={0.6} />
<stop offset="100%" stopColor="#1e40af" stopOpacity={0.1} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#eef2f7" vertical={false} />
<XAxis dataKey="dia" tick={{ fontSize: 10, fill: "#94a3b8" }} interval={0}
axisLine={{ stroke: "#e2e8f0" }} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: "#94a3b8" }} allowDecimals={false}
axisLine={false} tickLine={false} width={32} />
<Tooltip contentStyle={{ borderRadius: 10, border: "1px solid #e2e8f0",
boxShadow: "0 6px 20px rgba(0,0,0,0.1)", fontSize: 12 }} />
<Legend iconType="circle" wrapperStyle={{ fontSize: 12, paddingTop: 4 }} />
<Area type="linear" dataKey="totales" name="Leads Totales" stroke="#60a5fa"
fill="url(#gradTot)" strokeWidth={2.5}
dot={{ r: 2.5, fill: "#60a5fa", stroke: "#fff", strokeWidth: 1 }}
activeDot={{ r: 5 }} />
<Area type="linear" dataKey="procesados" name="Leads Procesados" stroke="#1e40af"
fill="url(#gradProc)" strokeWidth={2.5}
dot={{ r: 2.5, fill: "#1e40af", stroke: "#fff", strokeWidth: 1 }}
activeDot={{ r: 5 }} />
</AreaChart>
</ResponsiveContainer>
</div>
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
<div className="table-wrap" style={{ border: "none", maxHeight: 360, overflowY: "auto" }}>
<table>
<thead>
<tr>
<th className="col-name" style={{ position: "sticky", top: 0, zIndex: 2, minWidth: 150 }}>Sede</th>
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Importe Gastado</th>
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Resultados</th>
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Recibidos</th>
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Procesados</th>
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Matr.</th>
<th style={{ position: "sticky", top: 0, zIndex: 2, minWidth: 130, whiteSpace: "nowrap" }}>Valor Venta</th>
</tr>
</thead>
<tbody>
{(data.tabla_pauta?.filas || []).map((f) => {
const abierto = !!expSede[f.sede];
return (
<Fragment key={f.sede}>
<tr>
<td className="col-name">
<button onClick={() => setExpSede((p) => ({ ...p, [f.sede]: !p[f.sede] }))}
style={{ border: "none", background: "transparent", cursor: "pointer",
fontWeight: 700, marginRight: 6, color: "#2563eb", fontSize: 14 }}>
{abierto ? "−" : "+"}
</button>
{f.sede}
</td>
<td style={{ fontWeight: 600 }}>{f.importe ? `$${Number(f.importe).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : "—"}</td>
<td>{f.resultados}</td>
<td>{f.recibidos}</td>
<td>{f.procesados}</td>
<td>{f.matriculas}</td>
<td>S/ {Number(f.inversion || 0).toLocaleString("es-PE")}</td>
</tr>
{abierto && (f.subfilas || []).map((s) => {
const keyT = f.sede + "|" + s.tipo;
const abiertoT = !!expTipo[keyT];
const tieneCod = (s.codigos || []).length > 0;
return (
<Fragment key={keyT}>
<tr style={{ background: "#f8fafc" }}>
<td className="col-name" style={{ paddingLeft: 30, color: "#475569", fontSize: 12 }}>
{tieneCod && (
<button onClick={() => setExpTipo((p) => ({ ...p, [keyT]: !p[keyT] }))}
style={{ border: "none", background: "transparent", cursor: "pointer",
fontWeight: 700, marginRight: 6, color: "#7c3aed", fontSize: 13 }}>
{abiertoT ? "−" : "+"}
</button>
)}
{s.tipo}
</td>
<td style={{ color: "#475569", fontSize: 12, fontWeight: 600 }}>{s.importe ? `$${Number(s.importe).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : "—"}</td>
<td style={{ color: "#475569", fontSize: 12 }}>{s.resultados}</td>
<td style={{ color: "#475569", fontSize: 12 }}>{s.recibidos}</td>
<td style={{ color: "#475569", fontSize: 12 }}>{s.procesados}</td>
<td style={{ color: "#475569", fontSize: 12 }}>{s.matriculas}</td>
<td style={{ color: "#475569", fontSize: 12 }}>S/ {Number(s.inversion || 0).toLocaleString("es-PE")}</td>
</tr>
{abiertoT && (s.codigos || []).map((cd) => (
<tr key={keyT + "-" + cd.codigo} style={{ background: "#fcfdff" }}>
<td className="col-name" style={{ paddingLeft: 56, color: "#94a3b8", fontSize: 11 }}>cód. {cd.codigo}</td>
<td style={{ color: "#94a3b8", fontSize: 11 }}>{cd.importe ? `$${Number(cd.importe).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : "—"}</td>
<td style={{ color: "#94a3b8", fontSize: 11 }}>{cd.resultados ? cd.resultados : "—"}</td>
<td style={{ color: "#94a3b8", fontSize: 11 }}>{cd.recibidos}</td>
<td style={{ color: "#94a3b8", fontSize: 11 }}>{cd.procesados}</td>
<td style={{ color: "#94a3b8", fontSize: 11 }}>{cd.matriculas}</td>
<td style={{ color: "#94a3b8", fontSize: 11 }}>S/ {Number(cd.inversion || 0).toLocaleString("es-PE")}</td>
</tr>
))}
</Fragment>
);
})}
</Fragment>
);
})}
<tr className="total-row">
<td className="col-name">Total</td>
<td>{`$${Number(data.tabla_pauta.total.importe || 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`}</td>
<td>{data.tabla_pauta.total.resultados}</td>
<td>{data.tabla_pauta.total.recibidos}</td>
<td>{data.tabla_pauta.total.procesados}</td>
<td>{data.tabla_pauta.total.matriculas}</td>
<td>S/ {Number(data.tabla_pauta.total.inversion).toLocaleString("es-PE")}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
{/* ── FILA 3: Matriz por Curso (num_indice) ── */}
<div className="card" style={{ marginTop: 16, padding: 0, overflow: "hidden" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "14px 16px 6px", gap: 12, flexWrap: "wrap" }}>
<div className="card-title" style={{ padding: 0 }}>Detalle por Curso</div>
<div style={{ display: "flex", alignItems: "center", gap: 8,
background: "#f8fafc", border: "1px solid #e2e8f0",
borderRadius: 10, padding: "5px 8px" }}>
<span style={{ fontSize: 10, fontWeight: 700, color: "#94a3b8",
textTransform: "uppercase", letterSpacing: ".5px", marginRight: 2 }}>
Ver
</span>
{[["TOTAL", verTotal, setVerTotal], ["ASIGNADO", verAsig, setVerAsig]].map(([txt, val, set]) => (
<label key={txt} onClick={() => set((v) => !v)}
style={{
display: "flex", alignItems: "center", gap: 7, cursor: "pointer",
padding: "5px 12px", borderRadius: 8, userSelect: "none",
fontSize: 12, fontWeight: 700, letterSpacing: ".3px",
border: val ? "1px solid #1e3a5f" : "1px solid #e2e8f0",
background: val ? "#eef2f9" : "#fff",
color: val ? "#1e3a5f" : "#64748b",
transition: "all .12s ease",
}}>
<span style={{
width: 16, height: 16, borderRadius: 5, flexShrink: 0,
display: "flex", alignItems: "center", justifyContent: "center",
border: val ? "none" : "1.5px solid #cbd5e1",
background: val ? "#1e3a5f" : "#fff",
}}>
{val && (
<svg width="10" height="10" viewBox="0 0 24 24" fill="none"
stroke="#fff" strokeWidth="4" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
)}
</span>
{txt}
</label>
))}
</div>
</div>
<div className="table-wrap" style={{ border: "none", maxHeight: 420, overflow: "auto" }}>
<table className="matriz-grid" {...colsMat.tableProps}>
<colsMat.ColGroup />
<thead>
<tr>
{colsVisibles.map((col, i) => (
<th key={col.key} style={{ position: "sticky", top: 0, zIndex: 2,
textAlign: i === 0 ? "left" : "center" }}>{col.label}<colsMat.Resizer index={i} /></th>
))}
</tr>
</thead>
<tbody>
{(data.matriz_cursos?.filas || []).map((c) => {
const abierto = !!expMat[c.num_indice];
return (
<Fragment key={c.num_indice}>
<tr>
{colsVisibles.map((col) => col.prog ? (
<td key={col.key} className="col-name" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{(c.subfilas_canal && c.subfilas_canal.length > 0) && (
<span
onClick={() => setExpMat((p) => ({ ...p, [c.num_indice]: !p[c.num_indice] }))}
style={{ cursor: "pointer", fontWeight: 700, color: "#2563eb",
marginRight: 6, userSelect: "none", display: "inline-block", width: 14 }}
title="Ver por canal (Pauta / Web / Otros)">
{abierto ? "−" : "+"}
</span>
)}
{c.personalizado}
</td>
) : col.ver ? (
<td key={col.key} style={{ textAlign: "center" }}>
<button onClick={() => setModalCurso(c)}
style={{ border: "1px solid #1e3a5f", background: "#eef2f9", color: "#1e3a5f",
borderRadius: 7, padding: "3px 12px", fontSize: 12, fontWeight: 700,
cursor: "pointer" }}>
Ver
</button>
</td>
) : (
<td key={col.key} style={col.bold ? { fontWeight: 600 } : undefined}>
{col.render ? col.render(c) : c[col.key]}
</td>
))}
</tr>
{abierto && (c.subfilas_canal || []).map((sf) => (
<tr key={c.num_indice + sf.canal} style={{ background: "#f8fafc" }}>
{colsVisibles.map((col) => {
if (col.prog) return (
<td key={col.key} className="col-name" style={{ paddingLeft: 30, color: "#475569", fontSize: 12 }}>{sf.canal}</td>
);
if (col.ver) return <td key={col.key}></td>;
// Estas columnas -> "-" en subfilas (Importe y Leads Recibidos/Procesados)
const GUION = ["importe_pauta", "importe_pauta_mes",
"leads_nuevos", "leads_nuevos_asesor",
"leads_nuevos_mes", "leads_nuevos_mes_asesor"];
if (GUION.includes(col.key)) return (
<td key={col.key} style={{ color: "#94a3b8", fontSize: 12 }}>—</td>
);
const val = sf[col.key];
return (
<td key={col.key} style={{ color: "#475569", fontSize: 12 }}>{val === undefined ? "—" : val}</td>
);
})}
</tr>
))}
</Fragment>
);
})}
</tbody>
</table>
</div>
</div>
</>}
{/* ── Pop-up "Ver": num_indice, pauta y conjuntos de anuncios ── */}
{modalCurso && (
<div onClick={cerrarModal}
style={{ position: "fixed", inset: 0, background: "rgba(15,23,42,0.55)",
display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1000 }}>
<div onClick={(e) => e.stopPropagation()}
style={{ background: "#fff", borderRadius: 16, width: 560, maxWidth: "94vw",
maxHeight: "86vh", overflow: "auto", boxShadow: "0 24px 70px rgba(0,0,0,0.4)",
fontFamily: "system-ui, -apple-system, 'Segoe UI', sans-serif" }}>
<div style={{ background: "#1e3a5f", color: "#fff", padding: "16px 22px",
borderTopLeftRadius: 14, borderTopRightRadius: 14,
display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div style={{ fontWeight: 700, fontSize: 15 }}>{modalCurso.personalizado}</div>
<button onClick={cerrarModal}
style={{ background: "transparent", border: "none", color: "#fff", fontSize: 22,
cursor: "pointer", lineHeight: 1 }}>×</button>
</div>
<div style={{ padding: "18px 22px" }}>
<div style={{ display: "flex", gap: 24, marginBottom: 18, alignItems: "flex-start" }}>
<div>
<div style={{ fontSize: 11, color: "#94a3b8", fontWeight: 700, textTransform: "uppercase" }}>Num Índice</div>
<div style={{ fontSize: 18, fontWeight: 700, color: "#0f172a" }}>{modalCurso.num_indice || "—"}</div>
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 11, color: "#94a3b8", fontWeight: 700, textTransform: "uppercase" }}>Pauta</div>
{editando ? (
<input value={editPauta} onChange={(e) => setEditPauta(e.target.value)}
placeholder="Código de pauta"
style={{ marginTop: 4, padding: "7px 10px", border: "1px solid #cbd5e1",
borderRadius: 8, fontSize: 14, width: "100%" }} />
) : (
<div style={{ fontSize: 18, fontWeight: 700, color: "#1e40af" }}>{modalCurso.pauta || "—"}</div>
)}
</div>
</div>
{editando ? (
<>
<div style={{ fontSize: 11, color: "#94a3b8", fontWeight: 700, textTransform: "uppercase", marginBottom: 6 }}>
Vincular Conjunto de Anuncio (opcional)
</div>
<select value={editConj} onChange={(e) => setEditConj(e.target.value)}
style={{ padding: "8px 10px", border: "1px solid #cbd5e1", borderRadius: 8,
fontSize: 13, width: "100%", background: "#fff" }}>
<option value="">— Ninguno —</option>
{conjOpc.map((cj) => <option key={cj} value={cj}>{cj}</option>)}
</select>
{/* Switch: contar SI/NO */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between",
marginTop: 18, padding: "12px 14px", background: "#f8fafc",
border: "1px solid #e2e8f0", borderRadius: 10 }}>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: "#0f172a" }}>Mostrar en la lista</div>
<div style={{ fontSize: 12, color: "#64748b" }}>
{editContar === "SI" ? "Este programa aparece en el Detalle por Curso" : "Este programa quedará oculto"}
</div>
</div>
<div onClick={() => {
const nuevo = editContar === "SI" ? "NO" : "SI";
setEditContar(nuevo);
if (nuevo === "NO") setAvisoContar(true);
}}
style={{ width: 46, height: 26, borderRadius: 999, cursor: "pointer",
background: editContar === "SI" ? "#2563eb" : "#cbd5e1",
position: "relative", transition: "background .15s", flexShrink: 0 }}>
<div style={{ width: 20, height: 20, borderRadius: "50%", background: "#fff",
position: "absolute", top: 3, left: editContar === "SI" ? 23 : 3,
transition: "left .15s", boxShadow: "0 1px 3px rgba(0,0,0,0.3)" }} />
</div>
</div>
{avisoContar && editContar === "NO" && (
<div style={{ marginTop: 10, padding: "10px 12px", background: "#fffbeb",
border: "1px solid #fde68a", borderRadius: 8, fontSize: 12.5, color: "#92400e" }}>
⚠️ Al poner <b>NO</b> y guardar, este programa ya no aparecerá en la lista.
Solo podrás volver a añadirlo desde Configuración.
</div>
)}
{msgGuardar && <div style={{ marginTop: 10, fontSize: 13, color: msgGuardar.startsWith("✓") ? "#047857" : "#b91c1c" }}>{msgGuardar}</div>}
<div style={{ display: "flex", gap: 8, marginTop: 16, justifyContent: "flex-end" }}>
<button onClick={() => { setEditando(false); setMsgGuardar(""); setAvisoContar(false); }}
style={{ padding: "8px 16px", border: "1px solid #cbd5e1", background: "#fff",
borderRadius: 8, fontSize: 13, fontWeight: 600, cursor: "pointer" }}>
Cancelar
</button>
{(() => {
const cambioContar = editContar !== (modalCurso.contar || "SI");
const puedeGuardar = editPauta.trim() || cambioContar;
return (
<button disabled={guardando || !puedeGuardar}
onClick={async () => {
setMsgGuardar("");
try {
// Verificar uso solo si se ingresó pauta
if (editPauta.trim()) {
const uso = await api.usoPauta(editPauta.trim(), modalCurso.num_indice);
if (uso.en_uso) { setAvisoUso(uso); return; }
}
await hacerGuardado();
} catch (e) { setMsgGuardar("Error: " + e.message); }
}}
style={{ padding: "8px 18px", border: "none", background: "#1e3a5f", color: "#fff",
borderRadius: 8, fontSize: 13, fontWeight: 700,
cursor: guardando ? "wait" : "pointer", opacity: (!puedeGuardar || guardando) ? 0.6 : 1 }}>
{guardando ? "Guardando..." : "Guardar"}
</button>
);
})()}
</div>
</>
) : (
<>
<div style={{ fontSize: 11, color: "#94a3b8", fontWeight: 700, textTransform: "uppercase", marginBottom: 8 }}>
Conjuntos de Anuncios ({(modalCurso.conjuntos || []).length})
</div>
{(modalCurso.conjuntos || []).length === 0 ? (
<div style={{ color: "#94a3b8", fontSize: 13 }}>Sin conjuntos vinculados a esta pauta.</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{modalCurso.conjuntos.map((cj, i) => (
<div key={i} style={{ background: "#f8fafc", border: "1px solid #e2e8f0",
borderRadius: 8, padding: "8px 12px", fontSize: 13, color: "#334155" }}>
{cj}
</div>
))}
</div>
)}
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: 16 }}>
<button onClick={() => {
setEditando(true); setEditPauta(modalCurso.pauta || ""); setEditConj("");
setEditContar(modalCurso.contar || "SI"); setAvisoContar(false); setMsgGuardar("");
api.conjuntosSinPauta().then((r) => setConjOpc(r.conjuntos || [])).catch(() => setConjOpc([]));
}}
style={{ padding: "8px 18px", border: "1px solid #1e3a5f", background: "#eef2f9",
color: "#1e3a5f", borderRadius: 8, fontSize: 13, fontWeight: 700, cursor: "pointer" }}>
Editar
</button>
</div>
</>
)}
</div>
</div>
</div>
)}
{/* ── Advertencia: la pauta ya está en uso ── */}
{avisoUso && (
<div style={{ position: "fixed", inset: 0, background: "rgba(15,23,42,0.6)",
display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1100 }}>
<div style={{ background: "#fff", borderRadius: 14, width: 440, maxWidth: "92vw",
maxHeight: "82vh", overflow: "auto", boxShadow: "0 20px 60px rgba(0,0,0,0.4)" }}>
<div style={{ background: "#b45309", color: "#fff", padding: "14px 20px",
borderTopLeftRadius: 14, borderTopRightRadius: 14, fontWeight: 700, fontSize: 15 }}>
⚠️ Esta pauta ya está en uso
</div>
<div style={{ padding: "18px 22px" }}>
<div style={{ fontSize: 13, color: "#334155", marginBottom: 12 }}>
La pauta <b>{editPauta}</b> ya está asignada a:
</div>
{(avisoUso.cursos || []).length > 0 && (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 11, color: "#94a3b8", fontWeight: 700, textTransform: "uppercase", marginBottom: 4 }}>Programas</div>
{avisoUso.cursos.map((cu, i) => (
<div key={i} style={{ background: "#fffbeb", border: "1px solid #fde68a",
borderRadius: 8, padding: "6px 10px", fontSize: 12, color: "#92400e", marginBottom: 4 }}>
{cu.dsc_det_programa || "(sin nombre)"} <span style={{ color: "#b45309" }}>· índice {cu.num_indice}</span>
</div>
))}
</div>
)}
{(avisoUso.conjuntos || []).length > 0 && (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 11, color: "#94a3b8", fontWeight: 700, textTransform: "uppercase", marginBottom: 4 }}>Conjuntos de anuncios</div>
{avisoUso.conjuntos.map((cj, i) => (
<div key={i} style={{ background: "#f8fafc", border: "1px solid #e2e8f0",
borderRadius: 8, padding: "6px 10px", fontSize: 12, color: "#334155", marginBottom: 4 }}>
{cj}
</div>
))}
</div>
)}
<div style={{ fontSize: 13, color: "#0f172a", fontWeight: 600, marginTop: 10 }}>
¿Estás seguro de continuar?
</div>
<div style={{ display: "flex", gap: 8, marginTop: 16, justifyContent: "flex-end" }}>
<button onClick={() => setAvisoUso(null)}
style={{ padding: "8px 18px", border: "1px solid #cbd5e1", background: "#fff",
borderRadius: 8, fontSize: 13, fontWeight: 600, cursor: "pointer" }}>
No
</button>
<button onClick={hacerGuardado}
style={{ padding: "8px 20px", border: "none", background: "#b45309", color: "#fff",
borderRadius: 8, fontSize: 13, fontWeight: 700, cursor: "pointer" }}>
Sí, guardar
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}
// Tooltip personalizado: mini-gráfico de barras horizontales por tipo de programa
function TooltipMatriculas({ active, payload, label }) {
if (!active || !payload || !payload.length) return null;
const p = payload[0].payload;
const detalle = p.detalle || [];
const max = Math.max(1, ...detalle.map((d) => d.cantidad));
return (
<div style={{ background: "#fff", border: "1px solid #e2e8f0", borderRadius: 10,
padding: "10px 12px", boxShadow: "0 6px 20px rgba(0,0,0,0.12)", minWidth: 220 }}>
<div style={{ fontWeight: 700, fontSize: 12, color: "#1e293b", marginBottom: 2 }}>
Día {label} — {p.cantidad} matrícula(s)
</div>
{detalle.length === 0 ? (
<div style={{ fontSize: 11, color: "#94a3b8" }}>Sin matrículas</div>
) : detalle.map((d) => (
<div key={d.tipo} style={{ display: "flex", alignItems: "center", gap: 6, margin: "4px 0" }}>
<span style={{ fontSize: 10, color: "#475569", width: 90, textAlign: "right",
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{d.tipo}</span>
<div style={{ flex: 1, background: "#eff6ff", borderRadius: 3, height: 14, position: "relative" }}>
<div style={{ width: `${(d.cantidad / max) * 100}%`, background: "#60a5fa",
height: "100%", borderRadius: 3 }} />
</div>
<span style={{ fontSize: 11, fontWeight: 700, color: "#1e40af", width: 18 }}>{d.cantidad}</span>
</div>
))}
</div>
);
}
function KpiRow({ ico, label, value }) {
return (
<div className="kpi-row">
<span className="ico">{ico}</span>
<div className="txt">
<div className="label">{label}</div>
<div className="value">{value}</div>
</div>
</div>
);
}

View File

@@ -1,82 +0,0 @@
// src/pages/Login.jsx
import { useState } from "react";
import { useAuth } from "../lib/auth";
export default function Login() {
const { login } = useAuth();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState(null);
const [cargando, setCargando] = useState(false);
async function onSubmit(e) {
e.preventDefault();
setError(null);
setCargando(true);
const res = await login(email.trim(), password);
if (!res.ok) setError(res.msg);
setCargando(false);
}
return (
<div style={{
minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center",
background: "linear-gradient(135deg, #0f172a 0%, #1e3a5f 100%)", padding: 20,
}}>
<div style={{
background: "#fff", borderRadius: 16, padding: "40px 36px", width: "100%", maxWidth: 400,
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
}}>
<div style={{ textAlign: "center", marginBottom: 28 }}>
<div style={{ fontSize: 38, marginBottom: 8 }}>❄️</div>
<h1 style={{ fontSize: 20, fontWeight: 800, color: "#0f172a" }}>Escuela de Refrigeración</h1>
<p style={{ fontSize: 13, color: "#64748b", marginTop: 4 }}>Dashboard — Inicia sesión</p>
</div>
<form onSubmit={onSubmit}>
<div style={{ marginBottom: 16 }}>
<label style={{ fontSize: 12, fontWeight: 600, color: "#475569", display: "block", marginBottom: 6 }}>
Correo
</label>
<input
type="email" value={email} onChange={(e) => setEmail(e.target.value)}
placeholder="usuario@escuela.com" required autoFocus
style={inputStyle}
/>
</div>
<div style={{ marginBottom: 20 }}>
<label style={{ fontSize: 12, fontWeight: 600, color: "#475569", display: "block", marginBottom: 6 }}>
Contraseña
</label>
<input
type="password" value={password} onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••" required
style={inputStyle}
/>
</div>
{error && (
<div style={{
background: "#fee2e2", border: "1px solid #fca5a5", color: "#991b1b",
padding: "10px 12px", borderRadius: 8, fontSize: 13, marginBottom: 16,
}}>⚠️ {error}</div>
)}
<button type="submit" disabled={cargando} style={{
width: "100%", padding: "12px", border: "none", borderRadius: 10,
background: cargando ? "#94a3b8" : "#2563eb", color: "#fff", fontSize: 15, fontWeight: 600,
cursor: cargando ? "default" : "pointer",
}}>
{cargando ? "Ingresando..." : "Iniciar sesión"}
</button>
</form>
</div>
</div>
);
}
const inputStyle = {
width: "100%", padding: "11px 14px", border: "1px solid #cbd5e1", borderRadius: 10,
fontSize: 14, outline: "none", boxSizing: "border-box",
};

View File

@@ -1,307 +0,0 @@
// src/pages/Ocupabilidad.jsx
import { useState, useEffect, useMemo } from "react";
import { api } from "../lib/api";
import { Loader, ErrorBox, ProgressBar, Filters, Select } from "../components/UI";
import { useColumnasAjustables } from "../lib/useColumnasAjustables";
import Modal from "../components/Modal";
import { setVistaActual, onRefrescar } from "../lib/vistaActual";
const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
const ANOS = [2024, 2025, 2026];
const SEDES = ["TODOS", "LIMA", "AREQUIPA", "PIURA", "TRUJILLO"];
const PROGRAMAS = ["TODOS", "SEMINARIOS", "OTROS", "TEAC", "TERC"];
const COLS = ["PROGRAMA","FECHA INICIO","DÍAS PARA INICIO","TOTAL INSCRITOS","RETIRADOS","INSCRITOS EN CURSO","META INSCRITOS","AVANCE INSCRITOS","INSCRITOS MES","INSCRITOS P.C","INSCRITOS REFRIPERU","INSCRITOS CONTINUIDAD","OPC."];
function toNum(v) { const n = parseFloat(String(v).replace("%","").trim()); return isNaN(n) ? 0 : n; }
export default function Ocupabilidad() {
const hoy = new Date();
const [ano, setAno] = useState(hoy.getFullYear());
const [mes, setMes] = useState(hoy.getMonth() + 1);
const [sede, setSede] = useState("TODOS");
const [programa, setPrograma] = useState("TODOS");
const [mostrarRepro, setMostrarRepro] = useState(true);
const [datos, setDatos] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [modalCurso, setModalCurso] = useState(null);
const cols = useColumnasAjustables([280, 110, 120, 110, 100, 120, 110, 120, 100, 100, 130, 140, 80]);
useEffect(() => {
let activo = true;
setVistaActual(`ocupabilidad:${ano}|${mes}|${sede}|${programa}`);
const cargar = () => {
setLoading(true); setError(null);
api.ocupabilidad(ano, mes, sede, programa)
.then((res) => { if (activo) { setDatos(res.datos || []); setLoading(false); } })
.catch((e) => { if (activo) { setError(e.message); setLoading(false); } });
};
cargar();
const off = onRefrescar(cargar);
return () => { activo = false; off(); };
}, [ano, mes, sede, programa]);
// Toggle "Mostrar Reprogramados": si está apagado, ocultar filas de inicio
const datosVisibles = useMemo(() => {
if (mostrarRepro) return datos;
return datos.filter((d) => String(d.dias_para_inicio).trim().toUpperCase() !== "REPROGRAMADO");
}, [datos, mostrarRepro]);
const kpis = useMemo(() => {
if (!datosVisibles.length) return null;
const esRepro = (d) => String(d.dias_para_inicio).trim().toUpperCase() === "REPROGRAMADO";
// Las filas REPROGRAMADO (inicios) no entran en las métricas (igual que escritorio)
const validos = datosVisibles.filter((d) => !esRepro(d));
const insc = validos.reduce((s, d) => s + (parseInt(d.Inscritos_Activos) || 0), 0);
const totalInsc = validos.reduce((s, d) => s + (parseInt(d.Inscritos_Totales) || 0), 0);
const retirados = validos.reduce((s, d) => s + (parseInt(d.Retirados ?? d.Inscritos_Retirados) || 0), 0);
const meta = validos.reduce((s, d) => s + (parseInt(d.Meta_Curso) || 0), 0);
return {
cursos: validos.length, // solo programados (sin reprogramados)
reprogramados: datosVisibles.filter(esRepro).length, // inicios rojos (REPROGRAMADO)
inscritos: totalInsc, // columna TOTAL INSCRITOS
meta,
avance: meta > 0 ? (totalInsc / meta * 100) : 0,
desercion: totalInsc > 0 ? (retirados / totalInsc * 100) : 0,
sobreMeta: validos.filter((d) => (parseInt(d.Inscritos_Totales)||0) >= (parseInt(d.Meta_Curso)||1)).length,
pc: validos.reduce((s, d) => s + (parseInt(d.Inscritos_PC) || 0), 0),
};
}, [datosVisibles]);
const totales = useMemo(() => {
const t = { mes:0, total:0, pc:0, retirados:0, activos:0, refri:0, cont:0, meta:0 };
datosVisibles.forEach((d) => {
t.mes += parseInt(d.Inscritos_Mes)||0;
t.total += parseInt(d.Inscritos_Totales)||0;
t.pc += parseInt(d.Inscritos_PC)||0;
t.retirados += parseInt(d.Retirados ?? d.Inscritos_Retirados)||0;
t.activos += parseInt(d.Inscritos_Activos)||0;
t.refri += parseInt(d.Descuento)||0;
t.cont += parseInt(d.Inscritos_Continuidad)||0;
t.meta += parseInt(d.Meta_Curso)||0;
});
return t;
}, [datosVisibles]);
const KPI_CARDS = kpis ? [
["📚","Cursos Activos", kpis.cursos, "programas este mes"],
["🔁","Cursos Reprogramados", kpis.reprogramados, "inicios reprogramados"],
["👥","Total Inscritos", kpis.inscritos, "alumnos en curso"],
["🎯","Meta Total", kpis.meta, "inscritos objetivo"],
["📈","Avance Inscritos", `${kpis.avance.toFixed(1)}%`, "total inscritos / meta"],
["📉","Deserción", `${kpis.desercion.toFixed(1)}%`, "retirados / total inscritos"],
] : [];
async function exportarExcel() {
if (!datosVisibles.length) return;
// Cargar SheetJS desde CDN solo cuando se necesita
const XLSX = await import("xlsx");
const filas = datosVisibles.map((d) => ({
"PROGRAMA": d.programa_frecuencia || d.dsc_programa || "",
"FECHA INICIO": d.fch_inicio || "",
"DÍAS PARA INICIO": d.dias_para_inicio ?? "",
"TOTAL INSCRITOS": parseInt(d.Inscritos_Totales)||0,
"RETIRADOS": parseInt(d.Retirados ?? d.Inscritos_Retirados)||0,
"INSCRITOS EN CURSO": parseInt(d.Inscritos_Activos)||0,
"META INSCRITOS": parseInt(d.Meta_Curso)||0,
"AVANCE INSCRITOS": d.Avance_Inscritos || "",
"INSCRITOS MES": parseInt(d.Inscritos_Mes)||0,
"INSCRITOS P.C": parseInt(d.Inscritos_PC)||0,
"INSCRITOS REFRIPERU": parseInt(d.Descuento)||0,
"INSCRITOS CONTINUIDAD": parseInt(d.Inscritos_Continuidad)||0,
}));
const ws = XLSX.utils.json_to_sheet(filas);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "Ocupabilidad");
XLSX.writeFile(wb, `cursos_${ano}_${mes}.xlsx`);
}
return (
<div>
<h1 className="page-title">📊 Ocupabilidad</h1>
<Filters>
<Select label="Año" value={ano} options={ANOS} onChange={(v) => setAno(+v)} />
<Select label="Mes" value={mes} options={MESES.map((m,i)=>({value:i+1,label:m}))} onChange={(v) => setMes(+v)} />
<Select label="Sede" value={sede} options={SEDES} onChange={setSede} />
<Select label="Programa" value={programa} options={PROGRAMAS} onChange={setPrograma} />
<div className="filter-group">
<label>Reprogramados</label>
<label style={{display:"flex",alignItems:"center",gap:6,height:38,cursor:"pointer",fontSize:13}}>
<input type="checkbox" checked={mostrarRepro} onChange={(e)=>setMostrarRepro(e.target.checked)} />
Mostrar
</label>
</div>
<button className="btn btn-primary" style={{height:38,marginLeft:"auto"}} onClick={exportarExcel} disabled={!datosVisibles.length}>
⬇️ Exportar Excel
</button>
</Filters>
{loading ? <Loader text="Calculando ocupabilidad..." /> :
error ? <ErrorBox msg={error} /> :
<>
<div className="kpis">
{KPI_CARDS.map(([ico,label,val,sub]) => (
<div className="kpi" key={label}>
<div className="ico">{ico}</div>
<div className="label" style={{fontSize:15}}>{label}</div>
<div className="value">{val}</div>
<div className="sub">{sub}</div>
</div>
))}
</div>
<div className="table-wrap" style={{overflowX:"auto"}}>
<table {...cols.tableProps}>
<cols.ColGroup />
<thead><tr>{COLS.map((c, i) => <th key={c} style={{position:"relative"}}>{c}<cols.Resizer index={i} /></th>)}</tr></thead>
<tbody>
{datosVisibles.map((d, i) => {
const prog = d.programa_frecuencia || d.dsc_programa || "";
const dias = parseInt(d.dias_para_inicio);
const pct = toNum(d.Avance_Inscritos);
// Inicio reprogramado (existe en Supabase pero no en SQL este mes) → fila roja
const esInicio = String(d.dias_para_inicio).trim().toUpperCase() === "REPROGRAMADO";
const cel = (v) => esInicio ? "-" : v;
return (
<tr key={i} className={esInicio ? "row-inicio" : ""}>
<td className="col-name" style={{minWidth:300,maxWidth:480,whiteSpace:"nowrap",overflow:"hidden",textOverflow:"ellipsis"}}>{prog}</td>
<td>{d.fch_inicio || ""}</td>
<td style={!esInicio && dias < 0 ? {color:"#dc2626",fontWeight:700} : {}}>{d.dias_para_inicio ?? ""}</td>
<td>{cel(parseInt(d.Inscritos_Totales)||0)}</td>
<td>{cel(parseInt(d.Retirados ?? d.Inscritos_Retirados)||0)}</td>
<td>{cel(parseInt(d.Inscritos_Activos)||0)}</td>
<td>{cel(parseInt(d.Meta_Curso)||0)}</td>
<td>{esInicio ? "-" : <ProgressBar pct={pct} />}</td>
<td>{cel(parseInt(d.Inscritos_Mes)||0)}</td>
<td>{cel(parseInt(d.Inscritos_PC)||0)}</td>
<td>{cel(parseInt(d.Descuento)||0)}</td>
<td>{cel(parseInt(d.Inscritos_Continuidad)||0)}</td>
<td>
{esInicio ? "-" : (
<button className="btn btn-ghost" style={{padding:"4px 10px",fontSize:11}}
onClick={() => setModalCurso({ num_indice: d.num_indice, programa: prog })}>
👁️ Ver
</button>
)}
</td>
</tr>
);
})}
{datosVisibles.length > 0 && (
<tr className="total-row">
<td className="col-name">TOTAL GENERAL</td>
<td>—</td><td>—</td>
<td>{totales.total}</td>
<td>{totales.retirados}</td>
<td>{totales.activos}</td>
<td>{totales.meta}</td>
<td><ProgressBar pct={totales.meta>0 ? totales.total/totales.meta*100 : 0} /></td>
<td>{totales.mes}</td><td>{totales.pc}</td><td>{totales.refri}</td><td>{totales.cont}</td>
<td>—</td>
</tr>
)}
</tbody>
</table>
</div>
</>}
{modalCurso && (
<ModalAlumnosCurso
numIndice={modalCurso.num_indice}
programa={modalCurso.programa}
onClose={() => setModalCurso(null)}
/>
)}
</div>
);
}
function ModalAlumnosCurso({ numIndice, programa, onClose }) {
const [alumnos, setAlumnos] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let activo = true;
setLoading(true);
api.ocupabilidadAlumnos(numIndice)
.then((res) => { if (activo) { setAlumnos(res.alumnos || []); setLoading(false); } })
.catch(() => { if (activo) { setAlumnos([]); setLoading(false); } });
return () => { activo = false; };
}, [numIndice]);
const colorEstado = (e) => {
if (e === "ALU") return { bg:"#ecfdf5", fg:"#047857", bd:"#a7f3d0" };
if (e === "PRE") return { bg:"#eff6ff", fg:"#1e40af", bd:"#bfdbfe" };
if (e === "RET") return { bg:"#fef2f2", fg:"#b91c1c", bd:"#fecaca" };
return { bg:"#f1f5f9", fg:"#475569", bd:"#e2e8f0" };
};
const conteo = useMemo(() => {
const c = {};
alumnos.forEach((a) => { c[a.estado] = (c[a.estado] || 0) + 1; });
return c;
}, [alumnos]);
return (
<Modal title={`👥 Alumnos Inscritos — ${programa}`} onClose={onClose} width={1050}>
{loading ? <Loader text="Cargando alumnos..." /> :
alumnos.length === 0 ? <div style={{color:"#94a3b8",padding:20,textAlign:"center"}}>Sin alumnos.</div> :
<>
<div style={{display:"flex",gap:10,marginBottom:14,flexWrap:"wrap"}}>
<span style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px",
borderRadius:999,background:"#eff6ff",color:"#1e40af",fontSize:13,fontWeight:700,
border:"1px solid #bfdbfe"}}>
👥 Total: {alumnos.length} alumno{alumnos.length!==1?"s":""}
</span>
{["ALU","PRE","RET"].map((e)=> conteo[e] ? (
<span key={e} style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px",
borderRadius:999,background:colorEstado(e).bg,color:colorEstado(e).fg,fontSize:13,fontWeight:700,
border:`1px solid ${colorEstado(e).bd}`}}>
{e}: {conteo[e]}
</span>
) : null)}
</div>
<div className="table-wrap" style={{overflowX:"auto"}}>
<table className="cob-table" style={{minWidth:900}}>
<thead><tr>
<th style={{textAlign:"left"}}>ALUMNO</th>
<th>ESTADO</th>
<th style={{textAlign:"left"}}>CURSO ANTERIOR (TEAC/TERC)</th>
<th>ESTADO ANTERIOR</th>
</tr></thead>
<tbody>
{alumnos.map((a, i) => {
const c = colorEstado(a.estado);
const tieneAnt = a.curso_anterior && a.curso_anterior !== "-";
const cAnt = colorEstado(a.estado_anterior);
return (
<tr key={i}>
<td className="col-name" style={{textAlign:"left"}}>{a.alumno}</td>
<td style={{textAlign:"center"}}>
<span style={{display:"inline-block",padding:"2px 10px",borderRadius:999,
background:c.bg,color:c.fg,fontWeight:700,fontSize:12,border:`1px solid ${c.bd}`}}>
{a.estado}
</span>
</td>
<td className="col-name" style={{textAlign:"left",color:tieneAnt?undefined:"#cbd5e1"}}>
{a.curso_anterior || "-"}
</td>
<td style={{textAlign:"center"}}>
{tieneAnt ? (
<span style={{display:"inline-block",padding:"2px 10px",borderRadius:999,
background:cAnt.bg,color:cAnt.fg,fontWeight:700,fontSize:12,border:`1px solid ${cAnt.bd}`}}>
{a.estado_anterior}
</span>
) : <span style={{color:"#cbd5e1"}}>-</span>}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>}
</Modal>
);
}

View File

@@ -0,0 +1,334 @@
import { useState, useEffect, useMemo, Fragment } from "react";
import { api } from "../lib/api";
import { Loader, ErrorBox, Filters, Select } from "../components/UI";
const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
export default function OtrosGeneral() {
const hoy = new Date();
const [ano, setAno] = useState(String(hoy.getFullYear()));
const [mes, setMes] = useState(String(hoy.getMonth() + 1));
const [dia, setDia] = useState("TODOS");
const [filtros, setFiltros] = useState({ anos: [2026] });
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [expAlways, setExpAlways] = useState({});
const [expAlwaysProg, setExpAlwaysProg] = useState({});
const [expWeb, setExpWeb] = useState({});
const [expAsig, setExpAsig] = useState({});
const [reload, setReload] = useState(0);
useEffect(() => {
api.leadsFiltros().then(setFiltros).catch(() => {});
}, []);
// Auto-refresco cada 15 min (sin F5) + recarga al guardar en Config
useEffect(() => {
const id = setInterval(() => setReload((n) => n + 1), 15 * 60 * 1000);
const onActualizar = () => setReload((n) => n + 1);
window.addEventListener("datos-actualizar", onActualizar);
return () => { clearInterval(id); window.removeEventListener("datos-actualizar", onActualizar); };
}, []);
useEffect(() => {
let activo = true;
if (!data) setLoading(true); // spinner solo en la primera carga
setError(null);
api.otrosGeneral(ano, mes, dia)
.then((res) => { if (activo) { setData(res); setLoading(false); } })
.catch((e) => { if (activo) { setError(e.message); setLoading(false); } });
return () => { activo = false; };
}, [ano, mes, dia, reload]);
const optAnos = useMemo(() => (filtros.anos || []).map(String), [filtros]);
const optMes = useMemo(() => [{ value: "TODOS", label: "TODOS" },
...MESES.map((m,i)=>({ value: String(i+1), label: m }))], []);
const optDia = useMemo(() => ["TODOS", ...Array.from({length:31}, (_,i)=>String(i+1))], []);
const money = (v) => `$${Number(v || 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
const sol = (v) => `S/ ${Number(v || 0).toLocaleString("en-US", { maximumFractionDigits: 0 })}`;
return (
<div>
<h1 className="page-title">📊 Otros General</h1>
<Filters>
<Select label="Año" value={ano} options={optAnos} onChange={setAno} />
<Select label="Mes" value={mes} options={optMes} onChange={setMes} />
<Select label="Día" value={dia} options={optDia} onChange={setDia} />
</Filters>
{loading ? <Loader text="Cargando..." /> :
error ? <ErrorBox msg={error} /> :
!data ? <ErrorBox msg="Sin datos" /> :
<>
{/* ── Inversión Publicitaria por Sede (Always On) ── */}
{data.matriz_always && data.matriz_always.filas && (
<div className="card" style={{ marginTop: 4, padding: 0, overflow: "hidden" }}>
<div className="card-title" style={{ padding: "14px 16px 6px" }}>Inversión Publicitaria por Sede (Always On)</div>
<div className="table-wrap" style={{ border: "none", maxHeight: 420, overflow: "auto" }}>
<table>
<thead>
<tr>
<th className="col-name" style={{ position: "sticky", top: 0, zIndex: 2 }}>Sede</th>
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Importe Gastado</th>
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Resultados</th>
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Leads Recibidos</th>
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Leads Procesados</th>
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Matrículas</th>
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Valor Venta</th>
</tr>
</thead>
<tbody>
{data.matriz_always.filas.map((f) => {
const abierto = !!expAlways[f.sede];
return (
<Fragment key={f.sede}>
<tr>
<td className="col-name">
<span
onClick={() => setExpAlways((p) => ({ ...p, [f.sede]: !p[f.sede] }))}
style={{ cursor: "pointer", fontWeight: 700, color: "#2563eb",
marginRight: 6, userSelect: "none", display: "inline-block", width: 14 }}
title="Ver por programa">
{abierto ? "−" : "+"}
</span>
{f.sede}
</td>
<td style={{ fontWeight: 600 }}>{money(f.importe)}</td>
<td>{f.resultados}</td>
<td>{f.nuevos}</td>
<td>{f.nuevos_asig}</td>
<td style={{ fontWeight: 600 }}>{f.matriculas}</td>
<td style={{ fontWeight: 600 }}>{sol(f.venta)}</td>
</tr>
{abierto && (f.subfilas || []).map((s) => {
const kProg = f.sede + "|" + s.programa;
const abiertoP = !!expAlwaysProg[kProg];
return (
<Fragment key={kProg}>
<tr style={{ background: "#f8fafc" }}>
<td className="col-name" style={{ paddingLeft: 28, color: "#475569" }}>
<span
onClick={() => setExpAlwaysProg((p) => ({ ...p, [kProg]: !p[kProg] }))}
style={{ cursor: "pointer", fontWeight: 700, color: "#2563eb",
marginRight: 6, userSelect: "none", display: "inline-block", width: 14 }}
title="Ver por pauta">
{abiertoP ? "−" : "+"}
</span>
{s.programa}
</td>
<td style={{ color: "#475569", fontWeight: 600 }}>{money(s.importe)}</td>
<td style={{ color: "#475569" }}>{s.resultados}</td>
<td style={{ color: "#475569" }}>{s.nuevos}</td>
<td style={{ color: "#475569" }}>{s.nuevos_asig}</td>
<td style={{ color: "#475569", fontWeight: 600 }}>{s.matriculas}</td>
<td style={{ color: "#475569", fontWeight: 600 }}>{sol(s.venta)}</td>
</tr>
{abiertoP && (s.pautas || []).map((pt) => (
<tr key={kProg + pt.pauta} style={{ background: "#eef2f7" }}>
<td className="col-name" style={{ paddingLeft: 56, color: "#64748b", fontSize: 12 }}>Pauta {pt.pauta}</td>
<td style={{ color: "#64748b", fontSize: 12 }}>{money(pt.importe)}</td>
<td style={{ color: "#64748b", fontSize: 12 }}>{pt.resultados}</td>
<td style={{ color: "#64748b", fontSize: 12 }}>{pt.nuevos}</td>
<td style={{ color: "#64748b", fontSize: 12 }}>{pt.nuevos_asig}</td>
<td style={{ color: "#64748b", fontSize: 12 }}>{pt.matriculas}</td>
<td style={{ color: "#64748b", fontSize: 12 }}>{sol(pt.venta)}</td>
</tr>
))}
</Fragment>
);
})}
</Fragment>
);
})}
{data.matriz_always.total && (
<tr className="total-row">
<td className="col-name">Total</td>
<td>{money(data.matriz_always.total.importe)}</td>
<td>{data.matriz_always.total.resultados}</td>
<td>{data.matriz_always.total.nuevos}</td>
<td>{data.matriz_always.total.nuevos_asig}</td>
<td>{data.matriz_always.total.matriculas}</td>
<td>{sol(data.matriz_always.total.venta)}</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)}
{/* ── Leads Web Formulario por Sede ── */}
{data.matriz_webform && data.matriz_webform.filas && (
<div className="card" style={{ marginTop: 16, padding: 0, overflow: "hidden" }}>
<div className="card-title" style={{ padding: "14px 16px 6px" }}>Leads Web Formulario por Sede</div>
<div className="table-wrap" style={{ border: "none", maxHeight: 420, overflow: "auto" }}>
<table>
<thead>
<tr>
<th className="col-name" style={{ position: "sticky", top: 0, zIndex: 2, minWidth: 150 }}>Sede</th>
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Leads Recibidos</th>
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Leads Procesados</th>
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Matrículas</th>
<th style={{ position: "sticky", top: 0, zIndex: 2, minWidth: 130, whiteSpace: "nowrap" }}>Valor Venta</th>
</tr>
</thead>
<tbody>
{data.matriz_webform.filas.map((f) => {
const abierto = !!expWeb[f.sede];
return (
<Fragment key={f.sede}>
<tr>
<td className="col-name">
<button onClick={() => setExpWeb((p) => ({ ...p, [f.sede]: !p[f.sede] }))}
style={{ border: "none", background: "transparent", cursor: "pointer",
fontWeight: 700, marginRight: 6, color: "#2563eb", fontSize: 14 }}>
{abierto ? "−" : "+"}
</button>
{f.sede}
</td>
<td>{f.recibidos}</td>
<td>{f.procesados}</td>
<td style={{ fontWeight: 600 }}>{f.matriculas}</td>
<td style={{ fontWeight: 600 }}>{sol(f.venta)}</td>
</tr>
{abierto && (f.subfilas || []).map((s) => (
<tr key={f.sede + s.programa} style={{ background: "#f8fafc" }}>
<td className="col-name" style={{ paddingLeft: 30, color: "#475569", fontSize: 12 }}>{s.programa}</td>
<td style={{ color: "#475569", fontSize: 12 }}>{s.recibidos}</td>
<td style={{ color: "#475569", fontSize: 12 }}>{s.procesados}</td>
<td style={{ color: "#475569", fontSize: 12 }}>{s.matriculas}</td>
<td style={{ color: "#475569", fontSize: 12 }}>{sol(s.venta)}</td>
</tr>
))}
</Fragment>
);
})}
{data.matriz_webform.total && (
<tr className="total-row">
<td className="col-name">Total</td>
<td>{data.matriz_webform.total.recibidos}</td>
<td>{data.matriz_webform.total.procesados}</td>
<td>{data.matriz_webform.total.matriculas}</td>
<td>{sol(data.matriz_webform.total.venta)}</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)}
{/* ── Leads Asignados por Asesor y Día (pivot) ── */}
{data.matriz_asignados && data.matriz_asignados.filas && (
<div className="card" style={{ marginTop: 16, padding: 0, overflow: "hidden" }}>
<div className="card-title" style={{ padding: "14px 16px 6px" }}>Leads Asignados por Asesor y Día</div>
<div className="table-wrap" style={{ border: "none", maxHeight: 460, overflow: "auto" }}>
<table>
<thead>
<tr>
<th className="col-name" style={{ position: "sticky", top: 0, left: 0, zIndex: 3, minWidth: 180, background: "#1e3a5f" }}>Asesor</th>
{data.matriz_asignados.dias.map((d) => (
<th key={d} style={{ position: "sticky", top: 0, zIndex: 2 }}>{d}</th>
))}
<th style={{ position: "sticky", top: 0, zIndex: 2, minWidth: 70 }}>Total</th>
</tr>
</thead>
<tbody>
{data.matriz_asignados.filas.map((f) => {
const abierto = !!expAsig[f.asesor];
return (
<Fragment key={f.asesor}>
<tr>
<td className="col-name">
<button onClick={() => setExpAsig((p) => ({ ...p, [f.asesor]: !p[f.asesor] }))}
style={{ border: "none", background: "transparent", cursor: "pointer",
fontWeight: 700, marginRight: 6, color: "#2563eb", fontSize: 14 }}>
{abierto ? "−" : "+"}
</button>
{f.asesor}
</td>
{data.matriz_asignados.dias.map((d) => (
<td key={d} style={{ color: f.por_dia[d] ? "#0f172a" : "#cbd5e1" }}>{f.por_dia[d] || ""}</td>
))}
<td style={{ fontWeight: 700 }}>{f.total}</td>
</tr>
{abierto && (
<tr>
<td colSpan={data.matriz_asignados.dias.length + 2} style={{ background: "#f8fafc", padding: "8px 14px" }}>
{Object.keys(f.tels_dia || {}).length === 0
? <span style={{ color: "#94a3b8" }}>—</span>
: Object.entries(f.tels_dia || {}).map(([d, tels]) => (
<div key={d} style={{ marginBottom: 6 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: "#475569" }}>Día {d} ({(tels || []).length}): </span>
<span style={{ fontSize: 12, color: "#64748b" }}>{(tels || []).join(", ")}</span>
</div>
))}
</td>
</tr>
)}
</Fragment>
);
})}
<tr className="total-row">
<td className="col-name">Total</td>
{data.matriz_asignados.dias.map((d) => (
<td key={d}>{data.matriz_asignados.total_por_dia[d] || ""}</td>
))}
<td>{data.matriz_asignados.total}</td>
</tr>
</tbody>
</table>
</div>
</div>
)}
{/* ── Plantillas WhatsApp (cobradas) por Plantilla ── */}
{data.matriz_plantillas && data.matriz_plantillas.filas && (
<div className="card" style={{ marginTop: 16, padding: 0, overflow: "hidden" }}>
<div className="card-title" style={{ padding: "14px 16px 6px" }}>Plantillas WhatsApp (cobradas)</div>
<div className="table-wrap" style={{ border: "none", maxHeight: 460, overflow: "auto" }}>
<table>
<thead>
<tr>
<th className="col-name" style={{ position: "sticky", top: 0, zIndex: 2, minWidth: 220 }}>Plantilla</th>
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Enviadas</th>
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Respondidas</th>
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Asignadas</th>
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Matrículas</th>
<th style={{ position: "sticky", top: 0, zIndex: 2, minWidth: 130, whiteSpace: "nowrap" }}>Valor Venta</th>
</tr>
</thead>
<tbody>
{data.matriz_plantillas.filas.map((f) => (
<tr key={f.plantilla}>
<td className="col-name">{f.plantilla}</td>
<td style={{ fontWeight: 600 }}>{f.enviadas}</td>
<td>{f.respondidas}</td>
<td>{f.asignadas}</td>
<td style={{ fontWeight: 600 }}>{f.matriculas}</td>
<td style={{ fontWeight: 600 }}>{sol(f.venta)}</td>
</tr>
))}
{data.matriz_plantillas.total && (
<tr className="total-row">
<td className="col-name">Total</td>
<td>{data.matriz_plantillas.total.enviadas}</td>
<td>{data.matriz_plantillas.total.respondidas}</td>
<td>{data.matriz_plantillas.total.asignadas}</td>
<td>{data.matriz_plantillas.total.matriculas}</td>
<td>{sol(data.matriz_plantillas.total.venta)}</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)}
</>}
</div>
);
}

View File

@@ -1,276 +0,0 @@
// src/pages/Rentabilidad.jsx
import { useState, useEffect, useMemo } from "react";
import { api } from "../lib/api";
import { Loader, ErrorBox, Filters, Select } from "../components/UI";
import Modal from "../components/Modal";
import { useColumnasAjustables } from "../lib/useColumnasAjustables";
import { setVistaActual, onRefrescar } from "../lib/vistaActual";
const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
const ANOS = [2024, 2025, 2026];
const SEDES = ["TODOS","LIMA","AREQUIPA","PIURA","TRUJILLO"];
const PROGRAMAS = ["TODOS","SEMINARIOS","OTROS","TEAC","TERC"];
const COLS = ["PROGRAMA","FECHA INICIO","TOTAL","RETIRADOS","EN CURSO","CUOTA PROM.","PROM. DESC. E.","V. VENTA INICIAL","V. COSTO INICIAL","M.B. INICIAL %","V. VENTA ACTUAL","V. COSTO ACTUAL","M.B. ACTUAL %","OPC."];
// índices: inicial = 7,8,9 | actual = 10,11,12
const BG_INI = "#eef4ff", BG_ACT = "#edfff6";
function toNum(v){ const n=parseFloat(String(v).replace("S/","").replace(/,/g,"").replace("%","").trim()); return isNaN(n)?0:n; }
export default function Rentabilidad() {
const hoy = new Date();
const [ano, setAno] = useState(hoy.getFullYear());
const [mes, setMes] = useState(hoy.getMonth() + 1);
const [sede, setSede] = useState("TODOS");
const [programa, setPrograma] = useState("TODOS");
const [mostrarInscritos, setMostrarInscritos] = useState(true); // TOTAL, RETIRADOS, EN CURSO
const [filas, setFilas] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [modalVer, setModalVer] = useState(null);
// Índices de columnas TOTAL(2), RETIRADOS(3), EN CURSO(4) — se ocultan si el check está apagado
const IDX_INSCRITOS = [2, 3, 4];
const ocultar = (i) => !mostrarInscritos && IDX_INSCRITOS.includes(i);
const ANCHOS_BASE = [260, 110, 80, 90, 90, 110, 120, 120, 120, 110, 120, 120, 110, 80];
const anchosVisibles = ANCHOS_BASE.filter((_, i) => !ocultar(i));
const cols = useColumnasAjustables(anchosVisibles);
const [modalCostos, setModalCostos] = useState(null);
function cargar() {
setLoading(true); setError(null);
api.rentabilidad(ano, mes, sede, programa)
.then((res) => { setFilas(res.filas || []); setLoading(false); })
.catch((e) => { setError(e.message); setLoading(false); });
}
useEffect(() => {
let activo = true;
setVistaActual(`rentabilidad:${ano}|${mes}|${sede}|${programa}`);
const cargarSeguro = () => {
setLoading(true); setError(null);
api.rentabilidad(ano, mes, sede, programa)
.then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } })
.catch((e) => { if (activo) { setError(e.message); setLoading(false); } });
};
cargarSeguro();
const off = onRefrescar(cargarSeguro);
return () => { activo = false; off(); };
}, [ano, mes, sede, programa]);
const datos = useMemo(() => filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL GENERAL"), [filas]);
const total = useMemo(() => filas.find((f)=>String(f[0]).toUpperCase()==="TOTAL GENERAL"), [filas]);
const kpis = useMemo(() => {
if (!total) return null;
return {
programas: datos.length,
enCurso: total[4],
ventaAct: total[10],
costoAct: total[11],
mbAct: total[12],
};
}, [datos, total]);
function bgCol(ci){ if([7,8,9].includes(ci))return BG_INI; if([10,11,12].includes(ci))return BG_ACT; return ""; }
return (
<div>
<h1 className="page-title">📈 Rentabilidad</h1>
<Filters>
<Select label="Año" value={ano} options={ANOS} onChange={(v)=>setAno(+v)} />
<Select label="Mes" value={mes} options={MESES.map((m,i)=>({value:i+1,label:m}))} onChange={(v)=>setMes(+v)} />
<Select label="Sede" value={sede} options={SEDES} onChange={setSede} />
<Select label="Programa" value={programa} options={PROGRAMAS} onChange={setPrograma} />
<div className="filter-group" style={{marginLeft:"auto"}}>
<label>Inscritos</label>
<label style={{display:"flex",alignItems:"center",gap:6,height:38,cursor:"pointer",fontSize:13}}>
<input type="checkbox" checked={mostrarInscritos} onChange={(e)=>setMostrarInscritos(e.target.checked)} />
Total / Retirados / En curso
</label>
</div>
</Filters>
{loading ? <Loader text="Calculando rentabilidad..." /> :
error ? <ErrorBox msg={error} /> :
<>
{kpis && (
<div className="kpis" style={{gridTemplateColumns:"repeat(5,1fr)"}}>
{[["📚","Programas",kpis.programas,"este mes"],
["🎓","En Curso",kpis.enCurso,"alumnos activos"],
["💵","Venta Actual",kpis.ventaAct,"ingreso total"],
["💸","Costo Actual",kpis.costoAct,"gasto total"],
["📊","M.B. Actual",kpis.mbAct,"margen bruto"]].map(([ico,label,val,sub])=>(
<div className="kpi" key={label}>
<div className="ico">{ico}</div><div className="label">{label}</div>
<div className="value" style={{fontSize:18}}>{val}</div><div className="sub">{sub}</div>
</div>
))}
</div>
)}
<div className="table-wrap" style={{marginTop:16, overflowX:"auto"}}>
<table {...cols.tableProps}>
<cols.ColGroup />
<thead><tr>{COLS.filter((_,i)=>!ocultar(i)).map((c, vi)=><th key={c} style={{position:"relative"}}>{c}<cols.Resizer index={vi} /></th>)}</tr></thead>
<tbody>
{datos.map((f, ri) => (
<tr key={ri}>
{f.slice(0,13).map((v,ci)=>{
if (ocultar(ci)) return null;
const isPct = ci===9||ci===12;
return (
<td key={ci} className={ci===0?"col-name":""}
style={{background:bgCol(ci)||undefined,
textAlign: ci===0?"left":"center",
fontWeight:isPct?700:undefined,
minWidth:ci===0?260:undefined}}>{v}</td>
);
})}
<td style={{whiteSpace:"nowrap"}}>
<button className="btn btn-ghost" style={{padding:"3px 8px",fontSize:11,marginRight:4}}
onClick={()=>setModalVer({programa:f[0]})}>👁️</button>
<button className="btn btn-ghost" style={{padding:"3px 8px",fontSize:11}}
onClick={()=>setModalCostos({programa:f[0]})}>✏️</button>
</td>
</tr>
))}
{total && (
<tr className="total-row">
{total.slice(0,13).map((v,ci)=>(
ocultar(ci) ? null :
<td key={ci} className={ci===0?"col-name":""}
style={{textAlign:ci===0?"left":"center"}}>{v}</td>
))}
<td>—</td>
</tr>
)}
</tbody>
</table>
</div>
</>}
{modalVer && <ModalVerDetalle programa={modalVer.programa} ano={ano} mes={mes} onClose={()=>setModalVer(null)} />}
{modalCostos && <ModalCostos programa={modalCostos.programa} ano={ano} mes={mes} onClose={()=>setModalCostos(null)} onGuardado={cargar} />}
</div>
);
}
function ModalVerDetalle({ programa, ano, mes, onClose }) {
const [filas, setFilas] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let activo = true; setLoading(true);
api.rentabilidadDetalle(programa, ano, mes)
.then((res)=>{ if(activo){setFilas(res.filas||[]);setLoading(false);} })
.catch(()=>{ if(activo){setFilas([]);setLoading(false);} });
return ()=>{activo=false;};
}, [programa, ano, mes]);
const COLS_DET = ["VENDEDOR","ALUMNO","F. MATRÍCULA","F. CANCELACIÓN","TIPO","VALOR CUOTA","PROM. DESC. E.","VALOR VENTA"];
return (
<Modal title={`👁️ Detalle — ${programa}`} onClose={onClose} width={1000}>
{loading ? <Loader text="Cargando detalle..." /> :
filas.length===0 ? <div style={{color:"#94a3b8",padding:20,textAlign:"center"}}>Sin alumnos.</div> :
<div className="table-wrap">
<table>
<thead><tr>{COLS_DET.map((c)=><th key={c}>{c}</th>)}</tr></thead>
<tbody>
{filas.map((f,i)=>(
<tr key={i}>
{f.map((v,j)=>(
<td key={j} className={j===1?"col-name":""} style={{textAlign:j>=5?"right":(j===1?"left":"center")}}>{v}</td>
))}
</tr>
))}
</tbody>
</table>
</div>}
</Modal>
);
}
function ModalCostos({ programa, ano, mes, onClose, onGuardado }) {
const [costos, setCostos] = useState(null);
const [loading, setLoading] = useState(true);
const [guardando, setGuardando] = useState(false);
const [msg, setMsg] = useState(null);
// [keyGuardar, etiqueta, prefijoBackend]
const CAMPOS = [["epp","COSTO EPP","epp"],["certificado","COSTO CERTIFICADO","cert"],["docente","COSTO DOCENTE","doc"],["marketing","COSTO MARKETING","mkt"],["consumibles","COSTO CONSUMIBLES","cons"]];
const [ini, setIni] = useState({});
const [act, setAct] = useState({});
useEffect(() => {
let activo = true; setLoading(true);
api.rentabilidadCostos(programa, ano, mes)
.then((res)=>{
if(!activo) return;
const c = res.costos || {};
setCostos(c);
const gi={}, ga={};
CAMPOS.forEach(([k,,bp])=>{ gi[k]=+(c[`${bp}_inicial`]||0); ga[k]=+(c[`${bp}_actual`]||0); });
setIni(gi); setAct(ga); setLoading(false);
})
.catch(()=>{ if(activo){setCostos(null);setLoading(false);} });
return ()=>{activo=false;};
}, [programa, ano, mes]);
const totIni = Object.values(ini).reduce((a,b)=>a+(+b||0),0);
const totAct = Object.values(act).reduce((a,b)=>a+(+b||0),0);
async function guardar() {
setGuardando(true); setMsg(null);
try {
await api.guardarCostos(costos.num_indice, ini, act);
if (onGuardado) onGuardado(); // recarga la tabla principal
onClose(); // cierra el modal
} catch (e) {
setMsg({ ok:false, txt:"❌ Error al guardar. Verifica Supabase." });
setGuardando(false);
}
}
return (
<Modal title={`✏️ Editar Costos — ${programa}`} onClose={onClose} width={760}>
{loading ? <Loader text="Cargando costos..." /> :
!costos ? <div style={{color:"#94a3b8",padding:20,textAlign:"center"}}>Sin datos de costos.</div> :
<>
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:16}}>
<Columna titulo="INICIAL" color="#1e3a5f" campos={CAMPOS} valores={ini} setVal={setIni} total={totIni} />
<Columna titulo="ACTUAL" color="#1a5f4a" campos={CAMPOS} valores={act} setVal={setAct} total={totAct} />
</div>
{msg && <div style={{marginTop:14,padding:10,borderRadius:8,fontSize:13,
background:msg.ok?"#d1fae5":"#fee2e2",color:msg.ok?"#065f46":"#991b1b"}}>{msg.txt}</div>}
<div style={{display:"flex",gap:10,marginTop:16}}>
<button className="btn btn-primary" disabled={guardando} onClick={guardar}>
{guardando ? "Guardando..." : "💾 Guardar"}
</button>
<button className="btn btn-ghost" onClick={onClose}>Cerrar</button>
</div>
</>}
</Modal>
);
}
function Columna({ titulo, color, campos, valores, setVal, total }) {
return (
<div>
<div style={{background:color,color:"#fff",borderRadius:8,padding:"8px 12px",
marginBottom:10,fontWeight:600,fontSize:12,textAlign:"center"}}>{titulo}</div>
{campos.map(([k,label])=>(
<div key={k} style={{marginBottom:8}}>
<label style={{fontSize:11,fontWeight:600,color:"#64748b",display:"block",marginBottom:3}}>{label}</label>
<input type="number" value={valores[k]??0} min="0" step="100"
onChange={(e)=>setVal({...valores,[k]:+e.target.value})}
style={{width:"100%",padding:"7px 10px",border:"1px solid #cbd5e1",borderRadius:8,fontSize:13}} />
</div>
))}
<div style={{background:"#f1f5f9",borderRadius:8,padding:10,textAlign:"center",
fontWeight:700,color,marginTop:8}}>
TOTAL: S/ {total.toLocaleString("es-PE",{maximumFractionDigits:0})}
</div>
</div>
);
}

138
frontend/src/pages/Roas.jsx Normal file
View File

@@ -0,0 +1,138 @@
import { useState, useEffect, useMemo, Fragment } from "react";
import { api } from "../lib/api";
import { Loader, ErrorBox, Filters, Select } from "../components/UI";
const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
export default function Roas() {
const hoy = new Date();
const [ano, setAno] = useState(String(hoy.getFullYear()));
const [mes, setMes] = useState(String(hoy.getMonth() + 1));
const [filtros, setFiltros] = useState({ anos: [2026] });
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [expand, setExpand] = useState({}); // sede -> abierto/cerrado
const [expandP, setExpandP] = useState({}); // "sede|prog" -> abierto/cerrado
const [reload, setReload] = useState(0);
useEffect(() => { api.leadsFiltros().then(setFiltros).catch(() => {}); }, []);
useEffect(() => {
const id = setInterval(() => setReload((n) => n + 1), 15 * 60 * 1000);
const onActualizar = () => setReload((n) => n + 1); // recarga al guardar en Config
window.addEventListener("roas-actualizar", onActualizar);
window.addEventListener("datos-actualizar", onActualizar);
return () => { clearInterval(id);
window.removeEventListener("roas-actualizar", onActualizar);
window.removeEventListener("datos-actualizar", onActualizar); };
}, []);
useEffect(() => {
let activo = true;
if (!data) setLoading(true);
setError(null);
api.roas(ano, mes, "TODOS")
.then((res) => { if (activo) { setData(res); setLoading(false); } })
.catch((e) => { if (activo) { setError(e.message); setLoading(false); } });
return () => { activo = false; };
}, [ano, mes, reload]);
const optAnos = useMemo(() => (filtros.anos || []).map(String), [filtros]);
const optMes = useMemo(() => [{ value: "TODOS", label: "TODOS" },
...MESES.map((m,i)=>({ value: String(i+1), label: m }))], []);
const soles = (v) => "$" + Number(v || 0).toLocaleString("es-PE", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const num = (v) => Number(v || 0).toLocaleString("es-PE");
return (
<div>
<h1 className="page-title">📈 ROAS</h1>
<Filters>
<Select label="Año" value={ano} options={optAnos} onChange={setAno} />
<Select label="Mes" value={mes} options={optMes} onChange={setMes} />
</Filters>
{loading ? <Loader text="Cargando ROAS..." /> :
error ? <ErrorBox msg={error} /> :
!data ? <ErrorBox msg="Sin datos" /> :
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
<div className="card-title" style={{ padding: "14px 16px 6px" }}>Inversión por Sede</div>
<div className="table-wrap" style={{ border: "none", maxHeight: 460, overflow: "auto" }}>
<table>
<thead>
<tr>
<th className="col-name" style={{ position: "sticky", top: 0, zIndex: 2, minWidth: 160 }}>Sede</th>
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Importe Gastado</th>
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Resultados</th>
</tr>
</thead>
<tbody>
{(data.filas || []).map((f) => {
const subs = f.programas || [];
const abierto = !!expand[f.sede];
return (
<Fragment key={f.sede}>
<tr>
<td className="col-name">
{subs.length > 0 && (
<button onClick={() => setExpand((e) => ({ ...e, [f.sede]: !e[f.sede] }))}
style={{ border: "none", background: "transparent", cursor: "pointer",
color: "#2563eb", fontWeight: 700, fontSize: 15, marginRight: 8, width: 16 }}>
{abierto ? "−" : "+"}
</button>
)}
{f.sede}
</td>
<td style={{ fontWeight: 600 }}>{soles(f.importe)}</td>
<td>{num(f.resultados)}</td>
</tr>
{abierto && subs.map((p) => {
const claveP = f.sede + "|" + p.programa;
const abiertoP = !!expandP[claveP];
const conjs = p.conjuntos || [];
return (
<Fragment key={claveP}>
<tr style={{ background: "#f8fafc" }}>
<td className="col-name" style={{ paddingLeft: 24, color: "#475569", fontSize: 13 }}>
{conjs.length > 0 && (
<button onClick={() => setExpandP((e) => ({ ...e, [claveP]: !e[claveP] }))}
style={{ border: "none", background: "transparent", cursor: "pointer",
color: "#2563eb", fontWeight: 700, fontSize: 14, marginRight: 8, width: 14 }}>
{abiertoP ? "−" : "+"}
</button>
)}
{p.programa}
</td>
<td style={{ color: "#475569" }}>{soles(p.importe)}</td>
<td style={{ color: "#475569" }}>{num(p.resultados)}</td>
</tr>
{abiertoP && conjs.map((c) => (
<tr key={claveP + "-" + c.conjunto} style={{ background: "#eef2f7" }}>
<td className="col-name" title={c.conjunto} style={{ paddingLeft: 60, color: "#94a3b8", fontSize: 12.5,
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", maxWidth: 280 }}>{c.conjunto}</td>
<td style={{ color: "#94a3b8", fontSize: 12.5 }}>{soles(c.importe)}</td>
<td style={{ color: "#94a3b8", fontSize: 12.5 }}>{num(c.resultados)}</td>
</tr>
))}
</Fragment>
);
})}
</Fragment>
);
})}
{data.total && (
<tr className="total-row">
<td className="col-name">Total</td>
<td>{soles(data.total.importe)}</td>
<td>{num(data.total.resultados)}</td>
</tr>
)}
</tbody>
</table>
</div>
</div>}
</div>
);
}

View File

@@ -1,96 +0,0 @@
// src/pages/SaldoPendiente.jsx
import { useState, useEffect, useMemo } from "react";
import { api } from "../lib/api";
import { Loader, ErrorBox, Filters, Select } from "../components/UI";
const CUOTAS = ["1° Cuota","2° Cuota","3° Cuota","4° Cuota","5° Cuota"];
function toNum(v){ const n=parseFloat(String(v).replace("S/","").replace(/,/g,"").trim()); return isNaN(n)?0:n; }
function fmt(v){ const n=toNum(v); return "S/ " + n.toLocaleString("es-PE",{maximumFractionDigits:0}); }
export default function SaldoPendiente() {
const [tipoCuota, setTipoCuota] = useState("1° Cuota");
const [datos, setDatos] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [vendedor, setVendedor] = useState("TODOS");
useEffect(() => {
let activo = true;
setLoading(true); setError(null);
api.saldoPendiente(tipoCuota)
.then((res)=>{ if(activo){setDatos(res.datos||[]);setLoading(false);} })
.catch((e)=>{ if(activo){setError(e.message);setLoading(false);} });
return ()=>{activo=false;};
}, [tipoCuota]);
const colSaldo = `SALDO ${tipoCuota.toUpperCase()}`;
const vendedores = useMemo(() => {
const set = new Set(datos.map((d)=>String(d.VENDEDOR||"").trim()).filter(Boolean));
return ["TODOS", ...Array.from(set).sort()];
}, [datos]);
const filtrados = useMemo(() => {
let arr = datos.filter((d)=>toNum(d[colSaldo])>0.01);
if (vendedor!=="TODOS") arr = arr.filter((d)=>String(d.VENDEDOR||"").trim().toUpperCase()===vendedor.toUpperCase());
return arr;
}, [datos, vendedor, colSaldo]);
const totalSaldo = useMemo(()=>filtrados.reduce((s,d)=>s+toNum(d[colSaldo]),0),[filtrados,colSaldo]);
const totalMat = useMemo(()=>filtrados.reduce((s,d)=>s+toNum(d["SALDO MAT."]),0),[filtrados]);
const COLS = ["MATRÍCULA","VENDEDOR","ALUMNO","PROGRAMA","F. INICIO","F. MATR.","SALDO MAT.",colSaldo,"VENCIMIENTO","INV. NETA"];
const KEYS = ["MATRICULA","VENDEDOR","ALUMNO","PROGRAMA","FECHA INICIO","FECHA MATR.","SALDO MAT.",colSaldo,"VENCIMIENTO","INV. NETA"];
return (
<div>
<h1 className="page-title">⏳ Saldo Pendiente</h1>
<Filters>
<Select label="Tipo de cuota" value={tipoCuota} options={CUOTAS} onChange={setTipoCuota} />
<Select label="Vendedor" value={vendedor} options={vendedores} onChange={setVendedor} />
</Filters>
{loading ? <Loader text="Calculando saldos..." /> :
error ? <ErrorBox msg={error} /> :
<>
<div className="kpis" style={{gridTemplateColumns:"repeat(3,1fr)"}}>
<div className="kpi"><div className="ico">👥</div><div className="label">Alumnos con Saldo</div><div className="value">{filtrados.length}</div></div>
<div className="kpi"><div className="ico">⏳</div><div className="label">Saldo {tipoCuota}</div><div className="value" style={{fontSize:20}}>{fmt(totalSaldo)}</div></div>
<div className="kpi"><div className="ico">📋</div><div className="label">Saldo Matrícula</div><div className="value" style={{fontSize:20}}>{fmt(totalMat)}</div></div>
</div>
<div className="table-wrap" style={{marginTop:16}}>
<table style={{minWidth:1100}}>
<thead><tr>{COLS.map((c)=><th key={c}>{c}</th>)}</tr></thead>
<tbody>
{filtrados.map((d,i)=>(
<tr key={i}>
{KEYS.map((k,j)=>{
const isMonto = k.includes("SALDO")||k.includes("INV");
return (
<td key={j} className={k==="ALUMNO"||k==="PROGRAMA"?"col-name":""}
style={{textAlign:isMonto?"right":(k==="ALUMNO"||k==="PROGRAMA"?"left":"center"),
fontWeight:k===colSaldo?700:undefined,
color:k===colSaldo?"#dc2626":undefined}}>
{isMonto ? fmt(d[k]) : (d[k] ?? "")}
</td>
);
})}
</tr>
))}
{filtrados.length>0 && (
<tr className="total-row">
<td colSpan={6} style={{textAlign:"left"}}>TOTAL GENERAL ({filtrados.length})</td>
<td style={{textAlign:"right"}}>{fmt(totalMat)}</td>
<td style={{textAlign:"right"}}>{fmt(totalSaldo)}</td>
<td>—</td><td>—</td>
</tr>
)}
</tbody>
</table>
</div>
</>}
</div>
);
}

View File

@@ -1,130 +0,0 @@
// src/pages/Usuarios.jsx
import { useState, useEffect, useCallback } from "react";
import { api } from "../lib/api";
import { Loader, ErrorBox, Filters, Select } from "../components/UI";
const ROLES = ["ADMINISTRADOR", "VENTAS", "COBRANZA"];
export default function Usuarios() {
const [usuarios, setUsuarios] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// form nuevo usuario
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [nombre, setNombre] = useState("");
const [rol, setRol] = useState("COBRANZA");
const [creando, setCreando] = useState(false);
const [msg, setMsg] = useState(null);
const cargar = useCallback(() => {
setLoading(true); setError(null);
api.usuariosListar()
.then((res) => { setUsuarios(res.usuarios || []); setLoading(false); })
.catch((e) => { setError(e.message); setLoading(false); });
}, []);
useEffect(() => { cargar(); }, [cargar]);
async function crear() {
setMsg(null);
if (!email.trim() || !password.trim()) { setMsg({ ok:false, txt:"Correo y contraseña son obligatorios." }); return; }
setCreando(true);
try {
await api.usuariosCrear(email.trim(), password, nombre.trim(), rol);
setMsg({ ok:true, txt:`Usuario ${email} creado como ${rol}.` });
setEmail(""); setPassword(""); setNombre(""); setRol("COBRANZA");
cargar();
} catch (e) {
setMsg({ ok:false, txt:"Error: " + e.message });
}
setCreando(false);
}
async function cambiarRol(id, nuevoRol) {
try { await api.usuariosActualizarRol(id, nuevoRol); cargar(); }
catch (e) { alert("No se pudo cambiar el rol: " + e.message); }
}
async function eliminar(id, correo) {
if (!confirm(`¿Eliminar al usuario ${correo}? Esta acción no se puede deshacer.`)) return;
try { await api.usuariosEliminar(id); cargar(); }
catch (e) { alert("No se pudo eliminar: " + e.message); }
}
return (
<div>
<h1 className="page-title">🔐 Usuarios</h1>
{/* Crear nuevo usuario */}
<div style={{ background:"#fff", border:"1px solid #e2e8f0", borderRadius:12, padding:18, marginBottom:20 }}>
<h3 style={{ fontSize:15, fontWeight:700, color:"#0f172a", marginBottom:14 }}>➕ Crear nuevo usuario</h3>
<div style={{ display:"flex", gap:12, flexWrap:"wrap", alignItems:"flex-end" }}>
<Campo label="Correo">
<input value={email} onChange={(e)=>setEmail(e.target.value)} placeholder="usuario@escuela.com" style={inp} />
</Campo>
<Campo label="Contraseña">
<input type="text" value={password} onChange={(e)=>setPassword(e.target.value)} placeholder="mín. 6 caracteres" style={inp} />
</Campo>
<Campo label="Nombre">
<input value={nombre} onChange={(e)=>setNombre(e.target.value)} placeholder="Nombre completo" style={inp} />
</Campo>
<Campo label="Rol">
<Select value={rol} options={ROLES} onChange={setRol} />
</Campo>
<button className="btn btn-primary" style={{height:38}} disabled={creando} onClick={crear}>
{creando ? "Creando..." : "Crear usuario"}
</button>
</div>
{msg && (
<div style={{ marginTop:12, padding:10, borderRadius:8, fontSize:13,
background: msg.ok ? "#d1fae5" : "#fee2e2", color: msg.ok ? "#065f46" : "#991b1b" }}>
{msg.ok ? "✅" : "⚠️"} {msg.txt}
</div>
)}
</div>
{/* Lista de usuarios */}
{loading ? <Loader text="Cargando usuarios..." /> :
error ? <ErrorBox msg={error} /> :
<div className="table-wrap">
<table>
<thead><tr><th>CORREO</th><th>NOMBRE</th><th>ROL</th><th>ACCIONES</th></tr></thead>
<tbody>
{usuarios.map((u) => (
<tr key={u.id}>
<td className="col-name">{u.email}</td>
<td>{u.nombre || "—"}</td>
<td>
<select value={u.rol} onChange={(e)=>cambiarRol(u.id, e.target.value)}
style={{ padding:"5px 8px", border:"1px solid #cbd5e1", borderRadius:6, fontSize:12 }}>
{ROLES.map((r)=><option key={r} value={r}>{r}</option>)}
</select>
</td>
<td>
<button className="btn" style={{ padding:"4px 10px", fontSize:11, background:"#fee2e2", color:"#991b1b" }}
onClick={()=>eliminar(u.id, u.email)}>🗑️ Eliminar</button>
</td>
</tr>
))}
{usuarios.length === 0 && (
<tr><td colSpan={4} style={{ padding:20, textAlign:"center", color:"#94a3b8" }}>Sin usuarios.</td></tr>
)}
</tbody>
</table>
</div>}
</div>
);
}
function Campo({ label, children }) {
return (
<div className="filter-group">
<label>{label}</label>
{children}
</div>
);
}
const inp = { padding:"8px 12px", border:"1px solid #cbd5e1", borderRadius:8, fontSize:13, minWidth:180 };

View File

@@ -0,0 +1,423 @@
import { useState, useEffect, useMemo } from "react";
import { api } from "../lib/api";
import { Loader, ErrorBox, Filters, Select } from "../components/UI";
import {
AreaChart, Area, BarChart, Bar, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
} from "recharts";
const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
export default function Vendedores() {
const _hoy = new Date();
const [vend, setVend] = useState("TODOS");
const [ano, setAno] = useState(String(_hoy.getFullYear())); // año actual
const [mes, setMes] = useState(String(_hoy.getMonth() + 1)); // mes actual
const [dia, setDia] = useState("TODOS");
const [modoTR, setModoTR] = useState("asig"); // "asig" | "resp" para el grafico de tiempo
const [filtros, setFiltros] = useState({ anos: [2026] });
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [recargando, setRecargando] = useState(false);
const [error, setError] = useState(null);
const [reload, setReload] = useState(0);
useEffect(() => { api.leadsFiltros().then(setFiltros).catch(() => {}); }, []);
useEffect(() => {
const id = setInterval(() => setReload((n) => n + 1), 15 * 60 * 1000);
const onActualizar = () => setReload((n) => n + 1); // recarga al guardar en Config
window.addEventListener("datos-actualizar", onActualizar);
return () => { clearInterval(id); window.removeEventListener("datos-actualizar", onActualizar); };
}, []);
useEffect(() => {
let activo = true;
if (!data) setLoading(true);
setRecargando(true); // atenuar contenido mientras se recalcula
setError(null);
api.vendedores(ano, mes, dia)
.then((res) => { if (activo) { setData(res); setLoading(false); setRecargando(false); } })
.catch((e) => { if (activo) { setError(e.message); setLoading(false); setRecargando(false); } });
return () => { activo = false; };
}, [ano, mes, dia, reload]);
const optVend = useMemo(() => ["TODOS", ...((data && data.vendedores) || [])], [data]);
const optAnos = useMemo(() => {
const anos = (filtros.anos || []).map(String);
const actual = String(_hoy.getFullYear());
if (!anos.includes(actual)) anos.push(actual); // asegura que el año actual sea opcion
return ["TODOS", ...anos];
}, [filtros]);
const optMes = useMemo(() => [{ value: "TODOS", label: "TODOS" },
...MESES.map((m,i)=>({ value: String(i+1), label: m }))], []);
const optDia = useMemo(() => ["TODOS", ...Array.from({length:31}, (_,i)=>String(i+1))], []);
// Filas visibles: si hay vendedor filtrado, solo esa; si no, todas
const filas = useMemo(() => {
if (!data) return [];
if (vend === "TODOS") return data.filas;
return data.filas.filter((f) => f.vendedor === vend);
}, [data, vend]);
const num = (v) => Number(v || 0).toLocaleString("es-PE");
// Resumen para las tarjetas: si hay 1 vendedor filtrado -> esa fila; si TODOS -> el total
const resumen = useMemo(() => {
if (!data) return null;
if (vend !== "TODOS") return data.filas.find((f) => f.vendedor === vend) || null;
return data.total || null;
}, [data, vend]);
// Matriz por programa: si hay 1 vendedor -> la suya; si TODOS -> el total
const matrizProg = useMemo(() => {
if (!data) return [];
if (vend !== "TODOS") return (data.matriz_prog && data.matriz_prog[vend]) || [];
return data.matriz_prog_total || [];
}, [data, vend]);
// Matriz Estado/Objeción (Ultima_Etiqueta): por vendedor o total
const matrizEtiq = useMemo(() => {
if (!data) return [];
if (vend !== "TODOS") return (data.matriz_etiq && data.matriz_etiq[vend]) || [];
return data.matriz_etiq_total || [];
}, [data, vend]);
// Serie diaria (asignados + matriculas): por vendedor o total
const serieDia = useMemo(() => {
if (!data) return [];
if (vend !== "TODOS") return (data.serie_dia && data.serie_dia[vend]) || [];
return data.serie_dia_total || [];
}, [data, vend]);
// Serie tiempo de respuesta (promedio min por dia de asignacion)
const serieTR = useMemo(() => {
if (!data) return [];
if (vend !== "TODOS") return (data.tiempo_resp && data.tiempo_resp[vend]) || [];
return data.tiempo_resp_total || [];
}, [data, vend]);
// Serie tiempo de respuesta (promedio min por dia de RESPUESTA)
const serieTRr = useMemo(() => {
if (!data) return [];
if (vend !== "TODOS") return (data.tiempo_resp_r && data.tiempo_resp_r[vend]) || [];
return data.tiempo_resp_r_total || [];
}, [data, vend]);
return (
<div>
<h1 className="page-title">🧑‍💼 Vendedores</h1>
<Filters>
<Select label="Vendedor" value={vend} options={optVend} onChange={setVend} />
<Select label="Año" value={ano} options={optAnos} onChange={setAno} />
<Select label="Mes" value={mes} options={optMes} onChange={setMes} />
<Select label="Día" value={dia} options={optDia} onChange={setDia} />
</Filters>
{loading ? <Loader text="Cargando vendedores..." /> :
error ? <ErrorBox msg={error} /> :
!data ? <ErrorBox msg="Sin datos" /> :
<div style={{ opacity: recargando ? 0.45 : 1,
filter: recargando ? "grayscale(0.2)" : "none",
pointerEvents: recargando ? "none" : "auto",
transition: "opacity 0.25s ease, filter 0.25s ease" }}>
{resumen && <ResumenTarjetas r={resumen} num={num} />}
{/* Gráfico día a día + Estado/Objeción lado a lado */}
<div style={{ display: "grid", gridTemplateColumns: "minmax(380px, 2fr) minmax(300px, 1fr)",
gap: 16, marginTop: 16 }}>
{/* Gráfico día a día: Leads Procesados vs Matrículas */}
<div className="card">
<div className="card-title">
Leads Asignados y Matrículas por Día{vend !== "TODOS" ? ` — ${vend}` : ""}
</div>
<ResponsiveContainer width="100%" height={360}>
<AreaChart data={serieDia} margin={{ top: 10, right: 24, left: -6, bottom: 8 }}>
<defs>
<linearGradient id="gradProcV" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#60a5fa" stopOpacity={0.55} />
<stop offset="100%" stopColor="#60a5fa" stopOpacity={0.05} />
</linearGradient>
<linearGradient id="gradMatV" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#0f766e" stopOpacity={0.55} />
<stop offset="100%" stopColor="#0f766e" stopOpacity={0.08} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#eef2f7" vertical={false} />
<XAxis dataKey="dia" tick={{ fontSize: 10, fill: "#94a3b8" }} interval={0}
axisLine={{ stroke: "#e2e8f0" }} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: "#94a3b8" }} allowDecimals={false}
axisLine={false} tickLine={false} width={32} />
<Tooltip contentStyle={{ borderRadius: 10, border: "1px solid #e2e8f0",
boxShadow: "0 6px 20px rgba(0,0,0,0.1)", fontSize: 12 }} />
<Legend iconType="circle" wrapperStyle={{ fontSize: 12, paddingTop: 4 }} />
<Area type="linear" dataKey="asignados" name="Leads Asignados" stroke="#60a5fa"
fill="url(#gradProcV)" strokeWidth={2.5}
dot={{ r: 2.5, fill: "#60a5fa", stroke: "#fff", strokeWidth: 1 }}
activeDot={{ r: 5 }} />
<Area type="linear" dataKey="matriculas" name="Matrículas" stroke="#0f766e"
fill="url(#gradMatV)" strokeWidth={2.5}
dot={{ r: 2.5, fill: "#0f766e", stroke: "#fff", strokeWidth: 1 }}
activeDot={{ r: 5 }} />
</AreaChart>
</ResponsiveContainer>
</div>
{/* Matriz Estado/Objeción (Ultima_Etiqueta) — al costado del gráfico */}
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
<div className="card-title" style={{ padding: "14px 16px 6px" }}>
Estado/Objeción{vend !== "TODOS" ? ` — ${vend}` : ""}
</div>
<div className="table-wrap" style={{ border: "none", maxHeight: 360, overflow: "auto" }}>
<table>
<thead>
<tr>
<th className="col-name" style={{ position: "sticky", top: 0, zIndex: 2, minWidth: 160 }}>Estado/Objeción</th>
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Leads Asignados</th>
</tr>
</thead>
<tbody>
{matrizEtiq.map((e) => (
<tr key={e.estado}>
<td className="col-name">{e.estado}</td>
<td>{num(e.cantidad)}</td>
</tr>
))}
{matrizEtiq.length > 0 && (
<tr className="total-row">
<td className="col-name">Total</td>
<td>{num(matrizEtiq.reduce((s, e) => s + (e.cantidad || 0), 0))}</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
{/* Tiempo de respuesta + Sin responder, lado a lado */}
<div style={{ display: "grid", gridTemplateColumns: "1.5fr 1fr", gap: 16, marginTop: 16 }}>
{(() => {
const esResp = modoTR === "resp";
const serie = esResp ? serieTRr : serieTR;
const color = esResp ? "#2563eb" : "#f59e0b";
const colorSoft = esResp ? "#3b82f6" : "#fbbf24";
return (
<div>
<div className="card" style={{ display: "flex", flexDirection: "column", height: 400 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center",
height: 40, marginBottom: 8, gap: 8 }}>
<div style={{ fontSize: 15, fontWeight: 700, color: "#0f172a" }}>
Tiempo de Respuesta por Día (min){vend !== "TODOS" ? ` — ${vend}` : ""}
</div>
<select value={modoTR} onChange={(e) => setModoTR(e.target.value)}
style={{ fontSize: 12.5, padding: "6px 10px", borderRadius: 8,
border: "1px solid #e2e8f0", background: "#f8fafc",
color: "#334155", fontWeight: 600, cursor: "pointer" }}>
<option value="asig">📌 Según Asignación</option>
<option value="resp">✅ Según Respuesta</option>
</select>
</div>
<div style={{ flex: 1 }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={serie} margin={{ top: 12, right: 24, left: -6, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" vertical={false} />
<XAxis dataKey="dia" tick={{ fontSize: 10, fill: "#94a3b8" }} interval={0}
axisLine={{ stroke: "#e2e8f0" }} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: "#94a3b8" }} allowDecimals={false}
axisLine={false} tickLine={false} width={38} />
<Tooltip content={({ active, payload, label }) => {
if (!active || !payload || !payload.length) return null;
const d = payload[0].payload || {};
return (
<div style={{ background: "#fff", borderRadius: 10, border: "1px solid #e2e8f0",
boxShadow: "0 8px 24px rgba(0,0,0,0.12)", fontSize: 12,
padding: "10px 13px", lineHeight: 1.7 }}>
<div style={{ fontWeight: 700, color: "#0f172a", marginBottom: 4 }}>Día {label}</div>
<div style={{ color, fontWeight: 700 }}>Promedio: {d.promedio_min || 0} min</div>
<div style={{ color: "#0f766e" }}>Respondidos{esResp ? " ese día" : ""}: {d.respondidos || 0}</div>
{esResp
? <div style={{ color: "#94a3b8", fontSize: 11.5 }}>⚠ Respuesta tardía (+2 días): {d.tardios || 0}</div>
: <div style={{ color: "#b91c1c" }}>Sin responder: {d.no_respondidos || 0}</div>}
</div>
);
}} />
<Line type="linear" dataKey="promedio_min" name="Promedio (min)" stroke={color}
strokeWidth={2.5}
dot={{ r: 3, fill: color, stroke: "#fff", strokeWidth: 1.5 }}
activeDot={{ r: 6, fill: colorSoft, stroke: "#fff", strokeWidth: 2 }} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
})()}
{/* Leads SIN responder por día (fecha de asignación) — barras */}
<div>
<div className="card" style={{ display: "flex", flexDirection: "column", height: 400 }}>
<div className="card-title" style={{ display: "flex", alignItems: "center", gap: 6, height: 40, marginBottom: 8 }}>
<span style={{ width: 10, height: 10, borderRadius: 3, background: "#f43f5e", display: "inline-block" }} />
Leads Sin Responder por Día{vend !== "TODOS" ? ` — ${vend}` : ""}
</div>
<div style={{ flex: 1 }}>
{(() => {
const datosNR = serieTR.filter((d) => (d.no_respondidos || 0) >= 1);
if (datosNR.length === 0) {
return (
<div style={{ height: "100%", display: "flex", flexDirection: "column",
alignItems: "center", justifyContent: "center", color: "#94a3b8", gap: 8 }}>
<div style={{ fontSize: 34 }}>✅</div>
<div style={{ fontSize: 13.5, fontWeight: 600, color: "#0f766e" }}>Sin leads pendientes</div>
<div style={{ fontSize: 12 }}>Todos los leads del periodo fueron respondidos.</div>
</div>
);
}
return (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={datosNR} margin={{ top: 12, right: 24, left: -6, bottom: 8 }}>
<defs>
<linearGradient id="gradNoResp" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#fb7185" stopOpacity={1} />
<stop offset="100%" stopColor="#f43f5e" stopOpacity={0.85} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" vertical={false} />
<XAxis dataKey="dia" tick={{ fontSize: 10, fill: "#94a3b8" }} interval={0}
axisLine={{ stroke: "#e2e8f0" }} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: "#94a3b8" }} allowDecimals={false}
axisLine={false} tickLine={false} width={32} />
<Tooltip cursor={{ fill: "#fef2f2" }} content={({ active, payload, label }) => {
if (!active || !payload || !payload.length) return null;
const d = payload[0].payload || {};
return (
<div style={{ background: "#fff", borderRadius: 10, border: "1px solid #e2e8f0",
boxShadow: "0 8px 24px rgba(0,0,0,0.12)", fontSize: 12,
padding: "10px 13px", lineHeight: 1.7 }}>
<div style={{ fontWeight: 700, color: "#0f172a", marginBottom: 4 }}>Día {label}</div>
<div style={{ color: "#e11d48", fontWeight: 700 }}>Sin responder: {d.no_respondidos || 0}</div>
<div style={{ color: "#0f766e" }}>Respondidos: {d.respondidos || 0}</div>
</div>
);
}} />
<Bar dataKey="no_respondidos" name="Sin responder" fill="url(#gradNoResp)" radius={[4, 4, 0, 0]} maxBarSize={26} />
</BarChart>
</ResponsiveContainer>
);
})()}
</div>
</div>
</div>
</div>
{/* Matriz por tipo de programa (al final) */}
<div style={{ marginTop: 16 }}>
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
<div className="card-title" style={{ padding: "14px 16px 6px" }}>
Matrículas por Tipo de Programa{vend !== "TODOS" ? ` — ${vend}` : ""}
</div>
<div className="table-wrap" style={{ border: "none", maxHeight: 420, overflow: "auto" }}>
<table>
<thead>
<tr>
<th className="col-name" style={{ position: "sticky", top: 0, zIndex: 2, minWidth: 150 }}>Tipo de Programa</th>
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Total Matrículas</th>
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>Retiradas</th>
<th style={{ position: "sticky", top: 0, zIndex: 2 }}>% Retirados</th>
</tr>
</thead>
<tbody>
{matrizProg.map((p) => (
<tr key={p.programa}>
<td className="col-name">{p.programa}</td>
<td style={{ fontWeight: 600 }}>{num(p.total)}</td>
<td>{num(p.retiradas)}</td>
<td>{Number(p.pct_retiradas || 0).toLocaleString("es-PE")}%</td>
</tr>
))}
{matrizProg.length > 0 && (
<tr className="total-row">
<td className="col-name">Total</td>
<td>{num(matrizProg.reduce((s, p) => s + (p.total || 0), 0))}</td>
<td>{num(matrizProg.reduce((s, p) => s + (p.retiradas || 0), 0))}</td>
<td>{(() => {
const t = matrizProg.reduce((s, p) => s + (p.total || 0), 0);
const r = matrizProg.reduce((s, p) => s + (p.retiradas || 0), 0);
return `${(t ? Math.round(r / t * 1000) / 10 : 0).toLocaleString("es-PE")}%`;
})()}</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
</div>}
</div>
);
}
// ── Tarjetas de resumen: 3 grupos (Cartera / Matrículas / Leads) en lista ─────
function ResumenTarjetas({ r, num }) {
const pct = Number(r.pct_retiradas || 0);
const pctCont = Number(r.pct_contactados || 0);
const grupos = [
{
titulo: "Cartera", acento: "#2563eb",
filas: [
{ label: "Total", valor: num(r.cartera_total) },
{ label: "Copito", valor: num(r.cartera_copito) },
{ label: "Otros", valor: num(r.cartera_otros) },
],
},
{
titulo: "Matrículas", acento: "#0f766e",
filas: [
{ label: "Total", valor: num(r.mat_total) },
{ label: "Retiradas", valor: num(r.mat_retiradas) },
{ label: "% Retirados", valor: `${pct.toLocaleString("es-PE")}%`, alerta: true },
],
},
{
titulo: "Leads", acento: "#7c3aed",
filas: [
{ label: "Asignados", valor: num(r.leads_asignados) },
{ label: "Contactados", valor: num(r.leads_contactados) },
{ label: "% Contactados", valor: `${pctCont.toLocaleString("es-PE")}%`, resalta: "#7c3aed" },
],
},
];
return (
<div style={{ display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
gap: 12, marginBottom: 18 }}>
{grupos.map((g) => (
<div key={g.titulo} style={{
background: "#fff", border: "1px solid #e8ecf3", borderRadius: 12,
padding: "14px 16px", boxShadow: "0 1px 3px rgba(16,24,40,0.05)" }}>
<div style={{ fontSize: 11, fontWeight: 700, color: g.acento,
textTransform: "uppercase", letterSpacing: 0.5, marginBottom: 10 }}>
{g.titulo}
</div>
{g.filas.map((f, i) => (
<div key={f.label} style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
padding: "6px 0",
borderBottom: i < g.filas.length - 1 ? "1px solid #f1f5f9" : "none" }}>
<span style={{ fontSize: 12.5, color: "#64748b" }}>{f.label}</span>
<span style={{ fontSize: 15, fontWeight: 700,
color: f.alerta ? "#b91c1c" : (f.resalta || "#0f172a") }}>
{f.valor}
</span>
</div>
))}
</div>
))}
</div>
);
}

View File

@@ -1,272 +0,0 @@
// src/pages/Ventas.jsx
import { useState, useEffect, useMemo } from "react";
import { api } from "../lib/api";
import { Loader, ErrorBox, Filters, Select } from "../components/UI";
import Modal from "../components/Modal";
import { useColumnasAjustables } from "../lib/useColumnasAjustables";
import { setVistaActual, onRefrescar } from "../lib/vistaActual";
const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
const ANOS = [2024, 2025, 2026];
const SEDES = ["TODOS", "LIMA", "AREQUIPA", "PIURA", "TRUJILLO"];
const PROGRAMAS = ["TODOS", "SEMINARIOS", "OTROS", "TEAC", "TERC"];
const COLS = ["VENDEDOR","INSCRITOS","VENTA TOTAL","INSCRITOS P.C DEL MES","VENTA P.C DEL MES","INSCRITOS MES ANTERIOR","VENTA MES ANTERIOR","AVANCE P.C TOTAL","OPC."];
const TIPOS = ["Venta Inscritos", "Venta P.C", "Venta Pendientes"];
const HEADERS_DETALLE = ["VENDEDOR","ALUMNO","F. MATRÍCULA","F. CANCELACIÓN","TIPO","CUOTA PROM.","DESC. ESPECIAL","INV. NETA"];
export default function Ventas() {
const hoy = new Date();
const [ano, setAno] = useState(hoy.getFullYear());
const [mes, setMes] = useState(hoy.getMonth() + 1);
const [sede, setSede] = useState("TODOS");
const [programa, setPrograma] = useState("TODOS");
const [filas, setFilas] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [modal, setModal] = useState(null); // { vendedor }
const cols = useColumnasAjustables([200, 100, 130, 120, 130, 120, 130, 140, 100]);
useEffect(() => {
let activo = true;
setVistaActual(`ventas:${ano}|${mes}|${sede}|${programa}|False`);
const cargar = () => {
setLoading(true); setError(null);
api.ventas(ano, mes, sede, programa)
.then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } })
.catch((e) => { if (activo) { setError(e.message); setLoading(false); } });
};
cargar();
const off = onRefrescar(cargar);
return () => { activo = false; off(); };
}, [ano, mes, sede, programa]);
const total = useMemo(() => filas.find((f) => f[0] === "TOTAL GENERAL"), [filas]);
const datos = useMemo(() => filas.filter((f) => f[0] !== "TOTAL GENERAL"), [filas]);
const KPIS = total ? [
["👤","Vendedores", datos.length, "activos este mes"],
["🎓","Total Inscritos", total[1], "matriculados"],
["💵","Venta Total", total[2], "inversión neta"],
["✅","Venta P.C del Mes", total[4], "pagos completos"],
["🚀","Avance P.C Total", total[7], "P.C + Pendientes"],
] : [];
// Exportar saldos por cuota — Excel con formato (anchos, encabezado de color,
// fechas reales, montos numéricos en soles y fila TOTAL GENERAL).
async function exportarCuota(tipoCuota) {
const toNum = (v) => { const n = parseFloat(String(v).replace("S/","").replace(/,/g,"").trim()); return isNaN(n)?0:n; };
// Convierte "dd-mm-yyyy" o "dd/mm/yyyy" a Date; si no puede, devuelve el texto original
const toDate = (v) => {
if (!v) return "";
const s = String(v).trim().replace(/\//g,"-");
const p = s.split("-");
if (p.length === 3) {
let [d,m,y] = p;
if (y.length === 4) return new Date(+y, +m-1, +d);
}
return String(v);
};
let res;
try {
res = await api.saldoPendiente(tipoCuota);
} catch (e) {
alert("No se pudo obtener los datos de saldos: " + e.message);
return;
}
const datosS = res.datos || [];
const colSaldo = `SALDO ${tipoCuota.toUpperCase()}`;
const filtrados = datosS.filter((d) => toNum(d[colSaldo]) > 0.01);
if (filtrados.length === 0) { alert(`No hay saldos pendientes para ${tipoCuota}.`); return; }
const COLS = ["MATRÍCULA","VENDEDOR","ALUMNO","PROGRAMA","F. INICIO","F. MATR.","SALDO MAT.",colSaldo,"VENCIMIENTO","INV. NETA"];
const KEYS = ["MATRICULA","VENDEDOR","ALUMNO","PROGRAMA","FECHA INICIO","FECHA MATR.","SALDO MAT.",colSaldo,"VENCIMIENTO","INV. NETA"];
const COL_MONTO = ["SALDO MAT.", colSaldo, "INV. NETA"];
const COL_FECHA = ["F. INICIO","F. MATR.","VENCIMIENTO"];
const XLSX = await import("xlsx-js-style");
// Construir matriz de celdas (AOA) con tipos correctos
const headerStyle = {
font: { bold: true, color: { rgb: "FFFFFF" }, sz: 11 },
fill: { fgColor: { rgb: "1E40AF" } },
alignment: { horizontal: "center", vertical: "center" },
};
const moneyFmt = '"S/" #,##0';
const dateFmt = "dd/mm/yyyy";
const ws = {};
const range = { s: { r: 0, c: 0 }, e: { r: filtrados.length + 1, c: COLS.length - 1 } };
// Encabezado
COLS.forEach((h, c) => {
const ref = XLSX.utils.encode_cell({ r: 0, c });
ws[ref] = { v: h, t: "s", s: headerStyle };
});
// Filas de datos
filtrados.forEach((d, ri) => {
KEYS.forEach((k, c) => {
const ref = XLSX.utils.encode_cell({ r: ri + 1, c });
const col = COLS[c];
if (COL_MONTO.includes(col)) {
ws[ref] = { v: toNum(d[k]), t: "n", z: moneyFmt };
} else if (COL_FECHA.includes(col)) {
const dt = toDate(d[k]);
if (dt instanceof Date) ws[ref] = { v: dt, t: "d", z: dateFmt };
else ws[ref] = { v: dt, t: "s" };
} else {
ws[ref] = { v: d[k] ?? "", t: "s" };
}
});
});
// Fila TOTAL GENERAL: promedio en SALDO MAT. y SALDO CUOTA, suma en INV. NETA
const rTot = filtrados.length + 1;
const sum = (k) => filtrados.reduce((s,d)=>s+toNum(d[k]),0);
const prom = (k) => filtrados.length ? sum(k)/filtrados.length : 0;
const totalStyle = { font: { bold: true }, fill: { fgColor: { rgb: "EFF6FF" } } };
COLS.forEach((col, c) => {
const ref = XLSX.utils.encode_cell({ r: rTot, c });
if (c === 0) ws[ref] = { v: "TOTAL GENERAL", t: "s", s: totalStyle };
else if (col === "SALDO MAT.") ws[ref] = { v: prom("SALDO MAT."), t: "n", z: moneyFmt, s: totalStyle };
else if (col === colSaldo) ws[ref] = { v: prom(colSaldo), t: "n", z: moneyFmt, s: totalStyle };
else if (col === "INV. NETA") ws[ref] = { v: sum("INV. NETA"), t: "n", z: moneyFmt, s: totalStyle };
else ws[ref] = { v: "", t: "s", s: totalStyle };
});
ws["!ref"] = XLSX.utils.encode_range(range);
ws["!cols"] = [
{ wch: 12 }, // MATRÍCULA
{ wch: 22 }, // VENDEDOR
{ wch: 28 }, // ALUMNO
{ wch: 32 }, // PROGRAMA
{ wch: 12 }, // F. INICIO
{ wch: 12 }, // F. MATR.
{ wch: 13 }, // SALDO MAT.
{ wch: 15 }, // SALDO CUOTA
{ wch: 13 }, // VENCIMIENTO
{ wch: 13 }, // INV. NETA
];
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "Saldos");
const nombre = tipoCuota.replace("°","").replace(/\s/g,"_");
XLSX.writeFile(wb, `saldos_${nombre}.xlsx`);
}
return (
<div>
<h1 className="page-title">🛒 Ventas</h1>
<Filters>
<Select label="Año" value={ano} options={ANOS} onChange={(v) => setAno(+v)} />
<Select label="Mes" value={mes} options={MESES.map((m,i)=>({value:i+1,label:m}))} onChange={(v) => setMes(+v)} />
<Select label="Sede" value={sede} options={SEDES} onChange={setSede} />
<Select label="Programa" value={programa} options={PROGRAMAS} onChange={setPrograma} />
<div style={{display:"flex",gap:8,alignItems:"flex-end",marginLeft:"auto"}}>
<button className="btn btn-primary" style={{height:38}} onClick={() => exportarCuota("1° Cuota")}>
⬇️ Exportar 1° Cuota
</button>
<button className="btn btn-primary" style={{height:38}} onClick={() => exportarCuota("2° Cuota")}>
⬇️ Exportar 2° Cuota
</button>
</div>
</Filters>
{loading ? <Loader text="Calculando ventas..." /> :
error ? <ErrorBox msg={error} /> :
<>
<div className="kpis" style={{ gridTemplateColumns: "repeat(5,1fr)" }}>
{KPIS.map(([ico,label,val,sub]) => (
<div className="kpi" key={label}>
<div className="ico">{ico}</div>
<div className="label" style={{fontSize:15}}>{label}</div>
<div className="value" style={{fontSize:20}}>{val}</div>
<div className="sub">{sub}</div>
</div>
))}
</div>
<div className="table-wrap" style={{overflowX:"auto"}}>
<table {...cols.tableProps}>
<cols.ColGroup />
<thead><tr>{COLS.map((c, i) => <th key={c} style={{position:"relative"}}>{c}<cols.Resizer index={i} /></th>)}</tr></thead>
<tbody>
{datos.map((f, i) => (
<tr key={i}>
<td className="col-name">{f[0]}</td>
<td>{f[1]}</td><td>{f[2]}</td>
<td>{f[3]}</td><td>{f[4]}</td>
<td>{f[5]}</td><td>{f[6]}</td>
<td style={{fontWeight:600}}>{f[7]}</td>
<td>
<button className="btn btn-ghost" style={{padding:"4px 10px",fontSize:11}}
onClick={() => setModal({ vendedor: f[0] })}>👁️ Ver</button>
</td>
</tr>
))}
{total && (
<tr className="total-row">
<td className="col-name">TOTAL GENERAL</td>
<td>{total[1]}</td><td>{total[2]}</td>
<td>{total[3]}</td><td>{total[4]}</td>
<td>{total[5]}</td><td>{total[6]}</td>
<td>{total[7]}</td><td>—</td>
</tr>
)}
</tbody>
</table>
</div>
</>}
{modal && (
<ModalDetalle vendedor={modal.vendedor} ano={ano} mes={mes} onClose={() => setModal(null)} />
)}
</div>
);
}
function ModalDetalle({ vendedor, ano, mes, onClose }) {
const [tipo, setTipo] = useState(TIPOS[0]);
const [filas, setFilas] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let activo = true;
setLoading(true);
api.ventasDetalle(vendedor, ano, mes, tipo)
.then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } })
.catch(() => { if (activo) { setFilas([]); setLoading(false); } });
return () => { activo = false; };
}, [vendedor, ano, mes, tipo]);
// Columnas redimensionables (igual que las tablas principales)
const colsDet = useColumnasAjustables([200, 260, 120, 130, 120, 120, 130, 120]);
return (
<Modal title={`👤 ${vendedor}`} onClose={onClose} width={1500}>
<div style={{ marginBottom: 14 }}>
<Select label="Tipo de venta" value={tipo} options={TIPOS} onChange={setTipo} />
</div>
{loading ? <Loader text="Cargando detalle..." /> :
filas.length === 0 ? <div style={{color:"#94a3b8",padding:20,textAlign:"center"}}>Sin registros para esta categoría.</div> :
<div className="table-wrap" style={{overflowX:"auto"}}>
<table {...colsDet.tableProps}>
<colsDet.ColGroup />
<thead><tr>{HEADERS_DETALLE.map((h, i) => <th key={h} style={{position:"relative", whiteSpace:"nowrap"}}>{h}<colsDet.Resizer index={i} /></th>)}</tr></thead>
<tbody>
{filas.map((f, i) => (
<tr key={i}>
{f.slice(0, HEADERS_DETALLE.length).map((v, j) => (
<td key={j} className={j === 1 ? "col-name" : ""}
style={{textAlign:"center", whiteSpace:"nowrap", overflow:"hidden", textOverflow:"ellipsis"}}>{v}</td>
))}
</tr>
))}
</tbody>
</table>
</div>}
</Modal>
);
}

View File

@@ -1,127 +1,74 @@
/* src/styles.css */ * { box-sizing: border-box; margin: 0; padding: 0; }
* { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: 'Segoe UI', system-ui, sans-serif; background: #f1f5f9; color: #0f172a; }
body {
font-family: 'Segoe UI', -apple-system, Arial, sans-serif;
background: #f1f5f9;
color: #0f172a;
}
/* ── Layout ── */ .app { display: flex; height: 100vh; overflow: hidden; }
.app { display: flex; min-height: 100vh; }
/* ── Sidebar ── */ /* ── Sidebar fija ── */
.sidebar { .sidebar {
width: 240px; background: #0f172a; color: #e2e8f0; width: 240px; background: #0f172a; color: #e2e8f0;
display: flex; flex-direction: column; padding: 0; display: flex; flex-direction: column; flex-shrink: 0;
position: fixed; height: 100vh; left: 0; top: 0; height: 100vh; position: sticky; top: 0;
}
.sidebar-logo {
padding: 22px 20px; font-size: 16px; font-weight: 700; color: #fff;
border-bottom: 1px solid rgba(255,255,255,0.08); letter-spacing: 0.3px;
} }
.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; } .sidebar-logo span { color: #60a5fa; }
.nav { padding: 12px 10px; flex: 1; } .nav { padding: 12px 8px; }
.nav-item { .nav-item {
display: flex; align-items: center; gap: 11px; padding: 11px 14px; width: 100%; text-align: left; padding: 11px 14px; margin-bottom: 4px;
border-radius: 10px; cursor: pointer; font-size: 13.5px; font-weight: 500; background: transparent; border: none; color: #cbd5e1; border-radius: 8px;
color: #cbd5e1; margin-bottom: 3px; transition: all 0.15s; border: none; cursor: pointer; font-size: 14px; display: flex; align-items: center; gap: 10px;
background: transparent; width: 100%; text-align: left;
} }
.nav-item:hover { background: rgba(255,255,255,0.06); color: #fff; } .nav-item:hover { background: rgba(255,255,255,0.06); }
.nav-item.active { background: #2563eb; color: #fff; } .nav-item.active { background: #2563eb; color: #fff; font-weight: 600; }
.nav-item .ico { font-size: 16px; }
/* ── Main content ── */ .main { flex: 1; padding: 24px 28px; overflow-y: auto; height: 100vh; }
.main { margin-left: 240px; flex: 1; padding: 22px 28px; min-width: 0; overflow-x: hidden; } .page-title { font-size: 24px; font-weight: 700; margin-bottom: 18px; color: #0f172a; }
.page-title { font-size: 22px; font-weight: 700; color: #0f172a; margin-bottom: 14px; }
/* ── Filters bar ── */ /* ── Filtros ── */
.filters { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 18px; align-items: flex-end; } .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 { display: flex; flex-direction: column; gap: 4px; }
.filter-group label { .filter-group label { font-size: 11px; font-weight: 700; color: #64748b; text-transform: uppercase; letter-spacing: .3px; }
font-size: 11px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.4px;
}
.filter-group select { .filter-group select {
padding: 8px 12px; border: 1px solid #cbd5e1; border-radius: 8px; padding: 8px 12px; border: 1px solid #cbd5e1; border-radius: 8px; font-size: 14px;
font-size: 13px; background: #fff; color: #0f172a; cursor: pointer; min-width: 130px; background: #fff; min-width: 130px; cursor: pointer;
} }
.btn {
padding: 9px 16px; border: none; border-radius: 8px; font-size: 13px; font-weight: 600;
cursor: pointer; transition: all 0.15s;
}
.btn-primary { background: #2563eb; color: #fff; }
.btn-primary:hover { background: #1d4ed8; }
.btn-ghost { background: #f1f5f9; color: #475569; border: 1px solid #cbd5e1; }
.btn-ghost:hover { background: #e2e8f0; }
/* ── KPI cards ── */ /* ── KPIs ── */
.kpis { display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px; margin-bottom: 18px; } .kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 18px; }
.kpi { .kpi { background: #fff; border: 1px solid #e2e8f0; border-radius: 14px; padding: 16px; }
background: #fff; border-radius: 12px; padding: 16px; border: 1px solid #e2e8f0; .kpi .ico { font-size: 22px; margin-bottom: 6px; }
} .kpi .label { font-size: 13px; font-weight: 600; color: #475569; }
.kpi .ico { font-size: 18px; margin-bottom: 4px; } .kpi .value { font-size: 26px; font-weight: 800; color: #0f172a; margin: 4px 0; }
.kpi .label { font-size: 10px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.6px; } .kpi .sub { font-size: 11px; color: #94a3b8; }
.kpi .value { font-size: 24px; font-weight: 700; color: #0f172a; line-height: 1.3; }
.kpi .sub { font-size: 11px; color: #94a3b8; margin-top: 2px; }
/* ── Table ── */ /* tarjeta compuesta (varios sub-kpis) */
.table-wrap { .kpi-multi { background: #fff; border: 1px solid #e2e8f0; border-radius: 14px; padding: 14px 16px;
background: #fff; border-radius: 12px; border: 1px solid #e2e8f0; box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
overflow: hidden; overflow-x: auto; max-width: 100%; .kpi-row { display: flex; align-items: center; gap: 12px; padding: 9px 0; }
} .kpi-row + .kpi-row { border-top: 1px solid #f1f5f9; }
table { width: 100%; border-collapse: collapse; font-size: 12px; } .kpi-row .ico { font-size: 24px; width: 34px; text-align: center; flex-shrink: 0; }
thead th { .kpi-row .txt { line-height: 1.25; }
background: #334155; color: #f1f5f9; padding: 11px 10px; font-size: 11px; .kpi-row .txt .label { font-size: 12px; font-weight: 600; color: #64748b; }
font-weight: 700; text-transform: uppercase; letter-spacing: 0.4px; .kpi-row .txt .value { font-size: 21px; font-weight: 800; color: #0f172a; }
white-space: nowrap; text-align: center; position: sticky; top: 0;
border-right: 1px solid #475569;
}
thead th:last-child { border-right: none; }
tbody td { padding: 9px 8px; text-align: center; font-size: 12px; color: #374151; border-bottom: 1px solid #eef2f7; border-right: 1px solid #f1f5f9; }
tbody td:last-child { border-right: none; }
tbody tr:nth-child(even) { background: #f8fafc; }
tbody tr:hover td { background: #eff6ff; }
tr.total-row td { background: #eff6ff !important; font-weight: 700; color: #1e40af; }
.col-name { text-align: left !important; font-weight: 500; color: #111827; }
/* ── Progress bar ── */ /* ── Tablas ── */
.pbar { display: flex; align-items: center; gap: 6px; } .table-wrap { background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; overflow: auto; }
.pbar .pct { font-size: 11px; font-weight: 700; min-width: 42px; text-align: right; } table { width: 100%; border-collapse: collapse; }
.pbar .track { flex: 1; background: #e2e8f0; border-radius: 5px; height: 6px; overflow: hidden; min-width: 50px; } 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); }
.pbar .fill { height: 100%; border-radius: 5px; } td { padding: 9px 12px; font-size: 13px; border-right: 1px solid #eef2f7; text-align: center; }
th:last-child, td:last-child { border-right: none; }
.total-row td { background: #eff6ff; font-weight: 700; }
.col-name { text-align: left !important; }
/* ── Loader ── */ /* ── Loader / error ── */
.loader-wrap { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 60px; } .loader-wrap { display: flex; flex-direction: column; align-items: center; padding: 50px; color: #64748b; }
.spinner { .spinner { width: 36px; height: 36px; border: 4px solid #e2e8f0; border-top-color: #2563eb; border-radius: 50%; animation: spin 1s linear infinite; }
width: 44px; height: 44px; border: 4px solid #e2e8f0; border-top-color: #2563eb;
border-radius: 50%; animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin { to { transform: rotate(360deg); } }
.loader-text { margin-top: 14px; font-size: 13px; color: #64748b; } .loader-text { margin-top: 12px; font-size: 14px; }
.error-box { background: #fee2e2; color: #991b1b; padding: 14px 18px; border-radius: 10px; font-size: 14px; }
/* ── Error ── */ .card { background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; padding: 16px; }
.error-box { .card-title { font-size: 14px; font-weight: 700; color: #1e293b; margin-bottom: 12px; }
background: #fee2e2; border: 1px solid #fca5a5; color: #991b1b;
padding: 16px; border-radius: 10px; font-size: 13px;
}
/* ── Cobranza: colores por cartera + hover por cartera ── */ /* Matriz "Detalle por Curso": líneas verticales suaves entre columnas (solo cuerpo) */
.cob-table td.cart-ant { background: #fef3c7; } .matriz-grid tbody td { border-right: 1px solid #eef2f7; }
.cob-table td.cart-mes { background: #d1fae5; } .matriz-grid tbody td:last-child { border-right: none; }
.cob-table td.cart-tot { background: #ede9fe; }
.cob-table tbody tr:hover td.cart-ant { background: #fde68a !important; }
.cob-table tbody tr:hover td.cart-mes { background: #a7f3d0 !important; }
.cob-table tbody tr:hover td.cart-tot { background: #ddd6fe !important; }
/* Filas con deuda: rojo y su hover, prevalece sobre cartera */
.cob-table tbody tr.row-deuda td { background: #fee2e2 !important; }
.cob-table tbody tr.row-deuda:hover td { background: #fecaca !important; }
/* Hover normal de celdas sin cartera */
.cob-table tbody tr:hover td:not(.cart-ant):not(.cart-mes):not(.cart-tot) { background: #f1f5f9; }
/* ── Ocupabilidad: fila de Inicio reprogramado (desde Supabase) en rojo ── */
tbody tr.row-inicio td { background: #fecaca !important; color: #7f1d1d; font-weight: 600; }
tbody tr.row-inicio:hover td { background: #fca5a5 !important; }
/* Zona de redimensionado de columnas (popups con columnas ajustables) */
.col-resizer { transition: background 0.15s; }
.col-resizer:hover { background: #93c5fd; }

View File

@@ -1,7 +1,7 @@
import { defineConfig } from 'vite' import { defineConfig } from "vite";
import react from '@vitejs/plugin-react' import react from "@vitejs/plugin-react";
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { port: 3000, host: true } server: { port: 5174 },
}) });