Compare commits
58 Commits
6ed8fa1bb7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ee3cc1e3b | |||
| d444c8051b | |||
| 5b96e4cb59 | |||
| d711146674 | |||
| e5f0754493 | |||
| c50ee402f4 | |||
| b1d2dff29c | |||
| a0dbffd848 | |||
| 51d6de32dd | |||
| de1af4e8fe | |||
| 9e8e592bdd | |||
| 359e78f666 | |||
| 0e7c6989d4 | |||
| abaaa1c131 | |||
| 09f4a05609 | |||
| 3aec610cd0 | |||
| 4da3a5ae6e | |||
| 5a7b55e793 | |||
| 4b5f0f8d77 | |||
| c4b6be1810 | |||
| 16449d23cd | |||
| 9266dd82b9 | |||
| 329294fc13 | |||
| 8f743715af | |||
| 1a051760d4 | |||
| 0e0909f230 | |||
| f21c37c162 | |||
| e686ae9f1a | |||
| 3a029bc443 | |||
| a5df935e4f | |||
| c91026e4da | |||
| 2f8fae01ca | |||
| 27787b97be | |||
| b3eaed0863 | |||
| 71c4d1376e | |||
| 99762151da | |||
| fe14e2117b | |||
| 807a907ee8 | |||
| b66bfc4803 | |||
| 7f2f5134cb | |||
| f886370d5c | |||
| 725cc6f818 | |||
| 89d048035c | |||
| 8532eb12f2 | |||
| 6c06501bee | |||
| 3e95a1467c | |||
| 0f9cec175e | |||
| 92d578fcad | |||
| bbc2cd4958 | |||
| cd22ea94cc | |||
| 3a34921dc7 | |||
| 9f73b3ca76 | |||
| d0fbade0fe | |||
| 86dcef0a96 | |||
| b016961858 | |||
| f53ee398e0 | |||
| 132c01425b | |||
| daa40368e1 |
34
backend/Dockerfile
Normal file
34
backend/Dockerfile
Normal 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"]
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = []
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 = []
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
1
frontend/.env.production
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_URL=https://api.escueladerefrigeracion.lat
|
||||||
27
frontend/Dockerfile
Normal file
27
frontend/Dockerfile
Normal 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"]
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
97
frontend/src/components/IndicadorActualizacion.jsx
Normal file
97
frontend/src/components/IndicadorActualizacion.jsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
23
frontend/src/lib/vistaActual.js
Normal file
23
frontend/src/lib/vistaActual.js
Normal 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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
Reference in New Issue
Block a user