Compare commits

...

58 Commits

Author SHA1 Message Date
9ee3cc1e3b Actualizar frontend/src/pages/Cobranza.jsx 2026-08-26 13:23:39 -05:00
d444c8051b Actualizar frontend/src/pages/Cobranza.jsx 2026-08-26 11:18:39 -05:00
5b96e4cb59 Actualizar frontend/src/pages/Cobranza.jsx 2026-08-26 11:01:29 -05:00
d711146674 Actualizar frontend/src/lib/auth.jsx 2026-08-26 09:50:43 -05:00
e5f0754493 Actualizar frontend/src/pages/Cobranza.jsx 2026-08-26 09:47:21 -05:00
c50ee402f4 Actualizar frontend/src/lib/api.js 2026-08-26 09:41:35 -05:00
b1d2dff29c Actualizar backend/main.py 2026-08-26 09:40:21 -05:00
a0dbffd848 Actualizar backend/services.py 2026-08-26 09:38:11 -05:00
51d6de32dd Actualizar frontend/src/lib/auth.jsx 2026-08-22 11:23:25 -05:00
de1af4e8fe Actualizar frontend/src/lib/supabase.js 2026-08-22 11:22:02 -05:00
9e8e592bdd Actualizar backend/modules/rentabilidad/logic.py 2026-08-22 11:09:43 -05:00
359e78f666 Actualizar backend/modules/ocupabilidad/logic.py 2026-08-22 11:08:19 -05:00
0e7c6989d4 Actualizar backend/core/data_manager.py 2026-08-17 11:17:54 -05:00
abaaa1c131 Actualizar backend/core/data_manager.py 2026-08-17 11:17:15 -05:00
09f4a05609 Actualizar frontend/src/pages/Rentabilidad.jsx 2026-07-10 14:42:16 -05:00
3aec610cd0 Actualizar frontend/src/pages/Ocupabilidad.jsx 2026-07-10 14:40:09 -05:00
4da3a5ae6e Actualizar frontend/src/pages/Ventas.jsx 2026-07-10 14:39:01 -05:00
5a7b55e793 Actualizar frontend/src/pages/Cobranza.jsx 2026-07-10 14:37:34 -05:00
4b5f0f8d77 Añadir frontend/src/components/IndicadorActualizacion.jsx 2026-07-10 14:34:59 -05:00
c4b6be1810 Añadir frontend/src/lib/vistaActual.js 2026-07-10 14:34:20 -05:00
16449d23cd Actualizar frontend/src/App.jsx 2026-07-10 14:32:53 -05:00
9266dd82b9 Actualizar frontend/src/lib/auth.jsx 2026-07-10 14:30:32 -05:00
329294fc13 Actualizar frontend/src/lib/api.js 2026-07-10 14:27:26 -05:00
8f743715af Actualizar backend/main.py 2026-07-10 14:21:47 -05:00
1a051760d4 Actualizar backend/services.py 2026-07-10 14:14:34 -05:00
0e0909f230 Actualizar backend/core/data_manager.py 2026-07-10 14:10:55 -05:00
f21c37c162 Actualizar frontend/src/pages/Ocupabilidad.jsx 2026-07-03 17:00:13 -05:00
e686ae9f1a Actualizar frontend/src/lib/api.js 2026-07-03 16:55:35 -05:00
3a029bc443 Actualizar backend/main.py 2026-07-03 16:53:40 -05:00
a5df935e4f Actualizar backend/services.py 2026-07-03 16:50:46 -05:00
c91026e4da Actualizar backend/modules/ocupabilidad/processor.py 2026-07-03 16:48:26 -05:00
2f8fae01ca Actualizar backend/modules/cobranza/logic.py 2026-07-03 15:28:29 -05:00
27787b97be Actualizar frontend/src/pages/Cobranza.jsx 2026-07-03 15:26:07 -05:00
b3eaed0863 Actualizar backend/services.py 2026-07-01 11:31:55 -05:00
71c4d1376e Actualizar backend/services.py 2026-06-30 17:24:09 -05:00
99762151da Actualizar backend/modules/ventas/logic.py 2026-06-30 17:03:56 -05:00
fe14e2117b Actualizar backend/core/data_manager.py 2026-06-30 16:59:36 -05:00
807a907ee8 Actualizar backend/Dockerfile 2026-06-29 00:07:59 -05:00
b66bfc4803 Actualizar backend/Dockerfile 2026-06-29 00:02:10 -05:00
7f2f5134cb Actualizar backend/core/data_manager.py 2026-06-29 00:01:47 -05:00
f886370d5c Actualizar backend/services.py 2026-06-28 23:03:21 -05:00
725cc6f818 Actualizar docker-compose.yml 2026-06-28 04:34:51 -05:00
89d048035c Actualizar docker-compose.yml 2026-06-28 04:04:01 -05:00
8532eb12f2 Actualizar docker-compose.yml 2026-06-28 04:02:07 -05:00
6c06501bee Actualizar docker-compose.yml 2026-06-28 03:19:10 -05:00
3e95a1467c Actualizar frontend/src/lib/api.js 2026-06-28 03:14:06 -05:00
0f9cec175e Actualizar docker-compose.yml 2026-06-28 02:55:12 -05:00
92d578fcad Actualizar frontend/package.json 2026-06-28 02:50:22 -05:00
bbc2cd4958 Actualizar docker-compose.yml 2026-06-28 02:17:52 -05:00
cd22ea94cc Añadir frontend/.env.production 2026-06-28 00:15:18 -05:00
3a34921dc7 Actualizar backend/Dockerfile 2026-06-27 17:47:10 -05:00
9f73b3ca76 Actualizar backend/Dockerfile 2026-06-27 17:40:10 -05:00
d0fbade0fe Actualizar backend/Dockerfile 2026-06-27 12:53:57 -05:00
86dcef0a96 Actualizar docker-compose.yml 2026-06-27 12:47:41 -05:00
b016961858 Actualizar frontend/Dockerfile 2026-06-27 12:46:47 -05:00
f53ee398e0 Actualizar docker-compose.yml 2026-06-27 12:17:11 -05:00
132c01425b Añadir frontend/Dockerfile 2026-06-27 12:16:21 -05:00
daa40368e1 Añadir backend/Dockerfile 2026-06-27 12:15:58 -05:00
23 changed files with 1437 additions and 277 deletions

34
backend/Dockerfile Normal file
View File

@@ -0,0 +1,34 @@
# Usamos una versión estable y probada de Debian 12 (Bookworm)
FROM python:3.10-slim-bookworm
# Evitamos que Python guarde caché o bloquee los logs
ENV PYTHONUNBUFFERED=1
# 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 \
# 3. Agregar el repositorio oficial de Microsoft para Debian 12
&& curl -fsSL https://packages.microsoft.com/config/debian/12/prod.list | tee /etc/apt/sources.list.d/mssql-release.list \
&& apt-get update \
# 4. Instalar el ODBC Driver 17 aceptando los términos automáticamente
&& ACCEPT_EULA=Y apt-get install -y msodbcsql17 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Crear la carpeta de trabajo
WORKDIR /app
# Copiar las librerías e instalarlas
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copiar todo el código de tu backend
COPY . .
# Exponer el puerto
EXPOSE 8000
# Arrancar FastAPI
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -58,6 +58,11 @@ class DataManager:
self.query_cronograma_sql = "" self.query_cronograma_sql = ""
self.query_facturas_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() self.cargar_toda_configuracion()
# ========================================================================= # =========================================================================
@@ -94,20 +99,28 @@ class DataManager:
self.pendientes_data = set() self.pendientes_data = set()
def _get_json(self, url, default=None): def _get_json(self, url, default=None):
try: # 3 intentos, timeout 15s. Si falla, avisa en consola y devuelve el default.
r = requests.get(url, timeout=4) for intento in range(1, 4):
r.raise_for_status() try:
return r.json() r = requests.get(url, timeout=15)
except: r.raise_for_status()
return default or {} 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): def _get_text(self, url):
try: # 3 intentos, timeout 15s. Si falla, avisa en consola y devuelve texto vacio.
r = requests.get(url, timeout=4) for intento in range(1, 4):
r.raise_for_status() try:
return r.text r = requests.get(url, timeout=15)
except: r.raise_for_status()
return "" 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): def _cargar_pendientes(self):
# Ya cargado en paralelo dentro de cargar_toda_configuracion # Ya cargado en paralelo dentro de cargar_toda_configuracion
@@ -122,7 +135,7 @@ class DataManager:
def get_connection(self): def get_connection(self):
import pyodbc import pyodbc
conn_str = ( conn_str = (
f"DRIVER={{SQL Server}};" f"DRIVER={{ODBC Driver 17 for SQL Server}};" # <--- ¡ESTE ES EL CAMBIO MÁGICO!
f"SERVER={self.server};" f"SERVER={self.server};"
f"DATABASE={self.database};" f"DATABASE={self.database};"
f"UID={self.username};" f"UID={self.username};"
@@ -447,6 +460,9 @@ class DataManager:
try: lista_vip_list.append(int(item)) try: lista_vip_list.append(int(item))
except: pass 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) # Incluir matrículas con override de fecha (para que la query las traiga, incluso ANU)
_fov = getattr(self, '_fecha_canc_overrides', None) or {} _fov = getattr(self, '_fecha_canc_overrides', None) or {}
for mk in _fov.keys(): for mk in _fov.keys():
@@ -619,7 +635,7 @@ class DataManager:
ventas_por_vendedor[vendedor]['cantidad_pc'] += 1 ventas_por_vendedor[vendedor]['cantidad_pc'] += 1
# COLUMNA 4 y 5 — MES PASADO: matrícula anterior + pagó 1° cuota en el mes del filtro # COLUMNA 4 y 5 — MES PASADO: matrícula anterior + pagó 1° cuota en el mes del filtro
if matricula_mes_pasado and saldos_ok and pago_en_fecha_correcta: if matricula_int in set_lista_pendientes and pago_en_fecha_correcta:
ventas_por_vendedor[vendedor]['monto_pendientes'] += inv_neta ventas_por_vendedor[vendedor]['monto_pendientes'] += inv_neta
ventas_por_vendedor[vendedor]['cantidad_pendientes'] += 1 ventas_por_vendedor[vendedor]['cantidad_pendientes'] += 1
@@ -642,6 +658,10 @@ class DataManager:
return [] return []
def ejecutar_consulta_cronograma_cobranza(self, ano): 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: try:
if not self.query_cronograma_sql: if not self.query_cronograma_sql:
return [] return []
@@ -651,12 +671,17 @@ class DataManager:
columns = [col[0] for col in cursor.description] columns = [col[0] for col in cursor.description]
results = [dict(zip(columns, row)) for row in cursor.fetchall()] results = [dict(zip(columns, row)) for row in cursor.fetchall()]
conn.close() conn.close()
self._cache_cronograma[clave] = results # cachear SOLO si tuvo éxito
return results return results
except Exception as e: except Exception as e:
print(f"❌ Error cronograma cobranza: {e}") print(f"❌ Error cronograma cobranza: {e}")
return [] return []
def ejecutar_consulta_facturas_cobranza(self, ano): 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: try:
if not self.query_facturas_sql: if not self.query_facturas_sql:
return [] return []
@@ -666,11 +691,22 @@ class DataManager:
columns = [col[0] for col in cursor.description] columns = [col[0] for col in cursor.description]
results = [dict(zip(columns, row)) for row in cursor.fetchall()] results = [dict(zip(columns, row)) for row in cursor.fetchall()]
conn.close() conn.close()
self._cache_facturas[clave] = results # cachear SOLO si tuvo éxito
return results return results
except Exception as e: except Exception as e:
print(f"❌ Error facturas cobranza: {e}") print(f"❌ Error facturas cobranza: {e}")
return [] 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): def ejecutar_consulta_saldos_anual(self, ano):
try: try:
if not self.query_matriculas_sql: if not self.query_matriculas_sql:

View File

@@ -52,8 +52,22 @@ def cache_estadisticas():
def cache_refresh(): def cache_refresh():
services.refrescar_todo() services.refrescar_todo()
return {"status": "refrescado"} return {"status": "refrescado"}
@app.post("/api/cache/refresh-manual")
def cache_refresh_manual(clave: str = None):
"""Botón 'Actualizar': re-lee las bases para la vista actual (clave) al instante,
y recachea el resto en segundo plano."""
return services.refrescar_manual(clave)
@app.get("/api/ultima-actualizacion")
def api_ultima_actualizacion():
return services.ultima_actualizacion()
@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."""
return services.precarga_estado(modulo)
@app.get("/api/periodo-actual") @app.get("/api/periodo-actual")
def periodo_actual(): def periodo_actual():
dm = services.get_dm() dm = services.get_dm()
@@ -74,6 +88,14 @@ def get_ocupabilidad(
raise HTTPException(status_code=500, detail=str(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 ───────────────────────────────────────────────────────────────── # ── VENTAS ─────────────────────────────────────────────────────────────────
@app.get("/api/ventas") @app.get("/api/ventas")
def get_ventas(ano: int = Query(...), mes: int = Query(...), def get_ventas(ano: int = Query(...), mes: int = Query(...),
@@ -254,6 +276,32 @@ def get_cobranza_detalle_todos(ano: int, mes: int, sectorista: str = "TODOS"):
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
# ── ESTADO / OBSERVACIONES POR ALUMNO (Cobranza) ───────────────────────────
@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.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/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))
# ── USUARIOS ─────────────────────────────────────────────────────────────── # ── USUARIOS ───────────────────────────────────────────────────────────────
@app.get("/api/usuarios") @app.get("/api/usuarios")
def get_usuarios(): def get_usuarios():

View File

@@ -98,6 +98,7 @@ class CobranzaLogic:
alumno = d.get('ALUMNO', '') alumno = d.get('ALUMNO', '')
num_cuota = d.get('NUM_CUOTA', '-') num_cuota = d.get('NUM_CUOTA', '-')
fch_venc = d.get('FCH_VENC', '-') 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_ant = float(d.get('CTA_COB_ANT', 0.0))
cta_cur = float(d.get('CTA_COB_MES_CURSO', 0.0)) cta_cur = float(d.get('CTA_COB_MES_CURSO', 0.0))
@@ -113,7 +114,6 @@ class CobranzaLogic:
r1 = (cob_ant / cta_ant) * 100 if cta_ant > 0 else 0.0 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 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 r3 = (cob_tot / cta_tot) * 100 if cta_tot > 0 else 0.0
avance_ant = f"{r1:.0f}%" if cta_ant > 0 else "" avance_ant = f"{r1:.0f}%" if cta_ant > 0 else ""
avance_cur = f"{r2:.0f}%" if cta_cur > 0 else "" avance_cur = f"{r2:.0f}%" if cta_cur > 0 else ""
avance_tot = f"{r3:.0f}%" if cta_tot > 0 else "" avance_tot = f"{r3:.0f}%" if cta_tot > 0 else ""
@@ -123,7 +123,8 @@ class CobranzaLogic:
f"S/ {cta_ant:,.0f}", f"S/ {cob_ant:,.0f}", avance_ant, 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_cur:,.0f}", f"S/ {cob_cur:,.0f}", avance_cur,
f"S/ {cta_tot:,.0f}", f"S/ {cob_tot:,.0f}", avance_tot, f"S/ {cta_tot:,.0f}", f"S/ {cob_tot:,.0f}", avance_tot,
f"S/ {saldo:,.0f}" f"S/ {saldo:,.0f}",
programa # índice [14]: PROGRAMA (extra, al final; no altera columnas previas)
] ]
sheet_data.append(fila) sheet_data.append(fila)
@@ -136,7 +137,8 @@ class CobranzaLogic:
f"S/ {t_cta_ant:,.0f}", f"S/ {t_cob_ant:,.0f}", f"{r_tot_1:.0f}%", 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_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_cta_tot:,.0f}", f"S/ {t_cob_tot:,.0f}", f"{r_tot_3:.0f}%",
f"S/ {t_saldo:,.0f}" f"S/ {t_saldo:,.0f}",
"" # índice [14]: PROGRAMA vacío en la fila total
] ]
sheet_data.append(fila_total) sheet_data.append(fila_total)
return sheet_data return sheet_data

View File

@@ -72,12 +72,9 @@ class AnalizadorCursos:
orden_deseado = ["SEMINARIOS", "OTROS", "TEAC", "TERC"] orden_deseado = ["SEMINARIOS", "OTROS", "TEAC", "TERC"]
self.programas_disponibles_filtro = [p for p in orden_deseado if p in programas_disponibles] self.programas_disponibles_filtro = [p for p in orden_deseado if p in programas_disponibles]
# Auto-corrección si el programa no existe en la sede actual # NO auto-corregir: si el programa no existe en la sede/mes, se respeta el
if filtro_prog != "TODOS" and filtro_prog not in programas_disponibles: # filtro y la tabla queda vacia (antes cambiaba a TODOS y mostraba todo).
filtro_prog = "TODOS" self.filtro_corregido = None
self.filtro_corregido = "TODOS"
else:
self.filtro_corregido = None
if datos and (sede != "TODOS" or filtro_prog != "TODOS"): if datos and (sede != "TODOS" or filtro_prog != "TODOS"):
datos_filtrados = [] datos_filtrados = []

View File

@@ -154,6 +154,123 @@ class CursoProcessor:
print(f"❌ Error obteniendo datos procesados: {e}") print(f"❌ Error obteniendo datos procesados: {e}")
return [] 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): def normalizar_texto(self, texto):
if not texto: return "" if not texto: return ""
texto = str(texto).upper().strip() texto = str(texto).upper().strip()

View File

@@ -162,12 +162,8 @@ class RentabilidadLogic:
orden_deseado = ["SEMINARIOS", "OTROS", "TEAC", "TERC"] orden_deseado = ["SEMINARIOS", "OTROS", "TEAC", "TERC"]
self.programas_disponibles_filtro = [p for p in orden_deseado if p in programas_disponibles] self.programas_disponibles_filtro = [p for p in orden_deseado if p in programas_disponibles]
# AUTO-CORRECCIÓN: Si el programa elegido ya no existe en la nueva sede, forzamos a TODOS # NO auto-corregir: se respeta el filtro y la tabla queda vacia si no hay.
if filtro_prog != "TODOS" and filtro_prog not in programas_disponibles: self.filtro_corregido = None
filtro_prog = "TODOS"
self.filtro_corregido = "TODOS"
else:
self.filtro_corregido = None
if datos and (sede != "TODOS" or filtro_prog != "TODOS"): if datos and (sede != "TODOS" or filtro_prog != "TODOS"):
datos_filtrados = [] datos_filtrados = []

View File

@@ -163,6 +163,9 @@ class VentasLogic:
set_vip_actual.add(val) set_vip_actual.add(val)
lista_vip_str.append(str(val)) lista_vip_str.append(str(val))
except: pass 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) # Incluir matrículas con override de fecha (igual que ejecutar_consulta_ventas)
_fov_vip = getattr(self.data_manager, '_fecha_canc_overrides', None) or {} _fov_vip = getattr(self.data_manager, '_fecha_canc_overrides', None) or {}
@@ -217,6 +220,21 @@ class VentasLogic:
columns = [column[0] for column in cursor.description] columns = [column[0] for column in cursor.description]
datos_raw = [dict(zip(columns, row)) for row in cursor.fetchall()] 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) 🔥 # 🔥 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')])) lista_mats = list(set([str(d.get('num_matricula')).replace('.0','').strip() for d in datos_raw if d.get('num_matricula')]))
cuotas_raw = [] cuotas_raw = []
@@ -322,6 +340,7 @@ class VentasLogic:
saldo_mat = float(d.get('imp_saldo_matricula', 0.0)) saldo_mat = float(d.get('imp_saldo_matricula', 0.0))
saldo_c1 = float(d.get('imp_saldo_cuota1', 0.0)) saldo_c1 = float(d.get('imp_saldo_cuota1', 0.0))
fch_canc_raw = d.get('fch_cancelacion_cuota1') 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') fch_mat = d.get('fch_matricula')
# Override de fecha cancelación 1 (Supabase) → prioridad para clasificación + cuenta como pagado # Override de fecha cancelación 1 (Supabase) → prioridad para clasificación + cuenta como pagado
@@ -610,8 +629,20 @@ class VentasLogic:
if saldos_ok and pago_en_fecha: if saldos_ok and pago_en_fecha:
agregar_a_lista("Venta P.C") agregar_a_lista("Venta P.C")
# MES PASADO: matrícula anterior + pagó 1° cuota en el mes del filtro # VENTA PENDIENTES: SOLO de tu lista BASE_PENDIENTES y que pagaron su 1ª
if matricula_mes_pasado and saldos_ok and pago_en_fecha: # 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") agregar_a_lista("Venta Pendientes")

View File

@@ -1,4 +1,3 @@
# backend/services.py
""" """
Capa de servicios: envuelve la lógica existente (modules/) con caché. Capa de servicios: envuelve la lógica existente (modules/) con caché.
NO modifica la lógica de negocio — solo la llama y serializa el resultado. NO modifica la lógica de negocio — solo la llama y serializa el resultado.
@@ -14,6 +13,17 @@ from cache_manager import cache_get_or_set
# Instancia única del DataManager (como @st.cache_resource) # Instancia única del DataManager (como @st.cache_resource)
_DM = None _DM = None
# Hora de la última actualización de DATOS (se setea al precargar/refrescar)
_ultima_actualizacion = None
def _marcar_actualizacion():
global _ultima_actualizacion
from datetime import datetime
_ultima_actualizacion = datetime.now()
def ultima_actualizacion():
"""Devuelve la hora (ISO) del último refresco de datos, o None."""
return {"hora": _ultima_actualizacion.isoformat() if _ultima_actualizacion else None}
def get_dm() -> DataManager: def get_dm() -> DataManager:
global _DM global _DM
@@ -21,7 +31,6 @@ def get_dm() -> DataManager:
_DM = DataManager() _DM = DataManager()
return _DM return _DM
def _serializar_dicts(datos): def _serializar_dicts(datos):
out = [] out = []
for d in (datos or []): for d in (datos or []):
@@ -31,7 +40,6 @@ def _serializar_dicts(datos):
out.append(fila) out.append(fila)
return out return out
def _serializar_filas(filas, limite=None): def _serializar_filas(filas, limite=None):
out = [] out = []
for f in (filas or []): for f in (filas or []):
@@ -39,14 +47,24 @@ def _serializar_filas(filas, limite=None):
out.append([str(v) if v is not None else "" for v in vals]) out.append([str(v) if v is not None else "" for v in vals])
return out return out
# ── OCUPABILIDAD ─────────────────────────────────────────────────────────── # ── OCUPABILIDAD ───────────────────────────────────────────────────────────
def ocupabilidad(ano, mes, sede="TODOS", programa="TODOS"): def ocupabilidad(ano, mes, sede="TODOS", programa="TODOS"):
def _load(): def _load():
dm = get_dm() dm = get_dm()
analizador = AnalizadorCursos(dm) analizador = AnalizadorCursos(dm)
print(f"[DEBUG Ocupabilidad] Buscando ano={ano}, mes={mes}, sede={sede}, programa={programa}")
# Intento 1: Formato original (ej: "6")
datos = analizador.obtener_datos_procesados(str(ano), str(mes), sede, programa) datos = analizador.obtener_datos_procesados(str(ano), str(mes), sede, programa)
# Intento 2: Si viene vacío, probar con formato de dos dígitos (ej: "06")
if not datos and len(str(mes)) == 1:
mes_padded = str(mes).zfill(2)
print(f"[DEBUG Ocupabilidad] Sin resultados. Reintentando con mes acolchado: '{mes_padded}'")
datos = analizador.obtener_datos_procesados(str(ano), mes_padded, sede, programa)
filas = analizador.formatear_datos_para_tabla(datos) filas = analizador.formatear_datos_para_tabla(datos)
print(f"[DEBUG Ocupabilidad] Proceso completado. Registros encontrados: {len(datos) if datos else 0}")
return { return {
"datos": _serializar_dicts(datos), "datos": _serializar_dicts(datos),
"filas": _serializar_filas(filas), "filas": _serializar_filas(filas),
@@ -54,25 +72,79 @@ def ocupabilidad(ano, mes, sede="TODOS", programa="TODOS"):
return cache_get_or_set("ocupabilidad", (ano, mes, sede, programa), _load) return cache_get_or_set("ocupabilidad", (ano, mes, sede, programa), _load)
# ── VENTAS ───────────────────────────────────────────────────────────────── def ocupabilidad_alumnos(num_indice):
def ventas(ano, mes, sede="TODOS", programa="TODOS"): """Alumnos de un curso (num_indice) para el popup Ver: nombre + estado (sin ANU)."""
def _load(): def _load():
dm = get_dm() dm = get_dm()
dm._tc_override_mes = comisiones_tc_del_mes(ano, mes) # TC de Supabase o None analizador = AnalizadorCursos(dm)
dm._fecha_canc_overrides = comisiones_overrides_fecha() # {mat: fecha_canc override} alumnos = analizador.curso_processor.obtener_alumnos_curso(num_indice)
dm._inv_neta_overrides = comisiones_overrides_inv_neta() # {mat: inversion_neta override} return {"alumnos": alumnos}
return cache_get_or_set("ocup_alumnos", (str(num_indice),), _load)
# ── VENTAS ─────────────────────────────────────────────────────────────────
def ventas(ano, mes, sede="TODOS", programa="TODOS", con_overrides=False):
def _load():
dm = get_dm()
# Los overrides de Supabase SOLO aplican en Comisiones (con_overrides=True).
# En Ventas se ignoran: la tabla muestra los datos reales del SQL.
if con_overrides:
dm._tc_override_mes = comisiones_tc_del_mes(ano, mes)
dm._fecha_canc_overrides = comisiones_overrides_fecha()
dm._inv_neta_overrides = comisiones_overrides_inv_neta()
else:
dm._tc_override_mes = None
dm._fecha_canc_overrides = {}
dm._inv_neta_overrides = {}
logic = VentasLogic(dm) logic = VentasLogic(dm)
datos = logic.obtener_datos_brutos_filtrado(str(ano), str(mes), sede, programa) \ print(f"[DEBUG Ventas] Buscando ano={ano}, mes={mes}, sede={sede}, programa={programa}")
if hasattr(logic, "obtener_datos_brutos_filtrado") else logic.obtener_datos_brutos(str(ano), str(mes))
datos = [d for d in datos if d.get('VENDEDOR', 'SIN VENDEDOR') != 'SIN VENDEDOR'] mes_str = str(mes)
datos = logic.obtener_datos_brutos_filtrado(str(ano), mes_str, sede, programa) \
if hasattr(logic, "obtener_datos_brutos_filtrado") else logic.obtener_datos_brutos(str(ano), mes_str)
# Intento 2: Mes de dos dígitos si el original no trajo nada
if not datos and len(mes_str) == 1:
mes_padded = mes_str.zfill(2)
print(f"[DEBUG Ventas] Sin resultados. Reintentando con mes acolchado: '{mes_padded}'")
datos = logic.obtener_datos_brutos_filtrado(str(ano), mes_padded, sede, programa) \
if hasattr(logic, "obtener_datos_brutos_filtrado") else logic.obtener_datos_brutos(str(ano), mes_padded)
datos = [d for d in (datos or []) if d.get('VENDEDOR', 'SIN VENDEDOR') != 'SIN VENDEDOR']
# PENDIENTES: recalcular desde el MISMO detalle del "Ver" (__TODOS__), para que
# la tabla principal y el popup Ver salgan SIEMPRE idénticos (misma fuente/regla).
try:
det = logic.obtener_detalle_vendedor("__TODOS__", str(ano), mes_str)
pend_filas = det.get("Venta Pendientes", []) if isinstance(det, dict) else []
pend_por_vend = {}
for f in pend_filas:
if not f or str(f[0]).strip().upper() == "TOTAL GENERAL":
continue
vend = str(f[0]).strip()
try:
monto = float(str(f[7]).replace("S/", "").replace(",", "").strip())
except Exception:
monto = 0.0
acc = pend_por_vend.setdefault(vend, {"monto": 0.0, "cant": 0})
acc["monto"] += monto
acc["cant"] += 1
for d in datos:
v = str(d.get("VENDEDOR", "")).strip()
acc = pend_por_vend.get(v, {"monto": 0.0, "cant": 0})
d["PENDIENTES"] = round(acc["monto"], 2)
d["INSCRITOS_PENDIENTES"] = acc["cant"]
except Exception as e:
print(f"[ventas] no se pudo alinear PENDIENTES con el Ver: {e}")
filas = logic.formatear_datos_para_tabla(datos) filas = logic.formatear_datos_para_tabla(datos)
print(f"[DEBUG Ventas] Proceso completado. Registros encontrados: {len(datos)}")
return { return {
"filas": _serializar_filas(filas), "filas": _serializar_filas(filas),
"datos": _serializar_dicts(datos), "datos": _serializar_dicts(datos),
} }
return cache_get_or_set("ventas", (ano, mes, sede, programa), _load) return cache_get_or_set("ventas", (ano, mes, sede, programa), _load)
def _clasif_filtro_programa(dm, dscp): def _clasif_filtro_programa(dm, dscp):
"""Misma clasificación de programa que ejecutar_consulta_ventas (SEMINARIOS/TEAC/TERC/OTROS).""" """Misma clasificación de programa que ejecutar_consulta_ventas (SEMINARIOS/TEAC/TERC/OTROS)."""
up = str(dscp or "").upper() up = str(dscp or "").upper()
@@ -83,7 +155,6 @@ def _clasif_filtro_programa(dm, dscp):
return cat return cat
return fdata.get("DEFAULT", "OTROS") if isinstance(fdata.get("DEFAULT"), str) else "OTROS" return fdata.get("DEFAULT", "OTROS") if isinstance(fdata.get("DEFAULT"), str) else "OTROS"
def ventas_detalle(vendedor, ano, mes, tipo_lista, sede="TODOS", programa="TODOS"): def ventas_detalle(vendedor, ano, mes, tipo_lista, sede="TODOS", programa="TODOS"):
def _load(): def _load():
dm = get_dm() dm = get_dm()
@@ -93,8 +164,7 @@ def ventas_detalle(vendedor, ano, mes, tipo_lista, sede="TODOS", programa="TODOS
resultado = logic.obtener_detalle_vendedor(vendedor, str(ano), str(mes)) resultado = logic.obtener_detalle_vendedor(vendedor, str(ano), str(mes))
filas = resultado.get(tipo_lista, []) if isinstance(resultado, dict) else [] filas = resultado.get(tipo_lista, []) if isinstance(resultado, dict) else []
filas = _aplicar_overrides_comisiones(filas) filas = _aplicar_overrides_comisiones(filas)
# Filtro por SEDE [14] y PROGRAMA (clasificado desde el nombre [16]),
# mismas reglas que la tabla principal. Omite la fila TOTAL GENERAL.
_sede = str(sede or "TODOS").upper() _sede = str(sede or "TODOS").upper()
_prog = str(programa or "TODOS").upper() _prog = str(programa or "TODOS").upper()
if _sede != "TODOS" or _prog != "TODOS": if _sede != "TODOS" or _prog != "TODOS":
@@ -106,8 +176,6 @@ def ventas_detalle(vendedor, ano, mes, tipo_lista, sede="TODOS", programa="TODOS
out.append(f); continue out.append(f); continue
if _sede != "TODOS" and str(f[14]).strip().upper() != _sede: if _sede != "TODOS" and str(f[14]).strip().upper() != _sede:
continue continue
# Clasificar programa por el nombre de CLASIFICACIÓN [16] (prioriza dsc_programa);
# si no existe, usar el mostrado [8].
_prog_nombre = f[16] if len(f) > 16 else f[8] _prog_nombre = f[16] if len(f) > 16 else f[8]
if _prog != "TODOS" and _clasif_filtro_programa(dm, _prog_nombre) != _prog: if _prog != "TODOS" and _clasif_filtro_programa(dm, _prog_nombre) != _prog:
continue continue
@@ -116,10 +184,7 @@ def ventas_detalle(vendedor, ano, mes, tipo_lista, sede="TODOS", programa="TODOS
return {"filas": _serializar_filas(filas)} return {"filas": _serializar_filas(filas)}
return cache_get_or_set("ventas_det", (vendedor, ano, mes, tipo_lista, sede, programa), _load) return cache_get_or_set("ventas_det", (vendedor, ano, mes, tipo_lista, sede, programa), _load)
def comisiones_detalle_todos(ano, mes): def comisiones_detalle_todos(ano, mes):
"""Devuelve TODOS los matriculados (todos los años/meses) para el buscador.
Aplica overrides de Supabase. Cacheado globalmente (no depende del mes)."""
def _load(): def _load():
dm = get_dm() dm = get_dm()
logic = VentasLogic(dm) logic = VentasLogic(dm)
@@ -128,9 +193,7 @@ def comisiones_detalle_todos(ano, mes):
return {"filas": _serializar_filas(filas)} return {"filas": _serializar_filas(filas)}
return cache_get_or_set("comisiones_det_todos", ("GLOBAL",), _load) return cache_get_or_set("comisiones_det_todos", ("GLOBAL",), _load)
def comisiones_overrides_fecha(): def comisiones_overrides_fecha():
"""Devuelve dict {num_matricula: fecha_cancelacion1_override} para clasificación."""
try: try:
sb = _sb() sb = _sb()
res = sb.table("comisiones_overrides").select("num_matricula,fecha_cancelacion1").execute() res = sb.table("comisiones_overrides").select("num_matricula,fecha_cancelacion1").execute()
@@ -139,8 +202,6 @@ def comisiones_overrides_fecha():
mat = str(r["num_matricula"]) mat = str(r["num_matricula"])
v = r.get("fecha_cancelacion1") v = r.get("fecha_cancelacion1")
sv = "" if v is None else str(v).strip() sv = "" if v is None else str(v).strip()
# Solo "__VACIO__" = fecha borrada a propósito → anula la del SQL.
# None/"" = la fila tiene override de OTROS campos pero no toco la fecha -> usar SQL.
if sv == "__VACIO__": if sv == "__VACIO__":
out[mat] = "__VACIO__" out[mat] = "__VACIO__"
elif sv != "": elif sv != "":
@@ -149,10 +210,7 @@ def comisiones_overrides_fecha():
except Exception: except Exception:
return {} return {}
def comisiones_overrides_inv_neta(): def comisiones_overrides_inv_neta():
"""Devuelve dict {num_matricula: inversion_neta(float)} para la tabla principal.
El valor es el monto final en soles (no se re-convierte por TC)."""
try: try:
sb = _sb() sb = _sb()
res = sb.table("comisiones_overrides").select("num_matricula,inversion_neta").execute() res = sb.table("comisiones_overrides").select("num_matricula,inversion_neta").execute()
@@ -164,19 +222,14 @@ def comisiones_overrides_inv_neta():
s = str(v).replace("S/", "").replace(",", "").strip() s = str(v).replace("S/", "").replace(",", "").strip()
if s == "": if s == "":
continue continue
try: try: out[str(r["num_matricula"])] = float(s)
out[str(r["num_matricula"])] = float(s)
except Exception: except Exception:
pass pass
return out return out
except Exception: except Exception:
return {} return {}
def _aplicar_overrides_comisiones(filas): def _aplicar_overrides_comisiones(filas):
"""Sobreescribe valores de cada fila con los guardados en Supabase (por num_matricula).
Índices de fila: [2]F.MAT [7]INV.NETA [9]F.INI [10]SALDO MAT [11]SALDO C1
[12]DÍAS [13]VALOR ADIC [14]TIPO PROG [15]num_matricula. (5=PROMEDIO CUOTA)"""
try: try:
dm = get_dm() dm = get_dm()
if not getattr(dm, "supabase_client", None): if not getattr(dm, "supabase_client", None):
@@ -185,7 +238,6 @@ def _aplicar_overrides_comisiones(filas):
ov = {str(r["num_matricula"]): r for r in (res.data or [])} ov = {str(r["num_matricula"]): r for r in (res.data or [])}
if not ov: if not ov:
return filas return filas
# columna override -> índice en la fila
mapa = { mapa = {
"fch_matricula": 2, "promedio_cuota": 5, "inversion_neta": 7, "fch_matricula": 2, "promedio_cuota": 5, "inversion_neta": 7,
"fecha_inicio": 9, "saldo_matricula": 10, "saldo_cuota1": 11, "fecha_inicio": 9, "saldo_matricula": 10, "saldo_cuota1": 11,
@@ -196,10 +248,8 @@ def _aplicar_overrides_comisiones(filas):
if len(f) < 16: if len(f) < 16:
continue continue
mat = str(f[15]) mat = str(f[15])
# [16] = nombre para clasificar (si no vino, usar el mostrado [8])
# [17] = flag "tiene override" (0 por defecto)
if len(f) == 16: if len(f) == 16:
f.append(f[8]) # buscador: no trae [16] → reutiliza el nombre mostrado f.append(f[8])
if len(f) == 17: if len(f) == 17:
f.append("0") f.append("0")
if mat in ov: if mat in ov:
@@ -209,30 +259,38 @@ def _aplicar_overrides_comisiones(filas):
if val is None: if val is None:
continue continue
if str(val) == "__VACIO__": if str(val) == "__VACIO__":
f[idx] = "" # forzar vacío explícito (override "borrar") f[idx] = ""
elif str(val) != "": elif str(val) != "":
f[idx] = val f[idx] = val
f[17] = "1" # marcar fila como editada f[17] = "1"
return filas return filas
except Exception as e: except Exception as e:
print(f"[overrides comisiones] {e}") print(f"[overrides comisiones] {e}")
return filas return filas
# ── COBRANZA ─────────────────────────────────────────────────────────────── # ── COBRANZA ───────────────────────────────────────────────────────────────
def cobranza(ano, mes, sectorista="TODOS", agrupacion="SEDE"): def cobranza(ano, mes, sectorista="TODOS", agrupacion="SEDE"):
def _load(): def _load():
dm = get_dm() dm = get_dm()
logic = CobranzaLogic(dm) logic = CobranzaLogic(dm)
filas = logic.obtener_datos_tabla(str(ano), str(mes), sectorista, agrupacion) print(f"[DEBUG Cobranza] Buscando ano={ano}, mes={mes}, sectorista={sectorista}, agrupacion={agrupacion}")
sectoristas = logic.obtener_lista_sectoristas(str(ano), str(mes))
mes_str = str(mes)
filas = logic.obtener_datos_tabla(str(ano), mes_str, sectorista, agrupacion)
if not filas and len(mes_str) == 1:
mes_padded = mes_str.zfill(2)
print(f"[DEBUG Cobranza] Sin resultados. Reintentando con mes acolchado: '{mes_padded}'")
filas = logic.obtener_datos_tabla(str(ano), mes_padded, sectorista, agrupacion)
sectoristas = logic.obtener_lista_sectoristas(str(ano), mes_str)
print(f"[DEBUG Cobranza] Proceso completado. Filas encontradas: {len(filas) if filas else 0}")
return { return {
"filas": _serializar_filas(filas), "filas": _serializar_filas(filas),
"sectoristas": sectoristas or ["TODOS"], "sectoristas": sectoristas or ["TODOS"],
} }
return cache_get_or_set("cobranza", (ano, mes, sectorista, agrupacion), _load) return cache_get_or_set("cobranza", (ano, mes, sectorista, agrupacion), _load)
def cobranza_detalle(grupo, ano, mes, sectorista, agrupacion="PROGRAMA"): def cobranza_detalle(grupo, ano, mes, sectorista, agrupacion="PROGRAMA"):
def _load(): def _load():
dm = get_dm() dm = get_dm()
@@ -240,7 +298,6 @@ def cobranza_detalle(grupo, ano, mes, sectorista, agrupacion="PROGRAMA"):
if agrupacion == "ASESOR": if agrupacion == "ASESOR":
datos = logic.obtener_detalle_programa_formateado(str(ano), str(mes), grupo, "TODOS") datos = logic.obtener_detalle_programa_formateado(str(ano), str(mes), grupo, "TODOS")
elif agrupacion == "SEDE": elif agrupacion == "SEDE":
# Filtrar por sede usando el MISMO clasificador del processor (sede.json)
crudos = logic.processor.obtener_detalle_programa(str(ano), str(mes), sectorista, "TODOS") crudos = logic.processor.obtener_detalle_programa(str(ano), str(mes), sectorista, "TODOS")
grupo_up = str(grupo).upper() grupo_up = str(grupo).upper()
mats_sede = set() mats_sede = set()
@@ -250,14 +307,12 @@ def cobranza_detalle(grupo, ano, mes, sectorista, agrupacion="PROGRAMA"):
if sede_det == grupo_up: if sede_det == grupo_up:
mats_sede.add(str(d.get("MATRICULA", ""))) mats_sede.add(str(d.get("MATRICULA", "")))
todas = logic.obtener_detalle_programa_formateado(str(ano), str(mes), sectorista, "TODOS") todas = logic.obtener_detalle_programa_formateado(str(ano), str(mes), sectorista, "TODOS")
# Solo alumnos de la sede (sin la fila TOTAL global; el frontend recalcula el total)
datos = [f for f in (todas or []) if str(f[0]) in mats_sede] datos = [f for f in (todas or []) if str(f[0]) in mats_sede]
else: # PROGRAMA else:
datos = logic.obtener_detalle_programa_formateado(str(ano), str(mes), sectorista, grupo) datos = logic.obtener_detalle_programa_formateado(str(ano), str(mes), sectorista, grupo)
return {"filas": _serializar_filas(datos)} return {"filas": _serializar_filas(datos)}
return cache_get_or_set("cobranza_det", (grupo, ano, mes, sectorista, agrupacion), _load) return cache_get_or_set("cobranza_det", (grupo, ano, mes, sectorista, agrupacion), _load)
# ── RENTABILIDAD ─────────────────────────────────────────────────────────── # ── RENTABILIDAD ───────────────────────────────────────────────────────────
def rentabilidad(ano, mes, sede="TODOS", filtro_prog="TODOS"): def rentabilidad(ano, mes, sede="TODOS", filtro_prog="TODOS"):
def _load(): def _load():
@@ -270,7 +325,6 @@ def rentabilidad(ano, mes, sede="TODOS", filtro_prog="TODOS"):
return {"filas": filas_s, "programas": nombres} return {"filas": filas_s, "programas": nombres}
return cache_get_or_set("rentabilidad", (ano, mes, sede, filtro_prog), _load) return cache_get_or_set("rentabilidad", (ano, mes, sede, filtro_prog), _load)
def rentabilidad_detalle(programa, ano, mes): def rentabilidad_detalle(programa, ano, mes):
def _load(): def _load():
dm = get_dm() dm = get_dm()
@@ -279,7 +333,6 @@ def rentabilidad_detalle(programa, ano, mes):
return {"filas": _serializar_filas(resultado)} return {"filas": _serializar_filas(resultado)}
return cache_get_or_set("rentabilidad_det", (programa, ano, mes), _load) return cache_get_or_set("rentabilidad_det", (programa, ano, mes), _load)
def rentabilidad_costos(programa, ano, mes): def rentabilidad_costos(programa, ano, mes):
def _load(): def _load():
dm = get_dm() dm = get_dm()
@@ -287,7 +340,6 @@ def rentabilidad_costos(programa, ano, mes):
return {"costos": logic.obtener_datos_costos_programa(programa, str(ano), str(mes))} return {"costos": logic.obtener_datos_costos_programa(programa, str(ano), str(mes))}
return cache_get_or_set("rentabilidad_costos", (programa, ano, mes), _load) return cache_get_or_set("rentabilidad_costos", (programa, ano, mes), _load)
# ── SALDO PENDIENTE ──────────────────────────────────────────────────────── # ── SALDO PENDIENTE ────────────────────────────────────────────────────────
def saldo_pendiente(tipo_cuota): def saldo_pendiente(tipo_cuota):
def _load(): def _load():
@@ -297,8 +349,7 @@ def saldo_pendiente(tipo_cuota):
return {"datos": _serializar_dicts(datos)} return {"datos": _serializar_dicts(datos)}
return cache_get_or_set("saldo", (tipo_cuota,), _load) return cache_get_or_set("saldo", (tipo_cuota,), _load)
# ── PRECARGA GLOBAL ────────────────────────────────────────────────────────
# ── PRECARGA GLOBAL (calienta todo el caché) ───────────────────────────────
def precargar_todo(): def precargar_todo():
from datetime import datetime from datetime import datetime
ahora = datetime.now() ahora = datetime.now()
@@ -317,22 +368,24 @@ def precargar_todo():
t() t()
except Exception as e: except Exception as e:
print(f"[precarga] error: {e}") print(f"[precarga] error: {e}")
_marcar_actualizacion()
# Precargar mes actual + anterior de TODOS los módulos (sin entrar a ellos)
precargar_todos_modulos()
_refresh_count = 0 _refresh_count = 0
def _recalcular_clave(key): def _recalcular_clave(key):
"""Dada una clave 'prefijo:arg1|arg2|...', recalcula su valor sin usar el caché viejo."""
from cache_manager import cache_invalidate from cache_manager import cache_invalidate
try: try:
prefijo, _, resto = key.partition(":") prefijo, _, resto = key.partition(":")
args = resto.split("|") if resto else [] args = resto.split("|") if resto else []
# Invalidar SOLO esta entrada para forzar su recálculo
cache_invalidate(prefijo + ":" + resto if resto else prefijo) cache_invalidate(prefijo + ":" + resto if resto else prefijo)
if prefijo == "ocupabilidad" and len(args) == 4: if prefijo == "ocupabilidad" and len(args) == 4:
return ocupabilidad(args[0], args[1], args[2], args[3]) return ocupabilidad(args[0], args[1], args[2], args[3])
if prefijo == "ventas" and len(args) == 2: if prefijo == "ventas" and len(args) == 2:
return ventas(args[0], args[1]) return ventas(args[0], args[1])
if prefijo == "ventas" and len(args) == 5:
return ventas(args[0], args[1], args[2], args[3], args[4] == "True")
if prefijo == "cobranza" and len(args) == 4: if prefijo == "cobranza" and len(args) == 4:
return cobranza(args[0], args[1], args[2], args[3]) return cobranza(args[0], args[1], args[2], args[3])
if prefijo == "rentabilidad" and len(args) == 4: if prefijo == "rentabilidad" and len(args) == 4:
@@ -343,31 +396,204 @@ def _recalcular_clave(key):
return cobranza_detalle_todos(args[0], args[1], args[2]) return cobranza_detalle_todos(args[0], args[1], args[2])
except Exception as e: except Exception as e:
print(f"[recalcular] {key}: {e}") print(f"[recalcular] {key}: {e}")
return None # los detalles puntuales se recalculan al pedirse return None
def refrescar_todo(): def refrescar_todo():
"""Refresca SOLO las entradas ya cacheadas (no vacía el caché), para que los
meses ya visitados se mantengan rápidos. Cada ~1h recarga config de GitHub."""
global _refresh_count global _refresh_count
from cache_manager import cache_refresh_existing, cache_keys from cache_manager import cache_refresh_existing, cache_keys
_refresh_count += 1 _refresh_count += 1
# El hilo corre cada 900s (15 min); 4 ciclos ≈ 60 min → recargar config GitHub
if _refresh_count % 4 == 0: if _refresh_count % 4 == 0:
try: try:
get_dm().cargar_toda_configuracion() get_dm().cargar_toda_configuracion()
print("[config] Recarga de queries/JSON de GitHub completada") print("[config] Recarga de queries/JSON de GitHub completada")
except Exception as e: except Exception as e:
print(f"[config] error al recargar GitHub: {e}") print(f"[config] error al recargar GitHub: {e}")
# Si el caché está vacío (primer arranque), hacer precarga normal
if not cache_keys(): if not cache_keys():
precargar_todo() precargar_todo()
else: else:
# Limpiar el caché anual de cobranza para que el recálculo lea SQL fresco
try:
get_dm().limpiar_cache_cobranza()
except Exception:
pass
cache_refresh_existing(_recalcular_clave) cache_refresh_existing(_recalcular_clave)
_marcar_actualizacion()
# Reprecargar mes actual + anterior de todos los módulos (incluye combos nuevos)
precargar_todos_modulos()
# ── GUARDAR COSTOS (override Supabase) ───────────────────────────────────── def refrescar_manual(clave_actual=None):
"""Refresco a demanda (botón 'Actualizar'):
1) VACÍA el caché y RE-LEE las bases para la vista que el usuario ve AHORA.
2) El resto de vistas cacheadas se recalculan en SEGUNDO PLANO.
NO afecta al ciclo automático de 15 min."""
import threading
from cache_manager import cache_keys, cache_invalidate
claves_previas = cache_keys()
cache_invalidate()
try:
get_dm().limpiar_cache_cobranza()
except Exception:
pass
if clave_actual:
try:
_recalcular_clave(clave_actual)
except Exception as e:
print(f"[refresh manual] vista actual {clave_actual}: {e}")
resto = [k for k in claves_previas if k != clave_actual]
def _fondo():
for k in resto:
try:
_recalcular_clave(k)
except Exception as e:
print(f"[refresh manual bg] {k}: {e}")
_marcar_actualizacion()
print(f"[refresh manual] segundo plano completado ({len(resto)} vistas)")
threading.Thread(target=_fondo, daemon=True).start()
precargar_todos_modulos()
_marcar_actualizacion()
return ultima_actualizacion()
# ── PRECARGA EN SEGUNDO PLANO (cache warming por módulo) ────────────────────
import threading as _threading
_precarga_gen = {}
_precarga_estado = {}
_precarga_lock = _threading.Lock()
_PRECARGA_PAUSA = 0.15
def precarga_estado(modulo):
"""Progreso de la precarga de un módulo."""
modulo = (modulo or "").lower()
with _precarga_lock:
e = _precarga_estado.get(modulo)
return dict(e) if e else {"total": 0, "hechas": 0, "fallidas": 0, "completo": False}
def _periodos_act_ant():
"""(año, mes) del mes actual y el anterior (maneja el cambio de año en enero)."""
from datetime import datetime
hoy = datetime.now()
prev = (hoy.year - 1, 12) if hoy.month == 1 else (hoy.year, hoy.month - 1)
return [(hoy.year, hoy.month), prev]
def _combos_cobranza(ano):
"""(ano, mes, sectorista, agrupacion) a calentar: SOLO mes actual y anterior."""
periodos = _periodos_act_ant()
agrupaciones = ["SEDE", "PROGRAMA", "ASESOR"]
combos = []
for (yy, mm) in periodos:
try:
logic = CobranzaLogic(get_dm())
secs = logic.obtener_lista_sectoristas(str(yy), str(mm)) or ["TODOS"]
except Exception:
secs = ["TODOS"]
secs = list(dict.fromkeys(["TODOS"] + [s for s in secs if s and s != "TODOS"]))
for ag in agrupaciones:
for sec in secs:
combos.append((yy, mm, sec, ag))
return combos
def precargar_modulo(modulo, ano, en_hilo=True):
"""Precarga la tabla principal del módulo (mes actual + anterior)."""
modulo = (modulo or "").lower()
with _precarga_lock:
gen = _precarga_gen.get(modulo, 0) + 1
_precarga_gen[modulo] = gen
_precarga_estado[modulo] = {"total": 0, "hechas": 0, "fallidas": 0, "completo": False, "ano": ano}
def _vigente():
with _precarga_lock:
return _precarga_gen.get(modulo) == gen
def _sumar(campo):
with _precarga_lock:
if _precarga_gen.get(modulo) == gen:
_precarga_estado[modulo][campo] += 1
def _set_total(n):
with _precarga_lock:
if _precarga_gen.get(modulo) == gen:
_precarga_estado[modulo]["total"] = n
def _marcar_completo():
with _precarga_lock:
if _precarga_gen.get(modulo) == gen:
_precarga_estado[modulo]["completo"] = True
def _fondo():
import time
if modulo == "cobranza":
combos = _combos_cobranza(ano)
_set_total(len(combos))
for (yy, mm, sec, ag) in combos:
if not _vigente():
return
try:
cobranza(str(yy), str(mm), sec, ag)
_sumar("hechas")
except Exception as e:
print(f"[precarga cobranza] {yy}-{mm} {ag}/{sec}: {e}")
_sumar("fallidas")
time.sleep(_PRECARGA_PAUSA)
_marcar_completo()
print(f"[precarga cobranza] {ano} completado ({len(combos)} vistas)")
elif modulo in ("ocupabilidad", "ventas", "rentabilidad"):
periodos = _periodos_act_ant()
_set_total(len(periodos))
for (yy, mm) in periodos:
if not _vigente():
return
try:
if modulo == "ocupabilidad":
ocupabilidad(str(yy), str(mm), "TODOS", "TODOS")
elif modulo == "ventas":
ventas(str(yy), str(mm), "TODOS", "TODOS")
else:
rentabilidad(str(yy), str(mm), "TODOS", "TODOS")
_sumar("hechas")
except Exception as e:
print(f"[precarga {modulo}] {yy}-{mm}: {e}")
_sumar("fallidas")
time.sleep(_PRECARGA_PAUSA)
_marcar_completo()
print(f"[precarga {modulo}] {ano} completado ({len(periodos)} vistas)")
else:
print(f"[precarga] módulo no soportado aún: {modulo}")
if en_hilo:
_threading.Thread(target=_fondo, daemon=True).start()
else:
_fondo()
return {"status": "precarga iniciada", "modulo": modulo, "ano": ano}
def precargar_todos_modulos():
"""Precarga (en 2º plano) el mes actual y anterior de TODOS los módulos, de forma
SECUENCIAL en un solo hilo. Se llama al arrancar, cada 15 min y tras el refresco manual."""
from datetime import datetime
ano = datetime.now().year
def _todo():
for m in ("cobranza", "ocupabilidad", "ventas", "rentabilidad"):
try:
precargar_modulo(m, ano, en_hilo=False)
except Exception as e:
print(f"[precarga todos] {m}: {e}")
print("[precarga todos] completado (todos los módulos)")
_threading.Thread(target=_todo, daemon=True).start()
# ── OPERACIONES EXTRA ──────────────────────────────────────────────────────
def guardar_costos(num_indice, costos_inicial, costos_actual): def guardar_costos(num_indice, costos_inicial, costos_actual):
dm = get_dm() dm = get_dm()
ok1 = dm.guardar_override_costo(num_indice, "inicial", costos_inicial) ok1 = dm.guardar_override_costo(num_indice, "inicial", costos_inicial)
@@ -377,17 +603,12 @@ def guardar_costos(num_indice, costos_inicial, costos_actual):
cache_invalidate("rentabilidad_costos") cache_invalidate("rentabilidad_costos")
return bool(ok1 and ok2) return bool(ok1 and ok2)
# ── CLASIFICACIÓN DE SEDE (sede.json) ──────────────────────────────────────
def clasificar_programas(nombres): def clasificar_programas(nombres):
"""Devuelve {programa: sede} usando el clasificador del processor (sede.json)."""
dm = get_dm() dm = get_dm()
logic = CobranzaLogic(dm) logic = CobranzaLogic(dm)
return {n: str(logic.processor.clasificar_sede(n)) for n in nombres} return {n: str(logic.processor.clasificar_sede(n)) for n in nombres}
def cobranza_detalle_todos(ano, mes, sectorista="TODOS"): def cobranza_detalle_todos(ano, mes, sectorista="TODOS"):
"""Trae TODOS los alumnos en UNA sola consulta (para export y buscador)."""
def _load(): def _load():
dm = get_dm() dm = get_dm()
logic = CobranzaLogic(dm) logic = CobranzaLogic(dm)
@@ -415,14 +636,44 @@ def cobranza_detalle_todos(ano, mes, sectorista="TODOS"):
return {"alumnos": alumnos} return {"alumnos": alumnos}
return cache_get_or_set("cobranza_det_todos", (ano, mes, sectorista), _load) return cache_get_or_set("cobranza_det_todos", (ano, mes, sectorista), _load)
# ── GESTIÓN DE USUARIOS ────────────────────────────────────────────────────
# ── GESTIÓN DE USUARIOS (Supabase Auth + tabla perfiles) ───────────────────
def _sb(): def _sb():
dm = get_dm() dm = get_dm()
if not dm.supabase_client: if not dm.supabase_client:
raise RuntimeError("Supabase no está conectado") raise RuntimeError("Supabase no está conectado")
return dm.supabase_client return dm.supabase_client
# ── Estado/observaciones por alumno (tabla cobranza_estado_alumno) ──
def alumno_estado_obtener(matricula):
"""Devuelve {estado, observaciones} de un alumno. Si no existe, valores por defecto."""
sb = _sb()
res = sb.table("cobranza_estado_alumno").select("*").eq("matricula", str(matricula)).execute()
fila = (res.data or [None])[0]
if not fila:
return {"estado": "EN CURSO", "observaciones": []}
return {"estado": fila.get("estado") or "EN CURSO",
"observaciones": fila.get("observaciones") or []}
def alumno_estado_guardar(matricula, estado, observaciones, usuario=None):
"""Guarda (upsert) el estado y la lista de observaciones del alumno."""
sb = _sb()
from datetime import datetime, timezone
payload = {
"matricula": str(matricula),
"estado": estado or "EN CURSO",
"observaciones": observaciones or [],
"actualizado_en": datetime.now(timezone.utc).isoformat(),
"actualizado_por": usuario or None,
}
sb.table("cobranza_estado_alumno").upsert(payload).execute()
return {"status": "ok"}
def alumnos_por_retirar():
"""Lista de matrículas marcadas como POR RETIRAR (para tachar la fila en la tabla)."""
sb = _sb()
res = sb.table("cobranza_estado_alumno").select("matricula").eq("estado", "POR RETIRAR").execute()
return {"matriculas": [str(r["matricula"]) for r in (res.data or [])]}
def usuarios_listar(): def usuarios_listar():
sb = _sb() sb = _sb()
perfiles = sb.table("perfiles").select("id, nombre, rol, activo").execute().data or [] perfiles = sb.table("perfiles").select("id, nombre, rol, activo").execute().data or []
@@ -465,17 +716,13 @@ def usuarios_eliminar(user_id):
sb.auth.admin.delete_user(user_id) sb.auth.admin.delete_user(user_id)
return {"status": "ok"} return {"status": "ok"}
# ── CONFIG MENSUAL COMISIONES ──────────────────────────────────────────────
# ── VENDEDORES MANUALES (Comisiones, en Supabase) ──────────────────────────
# ── CONFIG MENSUAL COMISIONES (IMP_TC, META, COMISIÓN por tipo de programa) ──
def comisiones_config_listar(ano, mes): def comisiones_config_listar(ano, mes):
"""Lista la config de un mes (todas las filas por tipo_programa)."""
sb = _sb() sb = _sb()
res = sb.table("comisiones_config_mensual").select("*").eq("ano", int(ano)).eq("mes", int(mes)).execute() res = sb.table("comisiones_config_mensual").select("*").eq("ano", int(ano)).eq("mes", int(mes)).execute()
return {"config": res.data or []} return {"config": res.data or []}
def comisiones_config_guardar(ano, mes, filas): def comisiones_config_guardar(ano, mes, filas):
"""Reemplaza la config del mes. filas = [{tipo_programa, imp_tc, meta, comision}]."""
sb = _sb() sb = _sb()
sb.table("comisiones_config_mensual").delete().eq("ano", int(ano)).eq("mes", int(mes)).execute() sb.table("comisiones_config_mensual").delete().eq("ano", int(ano)).eq("mes", int(mes)).execute()
payload = [] payload = []
@@ -495,7 +742,6 @@ def comisiones_config_guardar(ano, mes, filas):
return {"status": "ok"} return {"status": "ok"}
def comisiones_tc_del_mes(ano, mes): def comisiones_tc_del_mes(ano, mes):
"""Devuelve el IMP_TC configurado para el mes (toma el primero que tenga TC > 0), o None."""
try: try:
sb = _sb() sb = _sb()
res = sb.table("comisiones_config_mensual").select("imp_tc").eq("ano", int(ano)).eq("mes", int(mes)).execute() res = sb.table("comisiones_config_mensual").select("imp_tc").eq("ano", int(ano)).eq("mes", int(mes)).execute()
@@ -506,11 +752,10 @@ def comisiones_tc_del_mes(ano, mes):
pass pass
return None return None
def vendedores_manuales_listar(ano, mes): def vendedores_manuales_listar(ano, mes):
sb = _sb() sb = _sb()
query = sb.table("vendedores_manuales").select("*").eq("ano", int(ano)) query = sb.table("vendedores_manuales").select("*").eq("ano", int(ano))
if int(mes) != 0: # mes=0 → traer todo el año (para cache en frontend) if int(mes) != 0:
query = query.eq("mes", int(mes)) query = query.eq("mes", int(mes))
res = query.execute() res = query.execute()
return {"vendedores": res.data or []} return {"vendedores": res.data or []}
@@ -530,7 +775,6 @@ def vendedores_manuales_eliminar(id):
sb.table("vendedores_manuales").delete().eq("id", id).execute() sb.table("vendedores_manuales").delete().eq("id", id).execute()
return {"status": "ok"} return {"status": "ok"}
def vendedores_manuales_actualizar(id, descripcion, fch_emision, monto): def vendedores_manuales_actualizar(id, descripcion, fch_emision, monto):
sb = _sb() sb = _sb()
sb.table("vendedores_manuales").update({ sb.table("vendedores_manuales").update({
@@ -539,14 +783,9 @@ def vendedores_manuales_actualizar(id, descripcion, fch_emision, monto):
}).eq("id", id).execute() }).eq("id", id).execute()
return {"status": "ok"} return {"status": "ok"}
def vendedores_manuales_guardar_lote(nombre, ano, mes, filas): def vendedores_manuales_guardar_lote(nombre, ano, mes, filas):
"""Reemplaza TODAS las filas de un vendedor manual en una sola operación.
filas = lista de dicts {descripcion, fch_emision, monto}. Si filas vacía → elimina el vendedor."""
sb = _sb() sb = _sb()
# Borrar las existentes de ese vendedor/mes
sb.table("vendedores_manuales").delete().eq("nombre", nombre).eq("ano", int(ano)).eq("mes", int(mes)).execute() sb.table("vendedores_manuales").delete().eq("nombre", nombre).eq("ano", int(ano)).eq("mes", int(mes)).execute()
# Insertar las nuevas en un solo batch
payload = [] payload = []
for f in filas: for f in filas:
payload.append({ payload.append({
@@ -560,12 +799,8 @@ def vendedores_manuales_guardar_lote(nombre, ano, mes, filas):
sb.table("vendedores_manuales").insert(payload).execute() sb.table("vendedores_manuales").insert(payload).execute()
return {"status": "ok"} return {"status": "ok"}
# ── OVERRIDES DE COMISIONES ────────────────────────────────────────────────
# ── OVERRIDES DE COMISIONES (editar valores por num_matricula) ─────────────
def comisiones_override_guardar(registros): def comisiones_override_guardar(registros):
"""Guarda/actualiza overrides en UNA sola operación (batch upsert)."""
sb = _sb() sb = _sb()
from cache_manager import cache_invalidate from cache_manager import cache_invalidate
validos = [r for r in registros if r.get("num_matricula")] validos = [r for r in registros if r.get("num_matricula")]
@@ -577,7 +812,6 @@ def comisiones_override_guardar(registros):
return {"status": "ok"} return {"status": "ok"}
def comisiones_override_restaurar(num_matricula): def comisiones_override_restaurar(num_matricula):
"""Borra el override de un alumno (vuelve a los valores de SQL)."""
sb = _sb() sb = _sb()
from cache_manager import cache_invalidate from cache_manager import cache_invalidate
sb.table("comisiones_overrides").delete().eq("num_matricula", str(num_matricula)).execute() sb.table("comisiones_overrides").delete().eq("num_matricula", str(num_matricula)).execute()

View File

@@ -1,35 +1,29 @@
services: services:
# ---------------------------------------------------------
# 1. EL BACKEND (FastAPI - Python) # 1. EL BACKEND (FastAPI - Python)
# ---------------------------------------------------------
backend: backend:
build: build:
context: ./backend context: ./backend
image: nixpacks/build:latest
environment: environment:
- SQL_SERVER=191.98.134.80 - SQL_SERVER=191.98.134.80
- SQL_DATABASE=BDUS_CK000040_0001 - SQL_DATABASE=BDUS_CK000040_0001
- SQL_USERNAME=ASEBASTIAN - SQL_USERNAME=ASEBASTIAN
- SQL_PASSWORD=24MY36$$z>&Uf - SQL_PASSWORD=24MY36$$z>&Uf # Escapado correctamente para Docker ($$)
- 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=2165$%sd3%DFG - PG_PASSWORD=2165$$%sd3%DFG # <--- ¡CORREGIDO AQUÍ! Añadido el doble $$ para Docker
- PG_PORT=5432 - PG_PORT=5432
- SUPABASE_URL=https://ogzjtkxnfswpbmnbhnjd.supabase.co - SUPABASE_URL=https://ogzjtkxnfswpbmnbhnjd.supabase.co
- SUPABASE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9nemp0a3huZnN3cGJtbmJobmpkIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc3NjYzNDk4MywiZXhwIjoyMDkyMjEwOTgzfQ.D7XB6GIs8UvI97lcMdf6Y6-8ON2ENhh0DOaiMro2f9Y - SUPABASE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9nemp0a3huZnN3cGJtbmJobmpkIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc3NjYzNDk4MywiZXhwIjoyMDkyMjEwOTgzfQ.D7XB6GIs8UvI97lcMdf6Y6-8ON2ENhh0DOaiMro2f9Y
- GITHUB_BASE=https://raw.githubusercontent.com/aron1798/prueba1/main/DASHBOARD - GITHUB_BASE=https://raw.githubusercontent.com/aron1798/prueba1/main/DASHBOARD
# --------------------------------------------------------- # 2. EL FRONTEND (Vite - React)
# 2. EL FRONTEND (Vite - React/Vue)
# ---------------------------------------------------------
frontend: frontend:
build: build:
context: ./frontend context: ./frontend
image: nixpacks/build:latest args:
environment: - VITE_SUPABASE_URL=https://ogzjtkxnfswpbmnbhnjd.supabase.co
- VITE_SUPABASE_URL=https://ogzjtkxnfswpbmnbhnjd.supabase.co - VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9nemp0a3huZnN3cGJtbmJobmpkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzY2MzQ5ODMsImV4cCI6MjA5MjIxMDk4M30.OEMjCJOXHBgAHPxtncQUOVC8iDjW40CnT5pczU-gz0U
- VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9nemp0a3huZnN3cGJtbmJobmpkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzY2MzQ5ODMsImV4cCI6MjA5MjIxMDk4M30.OEMjCJOXHBgAHPxtncQUOVC8iDjW40CnT5pczU-gz0U - VITE_API_URL=https://api.escueladerefrigeracion.lat
- VITE_API_URL=https://api.tudominio.com # (Recuerda cambiar esto por tu dominio real de API luego)
depends_on: depends_on:
- backend - backend

1
frontend/.env.production Normal file
View File

@@ -0,0 +1 @@
VITE_API_URL=https://api.escueladerefrigeracion.lat

27
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
# Usamos Node.js para compilar el proyecto
FROM node:18-alpine
# Creamos la carpeta de trabajo
WORKDIR /app
# Declaramos que recibiremos estos argumentos durante el build
ARG VITE_SUPABASE_URL
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 . .
RUN npm run build
# Instalamos el servidor estático y lo ejecutamos
RUN npm install -g serve
CMD ["serve", "-s", "dist", "-l", "5173"]

View File

@@ -5,7 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build --mode production",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {

View File

@@ -2,6 +2,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useAuth } from "./lib/auth"; import { useAuth } from "./lib/auth";
import Sidebar from "./components/Sidebar"; import Sidebar from "./components/Sidebar";
import IndicadorActualizacion from "./components/IndicadorActualizacion";
import Login from "./pages/Login"; import Login from "./pages/Login";
import Ocupabilidad from "./pages/Ocupabilidad"; import Ocupabilidad from "./pages/Ocupabilidad";
import Ventas from "./pages/Ventas"; import Ventas from "./pages/Ventas";
@@ -52,6 +53,7 @@ export default function App() {
<div className="app"> <div className="app">
<Sidebar active={pagina} onChange={setPagina} /> <Sidebar active={pagina} onChange={setPagina} />
<main className="main">{render()}</main> <main className="main">{render()}</main>
<IndicadorActualizacion />
</div> </div>
); );
} }

View File

@@ -0,0 +1,97 @@
// 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,7 +1,5 @@
// src/lib/api.js // ✅ DESPUÉS — Vite inyecta el valor real durante npm run build
// Cliente para el backend FastAPI. Cambia BASE_URL si el backend corre en otra IP. const BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8000";
const BASE_URL = "http://localhost:8000";
async function get(path, params = {}) { async function get(path, params = {}) {
const qs = new URLSearchParams(params).toString(); const qs = new URLSearchParams(params).toString();
const url = `${BASE_URL}${path}${qs ? "?" + qs : ""}`; const url = `${BASE_URL}${path}${qs ? "?" + qs : ""}`;
@@ -30,8 +28,15 @@ async function post(path, params = {}) {
export const api = { export const api = {
periodoActual: () => get("/api/periodo-actual"), periodoActual: () => get("/api/periodo-actual"),
ultimaActualizacion: () => get("/api/ultima-actualizacion"),
cacheRefresh: () => post("/api/cache/refresh"),
cacheRefreshManual: (clave) => post("/api/cache/refresh-manual", clave ? { clave } : {}),
precargaModulo: (modulo, ano) => post("/api/precarga/modulo", { modulo, ano }),
precargaEstado: (modulo) => get("/api/precarga/estado", { modulo }),
ocupabilidad: (ano, mes, sede = "TODOS", programa = "TODOS") => ocupabilidad: (ano, mes, sede = "TODOS", programa = "TODOS") =>
get("/api/ocupabilidad", { ano, mes, sede, programa }), get("/api/ocupabilidad", { ano, mes, sede, programa }),
ocupabilidadAlumnos: (num_indice) =>
get("/api/ocupabilidad/alumnos", { num_indice }),
ventas: (ano, mes, sede = "TODOS", programa = "TODOS") => get("/api/ventas", { ano, mes, sede, programa }), ventas: (ano, mes, sede = "TODOS", programa = "TODOS") => get("/api/ventas", { ano, mes, sede, programa }),
ventasDetalle: (vendedor, ano, mes, tipo, sede = "TODOS", programa = "TODOS") => ventasDetalle: (vendedor, ano, mes, tipo, sede = "TODOS", programa = "TODOS") =>
get("/api/ventas/detalle", { vendedor, ano, mes, tipo, sede, programa }), get("/api/ventas/detalle", { vendedor, ano, mes, tipo, sede, programa }),
@@ -39,6 +44,10 @@ export const api = {
get("/api/cobranza", { ano, mes, sectorista, agrupacion }), get("/api/cobranza", { ano, mes, sectorista, agrupacion }),
cobranzaDetalle: (grupo, ano, mes, sectorista = "TODOS", agrupacion = "PROGRAMA") => cobranzaDetalle: (grupo, ano, mes, sectorista = "TODOS", agrupacion = "PROGRAMA") =>
get("/api/cobranza/detalle", { grupo, ano, mes, sectorista, agrupacion }), get("/api/cobranza/detalle", { grupo, ano, mes, sectorista, agrupacion }),
alumnoEstado: (matricula) => get("/api/alumno/estado", { matricula }),
alumnoEstadoGuardar: (matricula, estado, observaciones, usuario) =>
postJson("/api/alumno/estado/guardar", { matricula, estado, observaciones, usuario }),
alumnosPorRetirar: () => get("/api/alumnos/por-retirar"),
rentabilidad: (ano, mes, sede = "TODOS", programa = "TODOS") => rentabilidad: (ano, mes, sede = "TODOS", programa = "TODOS") =>
get("/api/rentabilidad", { ano, mes, sede, programa }), get("/api/rentabilidad", { ano, mes, sede, programa }),
rentabilidadDetalle: (programa, ano, mes) => rentabilidadDetalle: (programa, ano, mes) =>

View File

@@ -6,65 +6,103 @@ const AuthContext = createContext(null);
export function AuthProvider({ children }) { export function AuthProvider({ children }) {
const [session, setSession] = useState(null); const [session, setSession] = useState(null);
const [perfil, setPerfil] = useState(null); // { nombre, rol } const [perfil, setPerfil] = useState(null);
const [permisos, setPermisos] = useState([]); // ["ocupabilidad", "cobranza", ...] const [permisos, setPermisos] = useState([]);
const [cargando, setCargando] = useState(true); const [cargando, setCargando] = useState(true);
// Cargar perfil (rol) y permisos del usuario logueado
const cargarPerfil = useCallback(async (userId) => { const cargarPerfil = useCallback(async (userId) => {
// 1) Traer el rol del usuario desde la tabla "perfiles" try {
const { data: perf, error: e1 } = await supabase const { data: perf, error: e1 } = await supabase
.from("perfiles") .from("perfiles")
.select("nombre, rol, activo") .select("nombre, rol, activo")
.eq("id", userId) .eq("id", userId)
.single(); .single();
if (e1 || !perf || perf.activo === false) {
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); setPerfil(null);
setPermisos([]); setPermisos([]);
return false; return false;
} }
// 2) Traer los módulos permitidos para ese rol desde "permisos"
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;
}, []); }, []);
// Al iniciar: revisar si ya hay sesión activa
useEffect(() => { useEffect(() => {
let activo = true; let activo = true;
supabase.auth.getSession().then(async ({ data }) => { let resuelto = false;
if (!activo) return;
const s = data?.session || null;
setSession(s);
if (s?.user) await cargarPerfil(s.user.id);
setCargando(false);
});
// Escuchar cambios de sesión (login/logout) 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) => { const { data: sub } = supabase.auth.onAuthStateChange(async (_evt, s) => {
setSession(s); try {
if (s?.user) await cargarPerfil(s.user.id); setSession(s);
else { setPerfil(null); setPermisos([]); } if (s?.user) await cargarPerfil(s.user.id);
else { setPerfil(null); setPermisos([]); }
} catch (e) {
console.error("[auth] onAuthStateChange falló:", e);
} finally {
terminar();
}
}); });
return () => { activo = false; sub?.subscription?.unsubscribe(); }; // 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]); }, [cargarPerfil]);
const login = async (email, password) => { const login = async (email, password) => {
const { data, error } = await supabase.auth.signInWithPassword({ email, password }); const { data, error } = await supabase.auth.signInWithPassword({ email, password });
if (error) return { ok: false, msg: traducirError(error.message) }; if (error) return { ok: false, msg: traducirError(error.message) };
// Verificar que tenga perfil/rol válido
const ok = await cargarPerfil(data.user.id); const ok = await cargarPerfil(data.user.id);
if (!ok) { if (!ok) {
await supabase.auth.signOut(); await supabase.auth.signOut();

View File

@@ -3,8 +3,13 @@
// IMPORTANTE: aquí va la clave PÚBLICA (anon), nunca la secreta (service_role). // IMPORTANTE: aquí va la clave PÚBLICA (anon), nunca la secreta (service_role).
import { createClient } from "@supabase/supabase-js"; import { createClient } from "@supabase/supabase-js";
// Estos valores se leen de frontend/.env (ver .env.example)
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || ""; const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || "";
const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY || ""; const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY || "";
export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY); export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
auth: {
persistSession: true,
autoRefreshToken: true,
detectSessionInUrl: true,
},
});

View File

@@ -0,0 +1,23 @@
// 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

@@ -4,6 +4,7 @@ import { api } from "../lib/api";
import { Loader, ErrorBox, Filters, Select, colorSemaforo } from "../components/UI"; import { Loader, ErrorBox, Filters, Select, colorSemaforo } from "../components/UI";
import Modal from "../components/Modal"; import Modal from "../components/Modal";
import { useColumnasAjustables } from "../lib/useColumnasAjustables"; 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 MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
const ANOS = [2024, 2025, 2026]; const ANOS = [2024, 2025, 2026];
@@ -68,16 +69,21 @@ export default function Cobranza() {
useEffect(() => { useEffect(() => {
let activo = true; let activo = true;
setLoading(true); setError(null); setVistaActual(`cobranza:${ano}|${mes}|${sectorista}|${agrupacion}`);
api.cobranza(ano, mes, sectorista, agrupacion) const cargar = () => {
.then((res) => { setLoading(true); setError(null);
if (!activo) return; api.cobranza(ano, mes, sectorista, agrupacion)
setFilas(res.filas || []); .then((res) => {
setSectoristas(res.sectoristas || ["TODOS"]); if (!activo) return;
setLoading(false); setFilas(res.filas || []);
}) setSectoristas(res.sectoristas || ["TODOS"]);
.catch((e) => { if (activo) { setError(e.message); setLoading(false); } }); setLoading(false);
return () => { activo = false; }; })
.catch((e) => { if (activo) { setError(e.message); setLoading(false); } });
};
cargar();
const off = onRefrescar(cargar);
return () => { activo = false; off(); };
}, [ano, mes, agrupacion, sectorista]); }, [ano, mes, agrupacion, sectorista]);
// Cargar TODOS los alumnos una sola vez (para exportar y buscar) — 1 consulta cacheada // Cargar TODOS los alumnos una sola vez (para exportar y buscar) — 1 consulta cacheada
@@ -89,6 +95,14 @@ export default function Cobranza() {
return () => { activo = false; }; return () => { activo = false; };
}, [ano, mes, sectorista]); }, [ano, mes, sectorista]);
// Cargar matrículas POR RETIRAR (para el "Saldo por Retirar" de las tarjetas)
const [porRetirar, setPorRetirar] = useState(new Set());
useEffect(() => {
api.alumnosPorRetirar()
.then((r) => setPorRetirar(new Set((r.matriculas || []).map(String))))
.catch(() => {});
}, [ano, mes]);
// Cargar clasificación de sede (sede.json) para los programas visibles // Cargar clasificación de sede (sede.json) para los programas visibles
useEffect(() => { useEffect(() => {
if (!esPrograma) return; if (!esPrograma) return;
@@ -155,11 +169,21 @@ export default function Cobranza() {
}, [alumnos, esPrograma, sede, frec]); }, [alumnos, esPrograma, sede, frec]);
function grupoDe(a) { function grupoDe(a) {
if (agrupacion === "SEDE") return a.sede; // El programa detallado del alumno, en TODOS los filtros (Sede, Asesor, Programa).
if (agrupacion === "ASESOR") return ""; // el sectorista ya filtra; no hay sub-grupo por alumno return a.programa;
return a.programa; // PROGRAMA
} }
// Saldo por retirar (ANT/MES/TOTAL) de los alumnos marcados POR RETIRAR (según filtros)
const retiroKpi = useMemo(() => {
let sAnt=0, sMes=0;
alumnosFiltrados.forEach((a) => {
if (!porRetirar.has(String(a.matricula))) return;
sAnt += ((Number(a.cta_ant)||0) - (Number(a.cob_ant)||0));
sMes += ((Number(a.cta_cur)||0) - (Number(a.cob_cur)||0));
});
return { sAnt, sMes, sTot: sAnt + sMes };
}, [alumnosFiltrados, porRetirar]);
// Exportar el DETALLE DE ALUMNOS (rápido: usa el dataset ya cargado, sin N consultas) // Exportar el DETALLE DE ALUMNOS (rápido: usa el dataset ya cargado, sin N consultas)
async function exportarDetalle() { async function exportarDetalle() {
setExportando(true); setExportando(true);
@@ -169,10 +193,7 @@ export default function Cobranza() {
const toDate = (v) => { const toDate = (v) => {
if (!v || v === "-") return ""; if (!v || v === "-") return "";
const s = String(v).trim().replace(/\//g,"-"); return String(v).trim();
const p = s.split("-");
if (p.length === 3 && p[2].length === 4) return new Date(+p[2], +p[1]-1, +p[0]);
return String(v);
}; };
const pct = (cob, cta) => cta > 0 ? cob/cta : null; const pct = (cob, cta) => cta > 0 ? cob/cta : null;
@@ -210,7 +231,6 @@ export default function Cobranza() {
const st = cellStyle(c); const st = cellStyle(c);
if ([5,6,8,9,11,12,14].includes(c)) ws[ref] = { v:Number(v)||0, t:"n", z:moneyFmt, ...(st?{s:st}:{}) }; if ([5,6,8,9,11,12,14].includes(c)) ws[ref] = { v:Number(v)||0, t:"n", z:moneyFmt, ...(st?{s:st}:{}) };
else if ([7,10,13].includes(c)) ws[ref] = (v===null) ? { v:"-", t:"s", ...(st?{s:st}:{}) } : { v:Number(v), t:"n", z:pctFmt, ...(st?{s:st}:{}) }; else if ([7,10,13].includes(c)) ws[ref] = (v===null) ? { v:"-", t:"s", ...(st?{s:st}:{}) } : { v:Number(v), t:"n", z:pctFmt, ...(st?{s:st}:{}) };
else if (c===4 && v instanceof Date) ws[ref] = { v, t:"d", z:dateFmt };
else ws[ref] = { v: v ?? "", t:"s" }; else ws[ref] = { v: v ?? "", t:"s" };
}); });
}); });
@@ -261,7 +281,7 @@ export default function Cobranza() {
{loading ? <Loader text="Calculando cobranza..." /> : {loading ? <Loader text="Calculando cobranza..." /> :
error ? <ErrorBox msg={error} /> : error ? <ErrorBox msg={error} /> :
<> <>
<Tarjetas kpis={kpis} /> <Tarjetas kpis={kpis} retiro={retiroKpi} />
<div className="table-wrap" style={{marginTop:16, overflowX:"auto"}}> <div className="table-wrap" style={{marginTop:16, overflowX:"auto"}}>
<table className="cob-table" {...cols.tableProps}> <table className="cob-table" {...cols.tableProps}>
@@ -357,7 +377,8 @@ export default function Cobranza() {
); );
} }
function Tarjetas({ kpis }) { function Tarjetas({ kpis, retiro }) {
const rt = retiro || { sAnt:0, sMes:0, sTot:0 };
const alDia = kpis.saldo <= 0.01; const alDia = kpis.saldo <= 0.01;
if (alDia) { if (alDia) {
return ( return (
@@ -370,13 +391,13 @@ function Tarjetas({ kpis }) {
); );
} }
const cards = [ const cards = [
["⚠ MES ANTERIOR","#d97706",kpis.cAnt,kpis.obAnt], ["⚠ MES ANTERIOR","#d97706",kpis.cAnt,kpis.obAnt, rt.sAnt],
["📅 MES EN CURSO","#059669",kpis.cMes,kpis.obMes], ["📅 MES EN CURSO","#059669",kpis.cMes,kpis.obMes, rt.sMes],
["💰 CARTERA TOTAL","#7c3aed",kpis.cTot,kpis.obTot], ["💰 CARTERA TOTAL","#7c3aed",kpis.cTot,kpis.obTot, rt.sTot],
]; ];
return ( return (
<div style={{display:"grid",gridTemplateColumns:"repeat(3,1fr)",gap:14}}> <div style={{display:"grid",gridTemplateColumns:"repeat(3,1fr)",gap:14}}>
{cards.map(([titulo,color,cta,cob]) => { {cards.map(([titulo,color,cta,cob,saldoRetiro]) => {
const ratio = cta>0 ? (cob/cta*100) : 0; const ratio = cta>0 ? (cob/cta*100) : 0;
const c = colorSemaforo(ratio); const c = colorSemaforo(ratio);
const saldo = cta - cob; const saldo = cta - cob;
@@ -387,6 +408,7 @@ function Tarjetas({ kpis }) {
<Row k="Cta x Cobrar" v={`S/ ${cta.toLocaleString("es-PE",{maximumFractionDigits:0})}`} /> <Row k="Cta x Cobrar" v={`S/ ${cta.toLocaleString("es-PE",{maximumFractionDigits:0})}`} />
<Row k="Cobrado" v={`S/ ${cob.toLocaleString("es-PE",{maximumFractionDigits:0})}`} /> <Row k="Cobrado" v={`S/ ${cob.toLocaleString("es-PE",{maximumFractionDigits:0})}`} />
<Row k="Saldo" v={`S/ ${saldo.toLocaleString("es-PE",{maximumFractionDigits:0})}`} bold color={saldo>0?"#dc2626":"#10b981"} /> <Row k="Saldo" v={`S/ ${saldo.toLocaleString("es-PE",{maximumFractionDigits:0})}`} bold color={saldo>0?"#dc2626":"#10b981"} />
<Row k="Saldo por Retirar" v={`S/ ${(saldoRetiro||0).toLocaleString("es-PE",{maximumFractionDigits:0})}`} color="#dc2626" />
{cta>0 && ( {cta>0 && (
<div style={{marginTop:10}}> <div style={{marginTop:10}}>
<div style={{display:"flex",justifyContent:"space-between",marginBottom:4}}> <div style={{display:"flex",justifyContent:"space-between",marginBottom:4}}>
@@ -416,15 +438,16 @@ function Row({ k, v, bold, color }) {
); );
} }
function TarjetasResumen({ r }) { function TarjetasResumen({ r, retiro }) {
const rt = retiro || { sAnt:0, sMes:0, sTot:0 };
const cards = [ const cards = [
["⚠ MES ANTERIOR","#d97706",r.cAnt,r.obAnt], ["⚠ MES ANTERIOR","#d97706",r.cAnt,r.obAnt, rt.sAnt],
["📅 MES EN CURSO","#059669",r.cMes,r.obMes], ["📅 MES EN CURSO","#059669",r.cMes,r.obMes, rt.sMes],
["💰 CARTERA TOTAL","#7c3aed",r.cTot,r.obTot], ["💰 CARTERA TOTAL","#7c3aed",r.cTot,r.obTot, rt.sTot],
]; ];
return ( return (
<div style={{display:"grid",gridTemplateColumns:"repeat(3,1fr)",gap:14}}> <div style={{display:"grid",gridTemplateColumns:"repeat(3,1fr)",gap:14}}>
{cards.map(([titulo,color,cta,cob]) => { {cards.map(([titulo,color,cta,cob,saldoRetiro]) => {
const ratio = cta>0 ? (cob/cta*100) : 0; const ratio = cta>0 ? (cob/cta*100) : 0;
const c = colorSemaforo(ratio); const c = colorSemaforo(ratio);
const saldo = cta - cob; const saldo = cta - cob;
@@ -435,6 +458,7 @@ function TarjetasResumen({ r }) {
<Row k="Cta x Cobrar" v={`S/ ${cta.toLocaleString("es-PE",{maximumFractionDigits:0})}`} /> <Row k="Cta x Cobrar" v={`S/ ${cta.toLocaleString("es-PE",{maximumFractionDigits:0})}`} />
<Row k="Cobrado" v={`S/ ${cob.toLocaleString("es-PE",{maximumFractionDigits:0})}`} /> <Row k="Cobrado" v={`S/ ${cob.toLocaleString("es-PE",{maximumFractionDigits:0})}`} />
<Row k="Saldo" v={`S/ ${saldo.toLocaleString("es-PE",{maximumFractionDigits:0})}`} bold color={saldo>0?"#dc2626":"#10b981"} /> <Row k="Saldo" v={`S/ ${saldo.toLocaleString("es-PE",{maximumFractionDigits:0})}`} bold color={saldo>0?"#dc2626":"#10b981"} />
<Row k="Saldo por Retirar" v={`S/ ${(saldoRetiro||0).toLocaleString("es-PE",{maximumFractionDigits:0})}`} color="#dc2626" />
{cta>0 && ( {cta>0 && (
<div style={{marginTop:8}}> <div style={{marginTop:8}}>
<div style={{display:"flex",justifyContent:"space-between",marginBottom:4}}> <div style={{display:"flex",justifyContent:"space-between",marginBottom:4}}>
@@ -457,6 +481,15 @@ function TarjetasResumen({ r }) {
function ModalBuscador({ alumnos, agrupacion, sectorista, onClose }) { function ModalBuscador({ alumnos, agrupacion, sectorista, onClose }) {
const [texto, setTexto] = useState(""); const [texto, setTexto] = useState("");
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [porRetirar, setPorRetirar] = useState(new Set());
const [infoAlumno, setInfoAlumno] = useState(null);
const cargarPorRetirar = () => {
api.alumnosPorRetirar()
.then((r)=>setPorRetirar(new Set((r.matriculas||[]).map(String))))
.catch(()=>{});
};
useEffect(() => { cargarPorRetirar(); }, []);
const resultados = useMemo(() => { const resultados = useMemo(() => {
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
@@ -468,13 +501,37 @@ function ModalBuscador({ alumnos, agrupacion, sectorista, onClose }) {
); );
}, [alumnos, query]); }, [alumnos, query]);
const COLS = ["GRUPO","MATRÍCULA","ALUMNO","N° CUOTA","F. VENC.", const COLS = ["PROGRAMA","ALUMNO","N° CUOTA","F. VENC.",
"CTA ANT.","COB ANT.","% ANT","CTA MES","COB MES","% MES","CTA TOT","COB TOT","% TOT","SALDO"]; "CTA ANT.","COB ANT.","% ANT","CTA MES","COB MES","% MES","CTA TOT","COB TOT","% TOT","SALDO","INFO"];
const anchosBusc = COLS.map((h,i) => {
if (i===0) return 250;
if (i===1) return 250;
if (h==="N° CUOTA" || h==="F. VENC.") return 100;
if (h==="INFO") return 70;
if (h.startsWith("%")) return 80;
return 110;
});
const cols = useColumnasAjustables(anchosBusc);
const FIJAS = 4;
const stickyCol = (j, { esHeader = false, bg = "#fff" } = {}) => {
if (j >= FIJAS) return {};
let left = 0;
for (let k = 0; k < j; k++) left += (cols.anchos[k] || 0);
const ultima = j === FIJAS - 1;
const lineaColor = esHeader ? "#475569" : "#e2e8f0";
return {
position: "sticky", left, zIndex: esHeader ? 20 : 10, backgroundColor: bg,
borderRight: "none",
boxShadow: `inset -1px 0 0 0 ${lineaColor}` + (ultima ? ", 3px 0 5px -2px rgba(0,0,0,0.18)" : ""),
};
};
const pctStr = (cob, cta) => cta > 0 ? `${Math.round(cob/cta*100)}%` : ""; const pctStr = (cob, cta) => cta > 0 ? `${Math.round(cob/cta*100)}%` : "";
return ( return (
<Modal title="🔍 Buscar Alumno" onClose={onClose} width={1200}> <Modal title="🔍 Buscar Alumno" onClose={onClose} width={1360}>
<div style={{display:"flex",gap:8,marginBottom:16}}> <div style={{display:"flex",gap:8,marginBottom:16}}>
<input <input
autoFocus autoFocus
@@ -501,40 +558,62 @@ function ModalBuscador({ alumnos, agrupacion, sectorista, onClose }) {
<div style={{fontSize:13,color:"#1e40af",fontWeight:600,marginBottom:8}}> <div style={{fontSize:13,color:"#1e40af",fontWeight:600,marginBottom:8}}>
{resultados.length} alumno(s) encontrado(s) {resultados.length} alumno(s) encontrado(s)
</div> </div>
<div className="table-wrap"> <div className="table-wrap" style={{overflow:"auto", maxHeight:"60vh"}}>
<table className="cob-table" style={{minWidth:1100}}> <table className="cob-table" {...cols.tableProps}>
<thead><tr>{COLS.map((c)=><th key={c}>{c}</th>)}</tr></thead> <cols.ColGroup />
<thead><tr>{COLS.map((c,i)=>{
const fija = i < FIJAS;
const stH = fija ? stickyCol(i,{esHeader:true,bg:"#334155"})
: { position:"sticky", top:0, zIndex:12, backgroundColor:"#334155" };
return <th key={c} style={{...stH, position:stH.position||"relative"}}>{c}<cols.Resizer index={i} /></th>;
})}</tr></thead>
<tbody> <tbody>
{resultados.map((a,i)=>{ {resultados.map((a,i)=>{
const deuda = a.saldo > 0.01; const deuda = a.saldo > 0.01;
const grp = agrupacion==="SEDE" ? a.sede : (agrupacion==="ASESOR" ? sectorista : a.programa); const retira = porRetirar.has(String(a.matricula));
const bgFila = (i % 2 === 1) ? "#f8fafc" : "#fff";
const celdas = [ const celdas = [
{v:grp, cls:"col-name", al:"left"}, {v:a.programa, cls:"col-name", al:"left", texto:true},
{v:a.matricula}, {v:a.alumno, cls:"col-name", al:"left", texto:true},
{v:a.alumno, cls:"col-name", al:"left"},
{v:a.num_cuota}, {v:a.num_cuota},
{v:a.fch_venc}, {v:a.fch_venc},
{v:fmtMoneda("S/ "+a.cta_ant), al:"right", cart:"cart-ant"}, {v:fmtMoneda("S/ "+a.cta_ant), cart:"cart-ant"},
{v:fmtMoneda("S/ "+a.cob_ant), al:"right", cart:"cart-ant"}, {v:fmtMoneda("S/ "+a.cob_ant), cart:"cart-ant"},
{pct:pctStr(a.cob_ant,a.cta_ant), cart:"cart-ant"}, {pct:pctStr(a.cob_ant,a.cta_ant), cart:"cart-ant"},
{v:fmtMoneda("S/ "+a.cta_cur), al:"right", cart:"cart-mes"}, {v:fmtMoneda("S/ "+a.cta_cur), cart:"cart-mes"},
{v:fmtMoneda("S/ "+a.cob_cur), al:"right", cart:"cart-mes"}, {v:fmtMoneda("S/ "+a.cob_cur), cart:"cart-mes"},
{pct:pctStr(a.cob_cur,a.cta_cur), cart:"cart-mes"}, {pct:pctStr(a.cob_cur,a.cta_cur), cart:"cart-mes"},
{v:fmtMoneda("S/ "+a.cta_tot), al:"right", cart:"cart-tot"}, {v:fmtMoneda("S/ "+a.cta_tot), cart:"cart-tot"},
{v:fmtMoneda("S/ "+a.cob_tot), al:"right", cart:"cart-tot"}, {v:fmtMoneda("S/ "+a.cob_tot), cart:"cart-tot"},
{pct:pctStr(a.cob_tot,a.cta_tot), cart:"cart-tot"}, {pct:pctStr(a.cob_tot,a.cta_tot), cart:"cart-tot"},
{v:fmtMoneda("S/ "+a.saldo), al:"right", saldo:true}, {v:fmtMoneda("S/ "+a.saldo), saldo:true},
]; ];
return ( return (
<tr key={i} className={deuda?"row-deuda":""}> <tr key={i} className={deuda?"row-deuda":""}
style={retira ? { textDecoration:"line-through", textDecorationColor:"#dc2626",
textDecorationThickness:"2px" } : undefined}>
{celdas.map((c,j)=>( {celdas.map((c,j)=>(
<td key={j} className={((c.cls||"")+" "+(c.cart||"")).trim()} <td key={j} className={((c.cls||"")+" "+(c.cart||"")).trim()}
title={c.texto ? String(c.v) : undefined}
style={{textAlign:c.al||"center", style={{textAlign:c.al||"center",
fontWeight:c.saldo?700:undefined, fontWeight:c.saldo?700:undefined,
color:c.saldo&&deuda?"#dc2626":undefined}}> color:c.saldo&&deuda?"#dc2626":undefined,
whiteSpace:"nowrap",
overflow:c.texto?"hidden":undefined,
textOverflow:c.texto?"ellipsis":undefined,
...stickyCol(j,{bg:bgFila})}}>
{c.pct!==undefined ? (c.pct ? <MiniBar valStr={c.pct} /> : "—") : c.v} {c.pct!==undefined ? (c.pct ? <MiniBar valStr={c.pct} /> : "—") : c.v}
</td> </td>
))} ))}
<td style={{textAlign:"center"}}>
<button
onClick={()=>setInfoAlumno({ matricula:a.matricula, alumno:a.alumno, programa:a.programa })}
style={{padding:"3px 10px",fontSize:11,fontWeight:700,cursor:"pointer",
border:"1px solid #cbd5e1",borderRadius:6,background:"#f8fafc",color:"#334155"}}
title="Ver/editar estado y observaciones">
INFO
</button>
</td>
</tr> </tr>
); );
})} })}
@@ -543,6 +622,10 @@ function ModalBuscador({ alumnos, agrupacion, sectorista, onClose }) {
</div> </div>
</> </>
)} )}
{infoAlumno && (
<ModalInfoAlumno alumno={infoAlumno} onClose={()=>setInfoAlumno(null)}
onGuardado={cargarPorRetirar} />
)}
</Modal> </Modal>
); );
} }
@@ -550,6 +633,9 @@ function ModalBuscador({ alumnos, agrupacion, sectorista, onClose }) {
function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose }) { function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose }) {
const [filas, setFilas] = useState([]); const [filas, setFilas] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [porRetirar, setPorRetirar] = useState(new Set()); // matrículas POR RETIRAR
const [soloRetirar, setSoloRetirar] = useState(false); // filtro: ver solo POR RETIRAR
const [infoAlumno, setInfoAlumno] = useState(null); // alumno del pop-up INFO
useEffect(() => { useEffect(() => {
let activo = true; let activo = true;
@@ -560,7 +646,34 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose })
return () => { activo = false; }; return () => { activo = false; };
}, [grupo, ano, mes, sectorista, agrupacion]); }, [grupo, ano, mes, sectorista, agrupacion]);
const COLS = ["MATRÍCULA","ALUMNO","N° CUOTA","F. VENC.","CTA ANT.","COB ANT.","% ANT","CTA MES","COB MES","% MES","CTA TOT","COB TOT","% TOT","SALDO"]; // Cargar (y recargar) las matrículas marcadas POR RETIRAR para tachar sus filas
const cargarPorRetirar = () => {
api.alumnosPorRetirar()
.then((r) => setPorRetirar(new Set((r.matriculas || []).map(String))))
.catch(() => {});
};
useEffect(() => { cargarPorRetirar(); }, []);
const COLS = ["PROGRAMA","ALUMNO","N° CUOTA","F. VENC.","CTA ANT.","COB ANT.","% ANT","CTA MES","COB MES","% MES","CTA TOT","COB TOT","% TOT","SALDO","INFO"];
// Columnas redimensionables (igual que la tabla principal): PROGRAMA y ALUMNO más anchas
const anchosDet = COLS.map((h, i) => i === 0 ? 240 : (i === 1 ? 220 : (h==="INFO" ? 70 : h.startsWith("%") ? 80 : 110)));
const cols = useColumnasAjustables(anchosDet);
// Congelar las 4 primeras columnas (PROGRAMA, ALUMNO, N° CUOTA, F. VENC.).
const FIJAS = 4;
const stickyCol = (j, { esHeader = false, bg = "#fff" } = {}) => {
if (j >= FIJAS) return {};
let left = 0;
for (let k = 0; k < j; k++) left += (cols.anchos[k] || 0);
const ultima = j === FIJAS - 1;
const lineaColor = esHeader ? "#475569" : "#e2e8f0";
return {
position: "sticky", left, zIndex: esHeader ? 20 : 10, backgroundColor: bg,
borderRight: "none",
boxShadow: `inset -1px 0 0 0 ${lineaColor}` + (ultima ? ", 3px 0 5px -2px rgba(0,0,0,0.18)" : ""),
};
};
// Resumen para las 3 tarjetas (siempre visibles) — suma del detalle de alumnos // Resumen para las 3 tarjetas (siempre visibles) — suma del detalle de alumnos
const resumen = useMemo(() => { const resumen = useMemo(() => {
@@ -574,55 +687,146 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose })
return { cAnt,obAnt,cMes,obMes,cTot,obTot }; return { cAnt,obAnt,cMes,obMes,cTot,obTot };
}, [filas]); }, [filas]);
// Conteo de alumnos: total, ya pagaron (saldo 0) y por pagar (saldo > 0)
const conteo = useMemo(() => {
let total=0, pagaron=0, deben=0;
filas.forEach((f) => {
if (String(f[0]).toUpperCase() === "TOTAL") return;
total++;
if (toMonto(f[13]) > 0.01) deben++; else pagaron++;
});
return { total, pagaron, deben };
}, [filas]);
// POR RETIRAR: cantidad de alumnos marcados y saldos por cartera (ANT, MES, TOTAL)
const retiro = useMemo(() => {
let cant=0, saldo=0, sAnt=0, sMes=0;
filas.forEach((f) => {
if (String(f[0]).toUpperCase() === "TOTAL") return;
if (porRetirar.has(String(f[0]))) {
cant++;
saldo += toMonto(f[13]);
sAnt += (toMonto(f[4]) - toMonto(f[5])); // CTA ANT - COB ANT
sMes += (toMonto(f[7]) - toMonto(f[8])); // CTA MES - COB MES
}
});
return { cant, saldo, sAnt, sMes, sTot: sAnt + sMes };
}, [filas, porRetirar]);
return ( return (
<Modal title={`📊 Estado de Cuenta — ${grupo}`} onClose={onClose} width={1500}> <Modal title={`📊 Estado de Cuenta — ${grupo}`} onClose={onClose} width={1500}>
{loading ? <Loader text="Cargando estado de cuenta..." /> : {loading ? <Loader text="Cargando estado de cuenta..." /> :
filas.length === 0 ? <div style={{color:"#94a3b8",padding:20,textAlign:"center"}}>Sin datos.</div> : filas.length === 0 ? <div style={{color:"#94a3b8",padding:20,textAlign:"center"}}>Sin datos.</div> :
<> <>
<div style={{marginBottom:16}}> <div style={{marginBottom:16}}>
<TarjetasResumen r={resumen} /> <TarjetasResumen r={resumen} retiro={retiro} />
</div> </div>
<div className="table-wrap"> {/* Etiquetas de conteo de alumnos */}
<table className="cob-table" style={{minWidth:1000}}> <div style={{display:"flex",gap:10,marginBottom:14,flexWrap:"wrap",alignItems:"center"}}>
<thead><tr>{COLS.map((c)=><th key={c}>{c}</th>)}</tr></thead> <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: {conteo.total}
</span>
<span style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px",
borderRadius:999,background:"#ecfdf5",color:"#047857",fontSize:13,fontWeight:700,
border:"1px solid #a7f3d0"}}>
Pagaron: {conteo.pagaron}
</span>
<span style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px",
borderRadius:999,background:"#fffbeb",color:"#b45309",fontSize:13,fontWeight:700,
border:"1px solid #fde68a"}}>
Por pagar: {conteo.deben}
</span>
<span style={{width:1,height:26,background:"#e2e8f0",margin:"0 4px"}} />
<span style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px",
borderRadius:999,background:"#fef2f2",color:"#dc2626",fontSize:13,fontWeight:700,
border:"1px solid #fecaca"}}>
🚫 Por retirar: {retiro.cant}
</span>
<span style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px",
borderRadius:999,background:"#fef2f2",color:"#dc2626",fontSize:13,fontWeight:700,
border:"1px solid #fecaca"}}>
💸 Saldo: S/ {retiro.saldo.toLocaleString("es-PE",{maximumFractionDigits:0})}
</span>
<label style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px",
borderRadius:999,background:soloRetirar?"#fee2e2":"#f8fafc",marginLeft:"auto",
color:soloRetirar?"#b91c1c":"#64748b",fontSize:13,fontWeight:700,cursor:"pointer",
border:`1px solid ${soloRetirar?"#fca5a5":"#e2e8f0"}`}}>
<input type="checkbox" checked={soloRetirar}
onChange={(e)=>setSoloRetirar(e.target.checked)} style={{cursor:"pointer"}} />
Ver solo por retirar
</label>
</div>
<div className="table-wrap" style={{overflow:"auto", maxHeight:"60vh"}}>
<table className="cob-table" {...cols.tableProps}>
<cols.ColGroup />
<thead><tr>{COLS.map((c,i)=>{
const fija = i < FIJAS;
const stH = fija
? stickyCol(i,{esHeader:true,bg:"#334155"})
: { position:"sticky", top:0, zIndex:12, backgroundColor:"#334155" };
return <th key={c} style={{...stH, position:stH.position||"relative"}}>{c}<cols.Resizer index={i} /></th>;
})}</tr></thead>
<tbody> <tbody>
{filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL").map((f, i) => { {filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL")
.filter((f)=> !soloRetirar || porRetirar.has(String(f[0])))
.map((f, i) => {
const saldo = toMonto(f[13]); const saldo = toMonto(f[13]);
const deuda = saldo > 0.01; const deuda = saldo > 0.01;
// Regla "-": si %ANT vacío → CTA/COB ANT a "—" (igual MES y TOT)
const antVacio = !String(f[6]||"").trim() || ["-","—"].includes(String(f[6]).trim()); const antVacio = !String(f[6]||"").trim() || ["-","—"].includes(String(f[6]).trim());
const mesVacio = !String(f[9]||"").trim() || ["-","—"].includes(String(f[9]).trim()); const mesVacio = !String(f[9]||"").trim() || ["-","—"].includes(String(f[9]).trim());
const totVacio = !String(f[12]||"").trim() || ["-","—"].includes(String(f[12]).trim()); const totVacio = !String(f[12]||"").trim() || ["-","—"].includes(String(f[12]).trim());
const programa = f[14] || "-";
const retira = porRetirar.has(String(f[0]));
return ( return (
<tr key={i} className={deuda?"row-deuda":""}> <tr key={i} className={deuda?"row-deuda":""}
{f.map((v, j) => { style={retira ? { textDecoration:"line-through", textDecorationColor:"#dc2626",
let val = v; textDecorationThickness:"2px" } : undefined}>
{f.slice(0, 14).map((v, j) => {
let val = (j===0) ? programa : v;
if ((j===4||j===5||j===6) && antVacio) val = "—"; if ((j===4||j===5||j===6) && antVacio) val = "—";
if ((j===7||j===8||j===9) && mesVacio) val = "—"; if ((j===7||j===8||j===9) && mesVacio) val = "—";
if ((j===10||j===11||j===12) && totVacio) val = "—"; if ((j===10||j===11||j===12) && totVacio) val = "—";
const isPct = [6,9,12].includes(j); const isPct = [6,9,12].includes(j);
const isMonto = String(val).includes("S/");
let cart = ""; let cart = "";
if ([4,5,6].includes(j)) cart = "cart-ant"; if ([4,5,6].includes(j)) cart = "cart-ant";
else if ([7,8,9].includes(j)) cart = "cart-mes"; else if ([7,8,9].includes(j)) cart = "cart-mes";
else if ([10,11,12].includes(j)) cart = "cart-tot"; else if ([10,11,12].includes(j)) cart = "cart-tot";
const clss = ((j===1?"col-name":"") + (cart?` ${cart}`:"")).trim(); const clss = (((j===0||j===1)?"col-name":"") + (cart?` ${cart}`:"")).trim();
const esTexto = (j===0||j===1);
const bgFila = (i % 2 === 1) ? "#f8fafc" : "#fff";
return ( return (
<td key={j} className={clss} <td key={j} className={clss}
style={{textAlign:j===1?"left":(isMonto?"right":"center"), title={esTexto ? String(val) : undefined}
style={{textAlign:esTexto?"left":"center",
whiteSpace:"nowrap", whiteSpace:"nowrap",
overflow:esTexto?"hidden":undefined,
textOverflow:esTexto?"ellipsis":undefined,
color:j===13&&deuda?"#dc2626":undefined, color:j===13&&deuda?"#dc2626":undefined,
fontWeight:j===13?700:undefined}}> fontWeight:j===13?700:undefined,
...stickyCol(j,{bg:bgFila})}}>
{isPct ? (val==="—"?<span style={{color:"#cbd5e1"}}></span>:<MiniBar valStr={val} />) : fmtMoneda(val)} {isPct ? (val==="—"?<span style={{color:"#cbd5e1"}}></span>:<MiniBar valStr={val} />) : fmtMoneda(val)}
</td> </td>
); );
})} })}
<td style={{textAlign:"center"}}>
<button
onClick={()=>setInfoAlumno({ matricula:f[0], alumno:f[1], programa:programa })}
style={{padding:"3px 10px",fontSize:11,fontWeight:700,cursor:"pointer",
border:"1px solid #cbd5e1",borderRadius:6,background:"#f8fafc",color:"#334155"}}
title="Ver/editar estado y observaciones">
INFO
</button>
</td>
</tr> </tr>
); );
})} })}
</tbody>
<tfoot>
{(() => { {(() => {
// TOTAL GENERAL del modal — solo alumnos visibles (respeta la sede filtrada) const alumnos = filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL")
const alumnos = filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL"); .filter((f)=> !soloRetirar || porRetirar.has(String(f[0])));
if (alumnos.length===0) return null; if (alumnos.length===0) return null;
const sum = (j)=>alumnos.reduce((s,f)=>s+toMonto(f[j]),0); const sum = (j)=>alumnos.reduce((s,f)=>s+toMonto(f[j]),0);
const t = {4:sum(4),5:sum(5),7:sum(7),8:sum(8),10:sum(10),11:sum(11),13:sum(13)}; const t = {4:sum(4),5:sum(5),7:sum(7),8:sum(8),10:sum(10),11:sum(11),13:sum(13)};
@@ -641,20 +845,160 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose })
{Array.from({length:14}).map((_,j)=>{ {Array.from({length:14}).map((_,j)=>{
const isPct=[6,9,12].includes(j); const isPct=[6,9,12].includes(j);
const val=celda(j); const val=celda(j);
const fija = j < FIJAS;
let left = 0; for (let k=0;k<j;k++) left += (cols.anchos[k]||0);
const st = {
position:"sticky", bottom:0,
zIndex: fija ? 15 : 8,
backgroundColor:"#eff6ff",
padding:"11px 8px",
...(fija ? { left, boxShadow:`inset -1px 0 0 0 #cbd5e1${j===FIJAS-1?", 3px 0 5px -2px rgba(0,0,0,0.18)":""}, inset 0 1px 0 0 #cbd5e1` } : { boxShadow:"inset 0 1px 0 0 #cbd5e1" }),
};
return ( return (
<td key={j} className={j===0?"col-name":""} <td key={j} className={j===0?"col-name":""}
style={{textAlign:j===0?"left":(String(val).includes("S/")?"right":"center"),whiteSpace:"nowrap"}}> style={{textAlign:j===0?"left":"center",whiteSpace:"nowrap",...st}}>
{isPct?<MiniBar valStr={val} />:val} {isPct?<MiniBar valStr={val} />:val}
</td> </td>
); );
})} })}
<td style={{position:"sticky",bottom:0,zIndex:8,backgroundColor:"#eff6ff",boxShadow:"inset 0 1px 0 0 #cbd5e1"}}></td>
</tr> </tr>
); );
})()} })()}
</tbody> </tfoot>
</table> </table>
</div> </div>
</>} </>}
{infoAlumno && (
<ModalInfoAlumno alumno={infoAlumno} onClose={()=>setInfoAlumno(null)}
onGuardado={cargarPorRetirar} />
)}
</Modal>
);
}
// Pop-up de INFO por alumno: cambiar estado (EN CURSO / POR RETIRAR) y observaciones.
// Carga y guarda en Supabase (tabla cobranza_estado_alumno).
function ModalInfoAlumno({ alumno, onClose, onGuardado }) {
const [estado, setEstado] = useState("EN CURSO");
const [nuevaObs, setNuevaObs] = useState("");
const [observaciones, setObservaciones] = useState([]);
const [cargando, setCargando] = useState(true);
const [guardando, setGuardando] = useState(false);
const [guardado, setGuardado] = useState(false);
useEffect(() => {
let activo = true;
api.alumnoEstado(alumno.matricula)
.then((r)=>{ if(activo){ setEstado(r.estado||"EN CURSO"); setObservaciones(r.observaciones||[]); } })
.catch(()=>{})
.finally(()=>{ if(activo) setCargando(false); });
return ()=>{ activo=false; };
}, [alumno.matricula]);
const agregarObs = () => {
const t = nuevaObs.trim();
if (!t) return;
const fecha = new Date().toLocaleString("es-PE", { dateStyle:"short", timeStyle:"short" });
setObservaciones((prev)=>[...prev, { texto:t, fecha }]);
setNuevaObs("");
setGuardado(false);
};
const guardar = async () => {
setGuardando(true); setGuardado(false);
try {
await api.alumnoEstadoGuardar(alumno.matricula, estado, observaciones);
setGuardado(true);
if (onGuardado) onGuardado();
} catch {
alert("No se pudo guardar. Intenta de nuevo.");
} finally {
setGuardando(false);
}
};
return (
<Modal title={` Información del alumno`} onClose={onClose} width={560}>
<div style={{marginBottom:16}}>
<div style={{fontSize:15,fontWeight:800,color:"#0f172a"}}>{alumno.alumno}</div>
<div style={{fontSize:12,color:"#64748b",marginTop:2}}>
Matrícula: {alumno.matricula} · {alumno.programa}
</div>
</div>
<div style={{marginBottom:18}}>
<label style={{fontSize:12,fontWeight:700,color:"#334155",display:"block",marginBottom:6}}>
Estado del alumno
</label>
<select value={estado} onChange={(e)=>{setEstado(e.target.value); setGuardado(false);}}
style={{width:"100%",padding:"8px 10px",fontSize:14,borderRadius:8,
border:"1px solid #cbd5e1",background:"#fff",
color: estado==="POR RETIRAR" ? "#b91c1c" : "#047857", fontWeight:700}}>
<option value="EN CURSO">EN CURSO</option>
<option value="POR RETIRAR">POR RETIRAR</option>
</select>
</div>
<div style={{marginBottom:14}}>
<label style={{fontSize:12,fontWeight:700,color:"#334155",display:"block",marginBottom:6}}>
Observaciones
</label>
<div style={{display:"flex",gap:8}}>
<input value={nuevaObs} onChange={(e)=>setNuevaObs(e.target.value)}
onKeyDown={(e)=>{ if(e.key==="Enter") agregarObs(); }}
placeholder="Escribe una observación…"
style={{flex:1,padding:"8px 10px",fontSize:13,borderRadius:8,border:"1px solid #cbd5e1"}} />
<button onClick={agregarObs}
style={{padding:"8px 14px",fontSize:13,fontWeight:700,cursor:"pointer",
border:"none",borderRadius:8,background:"#1e40af",color:"#fff"}}>
Agregar
</button>
</div>
<div style={{marginTop:10,display:"flex",flexDirection:"column",gap:6,maxHeight:180,overflowY:"auto"}}>
{observaciones.length===0 ? (
<div style={{fontSize:12,color:"#94a3b8",padding:"6px 2px"}}>Sin observaciones.</div>
) : observaciones.map((o,i)=>(
<div key={i} style={{background:"#f8fafc",border:"1px solid #eef2f7",borderRadius:8,
padding:"8px 10px",display:"flex",alignItems:"flex-start",gap:8}}>
<div style={{flex:1}}>
<div style={{fontSize:13,color:"#334155"}}>{o.texto}</div>
<div style={{fontSize:10,color:"#94a3b8",marginTop:2}}>{o.fecha}</div>
</div>
<button onClick={()=>{ setObservaciones(prev=>prev.filter((_,k)=>k!==i)); setGuardado(false); }}
title="Eliminar observación (se aplica al guardar)"
style={{border:"none",background:"transparent",color:"#dc2626",cursor:"pointer",
fontSize:16,fontWeight:800,lineHeight:1,padding:"0 2px"}}>
×
</button>
</div>
))}
</div>
</div>
<div style={{display:"flex",justifyContent:"flex-end",alignItems:"center",gap:10,marginTop:8,
borderTop:"1px solid #f1f5f9",paddingTop:12}}>
<button onClick={onClose}
style={{padding:"8px 16px",fontSize:13,fontWeight:700,cursor:"pointer",
border:"1px solid #cbd5e1",borderRadius:8,background:"#fff",color:"#334155"}}>
Cerrar
</button>
<button onClick={guardar} disabled={guardando||cargando}
style={{padding:"8px 18px",fontSize:13,fontWeight:700,
cursor:(guardando||cargando)?"not-allowed":"pointer",
border:"none",borderRadius:8,background:"#1e40af",color:"#fff",
opacity:(guardando||cargando)?.7:1}}>
{guardando ? "Guardando…" : "Guardar"}
</button>
</div>
{guardado && (
<div style={{marginTop:12,display:"flex",alignItems:"center",gap:8,
background:"#ecfdf5",border:"1px solid #a7f3d0",color:"#047857",
borderRadius:8,padding:"10px 14px",fontSize:13,fontWeight:700}}>
<span style={{fontSize:16}}></span> Guardado correctamente.
</div>
)}
</Modal> </Modal>
); );
} }

View File

@@ -3,14 +3,15 @@ import { useState, useEffect, useMemo } from "react";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { Loader, ErrorBox, ProgressBar, Filters, Select } from "../components/UI"; import { Loader, ErrorBox, ProgressBar, Filters, Select } from "../components/UI";
import { useColumnasAjustables } from "../lib/useColumnasAjustables"; 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 MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
const ANOS = [2024, 2025, 2026]; const ANOS = [2024, 2025, 2026];
const SEDES = ["TODOS", "LIMA", "AREQUIPA", "PIURA", "TRUJILLO"]; const SEDES = ["TODOS", "LIMA", "AREQUIPA", "PIURA", "TRUJILLO"];
const PROGRAMAS = ["TODOS", "SEMINARIOS", "OTROS", "TEAC", "TERC"]; 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"]; 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; } function toNum(v) { const n = parseFloat(String(v).replace("%","").trim()); return isNaN(n) ? 0 : n; }
export default function Ocupabilidad() { export default function Ocupabilidad() {
@@ -23,15 +24,21 @@ export default function Ocupabilidad() {
const [datos, setDatos] = useState([]); const [datos, setDatos] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const cols = useColumnasAjustables([280, 110, 120, 110, 100, 120, 110, 120, 100, 100, 130, 140]); const [modalCurso, setModalCurso] = useState(null);
const cols = useColumnasAjustables([280, 110, 120, 110, 100, 120, 110, 120, 100, 100, 130, 140, 80]);
useEffect(() => { useEffect(() => {
let activo = true; let activo = true;
setLoading(true); setError(null); setVistaActual(`ocupabilidad:${ano}|${mes}|${sede}|${programa}`);
api.ocupabilidad(ano, mes, sede, programa) const cargar = () => {
.then((res) => { if (activo) { setDatos(res.datos || []); setLoading(false); } }) setLoading(true); setError(null);
.catch((e) => { if (activo) { setError(e.message); setLoading(false); } }); api.ocupabilidad(ano, mes, sede, programa)
return () => { activo = false; }; .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]); }, [ano, mes, sede, programa]);
// Toggle "Mostrar Reprogramados": si está apagado, ocultar filas de inicio // Toggle "Mostrar Reprogramados": si está apagado, ocultar filas de inicio
@@ -170,6 +177,14 @@ export default function Ocupabilidad() {
<td>{cel(parseInt(d.Inscritos_PC)||0)}</td> <td>{cel(parseInt(d.Inscritos_PC)||0)}</td>
<td>{cel(parseInt(d.Descuento)||0)}</td> <td>{cel(parseInt(d.Descuento)||0)}</td>
<td>{cel(parseInt(d.Inscritos_Continuidad)||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> </tr>
); );
})} })}
@@ -183,12 +198,110 @@ export default function Ocupabilidad() {
<td>{totales.meta}</td> <td>{totales.meta}</td>
<td><ProgressBar pct={totales.meta>0 ? totales.total/totales.meta*100 : 0} /></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>{totales.mes}</td><td>{totales.pc}</td><td>{totales.refri}</td><td>{totales.cont}</td>
<td></td>
</tr> </tr>
)} )}
</tbody> </tbody>
</table> </table>
</div> </div>
</>} </>}
{modalCurso && (
<ModalAlumnosCurso
numIndice={modalCurso.num_indice}
programa={modalCurso.programa}
onClose={() => setModalCurso(null)}
/>
)}
</div> </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

@@ -4,6 +4,7 @@ import { api } from "../lib/api";
import { Loader, ErrorBox, Filters, Select } from "../components/UI"; import { Loader, ErrorBox, Filters, Select } from "../components/UI";
import Modal from "../components/Modal"; import Modal from "../components/Modal";
import { useColumnasAjustables } from "../lib/useColumnasAjustables"; 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 MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
const ANOS = [2024, 2025, 2026]; const ANOS = [2024, 2025, 2026];
@@ -44,11 +45,16 @@ export default function Rentabilidad() {
useEffect(() => { useEffect(() => {
let activo = true; let activo = true;
setLoading(true); setError(null); setVistaActual(`rentabilidad:${ano}|${mes}|${sede}|${programa}`);
api.rentabilidad(ano, mes, sede, programa) const cargarSeguro = () => {
.then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } }) setLoading(true); setError(null);
.catch((e) => { if (activo) { setError(e.message); setLoading(false); } }); api.rentabilidad(ano, mes, sede, programa)
return () => { activo = false; }; .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]); }, [ano, mes, sede, programa]);
const datos = useMemo(() => filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL GENERAL"), [filas]); const datos = useMemo(() => filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL GENERAL"), [filas]);

View File

@@ -4,6 +4,7 @@ import { api } from "../lib/api";
import { Loader, ErrorBox, Filters, Select } from "../components/UI"; import { Loader, ErrorBox, Filters, Select } from "../components/UI";
import Modal from "../components/Modal"; import Modal from "../components/Modal";
import { useColumnasAjustables } from "../lib/useColumnasAjustables"; 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 MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
const ANOS = [2024, 2025, 2026]; const ANOS = [2024, 2025, 2026];
@@ -27,11 +28,16 @@ export default function Ventas() {
useEffect(() => { useEffect(() => {
let activo = true; let activo = true;
setLoading(true); setError(null); setVistaActual(`ventas:${ano}|${mes}|${sede}|${programa}|False`);
api.ventas(ano, mes, sede, programa) const cargar = () => {
.then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } }) setLoading(true); setError(null);
.catch((e) => { if (activo) { setError(e.message); setLoading(false); } }); api.ventas(ano, mes, sede, programa)
return () => { activo = false; }; .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]); }, [ano, mes, sede, programa]);
const total = useMemo(() => filas.find((f) => f[0] === "TOTAL GENERAL"), [filas]); const total = useMemo(() => filas.find((f) => f[0] === "TOTAL GENERAL"), [filas]);