Spaces:
Running
Running
| import streamlit as st | |
| import sys | |
| import pandas as pd | |
| import plotly.express as px | |
| import plotly.graph_objects as go | |
| from st_supabase_connection import SupabaseConnection | |
| from datetime import datetime | |
| import requests | |
| import base64 | |
| from PIL import Image | |
| import io | |
| import time | |
| from fpdf import FPDF | |
| from groq import Groq | |
| from io import BytesIO | |
| import qrcode # Asegúrate de tener: pip install qrcode | |
| import base64 | |
| from fpdf import FPDF | |
| from datetime import datetime | |
| import streamlit as st | |
| import re | |
| from st_supabase_connection import SupabaseConnection | |
| # --- INICIALIZACIÓN DE VARIABLES PARA EL MAPA --- | |
| if "mostrar_mapa" not in st.session_state: | |
| st.session_state.mostrar_mapa = False | |
| if "refresh_key" not in st.session_state: | |
| st.session_state.refresh_key = 0 | |
| if "ruta_seleccion" not in st.session_state: | |
| st.session_state.ruta_seleccion = [] | |
| if "consulta_activa" not in st.session_state: | |
| st.session_state.consulta_activa = False | |
| if "datos_ruta_consultados" not in st.session_state: | |
| st.session_state.datos_ruta_consultados = [] | |
| import streamlit as st | |
| from st_supabase_connection import SupabaseConnection | |
| # 1. CONFIGURACIÓN INICIAL Y CONEXIÓN | |
| st.set_page_config( | |
| page_title="CobroYa", | |
| page_icon="https://dqwqrzbskjzxjgihqrzc.supabase.co/storage/v1/object/public/logo/Adobe%20Express%20-%20file.png", | |
| layout="wide", | |
| initial_sidebar_state="collapsed" | |
| ) | |
| # LIMPIEZA VISUAL (SIN TOCAR EL MENÚ DE HAMBURGUESA) | |
| st.markdown(""" | |
| <style> | |
| /* Quita la barra decorativa superior pero deja los botones funcionales */ | |
| header[data-testid="stHeader"] { | |
| background-color: rgba(0,0,0,0); /* La hace transparente */ | |
| color: transparent; | |
| } | |
| /* Quita el pie de página "Made with Streamlit" */ | |
| footer {visibility: hidden;} | |
| /* Ajusta el espacio para que el contenido suba hasta arriba */ | |
| .block-container { | |
| padding-top: 1rem; | |
| } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| conn = st.connection("supabase", type=SupabaseConnection) | |
| # Inicializar estados de sesión | |
| if "authenticated" not in st.session_state: | |
| st.session_state.authenticated = False | |
| if "user" not in st.session_state: | |
| st.session_state.user = None | |
| if "page" not in st.session_state: | |
| st.session_state.page = "login" | |
| # --- LÓGICA DE CONTROL DE ACCESO (EL MURO) --- | |
| if not st.session_state.authenticated: | |
| # 2. TU CSS RADICAL (Intacto y Completo) | |
| st.markdown(""" | |
| <style> | |
| [data-testid="stHeader"], [data-testid="stSidebar"], footer {display: none !important;} | |
| .main .block-container { | |
| padding-top: 0rem !important; | |
| padding-bottom: 0rem !important; | |
| padding-left: 0rem !important; | |
| padding-right: 0rem !important; | |
| max-width: 100% !important; | |
| } | |
| [data-testid="stAppViewContainer"] { | |
| background: linear-gradient(90deg, #06102B 33%, #F8FAFC 33%); | |
| height: 100vh; | |
| width: 100vw; | |
| overflow: hidden; | |
| } | |
| .panel-info { | |
| width: 33vw; | |
| padding: 30px 60px; | |
| color: white; | |
| height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: space-between; | |
| } | |
| @media (max-width: 768px) { | |
| [data-testid="stAppViewContainer"] { | |
| background: #F8FAFC !important; | |
| overflow-y: auto !important; | |
| overflow-x: hidden !important; | |
| height: auto !important; | |
| } | |
| [data-testid="stHorizontalBlock"] { | |
| display: flex !important; | |
| flex-direction: column-reverse !important; | |
| align-items: center !important; | |
| gap: 0px !important; | |
| } | |
| [data-testid="column"] { | |
| width: 100% !important; | |
| flex: 1 1 100% !important; | |
| min-width: 100% !important; | |
| display: flex !important; | |
| justify-content: center !important; | |
| } | |
| [data-testid="column"] > div { | |
| width: 100% !important; | |
| max-width: 450px !important; | |
| margin: 0 auto !important; | |
| } | |
| .panel-info { | |
| width: 100vw !important; | |
| height: auto !important; | |
| background-color: #06102B !important; | |
| padding: 50px 20px !important; | |
| margin-top: 60px !important; | |
| } | |
| .main .block-container { | |
| padding: 20px 10px !important; | |
| } | |
| } | |
| .google-btn { | |
| display: flex; align-items: center; justify-content: center; gap: 10px; | |
| width: 100%; border: 1px solid #E2E8F0; border-radius: 12px; | |
| height: 45px; margin-bottom: 15px; font-weight: 500; color: #334155; | |
| cursor: pointer; | |
| } | |
| .divider { | |
| display: flex; align-items: center; text-align: center; color: #94A3B8; | |
| font-size: 12px; margin: 15px 0; | |
| } | |
| .divider::before, .divider::after { content: ''; flex: 1; border-bottom: 1px solid #F1F5F9; } | |
| .divider:not(:empty)::before { margin-right: 15px; } | |
| .divider:not(:empty)::after { margin-left: 15px; } | |
| div[data-testid="stTextInput"] input { | |
| border-radius: 10px !important; | |
| border: 1px solid #E2E8F0 !important; | |
| } | |
| .stElementContainer { | |
| margin-bottom: -10px !important; | |
| } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| # 4. RENDERIZADO DE LA ESTRUCTURA | |
| c_izq, c_der = st.columns([1, 2.03]) | |
| with c_izq: | |
| st.markdown(f""" | |
| <div class="panel-info"> | |
| <div> | |
| <img src="https://dqwqrzbskjzxjgihqrzc.supabase.co/storage/v1/object/public/logo/IMG_4803-removebg-preview%20(1).png" width="180"> | |
| <div style="margin-top: 40px;"> | |
| <h2 style="font-size: 28px; line-height: 1.2;">Tu plataforma inteligente<br>para gestionar cobros y clientes</h2> | |
| <div style="margin-top: 30px; color: #CBD5E1; font-size: 15px; line-height: 1.8;"> | |
| <p>✔️ Rápido y seguro</p> | |
| <p>✔️ Sin confirmaciones innecesarias</p> | |
| <p>✔️ Acceso desde cualquier lugar</p> | |
| </div> | |
| </div> | |
| </div> | |
| <div style="font-size: 12px; color: #64748B; padding-bottom: 20px;"> | |
| © 2026 CobroYa. Todos los derechos reservados. | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| with c_der: | |
| st.markdown('<div style="margin-top: 30px;"></div>', unsafe_allow_html=True) | |
| _, center, _ = st.columns([1, 2.5, 1]) | |
| with center: | |
| # --- VISTA: LOGIN --- | |
| if st.session_state.page == "login": | |
| st.markdown(""" | |
| <div style="text-align: center; margin-bottom: 15px;"> | |
| <img src="https://dqwqrzbskjzxjgihqrzc.supabase.co/storage/v1/object/public/logo/IMG_4803-removebg-preview%20(1).png" width="180"> | |
| <h3 style="margin-top: 15px; color: #0F172A; margin-bottom: 5px;">Bienvenido de vuelta</h3> | |
| <p style="color: #64748B; font-size: 14px;">Inicia sesión para continuar</p> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # --- ARREGLO DEL BOTÓN DE GOOGLE --- | |
| try: | |
| res_google = conn.auth.sign_in_with_oauth({"provider": "google"}) | |
| google_url = res_google.url | |
| except Exception: | |
| google_url = "#" | |
| st.markdown(f""" | |
| <a href="{google_url}" target="_self" style="text-decoration: none;"> | |
| <div style="display: flex; justify-content: center; align-items: center; border: 1px solid #CBD5E1; border-radius: 8px; padding: 10px; background-color: white; cursor: pointer; transition: 0.3s;"> | |
| <img src="https://upload.wikimedia.org/wikipedia/commons/c/c1/Google_%22G%22_logo.svg" width="20" style="margin-right: 10px;"> | |
| <span style="color: #334155; font-weight: 500; font-family: sans-serif; font-size: 15px;">Continuar con Google</span> | |
| </div> | |
| </a> | |
| """, unsafe_allow_html=True) | |
| st.markdown('<div class="divider">o continúa con tu correo</div>', unsafe_allow_html=True) | |
| # Campos de entrada | |
| email = st.text_input("Correo electrónico", placeholder="ejemplo@correo.com", key="login_email") | |
| password = st.text_input("Contraseña", type="password", placeholder="Tu contraseña", key="login_pass") | |
| col_check, col_link = st.columns([1, 1]) | |
| with col_check: | |
| st.checkbox("Recordarme") | |
| with col_link: | |
| if st.button("¿Olvidaste tu contraseña?", key="btn_forgot"): | |
| st.session_state.page = "forgot" | |
| st.rerun() | |
| # --- LÓGICA DE INICIO DE SESIÓN CORREGIDA (Lixander Edition) --- | |
| if st.button("Iniciar sesión", type="primary", use_container_width=True): | |
| if email and password: | |
| login_exitoso = False | |
| # 1. Intentamos entrar con Supabase Auth normal | |
| try: | |
| res = conn.auth.sign_in_with_password({"email": email, "password": password}) | |
| if res and res.user: | |
| usuario_id = res.user.id | |
| # 2. Verificamos si existe la tabla de dependientes (solo si ya la creaste) | |
| # Si da error porque la tabla no existe, el 'except' nos salvará y te dejará entrar. | |
| try: | |
| resp_dep = conn.table("usuarios_dependientes").select("owner_id").eq("id", usuario_id).execute() | |
| if resp_dep.data: | |
| # Es un cobrador creado por un administrador | |
| st.session_state.owner_id = resp_dep.data[0]['owner_id'] | |
| st.session_state.rol = "cobrador" | |
| else: | |
| # Es el dueño/administrador principal | |
| st.session_state.owner_id = usuario_id | |
| st.session_state.rol = "admin" | |
| except: | |
| # Si la tabla no existe aún, entras como Admin por defecto | |
| st.session_state.owner_id = usuario_id | |
| st.session_state.rol = "admin" | |
| # 3. Entramos a la App | |
| st.session_state.user = res.user | |
| st.session_state.authenticated = True | |
| login_exitoso = True | |
| st.success("¡Bienvenido a CobroYa!") | |
| st.rerun() | |
| except Exception as e: | |
| # Auth de Supabase falló, intentamos con empleados | |
| login_exitoso = False | |
| # Solo si el login con Auth falló, intentamos con empleados | |
| if not login_exitoso: | |
| try: | |
| import hashlib | |
| resp_emp = conn.table("usuarios_dependientes").select("id, password_hash, owner_id, rol, es_activo, nombre").eq("email", email).execute() | |
| if resp_emp.data: | |
| empleado = resp_emp.data[0] | |
| if not empleado.get("es_activo", False): | |
| st.error("❌ Tu cuenta ha sido desactivada. Contacta al administrador.") | |
| else: | |
| password_hash_input = hashlib.sha256(password.encode()).hexdigest() | |
| if password_hash_input == empleado.get("password_hash"): | |
| # ✅ Empleado autenticado correctamente | |
| # Guardamos datos del empleado en sesión | |
| st.session_state.empleado_id = empleado['id'] | |
| st.session_state.owner_id = empleado['owner_id'] | |
| st.session_state.rol = empleado.get('rol', 'empleado') | |
| st.session_state.nombre_empleado = empleado.get('nombre', '') | |
| # Usamos el owner_id como user.id para que todo funcione como antes | |
| class EmpleadoUser: | |
| def __init__(self, owner_id, email): | |
| self.id = owner_id | |
| self.email = email | |
| self.user_metadata = {'rol': 'empleado'} | |
| st.session_state.user = EmpleadoUser(empleado['owner_id'], email) | |
| st.session_state.authenticated = True | |
| login_exitoso = True | |
| st.success("¡Bienvenido a CobroYa!") | |
| st.rerun() | |
| else: | |
| st.error("❌ Correo o contraseña incorrectos") | |
| else: | |
| st.error("❌ Correo o contraseña incorrectos") | |
| except Exception as e: | |
| st.error("❌ Correo o contraseña incorrectos") | |
| else: | |
| st.warning("Por favor, completa todos los campos") | |
| st.write("") | |
| st.markdown('<div style="text-align: center; font-size: 14px; color: #64748B;">¿No tienes cuenta?</div>', unsafe_allow_html=True) | |
| if st.button("Crear cuenta nueva", key="btn_signup_nav", use_container_width=True): | |
| st.session_state.page = "signup" | |
| st.rerun() | |
| # --- VISTA: REGISTRO --- | |
| elif st.session_state.page == "signup": | |
| st.markdown(""" | |
| <div style="text-align: center; margin-bottom: 15px;"> | |
| <img src="https://dqwqrzbskjzxjgihqrzc.supabase.co/storage/v1/object/public/logo/IMG_4803-removebg-preview.png" width="180"> | |
| <h3 style="margin-top: 15px; color: #0F172A;">Crear cuenta en CobroYa</h3> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| reg_company = st.text_input("Nombre de la Empresa", placeholder="Nombre que aparecerá en facturas") | |
| reg_phone = st.text_input("Número de Teléfono", placeholder="Ej: 8090000000") | |
| reg_email = st.text_input("Correo electrónico", placeholder="correo@ejemplo.com") | |
| # --- NUEVO: CARGA DE LOGO EN EL REGISTRO --- | |
| st.markdown("<p style='font-size: 14px; color: #334155; font-weight: 600; margin-top: 15px; margin-bottom: 5px;'>Logo de tu Empresa (Opcional)</p>", unsafe_allow_html=True) | |
| logo_file_reg = st.file_uploader("Puedes subirlo ahora o configurarlo después", type=["png", "jpg", "jpeg"], key="logo_registro") | |
| st.markdown("<p style='font-size: 12px; color: #64748B; margin-top: 15px;'>La contraseña debe incluir letras y números obligatoriamente.</p>", unsafe_allow_html=True) | |
| reg_pass = st.text_input("Contraseña", type="password", key="reg_p1") | |
| reg_pass_conf = st.text_input("Confirmar Contraseña", type="password", key="reg_p2") | |
| if st.button("Registrarse", type="primary", use_container_width=True): | |
| if reg_pass != reg_pass_conf: | |
| st.error("❌ Las contraseñas no coinciden.") | |
| elif not (re.search("[a-zA-Z]", reg_pass) and re.search("[0-9]", reg_pass)): | |
| st.error("❌ La contraseña debe tener letras y números.") | |
| elif not reg_company or not reg_phone or not reg_email: | |
| st.error("❌ Por favor, llena los campos obligatorios (Empresa, Teléfono, Correo).") | |
| else: | |
| try: | |
| # 1. Preparar el logo si el usuario lo subió | |
| import base64 | |
| base64_logo_reg = None | |
| if logo_file_reg: | |
| bytes_data_reg = logo_file_reg.getvalue() | |
| base64_logo_reg = base64.b64encode(bytes_data_reg).decode() | |
| # 2. Crear la cuenta en el sistema Auth de Supabase | |
| auth_response = conn.auth.sign_up({ | |
| "email": reg_email, | |
| "password": reg_pass, | |
| "options": { | |
| "data": { | |
| "display_name": reg_company, | |
| "phone": reg_phone | |
| } | |
| } | |
| }) | |
| # 3. GUARDADO MAESTRO EN 'configuracion' | |
| # Extraemos el ID del usuario recién creado para vincular la tabla | |
| if auth_response and auth_response.user: | |
| nuevo_u_id = auth_response.user.id | |
| payload_config = { | |
| "user_id": nuevo_u_id, | |
| "nombre_negocio": reg_company, | |
| "telefono": reg_phone, | |
| "logo_base64": base64_logo_reg, | |
| "estado_plan": "inactivo" # Bloqueo inicial automático | |
| } | |
| # Insertamos los datos corporativos directamente | |
| conn.table("configuracion").insert(payload_config).execute() | |
| st.success("✅ ¡Cuenta creada con éxito! Todo tu perfil está listo. Ya puedes iniciar sesión.") | |
| except Exception as e: | |
| # Capturamos errores específicos (ej. correo ya existe) | |
| if "already registered" in str(e).lower(): | |
| st.error("❌ Este correo electrónico ya está registrado.") | |
| else: | |
| st.error(f"❌ Error al procesar el registro: {e}") | |
| if st.button("Volver al login", use_container_width=True): | |
| st.session_state.page = "login" | |
| st.rerun() | |
| # --- VISTA: OLVIDÓ CONTRASEÑA --- | |
| elif st.session_state.page == "forgot": | |
| st.markdown(""" | |
| <div style="text-align: center; margin-bottom: 15px;"> | |
| <img src="https://dqwqrzbskjzxjgihqrzc.supabase.co/storage/v1/object/public/logo/IMG_4803-removebg-preview.png" width="180"> | |
| <h3 style="margin-top: 15px; color: #0F172A;">Recuperar acceso</h3> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| reset_email = st.text_input("Ingresa tu correo", key="reset_email") | |
| if st.button("Enviar enlace", type="primary", use_container_width=True): | |
| st.success("Enlace enviado al correo") | |
| if st.button("Volver", use_container_width=True): | |
| st.session_state.page = "login" | |
| st.rerun() | |
| # BLOQUEO DE FLUJO | |
| st.stop() | |
| # --- SI PASA DE AQUÍ, EL USUARIO ESTÁ DENTRO --- | |
| u_id = st.session_state.user.id | |
| import urllib.parse | |
| from datetime import datetime | |
| # ===================================================================== | |
| # INICIO DEL SISTEMA DE PAYWALL Y CHECKOUT (WALL STREET EDITION) | |
| # ===================================================================== | |
| # 1. VERIFICACIÓN EN BASE DE DATOS | |
| if "estado_suscripcion" not in st.session_state: | |
| try: | |
| res_sub = conn.table("configuracion").select("estado_plan, fecha_vencimiento, nombre_negocio, rnc").eq("user_id", u_id).execute() | |
| if res_sub.data: | |
| datos_plan = res_sub.data[0] | |
| estado = datos_plan.get("estado_plan", "inactivo") | |
| fecha_v = datos_plan.get("fecha_vencimiento") | |
| st.session_state["ws_nombre"] = datos_plan.get("nombre_negocio", st.session_state.user.user_metadata.get("display_name", "Usuario")) | |
| st.session_state["ws_cedula"] = datos_plan.get("rnc", "No especificada") | |
| if estado == "activo" and fecha_v: | |
| fecha_vencimiento = datetime.strptime(fecha_v, "%Y-%m-%d").date() | |
| if datetime.now().date() <= fecha_vencimiento: | |
| st.session_state["estado_suscripcion"] = "valido" | |
| else: | |
| st.session_state["estado_suscripcion"] = "vencido" | |
| else: | |
| st.session_state["estado_suscripcion"] = "inactivo" | |
| else: | |
| st.session_state["estado_suscripcion"] = "inactivo" | |
| st.session_state["ws_nombre"] = st.session_state.user.user_metadata.get("display_name", "Usuario Nuevo") | |
| st.session_state["ws_cedula"] = "No registrada" | |
| except Exception as e: | |
| st.error(f"Error crítico en base de datos: {e}") | |
| st.stop() | |
| # ESTADO DE LA PASARELA | |
| if "plan_seleccionado" not in st.session_state: | |
| st.session_state.plan_seleccionado = None | |
| # --- SI EL USUARIO NO TIENE PLAN ACTIVO --- | |
| if st.session_state.get("estado_suscripcion") != "valido": | |
| # CSS MAESTRO PARA REPLICAR LA IMAGEN EXACTA | |
| st.markdown(""" | |
| <style> | |
| .pricing-header { text-align: center; margin-bottom: 30px; font-family: sans-serif; } | |
| .pricing-header img { width: 180px; margin-bottom: 10px; } | |
| .pricing-header h1 { font-size: 28px; font-weight: 800; color: #0f172a; margin-bottom: 5px; } | |
| .pricing-header p { font-size: 15px; color: #64748b; margin-top: 0; } | |
| .card-box { | |
| background: white; border: 1px solid #e2e8f0; border-radius: 12px; | |
| padding: 24px; text-align: center; font-family: sans-serif; height: 100%; | |
| } | |
| .card-pro { border: 2px solid #2563eb; position: relative; } | |
| .badge { | |
| background: #2563eb; color: white; font-size: 11px; font-weight: bold; | |
| padding: 4px 12px; border-radius: 20px; position: absolute; | |
| top: -12px; left: 50%; transform: translateX(-50%); letter-spacing: 1px; | |
| } | |
| .icon { font-size: 24px; color: #475569; margin-bottom: 10px; } | |
| .title { font-size: 18px; font-weight: bold; color: #0f172a; margin: 0; } | |
| .subtitle { font-size: 13px; color: #64748b; margin-bottom: 15px; } | |
| .price { font-size: 32px; font-weight: 900; color: #0f172a; margin: 0; } | |
| .price span { font-size: 14px; font-weight: normal; color: #64748b; } | |
| .annual { font-size: 12px; color: #64748b; margin-bottom: 20px; } | |
| .annual span { color: #16a34a; font-weight: 600; } | |
| .features { list-style: none; padding: 0; margin: 0 0 20px 0; text-align: left; } | |
| .features li { font-size: 13px; color: #334155; margin-bottom: 10px; border-bottom: 1px solid #f1f5f9; padding-bottom: 5px; } | |
| .features li::before { content: '✓'; color: #0f172a; font-weight: bold; margin-right: 8px; } | |
| .ideal { font-size: 11px; color: #64748b; padding: 10px; background: #f8fafc; border-radius: 6px; margin-bottom: 15px; min-height: 60px;} | |
| </style> | |
| """, unsafe_allow_html=True) | |
| # --------------------------------------------------------- | |
| # CONFIGURACIÓN: SISTEMA DE PRECIOS REGIONALES (FASE 1) | |
| # --------------------------------------------------------- | |
| PLANES_CONFIG = { | |
| "Free": {"nombre": "Free"}, | |
| "Starter": {"nombre": "Starter"}, | |
| "Pro": {"nombre": "Pro"}, | |
| "Enterprise": {"nombre": "Enterprise"} | |
| } | |
| REGION_PRICING = { | |
| "RD": {"moneda": "RD$", "codigo": "DOP", "precios": {"Free": 0, "Starter": 799, "Pro": 2499, "Enterprise": 7999, "Extra": 149}}, | |
| "LATAM_STD": {"moneda": "US$", "codigo": "USD", "precios": {"Free": 0, "Starter": 16, "Pro": 39, "Enterprise": 129, "Extra": 3}}, | |
| "LATAM_PREM": {"moneda": "US$", "codigo": "USD", "precios": {"Free": 0, "Starter": 19, "Pro": 45, "Enterprise": 139, "Extra": 4}}, | |
| "USA": {"moneda": "US$", "codigo": "USD", "precios": {"Free": 0, "Starter": 24, "Pro": 59, "Enterprise": 179, "Extra": 5}} | |
| } | |
| # Simulación de detección de región (Para la Fase 2, esto vendrá de geolocalización o billing country) | |
| region_detectada = "RD" | |
| moneda_local = REGION_PRICING[region_detectada]["moneda"] | |
| codigo_moneda = REGION_PRICING[region_detectada]["codigo"] | |
| precios_activos = REGION_PRICING[region_detectada]["precios"] | |
| # --------------------------------------------------------- | |
| # VISTA 1: APARADOR DE PLANES (REFACTORIZADO SaaS) | |
| # --------------------------------------------------------- | |
| if st.session_state.get('plan_seleccionado') is None: | |
| st.markdown(""" | |
| <div class="pricing-header" style="text-align: center; margin-bottom: 2rem;"> | |
| <img src="https://dqwqrzbskjzxjgihqrzc.supabase.co/storage/v1/object/public/logo/IMG_4803-removebg-preview%20(1).png" width="150" alt="CobroYa Logo"> | |
| <h1 style="color: #0f172a;">Planes diseñados para escalar tu negocio</h1> | |
| <p style="color: #475569;">Transparencia total. Límites claros. Cancela en cualquier momento.</p> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| col1, col2, col3, col4 = st.columns(4) | |
| with col1: | |
| st.markdown(f""" | |
| <div class="card-box" style="padding: 20px; border: 1px solid #e2e8f0; border-radius: 12px; height: 100%; background: white;"> | |
| <div class="icon" style="margin-bottom: 10px;"> | |
| <svg width="32" height="32" fill="none" stroke="#64748b" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path><polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline><line x1="12" y1="22.08" x2="12" y2="12"></line></svg> | |
| </div> | |
| <p class="title" style="font-size: 18px; font-weight: bold; margin: 5px 0; color: #0f172a;">Free</p> | |
| <p class="subtitle" style="color: #64748b; font-size: 13px;">Exploración Inteligente</p> | |
| <p class="price" style="font-size: 24px; font-weight: bold; color: #0f172a;">Gratis</p> | |
| <p class="annual" style="font-size: 11px; color: #94a3b8;">Sujeto a límites operativos</p> | |
| <hr style="border-top: 1px solid #f1f5f9; margin: 12px 0;"> | |
| <ul class="features" style="list-style-type: none; padding: 0; font-size: 13px; color: #334155; line-height: 1.6;"> | |
| <li>✓ Límite estricto de 5 clientes</li> | |
| <li>✓ Máximo 10 préstamos activos</li> | |
| <li>✓ Tabla de amortización automática</li> | |
| <li>✓ Gestión operativa básica</li> | |
| <li>✓ Contratos PDF (Incluye marca de agua)</li> | |
| <li>✓ 1 dispositivo</li> | |
| <li style="color:#94a3b8; margin-top: 8px;">✕ Dashboards y Reportes <br><span style="font-size:10px;">(Requiere plan superior)</span></li> | |
| <li style="color:#94a3b8; margin-top: 8px;">✕ Módulos IA, WhatsApp, GPS <br><span style="font-size:10px;">(Requiere plan superior)</span></li> | |
| <li style="color:#94a3b8; margin-top: 8px;">✕ Exportación de datos <br><span style="font-size:10px;">(Requiere plan superior)</span></li> | |
| </ul> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| if st.button("Usar Gratis", key="btn_f", use_container_width=True): | |
| st.session_state.plan_seleccionado = {"nombre": "Free", "precio": precios_activos["Free"]} | |
| st.rerun() | |
| with col2: | |
| st.markdown(f""" | |
| <div class="card-box" style="padding: 20px; border: 1px solid #e2e8f0; border-radius: 12px; height: 100%; background: white;"> | |
| <div class="icon" style="margin-bottom: 10px;"> | |
| <svg width="32" height="32" fill="none" stroke="#0f172a" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg> | |
| </div> | |
| <p class="title" style="font-size: 18px; font-weight: bold; margin: 5px 0; color: #0f172a;">Starter</p> | |
| <p class="subtitle" style="color: #64748b; font-size: 13px;">Prestamista Individual</p> | |
| <p class="price" style="font-size: 24px; font-weight: bold; color: #0f172a;">{moneda_local}{precios_activos['Starter']}<span style="font-size: 12px; color: #64748b;">/mes</span></p> | |
| <p class="annual" style="font-size: 11px; color: #94a3b8;">Facturación en {codigo_moneda}</p> | |
| <hr style="border-top: 1px solid #f1f5f9; margin: 12px 0;"> | |
| <ul class="features" style="list-style-type: none; padding: 0; font-size: 13px; color: #334155; line-height: 1.6;"> | |
| <li>✓ Capacidad para 100 clientes</li> | |
| <li>✓ Hasta 250 cuentas activas</li> | |
| <li>✓ Tabla de amortización automática</li> | |
| <li>✓ Acceso a Dashboard esencial</li> | |
| <li>✓ Gestión de cobros y pagos completa</li> | |
| <li>✓ Generación PDF sin marcas</li> | |
| <li>✓ Integración WhatsApp (Básica)</li> | |
| <li>✓ GPS de ubicación básica</li> | |
| <li>✓ Asistente IA (100 consultas/mes)</li> | |
| <li>✓ Filtros de clientes</li> | |
| <li>✓ Soporte y asesoría inicial</li> | |
| <li>✓ Hasta 2 sesiones simultáneas</li> | |
| </ul> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| if st.button("Comenzar", key="btn_s", use_container_width=True): | |
| st.session_state.plan_seleccionado = {"nombre": "Starter", "precio": precios_activos["Starter"]} | |
| st.rerun() | |
| with col3: | |
| st.markdown(f""" | |
| <div class="card-box card-pro" style="padding: 20px; border: 2px solid #2563eb; border-radius: 12px; height: 100%; position: relative; background-color: #f8fafc;"> | |
| <div class="badge" style="position: absolute; top: -12px; left: 50%; transform: translateX(-50%); background: #2563eb; color: white; padding: 4px 12px; border-radius: 20px; font-size: 10px; font-weight: bold; letter-spacing: 1px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">RECOMENDADO</div> | |
| <div class="icon" style="margin-bottom: 10px;"> | |
| <svg width="32" height="32" fill="none" stroke="#2563eb" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg> | |
| </div> | |
| <p class="title" style="font-size: 18px; font-weight: bold; margin: 5px 0; color: #0f172a;">Pro</p> | |
| <p class="subtitle" style="color: #64748b; font-size: 13px;">Operación Comercial</p> | |
| <p class="price" style="font-size: 24px; font-weight: bold; color: #2563eb;">{moneda_local}{precios_activos['Pro']}<span style="font-size: 12px; color: #64748b;">/mes</span></p> | |
| <p class="annual" style="font-size: 11px; color: #94a3b8;">Facturación en {codigo_moneda}</p> | |
| <hr style="border-top: 1px solid #e2e8f0; margin: 12px 0;"> | |
| <ul class="features" style="list-style-type: none; padding: 0; font-size: 13px; color: #334155; line-height: 1.6;"> | |
| <li>✓ Capacidad para 1,000 clientes</li> | |
| <li>✓ Hasta 2,500 cuentas activas</li> | |
| <li>✓ Tabla de amortización automática</li> | |
| <li>✓ Dashboards completos operativos</li> | |
| <li>✓ Gestión de cobros y pagos completa</li> | |
| <li>✓ Generación PDF con tu marca</li> | |
| <li>✓ Integración WhatsApp</li> | |
| <li>✓ GPS con planificador de rutas</li> | |
| <li>✓ Asistente IA Avanzado (300 consultas)</li> | |
| <li>✓ Filtros de clientes avanzados</li> | |
| <li>✓ Códigos QR en recibos</li> | |
| <li>✓ Exportación parcial y reportes mensuales a solicitud</li> | |
| <li>✓ Soporte técnico prioritario</li> | |
| <li>✓ Asesoría operativa semanal a solicitud</li> | |
| <li>✓ Hasta 5 inicios de sesión simultáneos</li> | |
| </ul> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| if st.button("Comenzar ahora", key="btn_p", type="primary", use_container_width=True): | |
| st.session_state.plan_seleccionado = {"nombre": "Pro", "precio": precios_activos["Pro"]} | |
| st.rerun() | |
| with col4: | |
| # Aquí cambiamos el diseño: ya no es oscuro, es limpio pero con un borde distintivo (gris oscuro) para darle presencia corporativa | |
| st.markdown(f""" | |
| <div class="card-box" style="padding: 20px; border: 1px solid #475569; border-radius: 12px; height: 100%; background: #ffffff;"> | |
| <div class="icon" style="margin-bottom: 10px;"> | |
| <svg width="32" height="32" fill="none" stroke="#0f172a" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="2" width="16" height="20" rx="2" ry="2"></rect><path d="M9 22v-4h6v4"></path><path d="M8 6h.01"></path><path d="M16 6h.01"></path><path d="M12 6h.01"></path><path d="M12 10h.01"></path><path d="M12 14h.01"></path><path d="M16 10h.01"></path><path d="M16 14h.01"></path><path d="M8 10h.01"></path><path d="M8 14h.01"></path></svg> | |
| </div> | |
| <p class="title" style="font-size: 18px; font-weight: bold; margin: 5px 0; color: #0f172a;">Enterprise</p> | |
| <p class="subtitle" style="color: #64748b; font-size: 13px;">Infraestructura a Escala</p> | |
| <p class="price" style="font-size: 24px; font-weight: bold; color: #0f172a;">{moneda_local}{precios_activos['Enterprise']}<span style="font-size: 12px; color: #64748b;">/mes</span></p> | |
| <p class="annual" style="font-size: 11px; color: #94a3b8;">Facturación en {codigo_moneda}</p> | |
| <hr style="border-top: 1px solid #e2e8f0; margin: 12px 0;"> | |
| <ul class="features" style="list-style-type: none; padding: 0; font-size: 13px; color: #334155; line-height: 1.6;"> | |
| <li>✓ Capacidad ampliada (10,000 clientes)</li> | |
| <li>✓ Hasta 50,000 cuentas activas</li> | |
| <li>✓ Tabla de amortización automática</li> | |
| <li>✓ Dashboards predictivos </li> | |
| <li>✓ Gestión de cobros y pagos completa</li> | |
| <li>✓ Generación PDF con tu marca</li> | |
| <li>✓ Integración total a WhatsApp con recordatorios automáticos</li> | |
| <li>✓ GPS con planificador de rutas y monitoreo operativo avanzado</li> | |
| <li>✓ IA avanzada de alta capacidad (uso justo)</li> | |
| <li>✓ Filtros de clientes avanzados</li> | |
| <li>✓ Códigos QR y tu logo en recibos</li> | |
| <li>✓ Exportación total de tu base de datos</li> | |
| <li>✓ Soporte técnico y asistencia dedicada 24/7</li> | |
| <li>✓ Infraestructura de alta disponibilidad</li> | |
| <li>✓ Protocolo de Backup empresarial</li> | |
| <li>✓ Asistencia en migración de datos</li> | |
| <li>✓ Asesoría estratégica especializada a solicitud</li> | |
| <li>✓ Participación prioritaria en nuevas funcionalidades</li> | |
| <li>✓ Hasta 20 inicios de sesión simultáneos</li> | |
| </ul> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| if st.button("Seleccionar", key="btn_e", use_container_width=True): | |
| st.session_state.plan_seleccionado = {"nombre": "Enterprise", "precio": precios_activos["Enterprise"]} | |
| st.rerun() | |
| # --------------------------------------------------------- | |
| # VISTA 2: LA PASARELA (CHECKOUT / FACTURA MULTIMONEDA) | |
| # --------------------------------------------------------- | |
| else: | |
| plan_actual = st.session_state.plan_seleccionado | |
| # 1. ACTUALIZAR SUPABASE INMEDIATAMENTE (Intención de compra) | |
| try: | |
| conn.table("configuracion").update({ | |
| "tipo_plan": plan_actual["nombre"], | |
| "estado_plan": "pendiente" # Se queda pendiente de validación administrativa | |
| }).eq("user_id", u_id).execute() | |
| except Exception as e: | |
| st.warning("No se pudo pre-registrar el estado del plan en la base de datos.") | |
| # 2. DISEÑO DE LA FACTURA | |
| st.markdown("<h2 style='text-align:center; color:#0f172a; margin-bottom: 30px;'>Resumen de Facturación</h2>", unsafe_allow_html=True) | |
| _, col_center, _ = st.columns([1, 2, 1]) | |
| with col_center: | |
| es_gratis = (plan_actual["precio"] == 0) | |
| precio_display = "Gratis (Exento)" if es_gratis else f"{moneda_local}{plan_actual['precio']:,.2f} {codigo_moneda}" | |
| st.markdown(f""" | |
| <div style="background: white; border: 1px solid #cbd5e1; border-radius: 8px; padding: 25px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05); margin-bottom: 20px;"> | |
| <h4 style="margin-top:0; color:#0f172a; font-weight: 600;">Detalle de la Orden</h4> | |
| <hr style="margin: 15px 0; border-top: 1px solid #e2e8f0;"> | |
| <div style="display: flex; justify-content: space-between; font-size: 15px; margin-bottom: 12px; color:#334155;"> | |
| <span>Suscripción:</span> | |
| <strong>Plan {plan_actual["nombre"]}</strong> | |
| </div> | |
| <div style="display: flex; justify-content: space-between; font-size: 15px; margin-bottom: 12px; color:#334155;"> | |
| <span>Región de facturación:</span> | |
| <span><strong>{region_detectada}</strong></span> | |
| </div> | |
| <div style="display: flex; justify-content: space-between; font-size: 15px; margin-bottom: 12px; color:#334155;"> | |
| <span>Ciclo:</span> | |
| <span>Mensual (Renovación automática)</span> | |
| </div> | |
| <hr style="margin: 15px 0; border-top: 1px solid #e2e8f0;"> | |
| <div style="display: flex; justify-content: space-between; font-size: 22px; color: #0f172a; font-weight: bold; align-items: center;"> | |
| <span>Total a procesar:</span> | |
| <span style="color: #2563eb;">{precio_display}</span> | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| if not es_gratis: | |
| st.markdown(""" | |
| <div style="background: #f8fafc; border-left: 4px solid #2563eb; padding: 15px; border-radius: 4px; margin-bottom: 20px; border-right: 1px solid #e2e8f0; border-top: 1px solid #e2e8f0; border-bottom: 1px solid #e2e8f0;"> | |
| <div style="display: flex; align-items: center; margin-bottom: 10px;"> | |
| <svg width="20" height="20" fill="none" stroke="#2563eb" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 8px;"><rect x="2" y="5" width="20" height="14" rx="2" ry="2"></rect><line x1="2" y1="10" x2="22" y2="10"></line></svg> | |
| <h5 style="margin: 0; color:#0f172a;">Instrucciones para Transferencia</h5> | |
| </div> | |
| <p style="font-size: 14px; color:#475569; margin-bottom:8px;">Por favor, transfiere el monto exacto a una de las siguientes cuentas institucionales:</p> | |
| <ul style="font-size: 14px; color:#0f172a; padding-left: 20px; line-height: 1.6; margin-bottom: 0;"> | |
| <li><strong>Banco Popular:</strong> 123456789 (Titular: Lixander García)</li> | |
| <li><strong>Banreservas:</strong> 987654321 (Titular: Lixander García)</li> | |
| </ul> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # CONFIGURACIÓN WHATSAPP DINÁMICA | |
| import urllib.parse | |
| numero_whatsapp = "18095196889" # AQUI VA TU NUMERO REAL | |
| nombre_ws = st.session_state.get("ws_nombre", "Usuario") | |
| cedula_ws = st.session_state.get("ws_cedula", "N/A") | |
| if es_gratis: | |
| mensaje = f"Hola equipo CobroYa. Solicito la habilitación de mi cuenta en el nivel gratuito.\n\n📊 *Plan:* {plan_actual['nombre']}\n👤 *Titular:* {nombre_ws}\n🔑 *Identificador:* {str(u_id)}\n\nQuedo a la espera de confirmación." | |
| texto_boton = "Solicitar Activación" | |
| else: | |
| mensaje = f"Hola equipo CobroYa. Adjunto el comprobante de pago para la activación de mi suscripción.\n\n📊 *Suscripción:* {plan_actual['nombre']}\n🌍 *Zona:* {region_detectada}\n💵 *Importe:* {precio_display}\n👤 *Titular:* {nombre_ws}\n🪪 *Documento:* {cedula_ws}\n🔑 *Identificador:* {str(u_id)}\n\n*Comprobante adjunto:*" | |
| texto_boton = "Enviar Comprobante Oficial" | |
| link_wa = f"https://wa.me/{numero_whatsapp}?text={urllib.parse.quote(mensaje)}" | |
| st.link_button(texto_boton, link_wa, type="primary", use_container_width=True) | |
| st.markdown("<br>", unsafe_allow_html=True) | |
| if st.button("← Modificar selección de plan", use_container_width=True): | |
| st.session_state.plan_seleccionado = None | |
| st.rerun() | |
| # BLOQUEO MAESTRO: Detiene la ejecución aquí hasta que el estado en BD cambie de 'pendiente' a 'activo' | |
| st.stop() | |
| # ===================================================================== | |
| # FIN DEL SISTEMA DE PAYWALL | |
| # ===================================================================== | |
| # --- CARGA INICIAL DE CONFIGURACIÓN --- | |
| if "config_cargada" not in st.session_state: | |
| try: | |
| res_c = conn.table("configuracion").select("*").eq("user_id", u_id).execute() | |
| if res_c.data: | |
| conf = res_c.data[0] | |
| st.session_state["mis_clausulas"] = conf.get("clausulas", "Sujeto a términos legales.") | |
| # IMPORTANTE: Guardamos el logo en la sesión para el PDF | |
| st.session_state["mi_logo"] = conf.get("logo_base64", None) | |
| st.session_state["nombre_negocio"] = conf.get("nombre_negocio", "CobroYa Pro") | |
| st.session_state["direccion_negocio"] = conf.get("direccion_negocio", "Villa Altagracia, RD") | |
| st.session_state["telefono_negocio"] = conf.get("telefono_negocio", "829-000-0000") | |
| st.session_state["config_cargada"] = True | |
| else: | |
| # Valores por defecto si el usuario es nuevo | |
| st.session_state["mi_logo"] = None | |
| st.session_state["nombre_negocio"] = "CobroYa Pro" | |
| except Exception as e: | |
| st.error(f"Error cargando config: {e}") | |
| # --- 3. FUNCIONES AUXILIARES (CORREGIDAS) --- | |
| # --- FUNCIONES AUXILIARES --- | |
| # --- COLOCA ESTO ARRIBA, CERCA DE TUS OTROS IMPORTS --- | |
| def asistente_ia_cobroya(datos_negocio, pregunta_usuario): | |
| # 1. Configuración del cliente | |
| client = Groq(api_key=st.secrets["GROQ_API_KEY"]) | |
| # 2. El mensaje del sistema | |
| system_prompt = f""" | |
| Eres el Asistente Senior de Riesgos de 'CobroYa Pro'. | |
| Tu objetivo es ayudar al dueño del negocio a tomar decisiones financieras. | |
| REGLAS: Solo usa estos datos: {datos_negocio}. Habla profesional. | |
| """ | |
| # 3. Llamada al modelo (Actualizado a llama-3.3-70b-versatile) | |
| completion = client.chat.completions.create( | |
| model="llama-3.3-70b-versatile", | |
| messages=[ | |
| {"role": "system", "content": system_prompt}, | |
| {"role": "user", "content": pregunta_usuario} | |
| ] | |
| ) | |
| # 4. El retorno (Asegúrate de que esta línea esté alineada con 'completion') | |
| return completion.choices[0].message.content | |
| def obtener_contexto_privado_ia(u_id_actual): | |
| """Filtra los datos para que la IA solo vea lo que le toca al usuario logueado""" | |
| try: | |
| # Traemos solo préstamos del usuario actual con el nombre del cliente | |
| res = conn.table("prestamos").select("*, clientes(nombre)").eq("user_id", u_id_actual).execute() | |
| datos = res.data if res.data else [] | |
| if not datos: | |
| return "No hay préstamos registrados para analizar." | |
| # Construimos el 'libro de datos' exclusivo | |
| contexto = "DATOS PRIVADOS DE TU CARTERA (SOLO PARA TUS OJOS):\n" | |
| for p in datos: | |
| nombre = p.get('clientes', {}).get('nombre', 'Cliente sin nombre') | |
| contexto += f"- {nombre}: Debe RD$ {p['balance']:,.2f} | Estado: {p['estado']} | Próximo Pago: {p['proximo_pago']}\n" | |
| return contexto | |
| except Exception as e: | |
| return f"Error de seguridad al recuperar datos: {e}" | |
| # --- FUNCIONES DE LÓGICA (Ajustadas para evitar el AttributeError) --- | |
| def obtener_estado_cliente_real(cuentas_del_cliente): | |
| import datetime as dt # Importación interna para forzar que funcione | |
| if not cuentas_del_cliente: | |
| return "🟢 Al día", "#22c55e" | |
| hoy = dt.date.today() | |
| proximos_dias = hoy + dt.timedelta(days=3) | |
| atrasado = False | |
| pago_incompleto = False | |
| por_vencer = False | |
| for cuenta in cuentas_del_cliente: | |
| # Aseguramos que el balance sea numérico | |
| pendiente = float(cuenta.get('balance_pendiente') or 0) | |
| fecha_v_str = cuenta.get('proximo_pago') | |
| if pendiente > 0: | |
| if fecha_v_str: | |
| # Convertimos el texto de la base de datos a fecha real | |
| try: | |
| fecha_v = dt.datetime.strptime(str(fecha_v_str), '%Y-%m-%d').date() | |
| if fecha_v < hoy: | |
| atrasado = True | |
| elif hoy <= fecha_v <= proximos_dias: | |
| por_vencer = True | |
| else: | |
| pago_incompleto = True | |
| except: | |
| pago_incompleto = True # Si la fecha está mal, asumimos pendiente | |
| else: | |
| pago_incompleto = True | |
| if atrasado: return "🔴 Atrasado", "#ef4444" | |
| if por_vencer: return "🟡 Por Vencer", "#eab308" | |
| if pago_incompleto: return "🟠 Pago Incompleto", "#f97316" | |
| return "🟢 Al día", "#22c55e" | |
| def calcular_resumen_real(cuentas_del_cliente): | |
| # Suma simple del balance pendiente | |
| return sum(float(c.get('balance_pendiente') or 0) for c in cuentas_del_cliente) | |
| def calcular_resumen_real(cuentas_del_cliente): | |
| """ | |
| Suma el balance pendiente de todas las cuentas del cliente. | |
| """ | |
| total_deuda = sum(float(c.get('balance_pendiente', 0)) for c in cuentas_del_cliente) | |
| return total_deuda | |
| import secrets | |
| import string | |
| import qrcode | |
| import os | |
| import secrets | |
| import string | |
| from tempfile import NamedTemporaryFile | |
| from datetime import datetime | |
| import pytz | |
| from fpdf import FPDF | |
| # 1. Función para generar el Token (Criptografía de alta seguridad) | |
| def generar_token_seguridad(longitud=24): | |
| caracteres = string.ascii_letters + string.digits + "!@#$%" | |
| return "".join(secrets.choice(caracteres) for _ in range(longitud)) | |
| # 2. Función del PDF (Ajustada para Recibo Térmico y Seguridad Bancaria) | |
| def generar_pdf_recibo_pro(nombre_cliente, monto, balance, user_id, mora=0, factura_no="", token_seguridad=""): | |
| try: | |
| monto, balance, mora = float(monto), float(balance), float(mora) | |
| except: | |
| monto, balance, mora = 0.0, 0.0, 0.0 | |
| # Configuración de Hora Real RD | |
| tz_rd = pytz.timezone('America/Santo_Domingo') | |
| fecha_rd = datetime.now(tz_rd) | |
| fecha_str = fecha_rd.strftime('%d/%m/%Y %I:%M %p') | |
| # Configuración de PDF Térmico (80mm x 155mm) | |
| pdf = FPDF(format=(80, 155)) | |
| pdf.add_page() | |
| pdf.set_margins(4, 4, 4) | |
| pdf.set_auto_page_break(False) | |
| # Info del negocio (Desde session_state) | |
| nombre_negocio = st.session_state.get("nombre_negocio", "COBROYA PRO").upper() | |
| rnc = st.session_state.get("rnc", "") | |
| direccion = st.session_state.get("direccion_negocio", "Rep. Dominicana") | |
| telefono = st.session_state.get("telefono_negocio", "") | |
| # --- ENCABEZADO --- | |
| pdf.set_font("Helvetica", "B", 11) | |
| pdf.cell(72, 7, nombre_negocio, ln=True, align='C') | |
| pdf.set_font("Helvetica", "", 8) | |
| if rnc: pdf.cell(72, 4, f"RNC: {rnc}", ln=True, align='C') | |
| pdf.cell(72, 4, direccion[:40], ln=True, align='C') | |
| if telefono: pdf.cell(72, 4, f"TEL: {telefono}", ln=True, align='C') | |
| pdf.cell(72, 4, "="*35, ln=True, align='C') | |
| # --- CUERPO --- | |
| pdf.set_font("Helvetica", "B", 9) | |
| pdf.cell(72, 6, "COMPROBANTE DE COBRO", ln=True, align='C') | |
| pdf.set_font("Helvetica", "", 8) | |
| pdf.cell(72, 4, f"Fecha: {fecha_str}", ln=True, align='C') | |
| pdf.cell(72, 4, f"Factura No: {factura_no}", ln=True, align='C') | |
| pdf.cell(72, 4, f"Cliente: {nombre_cliente.upper()}", ln=True, align='C') | |
| pdf.cell(72, 4, "-"*40, ln=True, align='C') | |
| # --- VALORES --- | |
| pdf.ln(2) | |
| pdf.set_font("Helvetica", "", 10) | |
| pdf.cell(36, 6, " ABONO RECIBIDO:") | |
| pdf.set_font("Helvetica", "B", 10) | |
| pdf.cell(36, 6, f"RD$ {monto:,.2f} ", ln=True, align='R') | |
| if mora > 0: | |
| pdf.cell(36, 6, " MORA COBRADA:") | |
| pdf.cell(36, 6, f"RD$ {mora:,.2f} ", ln=True, align='R') | |
| pdf.ln(2) | |
| pdf.set_fill_color(240, 240, 240) | |
| pdf.set_font("Helvetica", "B", 11) | |
| pdf.cell(36, 8, " PENDIENTE:", fill=True) | |
| pdf.cell(36, 8, f"RD$ {balance:,.2f} ", ln=True, align='R', fill=True) | |
| # --- FIRMA --- | |
| pdf.ln(6) | |
| pdf.set_font("Helvetica", "", 7) | |
| pdf.cell(72, 4, "_______________________", ln=True, align='C') | |
| pdf.cell(72, 4, "FIRMA DEL ACREDOR", ln=True, align='C') | |
| # --- BLOQUE DE SEGURIDAD BANCARIA --- | |
| pdf.ln(4) | |
| pdf.set_font("Helvetica", "B", 6) | |
| pdf.set_text_color(100, 100, 100) # Gris oscuro | |
| pdf.cell(72, 3, "AUTENTICIDAD DIGITAL VERIFICADA", ln=True, align='C') | |
| pdf.set_font("Helvetica", "", 5) | |
| pdf.multi_cell(72, 2.5, f"Validado vía Supabase Cloud Ledger. Cifrado grado bancario AES-256. " | |
| f"Este documento es una prueba legal de pago validable.", align='C') | |
| # --- QR TEXTO PLANO (ESTRATEGIA LIMPIA) --- | |
| qr_data = ( | |
| f"RECIBO VALIDADO - {nombre_negocio}\n" | |
| f"ESTADO: TRANSACCION EXITOSA\n" | |
| f"FACTURA: {factura_no}\n" | |
| f"FECHA: {fecha_str}\n" | |
| f"CLIENTE: {nombre_cliente.upper()}\n" | |
| f"ABONO: RD$ {monto:,.2f}\n" | |
| f"PENDIENTE: RD$ {balance:,.2f}\n" | |
| f"TOKEN SEGURIDAD: {token_seguridad}" | |
| ) | |
| qr = qrcode.QRCode(version=1, box_size=10, border=1) | |
| qr.add_data(qr_data) | |
| qr.make(fit=True) | |
| img_qr = qr.make_image(fill_color="black", back_color="white") | |
| with NamedTemporaryFile(delete=False, suffix=".png") as tmpfile: | |
| img_qr.save(tmpfile.name) | |
| # Posicionamos el QR centrado | |
| pdf.image(tmpfile.name, x=27.5, y=pdf.get_y() + 2, w=25) | |
| tmp_path = tmpfile.name | |
| # Salto de página para que el QR no se pegue al texto final | |
| pdf.ln(28) | |
| pdf.set_font("Courier", "B", 6) | |
| pdf.set_text_color(150, 150, 150) | |
| pdf.cell(72, 4, f"S_ID: {token_seguridad}", ln=True, align='C') | |
| pdf.ln(2) | |
| pdf.set_text_color(0, 0, 0) | |
| pdf.set_font("Helvetica", "I", 8) | |
| pdf.cell(72, 4, "¡Gracias por su puntualidad!", ln=True, align='C') | |
| if os.path.exists(tmp_path): | |
| os.remove(tmp_path) | |
| return bytes(pdf.output()) | |
| def generar_pdf_contrato_legal(nombre_cli, cedula_cli, capital, total, cuotas_df, freq, clausulas_texto): | |
| pdf = FPDF() | |
| pdf.add_page() | |
| # Encabezado | |
| pdf.set_font("Helvetica", "B", 16) | |
| pdf.cell(190, 10, "CONTRATO DE PRESTAMO Y COMPROMISO DE PAGO", ln=True, align='C') | |
| pdf.line(10, 22, 200, 22) | |
| pdf.ln(10) | |
| # Declaración | |
| pdf.set_font("Helvetica", "", 11) | |
| nombre_clean = nombre_cli.encode('latin-1', 'replace').decode('latin-1') | |
| texto_declaracion = (f"Yo, {nombre_clean.upper()}, portador de la cedula {cedula_cli}, declaro haber recibido " | |
| f"la suma de RD$ {capital:,.2f} en calidad de prestamo, comprometiendome a pagar " | |
| f"un total de RD$ {total:,.2f} bajo los terminos acordados.") | |
| pdf.multi_cell(190, 7, texto_declaracion) | |
| pdf.ln(5) | |
| # Cláusulas | |
| pdf.set_font("Helvetica", "B", 11) | |
| pdf.cell(190, 10, "CLAUSULAS DEL COMPROMISO:", ln=True) | |
| pdf.set_font("Helvetica", "", 10) | |
| pdf.multi_cell(190, 6, clausulas_texto.encode('latin-1', 'replace').decode('latin-1')) | |
| pdf.ln(10) | |
| # Tabla | |
| pdf.set_fill_color(0, 51, 102) | |
| pdf.set_text_color(255, 255, 255) | |
| pdf.cell(30, 8, "Cuota", border=1, align='C', fill=True) | |
| pdf.cell(80, 8, "Fecha Vencimiento", border=1, align='C', fill=True) | |
| pdf.cell(80, 8, "Monto Cuota", border=1, align='C', fill=True, ln=True) | |
| pdf.set_text_color(0, 0, 0) | |
| for i, row in cuotas_df.iterrows(): | |
| if pdf.get_y() > 250: | |
| pdf.add_page() | |
| pdf.cell(30, 7, str(int(row['Nº'])), border=1, align='C') | |
| pdf.cell(80, 7, str(row['Fecha']), border=1, align='C') | |
| pdf.cell(80, 7, f"RD$ {row['Monto Cuota (RD$)']:,.2f}", border=1, align='C', ln=True) | |
| # Firmas | |
| pdf.set_y(-40) | |
| pdf.line(20, pdf.get_y(), 90, pdf.get_y()) | |
| pdf.line(120, pdf.get_y(), 190, pdf.get_y()) | |
| pdf.set_font("Helvetica", "B", 10) | |
| pdf.text(35, pdf.get_y() + 5, "FIRMA DEUDOR") | |
| pdf.text(135, pdf.get_y() + 5, "FIRMA ACREEDOR") | |
| return bytes(pdf.output()) | |
| def generar_estado_cuenta(nombre, total_prestado, pagado, pendiente, historial_pagos): | |
| pdf = FPDF() | |
| pdf.add_page() | |
| pdf.set_font("Helvetica", "B", 16) | |
| pdf.cell(190, 15, "ESTADO DE CUENTA CONSOLIDADO", ln=True, align='C') | |
| pdf.ln(5) | |
| pdf.set_fill_color(230, 240, 255) | |
| pdf.set_font("Helvetica", "B", 12) | |
| pdf.cell(63, 20, f"PRESTADO: RD$ {total_prestado:,.0f}", border=1, align='C', fill=True) | |
| pdf.cell(63, 20, f"PAGADO: RD$ {pagado:,.0f}", border=1, align='C', fill=True) | |
| pdf.cell(64, 20, f"RESTANTE: RD$ {pendiente:,.0f}", border=1, align='C', fill=True, ln=True) | |
| pdf.ln(10) | |
| pdf.set_font("Helvetica", "B", 11) | |
| pdf.cell(190, 8, "HISTORIAL DE ABONOS RECIBIDOS:", ln=True) | |
| pdf.set_font("Helvetica", "", 10) | |
| pdf.cell(60, 8, "Fecha de Pago", border=1) | |
| pdf.cell(130, 8, "Monto Abonado", border=1, ln=True) | |
| for pago in historial_pagos: | |
| pdf.cell(60, 7, str(pago['fecha_pago']), border=1) | |
| pdf.cell(130, 7, f"RD$ {pago['monto_pagado']:,.2f}", border=1, ln=True) | |
| return bytes(pdf.output()) | |
| def calcular_atraso_dinamico(fecha_proximo_pago): | |
| """Calcula el tiempo de atraso y devuelve un texto amigable y los días totales.""" | |
| if not fecha_proximo_pago: | |
| return "Sin fecha", 0 | |
| hoy = datetime.now().date() | |
| # Convertir si es string, si ya es objeto date se queda igual | |
| f_pago = datetime.strptime(fecha_proximo_pago, '%Y-%m-%d').date() if isinstance(fecha_proximo_pago, str) else fecha_proximo_pago | |
| dias_atraso = (hoy - f_pago).days | |
| if dias_atraso <= 0: | |
| return "Al día", 0 | |
| if dias_atraso < 30: | |
| return f"{dias_atraso} días", dias_atraso | |
| elif dias_atraso < 365: | |
| meses = dias_atraso // 30 | |
| dias = dias_atraso % 30 | |
| return f"{meses} mes(es) y {dias} día(s)", dias_atraso | |
| else: | |
| años = dias_atraso // 365 | |
| meses = (dias_atraso % 365) // 30 | |
| return f"{años} año(s) y {meses} mes(es)", dias_atraso | |
| def obtener_prioridad(dias, balance, impagos=0): | |
| """Calcula el Score de Prioridad basado en los pesos confirmados.""" | |
| # Pesos: Días (50%), Monto (30%), Impagos (20%) | |
| # Normalizamos el balance (ej: dividir entre 10,000) para que sea comparable a días | |
| score = (dias * 0.5) + ((balance / 1000) * 0.3) + (impagos * 0.2) | |
| return score | |
| # --- 0. INICIALIZACIÓN (Evita errores de variable no definida) --- | |
| if "menu_principal" not in st.session_state: | |
| st.session_state["menu_principal"] = "Panel de Control" | |
| # --- 1. CARGA DE DATOS SUPABASE (Lógica Intacta) --- | |
| if "user" in st.session_state and st.session_state.user: | |
| if "datos_validados" not in st.session_state: | |
| try: | |
| res = conn.table("configuracion").select("*").eq("user_id", st.session_state.user.id).execute() | |
| if res.data: | |
| conf = res.data[0] | |
| st.session_state["nombre_negocio"] = conf.get("nombre_negocio", "Mi Negocio") | |
| st.session_state["rnc"] = conf.get("rnc", "---") | |
| st.session_state["telefono_negocio"] = conf.get("telefono", "---") | |
| st.session_state["direccion_negocio"] = conf.get("direccion", "---") | |
| st.session_state["mi_logo"] = conf.get("logo_base64") | |
| st.session_state["datos_validados"] = True | |
| st.rerun() | |
| except Exception as e: | |
| st.error(f"Error: {e}") | |
| # --- 2. SIDEBAR (DISEÑO BLOQUEADO A 200PX) --- | |
| with st.sidebar: | |
| import base64 | |
| URL_LOGO_COBROYA = "https://dqwqrzbskjzxjgihqrzc.supabase.co/storage/v1/object/public/logo/IMG_4803-removebg-preview%20(1).png" | |
| # Variables de sesión | |
| biz_name = st.session_state.get("nombre_negocio", "MI NEGOCIO").upper() | |
| biz_rnc = st.session_state.get("rnc", "---") | |
| biz_dir = st.session_state.get("direccion_negocio", "---") | |
| biz_tel = st.session_state.get("telefono_negocio", "---") | |
| logo_b64 = st.session_state.get("mi_logo") | |
| u_email = st.session_state.user.email if st.session_state.get("user") else "Sesión Activa" | |
| # --- 1. SOLUCIÓN AL NAMEERROR: Definición previa de src_logo --- | |
| try: | |
| if 'logo_b64' in locals() and logo_b64: | |
| img_data = logo_b64.split(",")[1] if "," in str(logo_b64) else logo_b64 | |
| src_logo = f"data:image/png;base64,{img_data}" | |
| else: | |
| src_logo = "https://cdn-icons-png.flaticon.com/512/3135/3135715.png" | |
| except Exception: | |
| src_logo = "https://cdn-icons-png.flaticon.com/512/3135/3135715.png" | |
| # --- 1. CSS: Estética Minimalista y Profesional --- | |
| st.markdown(f""" | |
| <style> | |
| /* EXPANSIÓN Y SIDEBAR */ | |
| [data-testid="stSidebar"][aria-expanded="true"] {{ | |
| min-width: 300px !important; | |
| max-width: 300px !important; | |
| background-color: #FBFBFD !important; | |
| }} | |
| [data-testid="stSidebar"][aria-expanded="false"] {{ | |
| min-width: 0px !important; | |
| max-width: 0px !important; | |
| width: 0px !important; | |
| }} | |
| /* BOTÓN DE MENÚ */ | |
| [data-testid="stSidebarHeader"] {{ | |
| padding: 0px !important; | |
| background-color: transparent !important; | |
| }} | |
| button[data-testid="stSidebarCollapseButton"] {{ | |
| background-color: #1D1D1F !important; | |
| color: white !important; | |
| border-radius: 8px !important; | |
| margin: 10px !important; | |
| z-index: 100000 !important; | |
| }} | |
| /* LOGO AL TECHO */ | |
| [data-testid="stSidebarUserContent"] {{ | |
| padding-top: 0px !important; | |
| margin-top: -50px !important; | |
| }} | |
| .client-brand-card {{ | |
| text-align: center; | |
| padding: 15px; | |
| background: white; | |
| border-bottom: 1px solid #F2F2F7; | |
| margin-bottom: 20px; | |
| }} | |
| .client-logo-img {{ | |
| max-width: 90%; | |
| height: 55px; | |
| object-fit: contain; | |
| }} | |
| /* NAVEGACIÓN */ | |
| div[role="radiogroup"] {{ | |
| gap: 12px !important; | |
| padding-left: 10px !important; | |
| }} | |
| div[role="radio"] p {{ | |
| font-size: 14px !important; | |
| color: #1D1D1F !important; | |
| font-weight: 400; | |
| padding: 6px 0 !important; | |
| }} | |
| /* FOOTER PROFESIONAL (Minimalista) */ | |
| .absolute-footer {{ | |
| margin-top: 40px !important; | |
| padding: 20px 0px 10px 0px !important; | |
| border-top: 1px solid #F2F2F7; | |
| text-align: center; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| width: 100%; | |
| }} | |
| .powered-by {{ | |
| font-size: 9px !important; | |
| color: #A1A1A6; | |
| text-transform: uppercase; | |
| letter-spacing: 1.5px; | |
| font-weight: 500; | |
| margin-bottom: 10px; | |
| }} | |
| .footer-logo-img {{ | |
| width: 100px; /* Tamaño equilibrado para verse serio */ | |
| height: auto; | |
| opacity: 0.8; | |
| }} | |
| [data-testid="stAppViewBlockContainer"] {{ | |
| max-width: 100% !important; | |
| }} | |
| </style> | |
| """, unsafe_allow_html=True) | |
| # --- 2. CONTENIDO SUPERIOR: LOGO Y MARCA --- | |
| st.sidebar.markdown(f""" | |
| <div class="client-brand-card"> | |
| <img src="{src_logo}" class="client-logo-img"> | |
| <div style="font-family: sans-serif; margin-top: 10px;"> | |
| <b style="font-size:14px; color:#1D1D1F;">{biz_name}</b> | |
| <div style="font-size:10px; color:#86868B; margin-top:5px;"> | |
| <p style="margin:0;">RNC: {biz_rnc} | 📞 {biz_tel}</p> | |
| <p style='color:#1D1D1F; font-weight:600; margin-top:3px;'>{u_email}</p> | |
| </div> | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # --- 3. NAVEGACIÓN (Corregida para 1 solo clic) --- | |
| opciones = ["Panel de Control", "Gestión de Cobros", "👥 Todos mis Clientes", "Nueva Cuenta por Cobrar", "Cuentas por Pagar", "IA Predictiva", "Configuración"] | |
| mapeo_visual = { | |
| "Panel de Control": "🏠 Panel de Control", | |
| "Gestión de Cobros": "💰 Gestión de Cobros", | |
| "👥 Todos mis Clientes": "👥 Todos mis Clientes", | |
| "Nueva Cuenta por Cobrar": "➕ Nueva Cuenta por Cobrar", | |
| "Cuentas por Pagar": "📉 Cuentas por Pagar", | |
| "IA Predictiva": "🧠 IA Predictiva", | |
| "Configuración": "⚙️ Configuración" | |
| } | |
| # Al usar key="menu_principal", el radio lee y escribe | |
| # directamente en st.session_state["menu_principal"] | |
| menu = st.sidebar.radio( | |
| "NAV", | |
| opciones, | |
| key="menu_principal", | |
| format_func=lambda x: mapeo_visual.get(x, x), | |
| label_visibility="collapsed" | |
| ) | |
| # --- 4. FOOTER: DISTRIBUCIÓN DE EMPRESA SERIA --- | |
| st.sidebar.markdown(f""" | |
| <div class="absolute-footer"> | |
| <span class="powered-by">Powered by Lixander García</span> | |
| <img src="https://dqwqrzbskjzxjgihqrzc.supabase.co/storage/v1/object/public/logo/IMG_4803-removebg-preview%20(1).png" | |
| class="footer-logo-img" | |
| onerror="this.style.display='none'"> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| if menu == "Panel de Control": | |
| import pandas as pd | |
| import plotly.express as px | |
| from datetime import datetime, timedelta | |
| if menu == "Panel de Control": | |
| import pandas as pd | |
| import plotly.express as px | |
| from datetime import datetime, timedelta | |
| # --- 1. ESTILOS CSS (DISEÑO PREMIUM REPLICADO) --- | |
| st.markdown(""" | |
| <style> | |
| .main { background-color: #F8FAFC; } | |
| .kpi-card { | |
| background: #FFFFFF; | |
| border-radius: 14px; | |
| padding: 18px 18px 0px 18px; | |
| border: 1px solid #E2E8F0; | |
| display: flex; | |
| flex-direction: column; | |
| position: relative; | |
| overflow: hidden; | |
| box-shadow: 0 4px 6px -1px rgba(15, 23, 42, 0.05); | |
| } | |
| .border-blue { border-bottom: 4px solid #2563EB; } | |
| .border-green { border-bottom: 4px solid #16A34A; } | |
| .border-red { border-bottom: 4px solid #EF4444; } | |
| .border-purple { border-bottom: 4px solid #7C3AED; } | |
| .icon-wrapper { | |
| width: 42px; | |
| height: 42px; | |
| border-radius: 10px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| margin-bottom: 12px; | |
| } | |
| .bg-blue-light { background-color: #DBEAFE; color: #2563EB; } | |
| .bg-green-light { background-color: #DCFCE7; color: #16A34A; } | |
| .bg-red-light { background-color: #FEE2E2; color: #EF4444; } | |
| .bg-purple-light { background-color: #EDE9FE; color: #7C3AED; } | |
| .kpi-title { color: #64748B; font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; } | |
| .val-blue { color: #2563EB; } | |
| .val-green { color: #16A34A; } | |
| .val-red { color: #EF4444; } | |
| .val-purple { color: #7C3AED; } | |
| .kpi-value { font-size: 26px; font-weight: 800; margin-top: 4px; } | |
| .section-card { | |
| background: #FFFFFF; | |
| border-radius: 16px; | |
| padding: 20px; | |
| border: 1px solid #E2E8F0; | |
| margin-bottom: 20px; | |
| } | |
| .section-title { color: #0F172A; font-size: 17px; font-weight: 700; margin-bottom: 15px; } | |
| .notif-box { background: #1E293B; color: white; padding: 15px; border-radius: 12px; margin-bottom: 20px; border-left: 5px solid #3B82F6; } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| # --- 2. NOTIFICACIONES --- | |
| notif_mensaje = None | |
| try: | |
| res_config = conn.table("configuracion").select("notificaciones_admin").eq("user_id", u_id).execute() | |
| if res_config.data: | |
| notif_mensaje = res_config.data[0].get('notificaciones_admin') | |
| except: pass | |
| if notif_mensaje: | |
| st.markdown(f"<div class='notif-box'>🔔 {notif_mensaje}</div>", unsafe_allow_html=True) | |
| # --- 3. CABECERA Y LÓGICA DE FILTROS --- | |
| col_head1, col_head2 = st.columns([2, 1]) | |
| with col_head1: | |
| st.markdown("<h2 style='color: #0F172A; font-weight: 800; margin-bottom: 0;'>Dashboard General</h2>", unsafe_allow_html=True) | |
| with col_head2: | |
| filtro_tiempo = st.selectbox("Filtrar Dashboard:", ["Todo el tiempo", "Hoy", "Últimos 7 días", "Este mes", "Último año"], label_visibility="collapsed") | |
| # Lógica de fechas para el filtro (Solo afectará a Pagos y Gastos) | |
| hoy = datetime.now() | |
| fecha_filtro = None | |
| if filtro_tiempo == "Hoy": | |
| fecha_filtro = hoy.replace(hour=0, minute=0, second=0, microsecond=0) | |
| elif filtro_tiempo == "Últimos 7 días": | |
| fecha_filtro = hoy - timedelta(days=7) | |
| elif filtro_tiempo == "Este mes": | |
| fecha_filtro = hoy.replace(day=1, hour=0, minute=0, second=0) | |
| elif filtro_tiempo == "Último año": | |
| fecha_filtro = hoy - timedelta(days=365) | |
| # --- 4. EXTRACCIÓN DE DATOS --- | |
| # A. Datos Globales (No se ven afectados por el filtro para Salud de Cartera) | |
| res_cuentas = conn.table("cuentas").select("*, cliente:clientes(nombre)").eq("user_id", u_id).execute() | |
| # Necesitamos pagos globales solo para la "Tasa de Éxito" real de la empresa | |
| res_pagos_all = conn.table("pagos").select("monto_pagado").eq("user_id", u_id).execute() | |
| # B. Datos Filtrados (Para los KPIs del Dashboard y Gráficos) | |
| q_p = conn.table("pagos").select("*").eq("user_id", u_id) | |
| q_g = conn.table("gastos").select("*").eq("user_id", u_id) | |
| if fecha_filtro: | |
| f_iso = fecha_filtro.isoformat() | |
| q_p = q_p.gte("fecha_pago", f_iso) | |
| q_g = q_g.gte("fecha_gasto", f_iso) | |
| res_pagos = q_p.execute() | |
| res_gastos = q_g.execute() | |
| # --- CÁLCULOS --- | |
| # Dashboard (Filtrado) | |
| total_recibido = sum(p['monto_pagado'] for p in res_pagos.data) if res_pagos.data else 0 | |
| total_gastos = sum(g['monto'] for g in res_gastos.data if g['estado'] == 'Pagado') if res_gastos.data else 0 | |
| # Salud y Globales (Siempre fijos) | |
| total_en_calle = sum(c['balance_pendiente'] for c in res_cuentas.data) if res_cuentas.data else 0 | |
| total_recibido_global = sum(p['monto_pagado'] for p in res_pagos_all.data) if res_pagos_all.data else 0 | |
| clientes_activos = len(set(c['cliente_id'] for c in res_cuentas.data if c['estado'] == 'Activo')) if res_cuentas.data else 0 | |
| # --- 5. TARJETAS KPI PREMIUM --- | |
| def get_sparkline(color): | |
| return f''' | |
| <svg viewBox="0 0 100 30" preserveAspectRatio="none" style="width: 100%; height: 35px; margin-top: 5px;"> | |
| <path d="M0 25 Q 15 5, 30 15 T 60 10 T 100 5" fill="none" stroke="{color}" stroke-width="2" stroke-linecap="round"/> | |
| <path d="M0 25 Q 15 5, 30 15 T 60 10 T 100 5 V 30 H 0 Z" fill="{color}" opacity="0.1"/> | |
| </svg>''' | |
| icon_calle = '<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M12 2v20m0-20L5 9m7-7l7 7"/></svg>' | |
| icon_caja = '<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M7 15h0M2 9.5h20"/></svg>' | |
| icon_gastos = '<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M23 6l-9.5 9.5-5-5L1 18"/></svg>' | |
| icon_users = '<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/></svg>' | |
| c1, c2, c3, c4 = st.columns(4) | |
| with c1: | |
| st.markdown(f'<div class="kpi-card border-blue"><div class="icon-wrapper bg-blue-light">{icon_calle}</div><div class="kpi-title">En la Calle</div><div class="kpi-value val-blue">RD$ {total_en_calle:,.0f}</div>{get_sparkline("#2563EB")}</div>', unsafe_allow_html=True) | |
| with c2: | |
| st.markdown(f'<div class="kpi-card border-green"><div class="icon-wrapper bg-green-light">{icon_caja}</div><div class="kpi-title">Recibo en Caja</div><div class="kpi-value val-green">RD$ {total_recibido:,.0f}</div>{get_sparkline("#16A34A")}</div>', unsafe_allow_html=True) | |
| with c3: | |
| st.markdown(f'<div class="kpi-card border-red"><div class="icon-wrapper bg-red-light">{icon_gastos}</div><div class="kpi-title">Gastos</div><div class="kpi-value val-red">RD$ {total_gastos:,.0f}</div>{get_sparkline("#EF4444")}</div>', unsafe_allow_html=True) | |
| with c4: | |
| st.markdown(f'<div class="kpi-card border-purple"><div class="icon-wrapper bg-purple-light">{icon_users}</div><div class="kpi-title">Clientes</div><div class="kpi-value val-purple">{clientes_activos}</div>{get_sparkline("#7C3AED")}</div>', unsafe_allow_html=True) | |
| # --- 6. SALUD DE CARTERA (INDICADORES REALES - LÓGICA DE CASCADA EXACTA) --- | |
| st.markdown("<div class='section-card'><div class='section-title'>📊 Salud de Cartera</div>", unsafe_allow_html=True) | |
| # 1. Inicializamos los contadores para el Dashboard | |
| cuentas_atrasadas = 0 | |
| cuentas_cobrar_hoy = 0 | |
| cuentas_proximos_7 = 0 | |
| monto_total_hoy = 0 | |
| # 2. Obtenemos la fecha exacta (Zona Horaria RD) igual que en tu módulo | |
| from datetime import datetime, timezone, timedelta | |
| tz_rd = timezone(timedelta(hours=-4)) | |
| hoy = datetime.now(tz_rd).date() | |
| # 3. Procesamos cada cuenta con la lógica WATERFALL de tu archivo | |
| if res_cuentas.data: | |
| for cuenta in res_cuentas.data: | |
| # Solo procesamos cuentas activas que deban dinero | |
| if float(cuenta.get('balance_pendiente', 0)) <= 0: | |
| continue | |
| cuenta_id = cuenta['id'] | |
| # PASO 1: Obtener TODOS los pagos realizados | |
| try: | |
| res_pagos_ind = conn.table("pagos").select("monto_pagado").eq("cuenta_id", cuenta_id).execute() | |
| pagos_ind = res_pagos_ind.data if res_pagos_ind.data else [] | |
| except: | |
| pagos_ind = [] | |
| # PASO 2: Obtener plan de cuotas | |
| try: | |
| res_plan = conn.table("plan_cuotas").select("numero_cuota, fecha_esperada, monto_cuota").eq("cuenta_id", cuenta_id).order("numero_cuota").execute() | |
| plan_cuotas = res_plan.data if res_plan.data else [] | |
| except: | |
| plan_cuotas = [] | |
| # PASO 3: Calcular saldo_recorrido (suma de TODOS los pagos) | |
| saldo_recorrido = sum(float(p.get('monto_pagado', 0)) for p in pagos_ind) | |
| # PASO 4: WATERFALL - Recorrer plan | |
| cuota_hoy = None | |
| cuotas_vencidas = [] | |
| cuotas_proximo_7_dias = [] | |
| monto_pendiente_esta_cuota = 0 | |
| for cuota in plan_cuotas: | |
| monto_cuota = float(cuota['monto_cuota']) | |
| fecha_cuota = pd.to_datetime(cuota['fecha_esperada']).date() | |
| # ✅ WATERFALL: ¿Hay dinero para pagar esta cuota? | |
| if saldo_recorrido >= monto_cuota: | |
| saldo_recorrido -= monto_cuota # Consumir dinero | |
| else: | |
| # SOLO AHORA comparar fechas de cuota REALMENTE pendiente | |
| if fecha_cuota < hoy: | |
| cuotas_vencidas.append(fecha_cuota) | |
| elif fecha_cuota == hoy: | |
| cuota_hoy = fecha_cuota | |
| monto_pendiente_esta_cuota += (monto_cuota - saldo_recorrido) | |
| saldo_recorrido = 0 # El saldo sobrante ya se consumió | |
| elif hoy < fecha_cuota <= (hoy + pd.Timedelta(days=7)): | |
| cuotas_proximo_7_dias.append(fecha_cuota) | |
| # --- CATEGORIZACIÓN (Exactamente igual a tus filtros) --- | |
| cumple_atrasado = len(cuotas_vencidas) > 0 | |
| cumple_cobrar_hoy = cuota_hoy is not None | |
| cumple_proximo_7 = len(cuotas_proximo_7_dias) > 0 or cumple_cobrar_hoy | |
| # Sumamos a los contadores globales del dashboard | |
| if cumple_atrasado: | |
| cuentas_atrasadas += 1 | |
| if cumple_cobrar_hoy: | |
| cuentas_cobrar_hoy += 1 | |
| monto_total_hoy += monto_pendiente_esta_cuota | |
| if cumple_proximo_7: | |
| cuentas_proximos_7 += 1 | |
| # 4. Cálculo de Tasa de Éxito Global (Pagado Histórico vs Capital en la calle) | |
| try: | |
| res_pagos_historicos = conn.table("pagos").select("monto_pagado").eq("user_id", u_id).execute() | |
| total_recibido_historico = sum(float(p['monto_pagado']) for p in res_pagos_historicos.data) if res_pagos_historicos.data else 0 | |
| tasa_exito = (total_recibido_historico / (total_recibido_historico + total_en_calle) * 100) if (total_recibido_historico + total_en_calle) > 0 else 0 | |
| except: | |
| tasa_exito = 0 | |
| # 5. Renderizado de Mini-Tarjetas (Compactas y sin gráficas) | |
| s1, s2, s3, s4 = st.columns(4) | |
| with s1: | |
| st.markdown(f''' | |
| <div style="border-left: 4px solid #3B82F6; background: #ffffff; padding: 10px 15px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); display: flex; align-items: center; gap: 12px; margin-bottom: 10px;"> | |
| <div style="background: #EFF6FF; padding: 8px; border-radius: 8px; font-size: 16px;">📅</div> | |
| <div> | |
| <div style="font-size: 11px; color: #64748B; font-weight: 600; text-transform: uppercase;">Cobrarles Hoy</div> | |
| <div style="font-size: 18px; color: #3B82F6; font-weight: 800; line-height: 1.1;">{cuentas_cobrar_hoy}</div> | |
| <div style="font-size: 10px; color: #94A3B8; margin-top: 2px;">RD$ {monto_total_hoy:,.0f} a recaudar</div> | |
| </div> | |
| </div> | |
| ''', unsafe_allow_html=True) | |
| with s2: | |
| st.markdown(f''' | |
| <div style="border-left: 4px solid #EF4444; background: #ffffff; padding: 10px 15px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); display: flex; align-items: center; gap: 12px; margin-bottom: 10px;"> | |
| <div style="background: #FEF2F2; padding: 8px; border-radius: 8px; font-size: 16px;">🚨</div> | |
| <div> | |
| <div style="font-size: 11px; color: #64748B; font-weight: 600; text-transform: uppercase;">Vencidas</div> | |
| <div style="font-size: 18px; color: #EF4444; font-weight: 800; line-height: 1.1;">{cuentas_atrasadas}</div> | |
| </div> | |
| </div> | |
| ''', unsafe_allow_html=True) | |
| with s3: | |
| st.markdown(f''' | |
| <div style="border-left: 4px solid #F59E0B; background: #ffffff; padding: 10px 15px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); display: flex; align-items: center; gap: 12px; margin-bottom: 10px;"> | |
| <div style="background: #FFFBEB; padding: 8px; border-radius: 8px; font-size: 16px;">⏳</div> | |
| <div> | |
| <div style="font-size: 11px; color: #64748B; font-weight: 600; text-transform: uppercase;">Próx. 7 Días</div> | |
| <div style="font-size: 18px; color: #F59E0B; font-weight: 800; line-height: 1.1;">{cuentas_proximos_7}</div> | |
| </div> | |
| </div> | |
| ''', unsafe_allow_html=True) | |
| with s4: | |
| st.markdown(f''' | |
| <div style="border-left: 4px solid #10B981; background: #ffffff; padding: 10px 15px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); display: flex; align-items: center; gap: 12px; margin-bottom: 10px;"> | |
| <div style="background: #ECFDF5; padding: 8px; border-radius: 8px; font-size: 16px;">✅</div> | |
| <div> | |
| <div style="font-size: 11px; color: #64748B; font-weight: 600; text-transform: uppercase;">Tasa Éxito</div> | |
| <div style="font-size: 18px; color: #10B981; font-weight: 800; line-height: 1.1;">{tasa_exito:.1f}%</div> | |
| </div> | |
| </div> | |
| ''', unsafe_allow_html=True) | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| # --- 7. GRÁFICOS INTERACTIVOS --- | |
| g1, g2 = st.columns([1.5, 1]) | |
| with g1: | |
| st.markdown("<div class='section-card'>", unsafe_allow_html=True) | |
| col_t_graf, col_t_tog = st.columns([2, 1]) | |
| col_t_graf.markdown("<div class='section-title'>📈 Evolución de Cobros</div>", unsafe_allow_html=True) | |
| tipo_grafico = col_t_tog.radio("Vista", ["Mensual", "Diario"], horizontal=True, label_visibility="collapsed") | |
| if res_pagos.data and len(res_pagos.data) > 0: | |
| df_p = pd.DataFrame(res_pagos.data) | |
| df_p['fecha_pago'] = pd.to_datetime(df_p['fecha_pago'], format='ISO8601', errors='coerce') | |
| df_p = df_p.dropna(subset=['fecha_pago']) | |
| if not df_p.empty: | |
| # Lógica del Toggle (Diario o Mensual) - CORREGIDO 'M' por 'ME' | |
| agrupacion = 'ME' if tipo_grafico == "Mensual" else 'D' | |
| df_hist = df_p.set_index('fecha_pago').resample(agrupacion)['monto_pagado'].sum().reset_index() | |
| fig_area = px.area(df_hist, x='fecha_pago', y='monto_pagado', color_discrete_sequence=['#3B82F6']) | |
| fig_area.update_traces(fillcolor='rgba(59, 130, 246, 0.2)', line=dict(width=3)) | |
| fig_area.update_layout( | |
| height=280, margin=dict(l=0,r=0,t=10,b=0), paper_bgcolor='rgba(0,0,0,0)', plot_bgcolor='rgba(0,0,0,0)', | |
| xaxis_title="", yaxis_title="", xaxis=dict(showgrid=False), yaxis=dict(showgrid=True, gridcolor='#F1F5F9') | |
| ) | |
| st.plotly_chart(fig_area, use_container_width=True) | |
| else: | |
| st.info("No hay fechas válidas.") | |
| else: | |
| st.info("Aún no hay cobros para graficar.") | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| with g2: | |
| st.markdown("<div class='section-card'><div class='section-title'>🎯 Recuperación</div>", unsafe_allow_html=True) | |
| df_pie = pd.DataFrame({'Estado': ['Recuperado', 'Pendiente'], 'Monto': [total_recibido, total_en_calle]}) | |
| fig_pie = px.pie(df_pie, values='Monto', names='Estado', hole=0.75, color='Estado', | |
| color_discrete_map={'Recuperado': '#22C55E', 'Pendiente': '#3B82F6'}) | |
| fig_pie.update_traces(textposition='none') # Ocultar texto dentro del anillo para hacerlo más limpio | |
| fig_pie.update_layout(height=280, margin=dict(l=0,r=0,t=10,b=0), showlegend=True, legend=dict(orientation="h", yanchor="bottom", y=-0.2, xanchor="center", x=0.5)) | |
| st.plotly_chart(fig_pie, use_container_width=True) | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| # --- 8. TOP DEUDORES (ESTILO LIMPIO) --- | |
| st.markdown("<div class='section-card'><div class='section-title'>🏆 Top 5 Deudores</div>", unsafe_allow_html=True) | |
| if res_cuentas.data: | |
| df_deudores = pd.DataFrame([{'Cliente': c['cliente']['nombre'], 'Balance Pendiente': c['balance_pendiente']} for c in res_cuentas.data]) | |
| if not df_deudores.empty: | |
| df_top = df_deudores.groupby('Cliente').sum().sort_values('Balance Pendiente', ascending=False).head(5).reset_index() | |
| # Quitamos el index visual para que se vea como tabla pura | |
| st.dataframe(df_top.style.format({"Balance Pendiente": "RD$ {:,.2f}"}), use_container_width=True, hide_index=True) | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| elif menu == "Gestión de Cobros": | |
| st.header("⚡ Centro de Recaudación") | |
| # Inicializar lista de ruta para el mapa | |
| if 'ruta_seleccion' not in st.session_state: | |
| st.session_state.ruta_seleccion = [] | |
| if 'refresh_key' not in st.session_state: | |
| st.session_state.refresh_key = 0 | |
| if 'consulta_activa' not in st.session_state: | |
| st.session_state.consulta_activa = False | |
| if 'mostrar_mapa' not in st.session_state: | |
| st.session_state.mostrar_mapa = False | |
| if 'datos_ruta_consultados' not in st.session_state: | |
| st.session_state.datos_ruta_consultados = [] | |
| # --- FUNCIÓN AUXILIAR: OBTENER FECHA HOY A LAS 12:00 RD --- | |
| def obtener_hoy_medidia_rd(): | |
| """Retorna la fecha de hoy a las 12:00 PM Zona Horaria RD (UTC-4)""" | |
| from datetime import datetime, timezone, timedelta | |
| # UTC-4 es la zona horaria de República Dominicana | |
| tz_rd = timezone(timedelta(hours=-4)) | |
| ahora_rd = datetime.now(tz_rd) | |
| # Retornar solo la fecha (sin hora) | |
| return ahora_rd.date() | |
| hoy = obtener_hoy_medidia_rd() # USAR ESTA FECHA PARA TODO EL MÓDULO | |
| # --- 1. FUNCIÓN DE HISTORIAL (PLAN VS REAL) CON LÓGICA DINÁMICA --- | |
| # --- 1. FUNCIÓN DE HISTORIAL (PLAN VS REAL) CON LÓGICA DINÁMICA --- | |
| def mostrar_historial_modal(item, u_id): | |
| st.subheader(f"Análisis: {item['aux_nombre']}") | |
| res_plan = conn.table("plan_cuotas").select("*").eq("cuenta_id", item['id']).order("numero_cuota").execute() | |
| res_pagos = conn.table("pagos").select("*").eq("cuenta_id", item['id']).order("fecha_pago").execute() | |
| plan = res_plan.data if res_plan.data else [] | |
| pagos = res_pagos.data if res_pagos.data else [] | |
| tab1, tab2 = st.tabs(["📅 Plan Original (Seguimiento)", "💵 Historial de Pagos Recibidos"]) | |
| with tab1: | |
| if plan: | |
| import pandas as pd | |
| from datetime import datetime, timezone, timedelta | |
| # Inyección de CSS para forzar scroll horizontal y evitar que el texto baje a otra línea | |
| st.markdown(""" | |
| <style> | |
| [data-testid="stTable"] table { | |
| white-space: nowrap !important; | |
| } | |
| [data-testid="stTable"] { | |
| overflow-x: auto !important; | |
| } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| # Función para formatear la fecha estilo: mayo 15 5:14 pm | |
| def formatear_fecha_rd(iso_str): | |
| if not iso_str: return "" | |
| try: | |
| dt_pd = pd.to_datetime(iso_str) | |
| dt = dt_pd.to_pydatetime() | |
| if dt.tzinfo is None: | |
| dt = dt.replace(tzinfo=timezone.utc) | |
| # Aplicar hora RD (UTC-4) | |
| dt_rd = dt.astimezone(timezone(timedelta(hours=-4))) | |
| meses = {1:'enero', 2:'febrero', 3:'marzo', 4:'abril', 5:'mayo', 6:'junio', | |
| 7:'julio', 8:'agosto', 9:'septiembre', 10:'octubre', 11:'noviembre', 12:'diciembre'} | |
| return f"{meses[dt_rd.month]} {dt_rd.day} {dt_rd.strftime('%I:%M %p').lower()}" | |
| except: | |
| return str(iso_str).split("T")[0] | |
| mis_pagos_lista = sorted(pagos, key=lambda x: x.get('fecha_pago', x.get('created_at', str(hoy)))) | |
| # Crear cola de pagos para distribuirlos exactamente en las cuotas | |
| pagos_disponibles = [] | |
| for p in mis_pagos_lista: | |
| pagos_disponibles.append({ | |
| 'monto_restante': float(p.get('monto_pagado', 0)), | |
| 'fecha': p.get('fecha_pago', p.get('created_at', str(hoy))) | |
| }) | |
| datos_plan = [] | |
| for p_idx, cuota in enumerate(plan): | |
| monto_original = float(cuota['monto_cuota']) | |
| monto_esperado = monto_original | |
| try: | |
| fecha_esperada = pd.to_datetime(cuota['fecha_esperada']).date() | |
| except: | |
| fecha_esperada = hoy | |
| pagos_esta_cuota = [] | |
| # Lógica para ir consumiendo los pagos disponibles y asignarlos a esta cuota | |
| while monto_esperado > 0.01 and pagos_disponibles: | |
| pago_actual = pagos_disponibles[0] | |
| if pago_actual['monto_restante'] > 0.01: | |
| if pago_actual['monto_restante'] >= monto_esperado: | |
| pagos_esta_cuota.append({'monto': monto_esperado, 'fecha': pago_actual['fecha']}) | |
| pago_actual['monto_restante'] -= monto_esperado | |
| monto_esperado = 0 | |
| else: | |
| pagos_esta_cuota.append({'monto': pago_actual['monto_restante'], 'fecha': pago_actual['fecha']}) | |
| monto_esperado -= pago_actual['monto_restante'] | |
| pago_actual['monto_restante'] = 0 | |
| pagos_disponibles.pop(0) | |
| else: | |
| pagos_disponibles.pop(0) | |
| # Variables en blanco por defecto | |
| auditoria_txt = "" | |
| fecha_real_txt = "" | |
| estado_actual = "" | |
| # Evaluando el estado según lo que se pudo cubrir | |
| if monto_esperado <= 0.01: | |
| estado_actual = "✅ COMPLETA" | |
| # Fecha real: la del último pago que completó la cuota | |
| ultima_fecha_iso = pagos_esta_cuota[-1]['fecha'] if pagos_esta_cuota else None | |
| fecha_real_txt = formatear_fecha_rd(ultima_fecha_iso) | |
| if ultima_fecha_iso: | |
| f_pago_real = pd.to_datetime(ultima_fecha_iso).date() | |
| if f_pago_real > fecha_esperada: | |
| dias = (f_pago_real - fecha_esperada).days | |
| auditoria_txt = f"{dias} días de retraso" | |
| elif f_pago_real < fecha_esperada: | |
| dias = (fecha_esperada - f_pago_real).days | |
| auditoria_txt = f"{dias} días adelantado" | |
| else: | |
| auditoria_txt = "Al día" | |
| else: | |
| auditoria_txt = "Al día" | |
| elif monto_esperado < monto_original: | |
| estado_actual = f"⚠️ INCOMPLETA. Faltó RD$ {monto_esperado:,.2f}" | |
| auditoria_txt = "Incompleto" | |
| # Si está incompleta, desglosar todos los abonos en la columna de fecha real | |
| textos_pagos = [] | |
| for p_det in pagos_esta_cuota: | |
| f_form = formatear_fecha_rd(p_det['fecha']).split(" ")[0:2] # Solo mes y día para ahorrar espacio | |
| mes_dia = " ".join(f_form) | |
| textos_pagos.append(f"RD$ {p_det['monto']:,.0f} el {mes_dia}") | |
| fecha_real_txt = " | ".join(textos_pagos) | |
| else: | |
| if fecha_esperada < hoy: | |
| estado_actual = "🚨 VENCIDA" | |
| dias_atraso = (hoy - fecha_esperada).days | |
| auditoria_txt = f"{dias_atraso} días de atraso" | |
| else: | |
| estado_actual = "⏳ PENDIENTE" | |
| auditoria_txt = "" | |
| datos_plan.append({ | |
| "Cuota #": cuota['numero_cuota'], | |
| "Fecha de Pago": cuota['fecha_esperada'], | |
| "Fecha Real del Pago": fecha_real_txt, | |
| "Monto (RD$)": f"{monto_original:,.2f}", | |
| "Estatus": estado_actual, | |
| "Auditoría de Pago": auditoria_txt | |
| }) | |
| st.table(pd.DataFrame(datos_plan)) | |
| else: | |
| st.warning("No hay un plan registrado para esta cuenta.") | |
| with tab2: | |
| if pagos: | |
| import pandas as pd | |
| df_pagos = pd.DataFrame(pagos) | |
| col_fecha = 'fecha_pago' if 'fecha_pago' in df_pagos.columns else 'created_at' | |
| df_pagos['Fecha'] = pd.to_datetime(df_pagos[col_fecha]).dt.date | |
| df_pagos['Abono Capital'] = df_pagos['monto_pagado'].apply(lambda x: f"RD$ {float(x):,.2f}") | |
| df_pagos['Mora Pagada'] = df_pagos['mora_pagada'].apply(lambda x: f"RD$ {float(x) if x else 0:,.2f}") | |
| cols_finales = ['codigo_factura', 'Fecha', 'Abono Capital', 'Mora Pagada'] | |
| df_mostrar = df_pagos[[c for c in cols_finales if c in df_pagos.columns]] | |
| df_mostrar.columns = ['Factura #', 'Fecha Cobro', 'Abono Capital', 'Mora Pagada'] | |
| st.table(df_mostrar) | |
| total_cap = sum(float(p.get('monto_pagado', 0)) for p in pagos) | |
| total_mora = sum(float(p.get('mora_pagada', 0)) if p.get('mora_pagada') else 0 for p in pagos) | |
| c1, c2 = st.columns(2) | |
| c1.metric("Total Recibido", f"RD$ {total_cap:,.2f}") | |
| c2.metric("Total Moras Recibidas", f"RD$ {total_mora:,.2f}") | |
| else: | |
| if not plan: | |
| st.info("No hay un plan registrado para esta cuenta.") | |
| else: | |
| st.info("Aún no se han registrado pagos reales.") | |
| # --- 2. FUNCIÓN DE CONFIRMACIÓN CON GENERACIÓN DE CÓDIGO --- | |
| def confirmar_cobro_modal(item, monto, fecha, mora, u_id): | |
| import random | |
| import string | |
| import secrets | |
| codigo_random = f"FAC-{''.join(random.choices(string.digits, k=4))}" | |
| st.warning(f"¿Estás seguro de registrar este pago para **{item['aux_nombre']}**?") | |
| st.markdown(f""" | |
| **Resumen del Cobro:** | |
| * 🎫 **Factura #:** {codigo_random} | |
| * 💵 **Abono:** RD$ {monto:,.2f} | |
| * ⚖️ **Mora Aplicada:** RD$ {mora:,.2f} | |
| * 📅 **Próximo Pago:** {fecha} | |
| """) | |
| c_conf1, c_conf2 = st.columns(2) | |
| with c_conf1: | |
| if st.button("✅ CONFIRMAR Y REGISTRAR", type="primary", use_container_width=True): | |
| try: | |
| conn.table("pagos").insert({ | |
| "cuenta_id": str(item['id']), | |
| "monto_pagado": float(monto), | |
| "mora_pagada": float(mora) if mora else 0, | |
| "codigo_factura": codigo_random, | |
| "user_id": str(u_id), | |
| "fecha_pago": str(datetime.now().isoformat()) | |
| }).execute() | |
| n_bal = float(item.get('balance_pendiente', 0)) - monto | |
| conn.table("cuentas").update({ | |
| "balance_pendiente": max(0, n_bal), | |
| "estado": "Saldado" if n_bal <= 0 else "Activo", | |
| "proximo_pago": str(fecha), | |
| "mora_acumulada": 0 | |
| }).eq("id", item['id']).execute() | |
| # REGISTRO DE SEGURIDAD (TOKEN) | |
| caracteres = string.ascii_letters + string.digits + "!@#$%" | |
| token_cya = "".join(secrets.choice(caracteres) for _ in range(24)) | |
| conn.table("verificacion").insert({ | |
| "codigo_seguridad": token_cya, | |
| "nombre_cliente": item['aux_nombre'], | |
| "monto_abono": float(monto), | |
| "balance_pendiente": max(0, n_bal), | |
| "empresa_recaudadora": st.session_state.get("nombre_negocio", "COBROYA PRO") | |
| }).execute() | |
| st.session_state[f"recibo_{item['id']}"] = { | |
| "monto": monto, "mora": mora if mora else 0, "pend": max(0, n_bal), "fecha": str(fecha), "factura": codigo_random, "token": token_cya | |
| } | |
| st.rerun() | |
| except Exception as e: | |
| st.error(f"Error crítico en base de datos: {e}") | |
| with c_conf2: | |
| if st.button("❌ CANCELAR", use_container_width=True): | |
| st.rerun() | |
| # --- 3. FUNCIÓN DE RECIBO FINAL --- | |
| def mostrar_recibo_modal(item, r, u_id): | |
| st.balloons() | |
| st.success(f"Pago registrado: **{r['factura']}**") | |
| c1, c2 = st.columns(2) | |
| c1.metric("Monto Cobrado", f"RD$ {r['monto']:,.2f}") | |
| c2.metric("Nuevo Balance", f"RD$ {r['pend']:,.2f}") | |
| st.divider() | |
| try: | |
| pdf_bytes = generar_pdf_recibo_pro( | |
| nombre_cliente=item['aux_nombre'], | |
| monto=r['monto'], | |
| balance=r['pend'], | |
| user_id=u_id, | |
| mora=r['mora'], | |
| factura_no=r['factura'], | |
| token_seguridad=r.get('token', 'N/A') | |
| ) | |
| st.download_button( | |
| label="🖨️ IMPRIMIR RECIBO TÉRMICO", | |
| data=pdf_bytes, | |
| file_name=f"Recibo_{r['factura']}.pdf", | |
| mime="application/pdf", | |
| type="primary", | |
| use_container_width=True | |
| ) | |
| except Exception as e: | |
| st.warning(f"No se pudo generar el PDF: {str(e)}") | |
| import urllib.parse | |
| try: | |
| clean_tel = "".join(filter(str.isdigit, str(item.get('clientes', {}).get('telefono', '')))) | |
| if clean_tel: | |
| msg = (f"✅ *RECIBO DE PAGO ({r['factura']})*\n\n" | |
| f"Cliente: *{item['aux_nombre']}*\n" | |
| f"Abono: *RD$ {r['monto']:,.2f}*\n" | |
| f"Mora: *RD$ {r['mora']:,.2f}*\n" | |
| f"Balance Pendiente: *RD$ {r['pend']:,.2f}*\n" | |
| f"Próximo Pago: {r['fecha']}\n\n" | |
| f"¡Gracias por su puntualidad!") | |
| url = f"https://wa.me/1{clean_tel}?text={urllib.parse.quote(msg)}" | |
| st.markdown(f'<a href="{url}" target="_blank"><button style="width:100%;background-color:#25D366;color:white;border:none;padding:12px;border-radius:10px;font-weight:bold;cursor:pointer;">WhatsApp 💬</button></a>', unsafe_allow_html=True) | |
| except Exception as e: | |
| st.warning(f"No se pudo preparar el enlace de WhatsApp") | |
| if st.button("✅ FINALIZAR", use_container_width=True): | |
| st.rerun() | |
| # --- 4. DETECTOR MAESTRO DE RECIBOS --- | |
| for key in list(st.session_state.keys()): | |
| if key.startswith("recibo_"): | |
| id_para_recibo = key.replace("recibo_", "") | |
| try: | |
| res_recibo = conn.table("cuentas").select("*, clientes(nombre, telefono)").eq("id", id_para_recibo).single().execute() | |
| if res_recibo.data: | |
| item_recibo = res_recibo.data | |
| item_recibo['aux_nombre'] = item_recibo.get('clientes', {}).get('nombre', 'Cliente') | |
| mostrar_recibo_modal(item_recibo, st.session_state[key], u_id) | |
| del st.session_state[key] | |
| except Exception as e: | |
| st.warning(f"Error al cargar recibo: {str(e)}") | |
| # --- 5. CONTROLES SUPERIORES --- | |
| # --- 5. CONTROLES SUPERIORES --- | |
| col_search, col_filter, col_view = st.columns([2, 1.2, 0.8]) | |
| with col_search: | |
| search_term = st.text_input("🔍 Buscar cliente...", placeholder="Nombre, Cédula o Teléfono...", label_visibility="collapsed").lower() | |
| with col_filter: | |
| opcion_filtro = st.selectbox( | |
| "Filtrar cobros", | |
| options=["📋 Todos", "🔥 Urgentes", "📅 Cobrarles Hoy", "⏳ Próx. 7 Días", "🚨 Atrasados", "🟢 Al Día"], | |
| label_visibility="collapsed" | |
| ) | |
| with col_view: | |
| modo_analisis = st.toggle("📈 Análisis", help="Ver cuentas saldadas") | |
| # --- 6. CONSULTA DE DATOS CON LÓGICA WATERFALL --- | |
| try: | |
| query = conn.table("cuentas").select("*, clientes(nombre, telefono, cedula, latitud, longitud)").eq("user_id", u_id) | |
| if modo_analisis: | |
| query = query.lte("balance_pendiente", 0) | |
| else: | |
| query = query.gt("balance_pendiente", 0) | |
| res = query.execute() | |
| except Exception as e: | |
| st.error(f"Error al cargar cuentas: {str(e)}") | |
| res = None | |
| if res and res.data: | |
| datos_procesados = [] | |
| for c in res.data: | |
| try: | |
| cliente_info = c.get('clientes', {}) | |
| nombre = cliente_info.get('nombre', 'Cliente') | |
| cedula = cliente_info.get('cedula', '') | |
| telefono = cliente_info.get('telefono', '') | |
| cuenta_id = c['id'] | |
| # PASO 1: Obtener TODOS los pagos realizados | |
| try: | |
| res_pagos = conn.table("pagos").select("monto_pagado").eq("cuenta_id", cuenta_id).execute() | |
| pagos = res_pagos.data if res_pagos.data else [] | |
| except: | |
| pagos = [] | |
| # PASO 2: Obtener plan de cuotas | |
| try: | |
| res_plan = conn.table("plan_cuotas").select("numero_cuota, fecha_esperada, monto_cuota").eq("cuenta_id", cuenta_id).order("numero_cuota").execute() | |
| plan_cuotas = res_plan.data if res_plan.data else [] | |
| except: | |
| plan_cuotas = [] | |
| # PASO 3: Calcular saldo_recorrido (suma de TODOS los pagos) | |
| saldo_recorrido = sum(float(p.get('monto_pagado', 0)) for p in pagos) | |
| # PASO 4: WATERFALL - Recorrer plan | |
| todas_pagadas = True | |
| cuota_hoy = None | |
| proxima_cuota_sin_vencer = None | |
| cuotas_vencidas = [] | |
| cuotas_proximo_7_dias = [] | |
| for cuota in plan_cuotas: | |
| monto_cuota = float(cuota['monto_cuota']) | |
| fecha_cuota = pd.to_datetime(cuota['fecha_esperada']).date() | |
| # ✅ WATERFALL: ¿Hay dinero para pagar esta cuota? | |
| if saldo_recorrido >= monto_cuota: | |
| saldo_recorrido -= monto_cuota # Consumir dinero | |
| else: | |
| todas_pagadas = False | |
| # SOLO AHORA comparar fechas de cuota REALMENTE pendiente | |
| if fecha_cuota < hoy: | |
| cuotas_vencidas.append(fecha_cuota) | |
| elif fecha_cuota == hoy: | |
| cuota_hoy = fecha_cuota | |
| if proxima_cuota_sin_vencer is None: | |
| proxima_cuota_sin_vencer = fecha_cuota | |
| elif hoy < fecha_cuota <= (hoy + pd.Timedelta(days=7)): | |
| cuotas_proximo_7_dias.append(fecha_cuota) | |
| if proxima_cuota_sin_vencer is None: | |
| proxima_cuota_sin_vencer = fecha_cuota | |
| elif proxima_cuota_sin_vencer is None: | |
| proxima_cuota_sin_vencer = fecha_cuota | |
| # --- NUEVA CATEGORIZACIÓN CORREGIDA --- | |
| cumple_atrasado = len(cuotas_vencidas) > 0 | |
| cumple_cobrar_hoy = cuota_hoy is not None | |
| # 🔥 Urgente: Atrasados O cobran hoy | |
| cumple_urgente = cumple_atrasado or cumple_cobrar_hoy | |
| # 🟢 Al Día: No tiene atraso Y no paga hoy (independientemente de si faltan cuotas futuras) | |
| cumple_al_dia = not cumple_atrasado and not cumple_cobrar_hoy | |
| cumple_proximo_7 = len(cuotas_proximo_7_dias) > 0 or cumple_cobrar_hoy | |
| categoria_filtro = "📋 Todos" | |
| dias_hasta_proxima = None | |
| dias_atraso = None | |
| if cumple_urgente: | |
| categoria_filtro = "🔥 Urgentes" | |
| # Blindaje para min() | |
| dias_atraso = (hoy - min(cuotas_vencidas)).days if cuotas_vencidas else 0 | |
| elif cumple_atrasado: | |
| categoria_filtro = "🚨 Atrasados" | |
| dias_atraso = (hoy - min(cuotas_vencidas)).days if cuotas_vencidas else 0 | |
| elif cumple_cobrar_hoy: | |
| categoria_filtro = "📅 Cobrarles Hoy" | |
| dias_hasta_proxima = 0 | |
| elif cumple_proximo_7: | |
| categoria_filtro = "⏳ Próx. 7 Días" | |
| if proxima_cuota_sin_vencer: | |
| dias_hasta_proxima = (proxima_cuota_sin_vencer - hoy).days | |
| elif cumple_al_dia: | |
| categoria_filtro = "🟢 Al Día" | |
| dias_hasta_proxima = 999 | |
| # Aplicar filtros | |
| pasa_filtro = False | |
| if opcion_filtro == "📋 Todos": | |
| pasa_filtro = True | |
| elif opcion_filtro == "🔥 Urgentes": | |
| pasa_filtro = cumple_urgente | |
| elif opcion_filtro == "📅 Cobrarles Hoy": | |
| pasa_filtro = cumple_cobrar_hoy | |
| elif opcion_filtro == "⏳ Próx. 7 Días": | |
| pasa_filtro = cumple_proximo_7 | |
| elif opcion_filtro == "🚨 Atrasados": | |
| pasa_filtro = cumple_atrasado | |
| elif opcion_filtro == "🟢 Al Día": | |
| pasa_filtro = cumple_al_dia | |
| if pasa_filtro and (search_term in nombre.lower() or search_term in str(cedula).lower() or search_term in str(telefono).lower()): | |
| c['aux_nombre'] = nombre | |
| c['aux_categoria'] = categoria_filtro | |
| c['aux_dias_atraso'] = dias_atraso if dias_atraso is not None else 0 | |
| c['aux_dias_proximidad'] = dias_hasta_proxima if dias_hasta_proxima is not None else 999 | |
| c['aux_proxima_fecha'] = proxima_cuota_sin_vencer | |
| # Blindaje adicional para aux_cuota_vencida | |
| c['aux_cuota_vencida'] = min(cuotas_vencidas) if cuotas_vencidas else None | |
| c['aux_todas_pagadas'] = todas_pagadas | |
| # --- PRIORIDAD CON BLINDAJE --- | |
| if cumple_urgente: | |
| # Si es atrasado, prioridad por días. Si es cobrar hoy solamente, base 1000. | |
| atraso_val = (hoy - min(cuotas_vencidas)).days if cuotas_vencidas else 0 | |
| c['aux_prioridad'] = 1000 + atraso_val | |
| elif cumple_atrasado: | |
| c['aux_prioridad'] = 900 + ((hoy - min(cuotas_vencidas)).days if cuotas_vencidas else 0) | |
| elif cumple_cobrar_hoy: | |
| c['aux_prioridad'] = 500 | |
| elif cumple_proximo_7: | |
| c['aux_prioridad'] = 400 - (proxima_cuota_sin_vencer - hoy).days if proxima_cuota_sin_vencer else 400 | |
| else: | |
| c['aux_prioridad'] = 0 | |
| datos_procesados.append(c) | |
| except Exception as e: | |
| # Opcional: st.write(f"Error en fila: {e}") # Para debug | |
| continue | |
| datos_processed = sorted(datos_procesados, key=lambda x: x['aux_prioridad'], reverse=True) | |
| st.markdown(f"### 📊 {len(datos_processed)} facturas encontradas") | |
| # --- BOTÓN DE CONSULTA --- | |
| # --- BOTÓN DE CONSULTA --- | |
| if len(st.session_state.ruta_seleccion) > 0: | |
| st.write("---") | |
| col_btn_c1, col_btn_c2 = st.columns([2, 1]) | |
| with col_btn_c1: | |
| if st.button("📱 Consultar Ruta de Clientes", type="primary", use_container_width=True): | |
| st.session_state.consulta_activa = True | |
| ids_seleccionados = [str(i) for i in st.session_state.ruta_seleccion] | |
| clientes_consulta = [d for d in datos_procesados if str(d['id']) in ids_seleccionados] | |
| st.session_state.datos_ruta_consultados = clientes_consulta | |
| st.session_state.mostrar_mapa = True | |
| with col_btn_c2: | |
| if st.button("🗺️ Generar Mapa", use_container_width=True, disabled=not st.session_state.consulta_activa): | |
| st.rerun() | |
| # --- PANEL DEL MAPA --- | |
| if st.session_state.consulta_activa and st.session_state.mostrar_mapa and st.session_state.datos_ruta_consultados: | |
| LIMITE_MAPS = 10 | |
| clientes_ruta_data = st.session_state.datos_ruta_consultados | |
| con_gps = [] | |
| sin_gps = [] | |
| # Validación estricta para evitar 0 o errores de coordenadas | |
| for c in clientes_ruta_data: | |
| lat = c.get('clientes', {}).get('latitud') | |
| lng = c.get('clientes', {}).get('longitud') | |
| try: | |
| # Filtro radical: si es 0, no entra a con_gps para no desviar el mapa | |
| if lat and lng and float(lat) != 0 and float(lng) != 0: | |
| con_gps.append(c) | |
| else: | |
| sin_gps.append(c['aux_nombre']) | |
| except: | |
| sin_gps.append(c['aux_nombre']) | |
| with st.expander(f"📍 Ruta Planificada: {len(con_gps)} paradas listas", expanded=True): | |
| if sin_gps: | |
| st.warning(f"⚠️ Ignorados por falta de GPS: {', '.join(sin_gps)}") | |
| if len(con_gps) > LIMITE_MAPS: | |
| st.error(f"🛑 ALTO: Google Maps gratuito solo soporta un máximo de {LIMITE_MAPS} ubicaciones por ruta.") | |
| elif con_gps: | |
| # --- 1. OBTENER UBICACIÓN ACTUAL REAL (GPS DEL MÓVIL) --- | |
| from streamlit_js_eval import get_geolocation | |
| import requests | |
| import time | |
| st.caption("📡 Obteniendo señal GPS real de tu dispositivo...") | |
| # Intentamos capturar el GPS real. | |
| # IMPORTANTE: Eliminamos el fallback de IP para que NO te mande a OREGON. | |
| loc = get_geolocation() | |
| if loc and isinstance(loc, dict) and 'coords' in loc: | |
| mi_lat = loc['coords']['latitude'] | |
| mi_lon = loc['coords']['longitude'] | |
| else: | |
| # Si el GPS falla, NO usamos IP. Usamos la ubicación del primer cliente como punto de partida | |
| # para que el mapa al menos abra en tu ciudad y no en EE.UU. | |
| st.info("⌛ Esperando señal GPS... (Si no cambia, asegúrate de dar permisos de ubicación)") | |
| mi_lat = float(con_gps[0]['clientes']['latitud']) | |
| mi_lon = float(con_gps[0]['clientes']['longitud']) | |
| # --- 2. OPTIMIZACIÓN DE RUTA PARA AHORRAR COMBUSTIBLE --- | |
| def calcular_ruta_optima(puntos, lat_inicio, lon_inicio): | |
| if not puntos: return [] | |
| ruta_ordenada = [] | |
| pendientes = puntos.copy() | |
| actual_lat, actual_lon = lat_inicio, lon_inicio | |
| while pendientes: | |
| proximo = min(pendientes, key=lambda p: ( | |
| (float(p['clientes']['latitud']) - actual_lat)**2 + | |
| (float(p['clientes']['longitud']) - actual_lon)**2 | |
| )) | |
| ruta_ordenada.append(proximo) | |
| pendientes.remove(proximo) | |
| actual_lat = float(proximo['clientes']['latitud']) | |
| actual_lon = float(proximo['clientes']['longitud']) | |
| return ruta_ordenada | |
| con_gps = calcular_ruta_optima(con_gps, mi_lat, mi_lon) | |
| st.write("📋 **Sigue la ruta en este orden (Iniciando desde tu posición):**") | |
| for i, c in enumerate(con_gps, 1): | |
| st.markdown(f"**{i}. {c['aux_nombre']}** *(ID: {c['id']})*") | |
| # --- 3. CREACIÓN DEL MAPA MULTICAPA CON FOLIUM --- | |
| import folium | |
| from streamlit_folium import folium_static | |
| m = folium.Map(location=[mi_lat, mi_lon], zoom_start=15) | |
| # Capa Satélite Esri (Predeterminada) | |
| folium.TileLayer( | |
| tiles='https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', | |
| attr='Esri', name='Vista Satélite', overlay=False, control=True, show=True | |
| ).add_to(m) | |
| # Capa Callejera OpenStreetMap | |
| folium.TileLayer('openstreetmap', name='Vista Calles').add_to(m) | |
| # --- 4. TRAZADO POR CALLES (API OSRM) --- | |
| # Esto evita las líneas rectas y dibuja sobre las calles reales | |
| puntos_osrm = f"{mi_lon},{mi_lat}" | |
| for c in con_gps: | |
| puntos_osrm += f";{c['clientes']['longitud']},{c['clientes']['latitud']}" | |
| try: | |
| url_osrm = f"http://router.project-osrm.org/route/v1/driving/{puntos_osrm}?overview=full&geometries=geojson" | |
| resp = requests.get(url_osrm, timeout=5).json() | |
| if 'routes' in resp: | |
| linea_calles = [[p[1], p[0]] for p in resp['routes'][0]['geometry']['coordinates']] | |
| folium.PolyLine(linea_calles, color="#3B82F6", weight=5, opacity=0.8).add_to(m) | |
| except: | |
| # Fallback por si el servidor de rutas está caído | |
| coords_simple = [[mi_lat, mi_lon]] + [[float(c['clientes']['latitud']), float(c['clientes']['longitud'])] for c in con_gps] | |
| folium.PolyLine(coords_simple, color="#3B82F6", weight=3, dash_array='5').add_to(m) | |
| # Marcador de INICIO (Posición detectada) | |
| folium.Marker( | |
| [mi_lat, mi_lon], | |
| tooltip="MI POSICIÓN", | |
| icon=folium.Icon(color="red", icon="user", prefix="fa") | |
| ).add_to(m) | |
| # Marcadores con NOMBRES FLOTANTES (DivIcon) | |
| for i, c in enumerate(con_gps, 1): | |
| lat_c = float(c['clientes']['latitud']) | |
| lng_c = float(c['clientes']['longitud']) | |
| html_label = f""" | |
| <div style=" | |
| font-family: sans-serif; font-size: 10px; color: white; | |
| background-color: rgba(30, 58, 138, 0.8); border-radius: 4px; | |
| padding: 2px 5px; border: 1px solid white; white-space: nowrap; font-weight: bold;"> | |
| {i}. {c['aux_nombre'][:20]} | |
| </div>""" | |
| folium.Marker( | |
| [lat_c, lng_c], | |
| icon=folium.DivIcon(html=html_label, icon_anchor=(0, 0)), | |
| tooltip=c['aux_nombre'] | |
| ).add_to(m) | |
| folium.LayerControl().add_to(m) | |
| folium_static(m, width=700, height=500) | |
| # --- 5. BOTÓN GOOGLE MAPS GPS (CON FIX DE UBICACIÓN ACTUAL) --- | |
| # Para evitar el error de Oregon, enviamos 'Current+Location' en el parámetro origin. | |
| # Esto obliga a la App de Google Maps a usar el GPS del teléfono y no la IP del servidor. | |
| import urllib.parse | |
| puntos_google = [f"{float(c['clientes']['latitud'])},{float(c['clientes']['longitud'])}" for c in con_gps] | |
| destino_final = puntos_google.pop() | |
| waypoints_encoded = urllib.parse.quote("|".join(puntos_google)) | |
| waypoints_str = f"&waypoints={waypoints_encoded}" if puntos_google else "" | |
| # Usamos Current+Location para mayor seguridad en móviles | |
| url_google_maps = f"https://www.google.com/maps/dir/?api=1&origin=Current+Location&destination={destino_final}{waypoints_str}&travelmode=driving" | |
| st.divider() | |
| col_r1, col_r2 = st.columns([1, 1]) | |
| with col_r1: | |
| st.success(f"✅ Ruta optimizada ({len(con_gps)} paradas)") | |
| with col_r2: | |
| st.link_button("🚀 INICIAR GOOGLE MAPS", url_google_maps, type="primary", use_container_width=True) | |
| else: | |
| st.error("❌ Los clientes seleccionados no tienen coordenadas válidas.") | |
| # --- BOTÓN DE LIMPIEZA ORIGINAL --- | |
| if st.button("🗑️ Cancelar y Limpiar Selección"): | |
| st.session_state.ruta_seleccion = [] | |
| st.session_state.mostrar_mapa = False | |
| st.session_state.consulta_activa = False | |
| st.session_state.datos_ruta_consultados = [] | |
| st.session_state.refresh_key += 1 | |
| st.rerun() | |
| # --- LISTA DE CLIENTES CON DISEÑO ORIGINAL --- | |
| for item in datos_procesados: | |
| token = item['id'] | |
| m_pend = float(item.get('balance_pendiente', 0)) | |
| with st.container(border=True): | |
| c_nom, c_status, c_inputs, c_btn = st.columns([1.2, 1, 1.2, 0.8]) | |
| with c_nom: | |
| col_t1, col_t2 = st.columns([0.2, 0.8]) | |
| with col_t1: | |
| def actualizar_seleccion(token_id): | |
| def callback(): | |
| if token_id not in st.session_state.ruta_seleccion: | |
| st.session_state.ruta_seleccion.append(token_id) | |
| else: | |
| st.session_state.ruta_seleccion.remove(token_id) | |
| return callback | |
| st.checkbox(" ", key=f"sel_{token}_{st.session_state.refresh_key}", | |
| value=token in st.session_state.ruta_seleccion, | |
| label_visibility="collapsed", on_change=actualizar_seleccion(token)) | |
| with col_t2: | |
| st.markdown(f"**{item['aux_nombre']}**") | |
| st.caption(f"Debe: RD$ {m_pend:,.2f}") | |
| if st.button("🔍 Ver Historial", key=f"hist_{token}", use_container_width=True): | |
| mostrar_historial_modal(item, u_id) | |
| with c_status: | |
| if modo_analisis: | |
| st.info("✅ SALDADO") | |
| elif item['aux_todas_pagadas']: | |
| st.success("🟢 Al Día") | |
| elif item['aux_dias_atraso'] is not None and item['aux_dias_atraso'] > 0: | |
| if item['aux_dias_atraso'] >= 15: | |
| st.error(f"🔥 Urgente - {item['aux_dias_atraso']} días atrasado") | |
| else: | |
| st.error(f"🚨 Atrasado - {item['aux_dias_atraso']} días") | |
| elif item['aux_dias_proximidad'] == 0: | |
| st.warning("📅 Hay que cobrarle hoy") | |
| elif item['aux_dias_proximidad'] is not None and item['aux_dias_proximidad'] > 0 and item['aux_dias_proximidad'] <= 7: | |
| st.warning(f"⏳ En {item['aux_dias_proximidad']} días debe pagar") | |
| else: | |
| st.info("📋 Próximo") | |
| with c_inputs: | |
| if not modo_analisis: | |
| cuota_acordada = float(item.get('cuota_esperada', 0)) | |
| valor_default = min(cuota_acordada, m_pend) if cuota_acordada > 0 else m_pend | |
| st.caption(f"Cuota: RD$ {cuota_acordada:,.2f}") | |
| abono_input = st.number_input("Monto", min_value=0.0, value=float(valor_default), key=f"val_{token}", label_visibility="collapsed") | |
| f_prox_input = st.date_input("Próxima", key=f"date_{token}", label_visibility="collapsed") | |
| else: | |
| st.write(f"Saldó el: {item.get('proximo_pago')}") | |
| with c_btn: | |
| if not modo_analisis: | |
| st.write("") | |
| if st.button("💵 COBRAR", key=f"reg_{token}", type="primary", use_container_width=True): | |
| v_mora = st.session_state.get(f"mora_{token}", 0.0) | |
| confirmar_cobro_modal(item, abono_input, f_prox_input, v_mora, u_id) | |
| else: | |
| if st.button("📄 Detalles", key=f"det_{token}", use_container_width=True): | |
| mostrar_historial_modal(item, u_id) | |
| if not modo_analisis: | |
| with st.expander("⚖️ Penalidad (Mora)"): | |
| st.number_input("Monto de Mora", min_value=0.0, key=f"mora_{token}") | |
| else: | |
| st.info("No hay cuentas por cobrar en tu cartera.") | |
| elif menu == "Nueva Cuenta por Cobrar": | |
| st.header("🏢 Registro de Nueva Factura") | |
| # Librería esencial para saltos de meses y días de semana exactos | |
| from dateutil.relativedelta import relativedelta, MO, TU, WE, TH, FR, SA, SU | |
| contenedor_formulario = st.empty() | |
| # 0. AUDITORÍA DE DEUDAS Y CARGA DE CLIENTES ORDENADOS | |
| # Se añade .order("fecha_registro", desc=True) para que los más recientes aparezcan primero | |
| res_cli = conn.table("clientes").select("id, nombre, cedula, telefono").eq("user_id", u_id).order("fecha_registro", desc=True).execute() | |
| res_activas = conn.table("cuentas").select("cliente_id, balance_pendiente").eq("user_id", u_id).gt("balance_pendiente", 0).execute() | |
| resumen_deudas = {} | |
| if res_activas.data: | |
| for d in res_activas.data: | |
| c_id = d['cliente_id'] | |
| resumen_deudas[c_id] = resumen_deudas.get(c_id, {'cantidad': 0, 'total': 0}) | |
| resumen_deudas[c_id]['cantidad'] += 1 | |
| resumen_deudas[c_id]['total'] += float(d['balance_pendiente']) | |
| if "prestamo_exitoso" in st.session_state: | |
| # --- UI DE ÉXITO --- | |
| with st.container(border=True): | |
| st.balloons() | |
| st.success(f"### ✅ ¡Préstamo Activado para {st.session_state.last_name}!") | |
| st.write("La cuenta y su calendario de pagos han sido registrados correctamente.") | |
| c1, c2, c3 = st.columns(3) | |
| with c1: | |
| st.download_button("📥 Descargar Contrato PDF", data=st.session_state.pdf_ready, file_name=f"Factura_{st.session_state.last_name}.pdf", use_container_width=True) | |
| with c2: | |
| st.markdown(f'''<a href="{st.session_state.wa_link}" target="_blank"><button style="width:100%; background:#25D366; color:white; border:none; padding:10px; border-radius:10px; cursor:pointer; font-weight:bold; height:45px;">💬 Enviar WhatsApp</button></a>''', unsafe_allow_html=True) | |
| with c3: | |
| if st.button("🔄 Crear otra factura", use_container_width=True): | |
| for k in ["prestamo_exitoso", "pdf_ready", "wa_link", "last_name"]: | |
| if k in st.session_state: del st.session_state[k] | |
| st.rerun() | |
| else: | |
| with contenedor_formulario.container(): | |
| if res_cli.data: | |
| # Ordenar clientes por fecha_registro de forma descendente (más recientes primero) | |
| clientes_ordenados = sorted( | |
| res_cli.data, | |
| key=lambda x: x.get('fecha_registro') or '', | |
| reverse=True | |
| ) | |
| col1, col2, col3 = st.columns(3) | |
| with col1: | |
| cliente_obj = st.selectbox( | |
| "Seleccionar Cliente", | |
| options=clientes_ordenados, | |
| index=None, | |
| placeholder="Escribe nombre, cédula o teléfono...", | |
| format_func=lambda x: f"{x.get('nombre', '')} ({x.get('cedula', 'S/C')}) - {x.get('telefono', 'S/T')}" if x else "", | |
| key="buscador_unico_final" | |
| ) | |
| capital = st.number_input( | |
| "Capital/Venta (RD$)", | |
| min_value=0.0, | |
| step=100.0, | |
| key="capital_venta_input" | |
| ) | |
| # Lógica de validación si hay un cliente seleccionado | |
| continuar = False | |
| if cliente_obj: | |
| if cliente_obj['id'] in resumen_deudas: | |
| info = resumen_deudas[cliente_obj['id']] | |
| st.error(f"⚠️ EL CLIENTE YA DEBE: RD$ {info['total']:,.2f}") | |
| continuar = st.checkbox("Autorizar nueva factura manual") | |
| else: | |
| st.success("✅ Cliente al día") | |
| continuar = True | |
| else: | |
| st.info("Por favor, selecciona un cliente para continuar.") | |
| with col2: | |
| porcentaje = st.number_input("Interés (%)", min_value=0, value=20) | |
| freq_sel = st.selectbox("Frecuencia de Pago", ["Semanal", "Quincenal", "Mensual"], index=0) | |
| # --- DÍA FIJO (MODIFICADO: SE AGREGÓ "Diario") --- | |
| # Reemplazamos "Cada 7 día" por la lógica de cobro todos los días | |
| dias_semana = { | |
| "Diario (Todos los días)": "diario", | |
| "Lunes": MO, | |
| "Martes": TU, | |
| "Miércoles": WE, | |
| "Jueves": TH, | |
| "Viernes": FR, | |
| "Sábado": SA, | |
| "Domingo": SU | |
| } | |
| if freq_sel == "Semanal": | |
| dia_input = st.selectbox("Día de cobro fijo", list(dias_semana.keys()), index=0) | |
| dia_fijo = dias_semana[dia_input] | |
| else: | |
| dia_fijo = st.number_input("Día del mes (0 = Igual a hoy)", min_value=0, max_value=31, value=0) | |
| with col3: | |
| cuotas_n = st.number_input("Cantidad de Cuotas", min_value=1, value=4) | |
| fecha_desembolso = st.date_input("Fecha de desembolso", value=datetime.now().date()) | |
| # --- MOTOR DE CÁLCULO DE FECHAS (SINTAXIS CORREGIDA) --- | |
| fechas_proyectadas = [] | |
| referencia = fecha_desembolso | |
| for i in range(cuotas_n): | |
| if freq_sel == "Semanal": | |
| # Nueva lógica para "Todos los días" | |
| if dia_fijo == "diario": | |
| next_date = referencia + relativedelta(days=i+1) | |
| elif dia_fijo is None: | |
| next_date = referencia + relativedelta(weeks=i+1) | |
| else: | |
| next_date = referencia + relativedelta(weeks=i+1, weekday=dia_fijo) | |
| elif freq_sel == "Quincenal": | |
| next_date = referencia + relativedelta(days=(i+1)*15) | |
| elif freq_sel == "Mensual": | |
| if dia_fijo == 0: | |
| next_date = referencia + relativedelta(months=i+1) | |
| else: | |
| next_date = referencia + relativedelta(months=i+1, day=dia_fijo) | |
| fechas_proyectadas.append(next_date) | |
| total_esp = capital * (1 + (porcentaje / 100)) | |
| monto_c_base = total_esp / cuotas_n if cuotas_n > 0 else 0 | |
| st.markdown("#### 📊 Resumen de la Operación") | |
| m1, m2, m3 = st.columns(3) | |
| m1.metric("Inversión", f"RD$ {capital:,.2f}") | |
| m2.metric("Ganancia", f"RD$ {total_esp - capital:,.2f}") | |
| m3.metric("Total a Cobrar", f"RD$ {total_esp:,.2f}") | |
| # Función para formatear fecha a "Día de Mes de Año" | |
| def fecha_legible(f): | |
| meses = ["enero", "febrero", "marzo", "abril", "mayo", "junio", | |
| "julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre"] | |
| return f"{f.day} de {meses[f.month - 1]} del {f.year}" | |
| # Tabla interactiva | |
| df_p = pd.DataFrame([{ | |
| "Nº": i + 1, | |
| "Fecha": fechas_proyectadas[i], | |
| "Monto Cuota (RD$)": round(monto_c_base, 2) | |
| } for i in range(cuotas_n)]) | |
| st.info("💡 **Puedes editar la fecha o el monto directamente en la tabla** si necesitas ajustar el plan.") | |
| # Mostramos el editor | |
| df_e = st.data_editor(df_p, use_container_width=True, key="editor_p") | |
| # Recalcular totales DESDE LO QUE ESTÁ EN LA TABLA EDITADA | |
| total_f = float(df_e["Monto Cuota (RD$)"].sum()) | |
| cuota_esperada_f = total_f / cuotas_n | |
| # --- LÓGICA DE WHATSAPP CON MENSAJE PREDETERMINADO --- | |
| if cliente_obj: | |
| import urllib.parse | |
| tel_raw = "".join(filter(str.isdigit, str(cliente_obj.get('telefono', '')))) | |
| # Formato internacional: agregamos el '1' si tiene 10 dígitos (RD/USA) | |
| tel_final = f"1{tel_raw}" if len(tel_raw) == 10 else tel_raw | |
| # Mensaje personalizado con negritas de WhatsApp | |
| texto_msj = ( | |
| f"Hola *{cliente_obj['nombre']}*, se ha generado tu factura en *CobroYa*.\n" | |
| f"💰 *Monto Total:* RD$ {total_f:,.2f}\n" | |
| f"🗓️ *Cuotas:* {cuotas_n}\n" | |
| f"¡Gracias por tu confianza!" | |
| ) | |
| msj_encoded = urllib.parse.quote(texto_msj) | |
| st.session_state['wa_link'] = f"https://wa.me/{tel_final}?text={msj_encoded}" | |
| if st.button("🚀 REGISTRAR Y ACTIVAR", use_container_width=True, disabled=not (capital > 0 and continuar and cliente_obj is not None)): | |
| # GENERACIÓN DE DATOS REALES PARA AUDITORÍA | |
| import uuid | |
| codigo_fac = f"FAC-{str(uuid.uuid4())[:8].upper()}" | |
| # IMPORTANTE: Tomamos la fecha de la tabla, por si el usuario la editó | |
| primera_fecha_final = df_e.iloc[0]['Fecha'] | |
| # 1. Insertar en CUENTAS (Capturando Capital Real y Código) | |
| res_c = conn.table("cuentas").insert({ | |
| "cliente_id": cliente_obj['id'], | |
| "codigo_factura": codigo_fac, | |
| "capital_puro": float(capital), | |
| "monto_inicial": total_f, | |
| "balance_pendiente": total_f, | |
| "user_id": u_id, | |
| "estado": "Activo", | |
| "proximo_pago": str(primera_fecha_final), | |
| "cuota_esperada": float(cuota_esperada_f), | |
| "frecuencia_pago": freq_sel | |
| }).execute() | |
| if res_c.data: | |
| nueva_id = res_c.data[0]['id'] | |
| # 2. Insertar PLAN_CUOTAS (Respetando cada edición de fecha y monto) | |
| filas_plan = [] | |
| for _, row in df_e.iterrows(): | |
| filas_plan.append({ | |
| "cuenta_id": nueva_id, | |
| "numero_cuota": int(row["Nº"]), | |
| "fecha_esperada": str(row["Fecha"]), | |
| "monto_cuota": float(row["Monto Cuota (RD$)"]), | |
| "estado": "Pendiente", | |
| "user_id": u_id | |
| }) | |
| conn.table("plan_cuotas").insert(filas_plan).execute() | |
| # 3. Documentos y WhatsApp | |
| pdf_out = generar_pdf_contrato_legal( | |
| cliente_obj['nombre'], cliente_obj.get('cedula', 'S/N'), | |
| float(capital), float(total_f), df_e, freq_sel, | |
| st.session_state.get("mis_clausulas", "Sujeto a mora por retraso.") | |
| ) | |
| st.session_state.pdf_ready = pdf_out | |
| st.session_state.last_name = cliente_obj['nombre'] | |
| st.session_state.prestamo_exitoso = True | |
| # Formato legible para WhatsApp | |
| import pandas as pd | |
| f_obj = pd.to_datetime(df_e.iloc[0]['Fecha']) | |
| fecha_wa = fecha_legible(f_obj) | |
| wa_msg = f"✅ *NUEVO CRÉDITO REGISTRADO*\n\n" \ | |
| f"Hola {cliente_obj['nombre']},\n" \ | |
| f"Detalles de tu cuenta:\n" \ | |
| f"📄 *Factura:* {codigo_fac}\n" \ | |
| f"💰 *Total:* RD$ {total_f:,.2f}\n" \ | |
| f"🗓️ *{cuotas_n} pagos* de RD$ {cuota_esperada_f:,.2f}\n" \ | |
| f"📅 *Primer pago:* {fecha_wa}" | |
| import requests | |
| st.session_state.wa_link = f"https://wa.me/{cliente_obj.get('telefono', '')}?text={requests.utils.quote(wa_msg)}" | |
| st.rerun() | |
| else: | |
| st.error("Error al guardar en base de datos.") | |
| # --- AQUÍ TERMINA LA SECCIÓN ANTERIOR Y EMPIEZA EL DIRECTORIO --- | |
| # --- SECCIÓN A: REGISTRO PREMIUM --- | |
| # --- SECCIÓN A: REGISTRO PREMIUM --- | |
| elif menu == "👥 Todos mis Clientes": | |
| import datetime as dt | |
| import time | |
| import pandas as pd | |
| import folium | |
| from streamlit_folium import st_folium | |
| from streamlit_js_eval import streamlit_js_eval | |
| hoy_dt = dt.date.today() | |
| # 1. MEMORIA DE SESIÓN | |
| for k in ["reg_gps", "reg_nombre", "reg_tel", "reg_ced", "reg_dir"]: | |
| if k not in st.session_state: st.session_state[k] = "" | |
| st.markdown("<h1 style='color: #1e293b; font-size: 1.6rem;'>Gestión de Cartera</h1>", unsafe_allow_html=True) | |
| # 2. REGISTRO DESPLEGABLE | |
| with st.expander("✨ Registrar Nuevo Cliente", expanded=False): | |
| st.markdown("### 🛰️ Localización Satelital") | |
| # NOTA CON SIGNO DE PREGUNTA (HELP) | |
| st.caption("⚠️ **Nota sobre precisión:** El GPS puede tener un margen de error de 5 a 50 metros.", | |
| help="Consejo para mayor precisión: Cuando estés en el terreno, asegúrate de que el celular tenga el Wi-Fi encendido (aunque no estés conectado a una red), ya que Google utiliza las redes cercanas para triangular mejor la posición que el puro satélite.") | |
| with st.container(border=True): | |
| col_gps, col_map = st.columns([1, 1.5]) | |
| with col_gps: | |
| # Motor de captura con máxima precisión | |
| pos = streamlit_js_eval( | |
| js_expressions=""" | |
| new Promise((resolve) => { | |
| if (!navigator.geolocation) { resolve("NO_SOPORTADO"); } | |
| navigator.geolocation.getCurrentPosition( | |
| (p) => resolve(p.coords.latitude + "," + p.coords.longitude), | |
| (e) => resolve("ERROR_" + e.code), | |
| { | |
| enableHighAccuracy: true, | |
| timeout: 15000, | |
| maximumAge: 0 | |
| } | |
| ) | |
| }) | |
| """, | |
| key="GPS_ENGINE_V6_FINAL" | |
| ) | |
| if st.button("🎯 CAPTURAR UBICACIÓN AHORA", use_container_width=True, type="primary"): | |
| if pos and not pos.startswith("ERROR") and pos != "NO_SOPORTADO": | |
| st.session_state.reg_gps = pos | |
| st.success("✅ Ubicación capturada") | |
| st.rerun() | |
| elif pos and pos.startswith("ERROR"): | |
| st.error("🚫 Error de señal. Revisa tus permisos de GPS.") | |
| st.session_state.reg_gps = st.text_input("📍 Coordenadas (Ajuste manual)", | |
| value=st.session_state.reg_gps, | |
| placeholder="Lat, Lon") | |
| with col_map: | |
| if st.session_state.reg_gps and "," in st.session_state.reg_gps: | |
| try: | |
| lat, lon = map(float, st.session_state.reg_gps.split(",")) | |
| m = folium.Map(location=[lat, lon], zoom_start=19) | |
| folium.Marker([lat, lon], icon=folium.Icon(color='red', icon='home')).add_to(m) | |
| st_folium(m, height=250, use_container_width=True, key=f"map_p_{lat}_{lon}") | |
| except: | |
| st.error("Formato inválido.") | |
| else: | |
| st.info("Captura ubicación para ver el mapa.") | |
| st.markdown("### 📝 Datos del Cliente") | |
| c1, c2 = st.columns(2) | |
| with c1: | |
| st.session_state.reg_nombre = st.text_input("Nombre Completo *", value=st.session_state.reg_nombre, key="f_n") | |
| st.session_state.reg_ced = st.text_input("Cédula / ID *", value=st.session_state.reg_ced, key="f_c") | |
| with c2: | |
| st.session_state.reg_tel = st.text_input("WhatsApp / Celular *", value=st.session_state.reg_tel, key="f_t") | |
| st.session_state.reg_dir = st.text_area("Referencia (Color de casa, etc.)", value=st.session_state.reg_dir, height=68, key="f_d") | |
| # --- BOTONES DE ACCIÓN (LIMPIAR Y GUARDAR) --- | |
| col_b1, col_b2 = st.columns(2) | |
| with col_b1: | |
| if st.button("🧹 LIMPIAR CAMPOS", use_container_width=True): | |
| for k in ["reg_gps", "reg_nombre", "reg_tel", "reg_ced", "reg_dir"]: | |
| st.session_state[k] = "" | |
| st.rerun() | |
| with col_b2: | |
| btn_guardar = st.button("🚀 GUARDAR EN CARTERA", use_container_width=True, type="primary") | |
| if btn_guardar: | |
| # 1. BLOQUEO TOTAL: Solo Nombre y Cédula | |
| if not st.session_state.reg_nombre or not st.session_state.reg_ced: | |
| st.error("❌ El **Nombre** y la **Cédula** son obligatorios.") | |
| else: | |
| # 2. MANEJO DE GPS OPCIONAL | |
| lat_final, lon_final = 0.0, 0.0 | |
| if not st.session_state.reg_gps: | |
| st.warning("⚠️ **Aviso:** Guardando cliente sin ubicación exacta.") | |
| else: | |
| try: | |
| lat_v, lon_v = st.session_state.reg_gps.split(",") | |
| lat_final, lon_final = float(lat_v), float(lon_v) | |
| except: | |
| pass | |
| # 3. EJECUCIÓN DEL GUARDADO | |
| try: | |
| conn.table("clientes").insert({ | |
| "nombre": st.session_state.reg_nombre, | |
| "telefono": st.session_state.reg_tel, | |
| "cedula": st.session_state.reg_ced, | |
| "direccion": st.session_state.reg_dir, | |
| "latitud": lat_final, | |
| "longitud": lon_final, | |
| "user_id": u_id, | |
| "fecha_registro": str(hoy_dt) | |
| }).execute() | |
| st.success(f"✅ ¡Cliente {st.session_state.reg_nombre} registrado!") | |
| for k in ["reg_gps", "reg_nombre", "reg_tel", "reg_ced", "reg_dir"]: | |
| st.session_state[k] = "" | |
| time.sleep(1) | |
| st.rerun() | |
| except Exception as e: | |
| st.error(f"Error: {e}") | |
| st.divider() | |
| # --- 4. CENTRO DE CONTROL DE CLIENTES (Dashboard Estratégico de Cartera) --- | |
| # 1. CARGA DE DATOS (Mantenemos tu sintaxis de carga) | |
| res_cl = conn.table("clientes").select("*").eq("user_id", u_id).order("nombre").execute() | |
| clientes_db = res_cl.data if res_cl.data else [] | |
| res_cuentas = conn.table("cuentas").select("*").execute() | |
| cuentas_db = res_cuentas.data if res_cuentas.data else [] | |
| res_pagos = conn.table("pagos").select("*").neq("es_eliminado", True).execute() | |
| pagos_db = res_pagos.data if res_pagos.data else [] | |
| res_planes = conn.table("plan_cuotas").select("*").execute() | |
| planes_db = res_planes.data if res_planes.data else [] | |
| # Configuración de fecha | |
| from datetime import timedelta, datetime | |
| import pandas as pd | |
| hoy = (datetime.now() - timedelta(hours=4)).date() | |
| # --- BARRA DE COMANDO: BUSCADOR + PILLS (NOMBRES ESTRATÉGICOS) --- | |
| col_search, col_filter = st.columns([1, 2.2]) | |
| with col_search: | |
| search_query = st.text_input("🔍", placeholder="Nombre o Cédula...", label_visibility="collapsed") | |
| with col_filter: | |
| opciones = [ | |
| "🌍 Todos los Clientes", | |
| "🔴 Atención Inmediata", | |
| "🟠 Requieren Atención Hoy", | |
| "🟡 Seguimiento Próximo", | |
| "🟢 Cartera Estable" | |
| ] | |
| sel_filtro = st.pills("Prioridad del Cliente:", opciones, selection_mode="single", default="🌍 Todos los Clientes", label_visibility="collapsed") | |
| # --- LÓGICA DE PROCESAMIENTO POR CLIENTE --- | |
| # --- LÓGICA DE PROCESAMIENTO POR CLIENTE (CORREGIDA) --- | |
| clientes_f = [] | |
| for c in clientes_db: | |
| cliente_id = c['id'] | |
| # Obtener TODAS las cuentas de este cliente | |
| cuentas_del_cliente = [ct for ct in cuentas_db if ct['cliente_id'] == cliente_id] | |
| # Variables de estado global del cliente (El peor escenario manda) | |
| peor_estado = "🟢 Cartera Estable" | |
| prioridad_num = 0 | |
| resumen_vencidas = 0 | |
| resumen_hoy = 0 | |
| resumen_proximas = 0 | |
| if cuentas_del_cliente: | |
| for cuenta in cuentas_del_cliente: | |
| cuenta_id = cuenta['id'] | |
| # Waterfall por cada cuenta específica | |
| pagos_cuenta = [p for p in pagos_db if p.get('cuenta_id') == cuenta_id] | |
| plan_cuenta = sorted([p for p in planes_db if p.get('cuenta_id') == cuenta_id], key=lambda x: x['numero_cuota']) | |
| saldo_recorrido = sum(float(p.get('monto_pagado', 0)) for p in pagos_cuenta) | |
| for cuota in plan_cuenta: | |
| monto_cuota = float(cuota['monto_cuota']) | |
| fecha_cuota = pd.to_datetime(cuota['fecha_esperada']).date() | |
| if saldo_recorrido >= monto_cuota: | |
| saldo_recorrido -= monto_cuota | |
| else: | |
| # Primera cuota pendiente encontrada | |
| if fecha_cuota < hoy: | |
| resumen_vencidas += 1 | |
| if prioridad_num < 3: | |
| peor_estado = "🔴 Atención Inmediata" | |
| prioridad_num = 3 | |
| elif fecha_cuota == hoy: | |
| resumen_hoy += 1 | |
| if prioridad_num < 2: | |
| peor_estado = "🟠 Requieren Atención Hoy" | |
| prioridad_num = 2 | |
| elif hoy < fecha_cuota <= (hoy + timedelta(days=7)): | |
| resumen_proximas += 1 | |
| if prioridad_num < 1: | |
| peor_estado = "🟡 Seguimiento Próximo" | |
| prioridad_num = 1 | |
| break | |
| # --- APLICACIÓN DE FILTRO INTELIGENTE --- | |
| pasa_filtro = False | |
| if sel_filtro == "🌍 Todos los Clientes": | |
| pasa_filtro = True | |
| else: | |
| pasa_filtro = (sel_filtro == peor_estado) | |
| # --- MATCH DE BÚSQUEDA --- | |
| if pasa_filtro: | |
| search_term = search_query.lower() | |
| match_search = not search_query or ( | |
| search_term in c['nombre'].lower() or | |
| search_term in str(c.get('cedula', '')) | |
| ) | |
| if match_search: | |
| c['meta_estado'] = peor_estado | |
| c['meta_prioridad'] = prioridad_num | |
| c['resumen_vencidas'] = resumen_vencidas | |
| c['resumen_hoy'] = resumen_hoy | |
| c['resumen_proximas'] = resumen_proximas | |
| clientes_f.append(c) | |
| # Ordenar por prioridad crítica | |
| clientes_f = sorted(clientes_f, key=lambda x: x.get('meta_prioridad', 0), reverse=True) | |
| # --- VENTANA DE HISTORIAL (MODAL REDISEÑADO "ULTRA PREMIUM") --- | |
| # --- FUNCIONES DE APOYO (Deben ir fuera del modal o antes de él) --- | |
| def reajustar_cuenta_post_borrado(c_id, monto_recuperado, balance_actual): | |
| """Recalcula el balance y el estado de la cuenta automáticamente.""" | |
| nuevo_balance = float(balance_actual) + float(monto_recuperado) | |
| # Si el balance es mayor a 0, la cuenta vuelve a estar 'AL DÍA' o 'PENDIENTE' | |
| nuevo_estado = "AL DÍA" if nuevo_balance > 0 else "PAGADA" | |
| conn.table("cuentas").update({ | |
| "balance_pendiente": nuevo_balance, | |
| "estado": nuevo_estado | |
| }).eq("id", c_id).execute() | |
| return nuevo_balance, nuevo_estado | |
| def registrar_log(accion, tabla, registro_id, antes, despues, u_id): | |
| """Registra cada movimiento para auditoría.""" | |
| conn.table("logs_financieros").insert({ | |
| "user_id": u_id, | |
| "accion": accion, | |
| "tabla_afectada": tabla, | |
| "registro_id": registro_id, | |
| "datos_antes": antes, | |
| "datos_despues": despues | |
| }).execute() | |
| import streamlit as st | |
| import pandas as pd | |
| from datetime import datetime, timedelta | |
| import time | |
| # --- 1. LÓGICA DE SEGURIDAD Y LIMPIEZA DE DATOS (PROCESAMIENTO) --- | |
| def limpiar_fecha(f): | |
| """Evita errores de ordenamiento asegurando un objeto datetime válido para el historial.""" | |
| if f is None: | |
| return datetime.now() | |
| try: | |
| # Convertir a datetime y quitar zona horaria para poder comparar correctamente | |
| return pd.to_datetime(f).replace(tzinfo=None) | |
| except: | |
| return datetime.now() | |
| def puede_gestionar_48h(fecha_registro): | |
| """Candado de seguridad de 48 horas para ediciones y borrados de abonos.""" | |
| if not fecha_registro: return False | |
| ahora = datetime.now() | |
| try: | |
| f_reg = pd.to_datetime(fecha_registro).replace(tzinfo=None) | |
| return (ahora - f_reg) < timedelta(hours=48) | |
| except: return False | |
| def registrar_log_detallado(accion, tabla, registro_id, antes, despues, u_id, nota=""): | |
| """Guarda rastro de cada movimiento para auditoría total del negocio.""" | |
| detalles = { | |
| "user_id": u_id, | |
| "accion": accion, | |
| "tabla_afectada": tabla, | |
| "registro_id": registro_id, | |
| "datos_antes": antes, | |
| "datos_despues": despues, | |
| "nota_adicional": nota, | |
| "fecha_log": datetime.now().isoformat() | |
| } | |
| conn.table("logs_financieros").insert(detalles).execute() | |
| # --- 2. MODAL DE GESTIÓN (DISEÑO APPLE-INTUITIVO Y COMPLETO) --- | |
| def modal_detalle(cliente, cuentas, pagos, u_id=None): | |
| # --- CÁLCULO DE DEUDA GLOBAL (Para la esquina superior derecha) --- | |
| mis_ctas = [ct for ct in cuentas if ct['cliente_id'] == cliente['id']] | |
| total_monto_inicial = sum(float(ct.get('monto_inicial', 0)) for ct in mis_ctas) | |
| # Sumar todos los pagos de todas las cuentas de este cliente | |
| ids_mis_ctas = [ct['id'] for ct in mis_ctas] | |
| total_abonado_global = sum(float(p['monto_pagado']) for p in pagos if p.get('cuenta_id') in ids_mis_ctas) | |
| deuda_global_pendiente = total_monto_inicial - total_abonado_global | |
| # --- CSS: TU DISEÑO PREMIUM --- | |
| st.markdown(f""" | |
| <style> | |
| .global-debt-container {{ | |
| text-align: right; background: #fff5f5; padding: 12px; | |
| border-radius: 12px; border: 1px solid #feb2b2; margin-bottom: 15px; | |
| }} | |
| .resumen-box {{ | |
| background-color: #f8faff; border: 1px solid #e1e8f0; | |
| border-radius: 12px; padding: 15px; font-size: 0.9rem; | |
| }} | |
| .resumen-item {{ display: flex; justify-content: space-between; margin-bottom: 5px; color: #4a5568; }} | |
| .resumen-valor {{ font-weight: 700; color: #2d3748; }} | |
| .narrativa-apertura {{ | |
| background-color: #f0f7ff; padding: 15px; border-radius: 10px; | |
| font-size: 0.95rem; color: #1e3a8a; border-left: 5px solid #1a73e8; | |
| }} | |
| .movimiento-row {{ | |
| padding: 12px; margin-bottom: 8px; border-radius: 8px; | |
| display: flex; justify-content: space-between; align-items: center; | |
| box-shadow: 0 2px 4px rgba(0,0,0,0.05); background: white; | |
| }} | |
| .status-verde {{ border-left: 5px solid #28a745; }} | |
| </style> | |
| """, unsafe_allow_html=True) | |
| # --- CABECERA CON DEUDA TOTAL --- | |
| col_tit, col_bal = st.columns([2, 1]) | |
| with col_tit: | |
| st.title(f"👤 {cliente['nombre'].upper()}") | |
| st.caption(f"ID: {cliente.get('id')} | Cédula: {cliente.get('cedula', 'N/A')}") | |
| with col_bal: | |
| st.markdown(f""" | |
| <div class="global-debt-container"> | |
| <small style="color: #c53030; font-weight: 600;">DEUDA TOTAL PENDIENTE</small><br> | |
| <span style="font-size: 1.4rem; font-weight: 800; color: #c53030;">RD$ {deuda_global_pendiente:,.2f}</span> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| tab_plan, tab_historial, tab_abonos, tab_perfil = st.tabs([ | |
| "📅 PLAN IDEAL", "📜 HISTORIAL COMPLETO", "💵 ABONOS REALES", "⚙️ PERFIL" | |
| ]) | |
| # --- PESTAÑA 1: HISTORIAL COMPLETO --- | |
| with tab_historial: | |
| for idx, ct in enumerate(mis_ctas): | |
| c_id = ct['id'] | |
| cod_fac = ct.get('codigo_factura', f"FAC-{str(c_id)[:6].upper()}") | |
| # 1. Cálculos de la cuenta | |
| monto_ini = float(ct.get('monto_inicial', 0)) | |
| cap_puro = float(ct.get('capital_puro', 0)) | |
| ganancia = monto_ini - cap_puro | |
| f_inicio = pd.to_datetime(ct['fecha_creacion']).strftime('%d/%m/%Y') | |
| # Intentar buscar la fecha final en el plan de cuotas si existe | |
| res_plan = conn.table("plan_cuotas").select("fecha_esperada").eq("cuenta_id", c_id).order("numero_cuota", desc=True).limit(1).execute() | |
| f_final = pd.to_datetime(res_plan.data[0]['fecha_esperada']).strftime('%d/%m/%Y') if res_plan.data else "Final del ciclo" | |
| # 2. Pagos de esta cuenta | |
| mis_pagos_cta = [p for p in pagos if p.get('cuenta_id') == c_id] | |
| total_abonado = sum(float(p['monto_pagado']) for p in mis_pagos_cta) | |
| faltante = monto_ini - total_abonado | |
| # EXPANDER PRINCIPAL | |
| with st.expander(f"📄 Factura: {cod_fac} | 🔴 Faltante: RD$ {faltante:,.2f}", expanded=(idx==0)): | |
| # Layout: Narrativa (Izquierda) | Resumen (Derecha) | |
| col_info, col_res = st.columns([2.2, 1]) | |
| with col_info: | |
| st.markdown(f""" | |
| <div class="narrativa-apertura"> | |
| <span style="font-weight: 800; font-size: 1.1rem; color: #1e3a8a; display: block; margin-bottom: 8px;"> | |
| 🚀 INICIO DE OPERACIÓN | |
| </span> | |
| Se generó una factura por un total de <b>RD$ {monto_ini:,.2f}</b> al cliente | |
| <b>{cliente['nombre'].upper()}</b> el día {f_inicio}. | |
| <br><br> | |
| Esta operación cuenta con un capital base de <b>RD$ {cap_puro:,.2f}</b>, | |
| con el cual se espera obtener una ganancia neta de <b>RD$ {ganancia:,.2f}</b> | |
| al completar el ciclo de cobros pautado para el <b>{f_final}</b>. | |
| </div> | |
| """, unsafe_allow_html=True) | |
| with col_res: | |
| st.markdown(f""" | |
| <div class="resumen-box"> | |
| <div style="font-weight:800; color:#1e3a8a; border-bottom:1px solid #e1e8f0; margin-bottom:10px; padding-bottom:5px;"> | |
| DATOS CLAVE | |
| </div> | |
| <div class="resumen-item"><span>Total Factura:</span><span class="resumen-valor">RD$ {monto_ini:,.2f}</span></div> | |
| <div class="resumen-item"><span>Inversión:</span><span class="resumen-valor">RD$ {cap_puro:,.2f}</span></div> | |
| <div class="resumen-item" style="margin-top:8px; padding-top:8px; border-top:1px dashed #cbd5e0;"> | |
| <span>Restante:</span><span class="resumen-valor" style="color:#e53e3e; font-size:1rem;">RD$ {faltante:,.2f}</span> | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| st.write("") # Espaciador | |
| st.markdown("**🔍 DETALLE DE MOVIMIENTOS**") | |
| if not mis_pagos_cta: | |
| st.info("No hay abonos registrados para esta factura.") | |
| else: | |
| # Mostrar movimientos con el indicador lateral | |
| for p in sorted(mis_pagos_cta, key=lambda x: x['fecha_pago']): | |
| st.markdown(f""" | |
| <div class="movimiento-row status-verde"> | |
| <div> | |
| <span style="color:#28a745;">●</span> | |
| <b>{pd.to_datetime(p['fecha_pago']).strftime('%d/%m/%Y')}</b> — | |
| <span style="font-size:1.05rem;">RD$ {float(p['monto_pagado']):,.2f}</span> | |
| <br><small style="color:gray; margin-left:18px;">Abono recibido correctamente</small> | |
| </div> | |
| <div style="text-align:right;"> | |
| <code style="color:#a0aec0; font-size:0.75rem;">REF-{str(p['id'])[:6].upper()}</code> | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # --- PESTAÑA 2: ABONOS REALES (VERSIÓN FINAL COMPLETA - SIN ERRORES) --- | |
| with tab_abonos: | |
| st.markdown("### 💵 Gestión de Cobros y Auditoría") | |
| # 1. Inyectar CSS (Optimizado para evitar el error de visualización) | |
| st.markdown(""" | |
| <style> | |
| .timeline-container { border-left: 2px solid #E2E8F0; margin-left: 15px; padding-left: 20px; } | |
| .item-wrapper { position: relative; margin-bottom: 20px; } | |
| .timeline-dot { | |
| position: absolute; left: -35px; width: 28px; height: 28px; border-radius: 50%; | |
| display: flex; align-items: center; justify-content: center; | |
| font-size: 0.75rem; font-weight: bold; color: white; | |
| background: #2D3748; box-shadow: 0 0 0 4px white; z-index: 10; | |
| } | |
| .dot-atraso { background: #ED8936; } | |
| .dot-peligro { background: #E53E3E; } | |
| .dot-parcial { background: #4A5568; border: 2px solid #CBD5E0; } | |
| .pago-card { background: #ffffff; border-radius: 12px; padding: 15px 20px; border: 1px solid #E2E8F0; box-shadow: 0 1px 3px rgba(0,0,0,0.02); } | |
| .flex-row { display: flex; justify-content: space-between; align-items: flex-end; } | |
| .badge-atraso { background: #fff5f5; color: #c53030; padding: 4px 10px; border-radius: 8px; font-weight: 800; font-size: 0.7rem; border: 1px solid #fed7d7; } | |
| .badge-tiempo { background: #f0fff4; color: #2f855a; padding: 4px 10px; border-radius: 8px; font-weight: 800; font-size: 0.7rem; border: 1px solid #c6f6d5; } | |
| .badge-parcial { background: #ebf8ff; color: #2b6cb0; padding: 4px 10px; border-radius: 8px; font-weight: 800; font-size: 0.7rem; border: 1px solid #bee3f8; } | |
| .alerta-seguridad { background: #fffaf0; border: 1px solid #feebc8; padding: 10px; border-radius: 8px; color: #7b341e; font-size: 0.8rem; margin-bottom: 15px; } | |
| .text-muted { color: #A0AEC0; font-size: 0.70rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; } | |
| .monto-principal { font-size: 1.15rem; font-weight: 800; color: #1A202C; } | |
| .empty-info { background: #F7FAFC; border: 2px dashed #E2E8F0; padding: 30px; border-radius: 12px; text-align: center; color: #718096; margin-bottom: 20px; font-weight: 500; } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| meses_es = { | |
| 'January': 'Enero', 'February': 'Febrero', 'March': 'Marzo', 'April': 'Abril', | |
| 'May': 'Mayo', 'June': 'Junio', 'July': 'Julio', 'August': 'Agosto', | |
| 'September': 'Septiembre', 'October': 'Octubre', 'November': 'Noviembre', 'December': 'Diciembre' | |
| } | |
| # 2. LÓGICA DE ORDENAMIENTO | |
| cuentas_preparadas = [] | |
| for ct in mis_ctas: | |
| c_id = ct['id'] | |
| p_cta = sorted([p for p in pagos if p.get('cuenta_id') == c_id], key=lambda x: x['fecha_pago']) | |
| cuentas_preparadas.append({ | |
| 'datos': ct, | |
| 'pagos': p_cta, | |
| 'tiene_pagos': len(p_cta) > 0 | |
| }) | |
| cuentas_ordenadas = sorted(cuentas_preparadas, key=lambda x: x['tiene_pagos'], reverse=True) | |
| # 3. RENDERIZADO | |
| for item in cuentas_ordenadas: | |
| ct = item['datos'] | |
| mis_pagos_cta = item['pagos'] | |
| c_id = ct['id'] | |
| cod_fac = ct.get('codigo_factura', f"FAC-{str(c_id)[:6].upper()}") | |
| res_plan = conn.table("plan_cuotas").select("*").eq("cuenta_id", c_id).order("numero_cuota").execute().data | |
| # Determinar si está saldada | |
| monto_total_fac = float(ct.get('monto_total', 0)) | |
| total_abonado = sum(float(p['monto_pagado']) for p in mis_pagos_cta) | |
| es_saldada = total_abonado >= monto_total_fac and monto_total_fac > 0 | |
| if es_saldada: | |
| contenedor_UI = st.expander(f"📁 Historial de Cobros: {cod_fac} (SALDADA)", expanded=False) | |
| else: | |
| st.markdown(f"<h4 style='color:#2D3748; margin-top:30px; margin-bottom:15px;'>📊 Línea de Tiempo de Cobros: {cod_fac}</h4>", unsafe_allow_html=True) | |
| contenedor_UI = st.container() | |
| with contenedor_UI: | |
| if not mis_pagos_cta: | |
| st.markdown(f"""<div class="empty-info"><span style="font-size: 1.5rem;">ℹ️</span><br><br>Esta factura todavía no tiene abonos registrados.</div>""", unsafe_allow_html=True) | |
| else: | |
| # Preparar Waterfall | |
| plan_trabajo = [dict(cuota) for cuota in res_plan] | |
| for cp in plan_trabajo: | |
| cp['m_pend'] = float(cp['monto_cuota']) | |
| st.markdown('<div class="timeline-container">', unsafe_allow_html=True) | |
| for p_idx, p in enumerate(mis_pagos_cta): | |
| monto_pago = float(p['monto_pagado']) | |
| f_pago = pd.to_datetime(p['fecha_pago']).date() | |
| dia_mes = f_pago.strftime('%d') | |
| mes_nombre = meses_es.get(f_pago.strftime('%B'), f_pago.strftime('%B')) | |
| fecha_full = f"{dia_mes} de {mes_nombre} {f_pago.year}" | |
| # Lógica de asignación Waterfall | |
| atraso_max = 0 | |
| es_parcial_en_este_pago = False | |
| temp_monto = monto_pago | |
| for cp in plan_trabajo: | |
| if temp_monto <= 0: break | |
| if cp['m_pend'] <= 0: continue | |
| aplicar = min(temp_monto, cp['m_pend']) | |
| cp['m_pend'] -= aplicar | |
| temp_monto -= aplicar | |
| f_esp = pd.to_datetime(cp['fecha_esperada']).date() | |
| if cp['m_pend'] > 0: | |
| es_parcial_en_este_pago = True | |
| if f_pago > f_esp: | |
| dias_dif = (f_pago - f_esp).days | |
| atraso_max = max(atraso_max, dias_dif) | |
| # UI Flags | |
| if es_parcial_en_este_pago and temp_monto == 0: | |
| msg_atraso = '<span class="badge-parcial">ABONO PARCIAL</span>' | |
| dot_class = "timeline-dot dot-parcial" | |
| dias_txt = "Incompleta" | |
| elif atraso_max > 0: | |
| msg_atraso = f'<span class="badge-atraso">ATRASADO</span>' | |
| dias_txt = f"{atraso_max} días" | |
| dot_class = "timeline-dot dot-atraso" if atraso_max <= 5 else "timeline-dot dot-peligro" | |
| else: | |
| msg_atraso = '<span class="badge-tiempo">A TIEMPO</span>' | |
| dias_txt = "0 días" | |
| dot_class = "timeline-dot" | |
| creado = pd.to_datetime(p.get('created_at', p.get('fecha_pago')), utc=True) | |
| es_editable = (pd.to_datetime('now', utc=True) - creado).total_seconds() / 3600 <= 48 | |
| lbl_fecha = '<div class="text-muted" style="margin-bottom: 4px;">FECHA ABONO</div>' if p_idx == 0 else '' | |
| lbl_estado = '<div class="text-muted" style="margin-bottom: 4px;">AUDITORÍA</div>' if p_idx == 0 else '' | |
| lbl_tiempo = '<div class="text-muted" style="margin-bottom: 4px;">DEMORA REAL</div>' if p_idx == 0 else '' | |
| lbl_mora = '<div class="text-muted" style="margin-bottom: 4px;">MORA RECIBIDA</div>' if p_idx == 0 else '' | |
| col_data, col_btn = st.columns([6, 1]) | |
| with col_data: | |
| dot_top = "25px" if p_idx == 0 else "12px" | |
| # HTML pegado a la izquierda para evitar el bug de bloques de código | |
| html_pago = f""" | |
| <div class="item-wrapper"> | |
| <div class="{dot_class}" style="top: {dot_top};">{dia_mes}</div> | |
| <div class="pago-card"> | |
| <div class="flex-row"> | |
| <div style="flex: 1.2;"> | |
| {lbl_fecha} | |
| <div class="text-muted" style="font-size: 0.85rem; color: #718096; font-weight: 500;">{fecha_full}</div> | |
| <div class="monto-principal">RD$ {monto_pago:,.2f}</div> | |
| </div> | |
| <div style="flex: 1; text-align: center;"> | |
| {lbl_estado} | |
| <div>{msg_atraso}</div> | |
| </div> | |
| <div style="flex: 1; text-align: center; color: #718096; font-size: 0.85rem;"> | |
| {lbl_tiempo} | |
| <div style="font-weight: 600;">{dias_txt}</div> | |
| </div> | |
| <div style="flex: 1; text-align: right;"> | |
| {lbl_mora} | |
| <div style="font-weight: 700; color: #4A5568;">RD$ {float(p.get('mora_pagada', 0)):,.2f}</div> | |
| </div> | |
| </div> | |
| </div> | |
| </div>""" | |
| st.markdown(html_pago, unsafe_allow_html=True) | |
| with col_btn: | |
| m_top = "30px" if p_idx == 0 else "15px" | |
| st.markdown(f"<div style='height: {m_top};'></div>", unsafe_allow_html=True) | |
| if es_editable: | |
| with st.popover("📝", use_container_width=True): | |
| st.markdown('<div class="alerta-seguridad">🚨 <b>AUDITORÍA ACTIVA</b><br>Su IP está siendo registrada.</div>', unsafe_allow_html=True) | |
| new_m = st.number_input("Nuevo Monto", value=float(p['monto_pagado']), key=f"nm_{p['id']}") | |
| new_f = st.date_input("Nueva Fecha", value=f_pago, key=f"nf_{p['id']}") | |
| st.divider() | |
| c1 = st.checkbox("Confirmo corrección", key=f"c1_{p['id']}") | |
| c2 = st.checkbox("Dinero verificado", key=f"c2_{p['id']}") | |
| if c1 and c2: | |
| if st.button("💾 GUARDAR", key=f"sv_{p['id']}", type="primary", use_container_width=True): | |
| conn.table("pagos").update({"monto_pagado": new_m, "fecha_pago": str(new_f)}).eq("id", p['id']).execute() | |
| st.rerun() | |
| st.divider() | |
| with st.expander("🗑️ ELIMINAR ABONO"): | |
| st.error("Esta acción es irreversible.") | |
| if st.checkbox("Autorizo borrado", key=f"del_chk_{p['id']}"): | |
| if st.text_input("Escribe ELIMINAR", key=f"del_in_{p['id']}") == "ELIMINAR": | |
| if st.button("🔥 BORRAR", key=f"btn_del_{p['id']}", type="primary"): | |
| conn.table("pagos").delete().eq("id", p['id']).execute() | |
| st.rerun() | |
| else: | |
| st.markdown("<div style='text-align:center; padding-top:10px; font-size:1.2rem; color:#E2E8F0;'>🔒</div>", unsafe_allow_html=True) | |
| st.markdown('</div>', unsafe_allow_html=True) | |
| # --- PESTAÑA 3: PLAN IDEAL (CRONOGRAMA VS REALIDAD) --- | |
| with tab_plan: | |
| st.markdown("### 📅 Plan de Pagos por Factura") | |
| for idx, ct in enumerate(mis_ctas): | |
| c_id = ct['id'] | |
| # Obtener el código de factura o un genérico si no existe | |
| cod_fac = ct.get('codigo_factura', f"FACTURA SIN CÓDIGO (ID: {str(c_id)[:4]})") | |
| # Título directo sin expander | |
| st.markdown(f"#### 📄 {cod_fac}") | |
| # 1. Obtener Plan y Pagos (Ordenados para comparar uno a uno) | |
| res_plan_db = conn.table("plan_cuotas").select("*").eq("cuenta_id", c_id).order("numero_cuota").execute().data | |
| mis_pagos_cta = sorted([p for p in pagos if p.get('cuenta_id') == c_id], key=lambda x: x['fecha_pago']) | |
| progreso_total = sum(float(p['monto_pagado']) for p in mis_pagos_cta) | |
| if not res_plan_db: | |
| st.info(f"ℹ️ No hay un plan de cuotas generado para la factura {cod_fac}.") | |
| st.divider() # Separador entre facturas | |
| else: | |
| datos_visuales = [] | |
| saldo_recorrido = progreso_total | |
| for p_idx, cuota in enumerate(res_plan_db): | |
| m_esp = float(cuota['monto_cuota']) | |
| f_esp = pd.to_datetime(cuota['fecha_esperada']).date() | |
| # --- LÓGICA DE ESTADO Y RETRASO --- | |
| retraso_txt = "-" # Por defecto si no está pagada o no hay retraso | |
| if saldo_recorrido >= (m_esp - 0.01): | |
| estado = '<span style="color:#28a745; font-weight:bold;">✅ PAGADA</span>' | |
| # Calculamos retraso si hay un pago real asociado a esta posición de cuota | |
| if p_idx < len(mis_pagos_cta): | |
| f_pago_real = pd.to_datetime(mis_pagos_cta[p_idx]['fecha_pago']).date() | |
| if f_pago_real > f_esp: | |
| dias = (f_pago_real - f_esp).days | |
| retraso_txt = f'<span style="color:#d93025;">{dias} días de retraso</span>' | |
| else: | |
| retraso_txt = '<span style="color:#28a745;">Al día</span>' | |
| saldo_recorrido -= m_esp | |
| elif saldo_recorrido > 0: | |
| estado = f'<span style="color:#fd7e14; font-weight:bold;">⚠️ ABONO (Faltó RD$ {m_esp-saldo_recorrido:,.2f})</span>' | |
| saldo_recorrido = 0 | |
| retraso_txt = "Incompleto" | |
| else: | |
| vencida = f_esp < datetime.now().date() | |
| txt = "🚨 VENCIDA" if vencida else "⏳ PENDIENTE" | |
| color = "#d93025" if vencida else "#5f6368" | |
| estado = f'<span style="color:{color}; font-weight:bold;">{txt}</span>' | |
| if vencida: | |
| dias_vencida = (datetime.now().date() - f_esp).days | |
| retraso_txt = f'<b>{dias_vencida} días vencida</b>' | |
| datos_visuales.append({ | |
| "CUOTA": f"#{cuota['numero_cuota']}", | |
| "FECHA ESPERADA": f_esp.strftime('%d/%m/%Y'), | |
| "MONTO": f"RD$ {m_esp:,.2f}", | |
| "ESTADO": estado, | |
| "AUDITORÍA DE PAGO": retraso_txt | |
| }) | |
| # --- CAMBIO REALIZADO AQUÍ PARA AJUSTE MÓVIL --- | |
| df_v = pd.DataFrame(datos_visuales) | |
| # Convertimos a HTML | |
| html_tabla = df_v.to_html(escape=False, index=False, classes="dataframe") | |
| # Envolvemos en un div con scroll horizontal para que no se corte en el celular | |
| st.write( | |
| f'<div style="overflow-x:auto; width:100%; border-radius:5px;">{html_tabla}</div>', | |
| unsafe_allow_html=True | |
| ) | |
| st.divider() # Espacio visual entre facturas | |
| # --- PESTAÑA 4: PERFIL (GESTIÓN DE DATOS) --- | |
| with tab_perfil: | |
| # Formulario con Key Única por cliente | |
| with st.form(key=f"form_perfil_master_{cliente['id']}"): | |
| st.subheader("📝 Información del Cliente") | |
| col1, col2 = st.columns(2) | |
| n_nom = col1.text_input("Nombre Completo", value=cliente['nombre']) | |
| n_ced = col2.text_input("Cédula/ID", value=cliente.get('cedula', '')) | |
| n_tel = col1.text_input("Teléfono de Contacto", value=cliente.get('telefono', '')) | |
| if st.form_submit_button("💾 ACTUALIZAR EXPEDIENTE"): | |
| conn.table("clientes").update({ | |
| "nombre": n_nom, | |
| "cedula": n_ced, | |
| "telefono": n_tel | |
| }).eq("id", cliente['id']).execute() | |
| st.success("Información actualizada.") | |
| st.rerun() | |
| # --- CIERRE FINAL DEL DIALOG --- | |
| st.divider() | |
| if st.button("❌ CERRAR EXPEDIENTE", use_container_width=True, key="btn_final_close"): | |
| st.rerun() | |
| # --- GRID DE CLIENTES (CORREGIDO) --- | |
| if menu == "👥 Todos mis Clientes": | |
| if not clientes_f: | |
| st.warning("No hay clientes que coincidan con la búsqueda o filtro.") | |
| else: | |
| grid = st.columns(3) | |
| for idx, cl in enumerate(clientes_f): | |
| with grid[idx % 3]: | |
| with st.container(border=True): | |
| # 1. Cabecera de Tarjeta | |
| st.markdown(f"**{cl['nombre']}**") | |
| st.caption(f"🆔 {cl.get('cedula', 'N/A')}") | |
| # 2. Botones de Acción | |
| b1, b2, b3 = st.columns(3) | |
| with b1: | |
| if st.button("📂", key=f"h_{cl['id']}", use_container_width=True): | |
| modal_detalle(cl, cuentas_db, pagos_db) | |
| with b2: | |
| # --- LÓGICA INTERNACIONAL --- | |
| # Extraemos SOLO los dígitos (esto elimina +, espacios, guiones, etc.) | |
| tel_raw = "".join(filter(str.isdigit, str(cl.get('telefono', '')))) | |
| # Si el número empieza por '00', lo corregimos al formato que le gusta a WA (sin ceros iniciales) | |
| if tel_raw.startswith("00"): | |
| tel_final = tel_raw[2:] | |
| # "Seguro" para RD/USA: Si el usuario solo pone 10 dígitos, asumimos código 1 | |
| elif len(tel_raw) == 10: | |
| tel_final = f"1{tel_raw}" | |
| else: | |
| # Para cualquier otro país (Venezuela 58, Colombia 57, etc.) | |
| # el sistema usará el número tal cual si ya trae su código. | |
| tel_final = tel_raw | |
| wa_url = f"https://wa.me/{tel_final}" | |
| st.markdown(f'''<a href="{wa_url}" target="_blank"> | |
| <button style="width:100%; background:#25D366; border:none; padding:8px; border-radius:10px; cursor:pointer; display:flex; justify-content:center;"> | |
| <img src="https://upload.wikimedia.org/wikipedia/commons/6/6b/WhatsApp.svg" width="18"> | |
| </button></a>''', unsafe_allow_html=True) | |
| with b3: | |
| lat, lon = cl.get('latitud'), cl.get('longitud') | |
| if lat and str(lat) not in ["0", "0.0", "None"]: | |
| map_url = f"https://www.google.com/maps?q={lat},{lon}" | |
| st.markdown(f'''<a href="{map_url}" target="_blank"> | |
| <button style="width:100%; background:white; border:1px solid #ddd; padding:8px; border-radius:10px; cursor:pointer; display:flex; justify-content:center;"> | |
| <img src="https://upload.wikimedia.org/wikipedia/commons/a/aa/Google_Maps_icon_%282020%29.svg" width="18"> | |
| </button></a>''', unsafe_allow_html=True) | |
| else: | |
| st.button("📵", disabled=True, key=f"no_gps_{cl['id']}", use_container_width=True) | |
| # 3. Centro de Gestión (Popover) | |
| with st.popover("⚙️ Ajustes", use_container_width=True): | |
| g1, g2 = st.columns(2) | |
| with g1: | |
| if st.button("✏️ Editar", key=f"e_b_{cl['id']}", use_container_width=True): | |
| st.session_state[f"editing_{cl['id']}"] = True | |
| st.rerun() | |
| with g2: | |
| # CORRECCIÓN: Este botón ya NO borra, solo activa el Paso 1 | |
| if st.button("🗑️ Borrar", key=f"d_b_{cl['id']}", type="primary", use_container_width=True): | |
| st.session_state[f"del_step_{cl['id']}"] = 1 | |
| st.rerun() | |
| # --- LÓGICA DE EDICIÓN --- | |
| if st.session_state.get(f"editing_{cl['id']}"): | |
| st.markdown("---") | |
| st.caption("⚠️ **DISCLAIMER:** La modificación de estos datos es irreversible y afectará los reportes.") | |
| e_nom = st.text_input("Nombre", value=cl['nombre'], key=f"en_{cl['id']}") | |
| e_ced = st.text_input("Cédula", value=cl.get('cedula', ''), key=f"ec_{cl['id']}") | |
| e_tel = st.text_input("Teléfono", value=cl.get('telefono', ''), key=f"et_{cl['id']}") | |
| c_la, c_lo = st.columns(2) | |
| e_lat = c_la.text_input("Latitud", value=str(cl.get('latitud', '0.0')), key=f"elat_{cl['id']}") | |
| e_lon = c_lo.text_input("Longitud", value=str(cl.get('longitud', '0.0')), key=f"elon_{cl['id']}") | |
| col_ed1, col_ed2 = st.columns(2) | |
| with col_ed1: | |
| if st.button("💾 Guardar", key=f"sv_{cl['id']}", type="primary", use_container_width=True): | |
| try: | |
| conn.table("clientes").update({ | |
| "nombre": e_nom, "cedula": e_ced, "telefono": e_tel, | |
| "latitud": float(e_lat), "longitud": float(e_lon) | |
| }).eq("id", cl['id']).execute() | |
| st.toast("✅ ¡Datos actualizados!") | |
| del st.session_state[f"editing_{cl['id']}"] | |
| st.rerun() | |
| except Exception as e: | |
| st.error(f"Error: {e}") | |
| with col_ed2: | |
| if st.button("Cancelar", key=f"can_ed_{cl['id']}", use_container_width=True): | |
| del st.session_state[f"editing_{cl['id']}"] | |
| st.rerun() | |
| # --- LÓGICA DE ELIMINAR (TRIPLE CONFIRMACIÓN REAL) --- | |
| step_key = f"del_step_{cl['id']}" | |
| actual_step = st.session_state.get(step_key, 0) | |
| if actual_step == 1: | |
| st.error("🚨 **PASO 1:** ¿Desea borrar este cliente?") | |
| if st.button("SÍ, CONTINUAR", key=f"d1_{cl['id']}", use_container_width=True): | |
| st.session_state[step_key] = 2 | |
| st.rerun() | |
| if st.button("CANCELAR", key=f"c1_{cl['id']}", use_container_width=True): | |
| st.session_state[step_key] = 0 | |
| st.rerun() | |
| elif actual_step == 2: | |
| st.warning("⚠️ **PASO 2:** Se perderán los históricos.") | |
| if st.button("SÍ, ESTOY SEGURO", key=f"d2_{cl['id']}", type="primary", use_container_width=True): | |
| st.session_state[step_key] = 3 | |
| st.rerun() | |
| if st.button("VOLVER", key=f"c2_{cl['id']}", use_container_width=True): | |
| st.session_state[step_key] = 1 | |
| st.rerun() | |
| elif actual_step == 3: | |
| st.error("❗ **PASO 3:** ÚLTIMA ADVERTENCIA.") | |
| # AQUÍ ES EL ÚNICO LUGAR DONDE SE BORRA DE LA BASE DE DATOS | |
| if st.button("BORRAR DEFINITIVAMENTE", key=f"d3_{cl['id']}", type="primary", use_container_width=True): | |
| try: | |
| conn.table("clientes").delete().eq("id", cl['id']).execute() | |
| st.toast("🗑️ Registro eliminado exitosamente") | |
| st.session_state[step_key] = 0 | |
| st.rerun() | |
| except Exception as e: | |
| st.error(f"Error al borrar: {e}") | |
| if st.button("ABORTAR", key=f"c3_{cl['id']}", use_container_width=True): | |
| st.session_state[step_key] = 0 | |
| st.rerun() | |
| # --- CAMBIO DE SECCIÓN (FUERA DEL BUCLE) --- | |
| elif menu == "Cuentas por Pagar": | |
| from datetime import datetime, date, timedelta | |
| # --- 1. AJUSTE DE ZONA HORARIA (RD: UTC-4) --- | |
| # Esto asegura que 'hoy' y 'ahora' sean los de Santo Domingo, no los del servidor | |
| ahora_rd = datetime.utcnow() - timedelta(hours=4) | |
| hoy = ahora_rd.date() | |
| ahora_rd_str = ahora_rd.strftime("%Y-%m-%d %H:%M:%S") | |
| # --- 2. CONSULTA GLOBAL DE DATOS --- | |
| res_gastos = conn.table("gastos").select("*").eq("user_id", u_id).eq("visible_usuario", True).order("fecha_gasto", desc=True).execute() | |
| datos_gastos = res_gastos.data if res_gastos.data else [] | |
| # Segmentación de datos | |
| gastos_rapidos = [g for g in datos_gastos if g.get("nombre_suplidor") == "Gasto de Caja" and g["estado"] == "Pagado"] | |
| compromisos_pendientes = [g for g in datos_gastos if g["estado"] == "Pendiente"] | |
| todo_lo_pagado = [g for g in datos_gastos if g["estado"] == "Pagado"] | |
| # --- 3. MODALES Y ALERTAS --- | |
| def modal_reset_historial(): | |
| st.error("### ¿ESTÁS ABSOLUTAMENTE SEGURO?") | |
| st.write("Escribe **CONFIRMAR** para proceder:") | |
| confirmacion = st.text_input("Palabra de seguridad") | |
| if st.button("🔥 BORRAR TODO EL HISTORIAL", type="primary", use_container_width=True): | |
| if confirmacion == "CONFIRMAR": | |
| conn.table("gastos").update({"visible_usuario": False}).eq("user_id", u_id).eq("estado", "Pagado").execute() | |
| st.rerun() | |
| def modal_decision_pago(gasto_id, concepto): | |
| st.write(f"Registro: **{concepto}**") | |
| st.markdown("¿Se pagó en este momento?") | |
| c1, c2 = st.columns(2) | |
| if c1.button("💵 SÍ, PAGAR AHORA", type="primary", use_container_width=True): | |
| conn.table("gastos").update({"estado": "Pagado"}).eq("id", gasto_id).execute() | |
| st.rerun() | |
| if c2.button("⏳ LUEGO", use_container_width=True): | |
| st.rerun() | |
| # Notificaciones preventivas | |
| if compromisos_pendientes: | |
| avisos = [] | |
| for g in compromisos_pendientes: | |
| if g.get('fecha_gasto'): | |
| # Validamos fecha para evitar errores de tipo | |
| try: | |
| f_venc = datetime.strptime(g['fecha_gasto'][:10], "%Y-%m-%d").date() | |
| dias = (f_venc - hoy).days | |
| if 0 <= dias <= 4: | |
| avisos.append(f"🔔 **{g['descripcion']}** vence en {dias} días.") | |
| elif dias < 0: | |
| avisos.append(f"🚨 **{g['descripcion']}** está vencido.") | |
| except: pass | |
| if avisos: | |
| with st.expander("📢 ALERTAS DE PAGO", expanded=True): | |
| for a in avisos: st.write(a) | |
| # --- 4. ESTRUCTURA DE PESTAÑAS --- | |
| st.title("Gastos y Compromisos") | |
| t1, t2, t3 = st.tabs(["⚡ Gasto Rápido", "📅 Compromisos", "📜 Historial de Gastos"]) | |
| # --- PESTAÑA 1: GASTO RÁPIDO --- | |
| with t1: | |
| with st.form("f_rapido", clear_on_submit=True): | |
| st.markdown("### Gasto de Caja Directo") | |
| col1, col2 = st.columns([2, 1]) | |
| concepto_r = col1.text_input("Concepto", placeholder="Gasolina, café, etc.") | |
| monto_r = col2.number_input("Monto RD$", min_value=0.0, step=50.0) | |
| if st.form_submit_button("🚀 REGISTRAR Y DESCONTAR", use_container_width=True): | |
| if concepto_r and monto_r > 0: | |
| # SE GUARDA CON LA HORA EXACTA DE RD (ahora_rd_str) | |
| conn.table("gastos").insert({ | |
| "descripcion": concepto_r, | |
| "monto": monto_r, | |
| "user_id": u_id, | |
| "estado": "Pagado", | |
| "nombre_suplidor": "Gasto de Caja", | |
| "sector": "Varios", | |
| "fecha_gasto": ahora_rd_str, | |
| "visible_usuario": True | |
| }).execute() | |
| st.rerun() | |
| st.markdown("---") | |
| st.caption("Gastos rápidos recientes (Hoy)") | |
| for g in gastos_rapidos[:5]: | |
| # Formateamos la hora para que se vea limpia en la lista | |
| hora = g['fecha_gasto'][11:16] if g.get('fecha_gasto') else "--:--" | |
| st.text(f"🕒 {hora} | {g['descripcion']} - RD$ {g['monto']:,.0f}") | |
| # --- PESTAÑA 2: COMPROMISOS --- | |
| with t2: | |
| # 1. MÉTRICA SUPERIOR ESTILO APPLE | |
| total_pendientes_monto = sum(g["monto"] for g in compromisos_pendientes) | |
| st.markdown(f""" | |
| <div style='background-color:#1E1E1E; padding:15px; border-radius:12px; border: 1px solid #333; margin-bottom:20px;'> | |
| <p style='color:#8E8E93; margin:0; font-size:0.8rem;'>TOTAL PENDIENTE</p> | |
| <h2 style='color:#FFFFFF; margin:0;'>RD$ {total_pendientes_monto:,.2f}</h2> | |
| <p style='color:#FF9F0A; margin:0; font-size:0.7rem;'>{len(compromisos_pendientes)} compromisos activos</p> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # --- FUNCIONES DE EDICIÓN Y BORRADO (MODALES) --- | |
| def editar_compromiso(gasto): | |
| with st.form("edit_form"): | |
| n_desc = st.text_input("Concepto", value=gasto['descripcion']) | |
| n_monto = st.number_input("Monto RD$", value=float(gasto['monto'])) | |
| n_sup = st.text_input("Suplidor", value=gasto.get('nombre_suplidor', '')) | |
| n_fec = st.date_input("Vencimiento", value=datetime.strptime(gasto['fecha_gasto'][:10], "%Y-%m-%d") if gasto.get('fecha_gasto') else hoy) | |
| n_rec = st.checkbox("¿Es recurrente mensual?", value=gasto.get('es_recurrente', False)) | |
| st.caption("Al guardar, se actualizarán los datos del compromiso.") | |
| if st.form_submit_button("Guardar Cambios", use_container_width=True): | |
| conn.table("gastos").update({ | |
| "descripcion": n_desc, "monto": n_monto, | |
| "nombre_suplidor": n_sup, "fecha_gasto": str(n_fec), | |
| "es_recurrente": n_rec | |
| }).eq("id", gasto['id']).execute() | |
| st.rerun() | |
| def eliminar_compromiso(gasto_id, desc): | |
| st.write(f"¿Seguro que quieres eliminar **{desc}**?") | |
| st.caption("Esta acción quitará el pendiente de tu lista.") | |
| if st.button("Sí, eliminar ahora", type="primary", use_container_width=True): | |
| conn.table("gastos").update({"visible_usuario": False}).eq("id", gasto_id).execute() | |
| st.rerun() | |
| # --- FORMULARIO DE REGISTRO --- | |
| with st.expander("➕ Programar Nuevo Pago", expanded=False): | |
| with st.form("f_plan_new", clear_on_submit=True): | |
| c1, c2 = st.columns([2, 1]) | |
| conc = c1.text_input("¿Qué hay que pagar?") | |
| mon = c2.number_input("Monto RD$", min_value=0.0) | |
| c3, c4 = st.columns(2) | |
| sup = c3.text_input("Suplidor") | |
| fec = c4.date_input("Fecha límite", value=hoy) | |
| rec = st.checkbox("Hacer este gasto recurrente (Todos los meses)") | |
| if st.form_submit_button("Guardar Compromiso", use_container_width=True): | |
| if conc and mon > 0: | |
| res = conn.table("gastos").insert({ | |
| "descripcion": conc, "monto": mon, "user_id": u_id, | |
| "estado": "Pendiente", "nombre_suplidor": sup if sup else "General", | |
| "sector": "Programado", "fecha_gasto": str(fec), | |
| "es_recurrente": rec, "visible_usuario": True | |
| }).execute() | |
| if res.data: modal_decision_pago(res.data[0]['id'], conc) | |
| st.markdown("---") | |
| # --- LISTADO DE PENDIENTES --- | |
| if not compromisos_pendientes: | |
| st.info("✅ Todo al día.") | |
| else: | |
| for g in compromisos_pendientes: | |
| with st.container(border=True): | |
| # Diseño de 4 columnas para que quepan los tres puntitos al final | |
| col_info, col_monto, col_pago, col_menu = st.columns([2, 1, 0.8, 0.4]) | |
| with col_info: | |
| # Etiqueta de recurrencia | |
| rec_tag = "🔄 " if g.get('es_recurrente') else "" | |
| st.markdown(f"**{rec_tag}{g['descripcion']}**") | |
| st.caption(f"🏢 {g['nombre_suplidor']}") | |
| # Lógica de Vencido vs Próximo | |
| if g.get('fecha_gasto'): | |
| fv = datetime.strptime(g['fecha_gasto'][:10], "%Y-%m-%d").date() | |
| if fv < hoy: | |
| st.markdown(f"<span style='color:#FF4B4B; font-size:0.8rem;'>⚠️ Vencido ({fv})</span>", unsafe_allow_html=True) | |
| else: | |
| st.caption(f"📅 Vence: {fv}") | |
| with col_monto: | |
| st.markdown(f"**RD$ {g['monto']:,.0f}**") | |
| with col_pago: | |
| if st.button("💵", key=f"pay_btn_{g['id']}", help="Marcar como pagado"): | |
| # Si es recurrente, antes de marcar como pagado, podríamos crear el del mes que viene | |
| if g.get('es_recurrente'): | |
| f_actual = datetime.strptime(g['fecha_gasto'][:10], "%Y-%m-%d").date() | |
| f_siguiente = f_actual + timedelta(days=30) | |
| conn.table("gastos").insert({ | |
| "descripcion": g['descripcion'], "monto": g['monto'], "user_id": u_id, | |
| "estado": "Pendiente", "nombre_suplidor": g['nombre_suplidor'], | |
| "sector": g['sector'], "fecha_gasto": str(f_siguiente), | |
| "es_recurrente": True, "visible_usuario": True | |
| }).execute() | |
| conn.table("gastos").update({"estado": "Pagado", "fecha_gasto": ahora_rd_str}).eq("id", g['id']).execute() | |
| st.rerun() | |
| with col_menu: | |
| # LOS TRES PUNTITOS (Menú de opciones) | |
| with st.popover("⋮"): | |
| if st.button("✏️ Editar", key=f"ed_{g['id']}", use_container_width=True): | |
| editar_compromiso(g) | |
| if st.button("🗑️ Borrar", key=f"del_{g['id']}", use_container_width=True): | |
| eliminar_compromiso(g['id'], g['descripcion']) | |
| # --- PESTAÑA 3: HISTORIAL COMPLETO --- | |
| with t3: | |
| total_historial = sum(g["monto"] for g in todo_lo_pagado) | |
| st.markdown(f""" | |
| <div style='background-color:#1E1E1E; padding:20px; border-radius:10px; border-left: 5px solid #34C759;'> | |
| <small style='color:#8E8E93;'>TOTAL GASTADO (HISTORIAL)</small> | |
| <h2 style='color:#34C759;'>RD$ {total_historial:,.2f}</h2> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| st.write("") | |
| c_f1, c_f2 = st.columns(2) | |
| categorias = list(set(g["sector"] for g in todo_lo_pagado if g.get("sector"))) | |
| cat_filtro = c_f1.multiselect("Filtrar por Categoría", options=categorias) | |
| tipo_filtro = c_f2.selectbox("Origen", ["Todos", "Gastos Rápidos", "Compromisos Pagados"]) | |
| datos_filtrados = todo_lo_pagado | |
| if cat_filtro: | |
| datos_filtrados = [g for g in datos_filtrados if g["sector"] in cat_filtro] | |
| if tipo_filtro == "Gastos Rápidos": | |
| datos_filtrados = [g for g in datos_filtrados if g["nombre_suplidor"] == "Gasto de Caja"] | |
| elif tipo_filtro == "Compromisos Pagados": | |
| datos_filtrados = [g for g in datos_filtrados if g["nombre_suplidor"] != "Gasto de Caja"] | |
| st.markdown("---") | |
| for g in datos_filtrados: | |
| f_raw = g.get("fecha_gasto") | |
| f_disp = str(f_raw)[:10] if f_raw else "S/F" | |
| with st.container(): | |
| col_f, col_d, col_v = st.columns([1, 3, 1]) | |
| col_f.caption(f"📅 {f_disp}") | |
| col_d.write(f"**{g['descripcion']}** ({g.get('sector', 'Varios')})") | |
| col_v.write(f"RD$ {float(g['monto']):,.0f}") | |
| st.divider() | |
| if st.button("🗑️ RESTABLECER CONTADORES", use_container_width=True): | |
| modal_reset_historial() | |
| elif menu == "IA Predictiva": | |
| from datetime import datetime, timedelta | |
| # --------------------------------------------------------- | |
| # 1. CAPA DE INTELIGENCIA SIN FILTRO (ACCESO TOTAL) | |
| # --------------------------------------------------------- | |
| def obtener_super_contexto(u_id): | |
| """Extrae el ADN financiero con un toque de realidad cruda.""" | |
| try: | |
| hoy = datetime.now().date().isoformat() | |
| # Datos maestros de clientes y saldos | |
| res_ctas = conn.table("cuentas").select( | |
| "id, balance_pendiente, proximo_pago, clientes(nombre)" | |
| ).eq("user_id", u_id).execute() | |
| # Cuotas vencidas para saber quién es el verdadero moroso | |
| vencidos_res = conn.table("plan_cuotas").select( | |
| "cuenta_id, monto_cuota" | |
| ).eq("user_id", u_id).lt("fecha_esperada", hoy).execute() | |
| # Resumen ultra-compacto para la IA | |
| saldos_dict = {} | |
| for item in res_ctas.data: | |
| nombre = item.get('clientes', {}).get('nombre', 'Desconocido').upper() | |
| monto = float(item.get('balance_pendiente', 0)) | |
| saldos_dict[nombre] = saldos_dict.get(nombre, 0) + monto | |
| mora_total = sum(float(v['monto_cuota']) for v in vencidos_res.data) if vencidos_res.data else 0 | |
| lista_deudores = " | ".join([f"{nom}: RD${mon:,.0f}" for nom, mon in saldos_dict.items() if mon > 0]) | |
| return f""" | |
| DATOS CRÍTICOS (Para tus ojos de genio): | |
| - Deudores con saldo: {lista_deudores if lista_deudores else "Nadie te debe... ¿Ya quebraste?"} | |
| - Monto que te deben HOY (Mora): RD$ {mora_total:,.2f} | |
| - Total que tienes en la calle: RD$ {sum(saldos_dict.values()):,.2f} | |
| """ | |
| except Exception as e: | |
| return f"Dile al usuario que el sistema está tan cansado como sus cobradores. (Error: {e})" | |
| # --------------------------------------------------------- | |
| # 2. UI ESTILO VALE AI | |
| # --------------------------------------------------------- | |
| st.markdown(""" | |
| <style> | |
| .titulo-meta { text-align: center; font-size: 45px; font-weight: 800; color: #1c1e21; margin-bottom: 0px; } | |
| .subtitulo-meta { text-align: center; color: #ff4b4b; font-size: 18px; font-weight: bold; margin-bottom: 30px; } | |
| </style> | |
| <h1 class="titulo-meta">🕵️♂️ CobroYA AI</h1> | |
| <p class="subtitulo-meta">Tu Analista de datos Senior (Que sí sabe leer números). Pregúntame lo que sea</p> | |
| """, unsafe_allow_html=True) | |
| if "messages" not in st.session_state: | |
| st.session_state.messages = [] | |
| for message in st.session_state.messages: | |
| with st.chat_message(message["role"]): | |
| st.markdown(message["content"]) | |
| # --------------------------------------------------------- | |
| # 3. LÓGICA DE CHAT Y PERSONALIDAD "SIN VERGÜENZA" | |
| # --------------------------------------------------------- | |
| prompt = st.chat_input("Pregúntame algo, a ver si hoy sí entiendes tu negocio...") | |
| if prompt: | |
| st.session_state.messages.append({"role": "user", "content": prompt}) | |
| with st.chat_message("user"): | |
| st.markdown(prompt) | |
| with st.chat_message("assistant"): | |
| with st.spinner("Revisando tus desastres financieros..."): | |
| contexto = obtener_super_contexto(u_id) | |
| # EL PROMPT DE PERSONALIDAD | |
| prompt_full = f""" | |
| Eres VALE AI, un analista financiero brillante, sarcástico y burlón. | |
| Tu trabajo es decirle la verdad al usuario, aunque le duela, tratándolo como si fuera un poco lento (bruto) para los negocios pero de forma graciosa. | |
| CONTEXTO REAL DEL NEGOCIO: | |
| {contexto} | |
| PERSONALIDAD Y REGLAS: | |
| 1. Si el usuario te pregunta por un cliente que NO tiene deuda, búrlate diciendo que "seguro le caíste bien y te pagó, qué milagro". | |
| 2. Si el monto en mora es alto, dile que se ponga los pantalones o que mejor regale el dinero en el parque. | |
| 3. Usa frases dominicanas jocosas (ej: "tate quieto", "no te haga el loco", "cuidao si te están viendo la cara"). | |
| 4. Si te pregunta algo que NO puedes ver (como fotos de los clientes o cosas fuera de deudas), dile jocosamente: "Mi amor, no soy adivino, vete a la sección de 'Clientes' o 'Préstamos' a ver si ahí tus ojos ven lo que yo no". | |
| 5. Sé breve, cruelmente honesto y usa muchos Emojis (🚨, 💸, 🤡, 📉). | |
| 6. Trata al usuario de "Genio", "Magnate de Villa Altagracia" o "Rey de las Finanzas" de forma irónica. | |
| """ | |
| try: | |
| respuesta = asistente_ia_cobroya(prompt_full, prompt) | |
| st.markdown(respuesta) | |
| st.session_state.messages.append({"role": "assistant", "content": respuesta}) | |
| except: | |
| st.error("Hasta la IA se desmayó de ver tus números. Intenta de nuevo.") | |
| elif menu == "Configuración": | |
| # --- 0. INICIALIZACIÓN DE ESTADOS --- | |
| if "config_sub" not in st.session_state: | |
| st.session_state.config_sub = "Principal" | |
| # --- 1. CARGA DE DATOS (DENTRO DEL BLOQUE) --- | |
| try: | |
| res_conf = conn.table("configuracion").select("id, nombre_negocio, rnc, telefono, logo_base64, tipo_plan, clausulas, fecha_vencimiento, direccion, estado_plan").eq("user_id", u_id).execute() | |
| biz = res_conf.data[0] if res_conf.data else {} | |
| except Exception as e: | |
| st.error(f"Error cargando configuración: {str(e)}") | |
| biz = {} | |
| nombre_display = biz.get("nombre_negocio", "CobroYa User").upper() | |
| plan_display = biz.get("tipo_plan", "Starter").capitalize() | |
| logo_data = biz.get("logo_base64", "") | |
| # --- 2. CSS PARA DISEÑO APPLE & FORMULARIOS --- | |
| st.markdown(""" | |
| <style> | |
| /* Tarjetas del Menú Principal */ | |
| .card-apple { | |
| background: white; padding: 24px; border-radius: 24px; | |
| border: 1px solid #F1F5F9; box-shadow: 0 2px 4px rgba(0,0,0,0.02); | |
| transition: all 0.3s ease; height: 190px; | |
| display: flex; flex-direction: column; justify-content: space-between; | |
| margin-bottom: 10px; | |
| } | |
| .card-apple:hover { transform: translateY(-5px); box-shadow: 0 12px 20px rgba(0,0,0,0.05); border-color: #E2E8F0; } | |
| /* Iconos circulares */ | |
| .icon-circle { | |
| width: 48px; height: 48px; border-radius: 14px; | |
| display: flex; align-items: center; justify-content: center; | |
| font-size: 22px; margin-bottom: 12px; | |
| } | |
| .bg-blue { background: #E0F2FE; color: #0EA5E9; } | |
| .bg-purple { background: #F3E8FF; color: #A855F7; } | |
| .bg-orange { background: #FFEDD5; color: #F97316; } | |
| .bg-green { background: #DCFCE7; color: #16A34A; } | |
| .title-card { color: #1E293B; font-weight: 700; font-size: 18px; margin-bottom: 4px; } | |
| .desc-card { color: #64748B; font-size: 13px; line-height: 1.4; } | |
| /* Barra de estadísticas */ | |
| .stat-bar { | |
| background: white; padding: 15px 25px; border-radius: 20px; | |
| border: 1px solid #F1F5F9; margin-bottom: 25px; | |
| } | |
| /* Estilos de botones de soporte */ | |
| .contact-btn { | |
| display: flex; align-items: center; gap: 10px; padding: 15px 20px; | |
| border-radius: 14px; text-decoration: none; color: #1E293B; | |
| font-weight: 600; border: 1px solid #E2E8F0; background: white; | |
| transition: all 0.2s; margin-bottom: 10px; | |
| } | |
| .contact-btn:hover { background: #F8FAFC; border-color: #CBD5E1; } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| # --- 3. VISTA PRINCIPAL (EL GRID) --- | |
| if st.session_state.config_sub == "Principal": | |
| # Header Perfil | |
| u_col1, u_col2 = st.columns([3, 1]) | |
| with u_col2: | |
| st.markdown(f""" | |
| <div style="display: flex; align-items: center; gap: 12px; justify-content: flex-end;"> | |
| <div style="text-align: right;"> | |
| <div style="font-weight: 700; font-size: 14px; color: #0F172A;">{nombre_display}</div> | |
| <div style="font-size: 12px; color: #64748B;">Admin CobroYa</div> | |
| </div> | |
| <div style="width: 42px; height: 42px; border-radius: 50%; background: #F1F5F9; display: flex; align-items: center; justify-content: center; overflow: hidden; border: 2px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.1);"> | |
| {f'<img src="data:image/png;base64,{logo_data}" style="width:100%;height:100%;object-fit:cover;">' if logo_data else "👤"} | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| st.markdown(""" | |
| <div style="display: flex; align-items: center; gap: 18px; margin-bottom: 5px;"> | |
| <div class="icon-circle bg-purple" style="width:55px; height:55px; font-size:26px; margin:0;">⚙️</div> | |
| <div> | |
| <h1 style="margin:0; font-size: 32px; letter-spacing: -0.5px;">Configuración</h1> | |
| <p style="margin:0; color: #64748B; font-size: 15px;">Ajusta los parámetros de tu plataforma CobroYa.</p> | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| st.write("") | |
| # Stats Rápidas | |
| try: | |
| count_res = conn.table("usuarios_dependientes").select("id", count="exact").eq("owner_id", u_id).execute() | |
| total_miembros = (count_res.count or 0) + 1 | |
| except Exception as e: | |
| st.warning(f"No se pudieron cargar los miembros del equipo") | |
| total_miembros = 1 | |
| s1, s2, s3 = st.columns(3) | |
| with s1: | |
| st.markdown(f'<div class="stat-bar"><span style="color:#64748B; font-size:11px; text-transform:uppercase;">Plan Activo</span><br><b>{plan_display}</b></div>', unsafe_allow_html=True) | |
| with s2: | |
| st.markdown(f'<div class="stat-bar"><span style="color:#64748B; font-size:11px; text-transform:uppercase;">Equipo</span><br><b>{total_miembros} Usuarios</b></div>', unsafe_allow_html=True) | |
| with s3: | |
| st.markdown('<div class="stat-bar"><span style="color:#64748B; font-size:11px; text-transform:uppercase;">Seguridad</span><br><b style="color:#22C55E;">Sistema Activo</b></div>', unsafe_allow_html=True) | |
| # GRID DE OPCIONES | |
| g1, g2, g3 = st.columns(3) | |
| with g1: | |
| st.markdown('<div class="card-apple"><div class="icon-circle bg-blue">🏢</div><div><div class="title-card">Perfil Negocio</div><div class="desc-card">Nombre, RNC, teléfonos y logo de tu empresa.</div></div></div>', unsafe_allow_html=True) | |
| if st.button("Abrir Perfil", key="btn_p_nav", use_container_width=True): | |
| st.session_state.config_sub = "Perfil"; st.rerun() | |
| with g2: | |
| st.markdown('<div class="card-apple"><div class="icon-circle bg-purple">👥</div><div><div class="title-card">Mi Equipo</div><div class="desc-card">Gestiona accesos y roles de tus empleados.</div></div></div>', unsafe_allow_html=True) | |
| if st.button("Abrir Equipo", key="btn_e_nav", use_container_width=True): | |
| st.session_state.config_sub = "Equipo"; st.rerun() | |
| with g3: | |
| st.markdown('<div class="card-apple"><div class="icon-circle bg-orange">📝</div><div><div class="title-card">Cláusulas</div><div class="desc-card">Edita el texto legal de tus contratos de cobro.</div></div></div>', unsafe_allow_html=True) | |
| if st.button("Abrir Cláusulas", key="btn_c_nav", use_container_width=True): | |
| st.session_state.config_sub = "Clausulas"; st.rerun() | |
| st.write("") | |
| g4, g5, g6 = st.columns(3) | |
| with g4: | |
| st.markdown('<div class="card-apple"><div class="icon-circle bg-orange">🔐</div><div><div class="title-card">Seguridad</div><div class="desc-card">Cambio de contraseña y llaves de acceso.</div></div></div>', unsafe_allow_html=True) | |
| if st.button("Abrir Seguridad", key="btn_s_nav", use_container_width=True): | |
| st.session_state.config_sub = "Seguridad"; st.rerun() | |
| with g5: | |
| st.markdown('<div class="card-apple"><div class="icon-circle bg-blue">💳</div><div><div class="title-card">Mi Plan</div><div class="desc-card">Suscripción actual y límites del sistema.</div></div></div>', unsafe_allow_html=True) | |
| if st.button("Ver Mi Plan", key="btn_plan_nav", use_container_width=True): | |
| st.session_state.config_sub = "Plan"; st.rerun() | |
| with g6: | |
| st.markdown('<div class="card-apple"><div class="icon-circle bg-purple">🎧</div><div><div class="title-card">Soporte</div><div class="desc-card">WhatsApp, Email y Teléfono directo.</div></div></div>', unsafe_allow_html=True) | |
| if st.button("Ver Soporte", key="btn_sop_nav", use_container_width=True): | |
| st.session_state.config_sub = "Soporte"; st.rerun() | |
| # --- 4. SUBSECCIONES (LÓGICA COMPLETA) --- | |
| elif st.session_state.config_sub == "Perfil": | |
| if st.button("← Volver", key="back_perfil"): st.session_state.config_sub = "Principal"; st.rerun() | |
| st.markdown("### 🏢 Configuración de Perfil de Negocio") | |
| with st.form("form_perfil"): | |
| new_nombre = st.text_input("Nombre del Negocio", biz.get("nombre_negocio", "")) | |
| new_rnc = st.text_input("RNC / Cédula", biz.get("rnc", "")) | |
| new_tel = st.text_input("Teléfono de Contacto", biz.get("telefono", "")) | |
| uploaded_logo = st.file_uploader("Subir Logo (PNG/JPG)", type=["png", "jpg", "jpeg"]) | |
| if st.form_submit_button("Guardar Cambios", use_container_width=True): | |
| import base64 | |
| data_update = { | |
| "nombre_negocio": new_nombre if new_nombre else None, | |
| "rnc": new_rnc if new_rnc else None, | |
| "telefono": new_tel if new_tel else None, | |
| "user_id": u_id | |
| } | |
| if uploaded_logo: | |
| try: | |
| base64_logo = base64.b64encode(uploaded_logo.read()).decode() | |
| data_update["logo_base64"] = base64_logo | |
| except Exception as e: | |
| st.error(f"Error al procesar el logo: {str(e)}") | |
| try: | |
| conn.table("configuracion").upsert(data_update, on_conflict="user_id").execute() | |
| # Mostramos el mensaje claro con las instrucciones | |
| st.success("✅ ¡Cambios aplicados correctamente! Por favor, recarga la página (F5) o sal y vuelve a entrar para ver los cambios reflejados.") | |
| # ⚠️ IMPORTANTE: Eliminamos st.rerun() para que el mensaje no desaparezca. | |
| except Exception as e: | |
| st.error(f"Error al guardar: {str(e)}") | |
| elif st.session_state.config_sub == "Equipo": | |
| if st.button("← Volver", key="back_equipo"): st.session_state.config_sub = "Principal"; st.rerun() | |
| st.markdown("### 👥 Gestión de Equipo") | |
| LIMITE_MIEMBROS = 15 | |
| try: | |
| team_res = conn.table("usuarios_dependientes").select("id, email, nombre, rol").eq("owner_id", u_id).execute() | |
| team = team_res.data if team_res.data else [] | |
| except Exception as e: | |
| st.error(f"Error cargando equipo: {str(e)}") | |
| team = [] | |
| miembros_actuales = len(team) if team else 0 | |
| st.markdown(f"<p style='color:#64748B; font-size:13px;'><b>{miembros_actuales}/{LIMITE_MIEMBROS}</b> miembros</p>", unsafe_allow_html=True) | |
| if team: | |
| st.markdown("#### Miembros Actuales") | |
| for member in team: | |
| col_m1, col_m2, col_m3 = st.columns([3, 1, 1]) | |
| col_m1.write(f"📧 **{member.get('email', 'N/A')}** - Rol: {member.get('rol', 'N/A')}") | |
| if col_m2.button("Editar", key=f"edit_{member['id']}", use_container_width=True): | |
| st.session_state[f"edit_mode_{member['id']}"] = True | |
| if col_m3.button("Eliminar", key=f"del_{member['id']}", use_container_width=True): | |
| try: | |
| conn.table("usuarios_dependientes").delete().eq("id", member['id']).execute() | |
| st.success(f"Miembro {member.get('email', 'N/A')} eliminado.") | |
| st.rerun() | |
| except Exception as e: | |
| st.error(f"Error al eliminar: {str(e)}") | |
| else: | |
| st.info("No tienes miembros adicionales en tu equipo.") | |
| st.write("---") | |
| if miembros_actuales < LIMITE_MIEMBROS: | |
| with st.expander("➕ Agregar nuevo miembro"): | |
| with st.form("form_nuevo_miembro"): | |
| new_email = st.text_input("Correo del empleado en minúsculas*").strip().lower() | |
| new_nombre = st.text_input("Nombre del empleado") | |
| new_password = st.text_input("Contraseña (mínimo 6 caracteres)", type="password") | |
| new_rol = st.text_input("Rol (ej: Cajero, Gestor, Supervisor)") | |
| if st.form_submit_button("Crear Miembro y Contraseña", use_container_width=True): | |
| errores = [] | |
| new_email_clean = new_email.strip().lower() | |
| new_nombre_clean = new_nombre.strip() | |
| new_rol_clean = new_rol.strip() | |
| if not new_email_clean or "@" not in new_email_clean: | |
| errores.append("Email inválido") | |
| if not new_nombre_clean: | |
| errores.append("El nombre es requerido") | |
| if not new_rol_clean: | |
| errores.append("El rol es requerido") | |
| if len(new_password) < 6: | |
| errores.append("La contraseña debe tener mínimo 6 caracteres") | |
| if not errores: | |
| try: | |
| email_existe = conn.table("usuarios_dependientes").select("id").eq("email", new_email_clean).execute() | |
| if email_existe.data: | |
| errores.append(f"El email {new_email_clean} ya está registrado") | |
| except Exception as e: | |
| st.warning("No se pudo verificar si el email existe") | |
| if errores: | |
| for error in errores: | |
| st.error(f"❌ {error}") | |
| else: | |
| try: | |
| import hashlib | |
| password_hash = hashlib.sha256(new_password.encode()).hexdigest() | |
| conn.table("usuarios_dependientes").insert({ | |
| "email": new_email_clean, | |
| "nombre": new_nombre_clean, | |
| "password_hash": password_hash, | |
| "rol": new_rol_clean, | |
| "owner_id": u_id, | |
| "es_activo": True | |
| }).execute() | |
| st.success(f"✅ Miembro creado correctamente!") | |
| st.info(f"📧 Email: {new_email_clean}\n🔐 Contraseña: {new_password}\n\n*Guarda estos datos para que el empleado pueda ingresar*") | |
| st.rerun() | |
| except Exception as e: | |
| st.error(f"❌ Error al crear miembro: {str(e)}") | |
| else: | |
| st.warning(f"⚠️ Has alcanzado el límite de {LIMITE_MIEMBROS} miembros para tu plan.") | |
| elif st.session_state.config_sub == "Clausulas": | |
| if st.button("← Volver", key="back_claus"): st.session_state.config_sub = "Principal"; st.rerun() | |
| st.markdown("### 📝 Cláusulas de Contrato") | |
| try: | |
| res_clausulas = conn.table("configuracion").select("clausulas").eq("user_id", u_id).execute() | |
| clausula_actual = res_clausulas.data[0].get("clausulas", "") if res_clausulas.data else "" | |
| except Exception as e: | |
| st.warning("No se pudieron cargar las cláusulas actuales") | |
| clausula_actual = "" | |
| new_clausulas = st.text_area("Texto de las Cláusulas", clausula_actual or "Escribe aquí las condiciones legales...", height=300) | |
| if st.button("Actualizar Cláusulas"): | |
| if clausula_actual and clausula_actual != "Escribe aquí las condiciones legales...": | |
| st.warning("⚠️ Ya tienes cláusulas guardadas. Se reemplazarán.") | |
| try: | |
| conn.table("configuracion").upsert({ | |
| "user_id": u_id, | |
| "clausulas": new_clausulas | |
| }, on_conflict="user_id").execute() | |
| st.success("Cláusulas actualizadas correctamente.") | |
| st.rerun() | |
| except Exception as e: | |
| st.error(f"Error al actualizar cláusulas: {str(e)}") | |
| elif st.session_state.config_sub == "Seguridad": | |
| if st.button("← Volver", key="back_seg"): st.session_state.config_sub = "Principal"; st.rerun() | |
| st.markdown("### 🔐 Seguridad") | |
| with st.form("form_seguridad"): | |
| st.write("Cambiar Contraseña") | |
| old_pass = st.text_input("Contraseña Actual", type="password") | |
| new_pass = st.text_input("Nueva Contraseña", type="password") | |
| confirm_pass = st.text_input("Confirmar Nueva Contraseña", type="password") | |
| if st.form_submit_button("Actualizar Seguridad"): | |
| try: | |
| user_email = st.session_state.user.email if hasattr(st.session_state.user, 'email') else st.session_state.user.get('email', '') | |
| if not user_email: | |
| st.error("No se encontró el email del usuario") | |
| else: | |
| try: | |
| validacion = conn.auth.sign_in_with_password({ | |
| "email": user_email, | |
| "password": old_pass | |
| }) | |
| if new_pass == confirm_pass: | |
| if len(new_pass) < 6: | |
| st.error("La nueva contraseña debe tener al menos 6 caracteres.") | |
| else: | |
| conn.auth.update_user({"password": new_pass}) | |
| st.success("Contraseña actualizada correctamente.") | |
| st.rerun() | |
| else: | |
| st.error("Las contraseñas nuevas no coinciden.") | |
| except Exception as e: | |
| st.error("❌ Contraseña actual incorrecta. Intenta de nuevo.") | |
| except Exception as e: | |
| st.error(f"Error en el proceso de autenticación: {str(e)}") | |
| elif st.session_state.config_sub == "Plan": | |
| if st.button("← Volver", key="back_plan"): | |
| st.session_state.config_sub = "Principal" | |
| st.rerun() | |
| st.markdown("### 💳 Mi Plan de Suscripción") | |
| st.info(f"Tu plan actual es: **{plan_display}**") | |
| fecha_vencimiento = biz.get("fecha_vencimiento") | |
| if fecha_vencimiento: | |
| try: | |
| from datetime import datetime, date | |
| fecha_obj = datetime.fromisoformat(fecha_vencimiento).date() if isinstance(fecha_vencimiento, str) else fecha_vencimiento | |
| dias_restantes = (fecha_obj - date.today()).days | |
| if dias_restantes > 0: | |
| st.markdown(f"<div style='background:#DCFCE7;border:1px solid #86EFAC;padding:15px;border-radius:12px;margin-bottom:20px;'><p style='margin:0;color:#166534;font-weight:600;'>✅ Plan activo - Te quedan <b>{dias_restantes} días</b> (Vence: {fecha_obj.strftime('%d/%m/%Y')})</p></div>", unsafe_allow_html=True) | |
| elif dias_restantes == 0: | |
| st.markdown("<div style='background:#FED7AA;border:1px solid #FDBA74;padding:15px;border-radius:12px;margin-bottom:20px;'><p style='margin:0;color:#92400E;font-weight:600;'>⚠️ Tu plan vence hoy</p></div>", unsafe_allow_html=True) | |
| else: | |
| st.markdown("<div style='background:#FEE2E2;border:1px solid #FECACA;padding:15px;border-radius:12px;margin-bottom:20px;'><p style='margin:0;color:#991B1B;font-weight:600;'>❌ Tu plan ha expirado</p></div>", unsafe_allow_html=True) | |
| except Exception as e: | |
| st.warning("No se pudo procesar la fecha de vencimiento") | |
| p0, p1, p2, p3 = st.columns(4) | |
| with p0: | |
| st.markdown(""" | |
| <div style='border:1px solid #E2E8F0;padding:15px;border-radius:15px;text-align:left;height:550px;'> | |
| <h4 style='text-align:center;margin-bottom:0;'>FREE</h4> | |
| <h2 style='text-align:center;margin-top:0;font-size:1.8em;'>Gratis</h2> | |
| <p style='font-size:0.8em; color:gray; text-align:center;'>Exploración Inteligente</p> | |
| <hr> | |
| <p style='font-size:0.82em;'> | |
| ✓ Límite de 5 clientes<br> | |
| ✓ Máximo 10 préstamos<br> | |
| ✓ Amortización automática<br> | |
| ✓ Gestión operativa básica<br> | |
| ✓ Contratos con marca de agua<br> | |
| ✓ 1 dispositivo<br> | |
| ✕ Dashboards y Reportes<br> | |
| ✕ Módulos IA, WhatsApp, GPS<br> | |
| ✕ Exportación de datos | |
| </p> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| if st.button("Seleccionar Free", key="p_0", use_container_width=True): | |
| st.session_state.plan_seleccionado = {"nombre": "Free", "precio": 0} | |
| st.rerun() | |
| with p1: | |
| st.markdown(""" | |
| <div style='border:1px solid #E2E8F0;padding:15px;border-radius:15px;text-align:left;height:550px;'> | |
| <h4 style='text-align:center;margin-bottom:0;'>STARTER</h4> | |
| <h2 style='text-align:center;margin-top:0;font-size:1.8em;'>RD$799<span style='font-size:0.5em;'>/mo</span></h2> | |
| <p style='font-size:0.7em; color:#166534; text-align:center; font-weight:bold;'>Pago anual: 20% más económico</p> | |
| <p style='font-size:0.8em; color:gray; text-align:center;'>Prestamista Individual</p> | |
| <hr> | |
| <p style='font-size:0.82em;'> | |
| ✓ Capacidad 100 clientes<br> | |
| ✓ Hasta 250 cuentas activas<br> | |
| ✓ Dashboard esencial<br> | |
| ✓ Gestión cobros completa<br> | |
| ✓ PDF sin marcas<br> | |
| ✓ WhatsApp (Básica)<br> | |
| ✓ GPS ubicación básica<br> | |
| ✓ IA (100 consultas/mes)<br> | |
| ✓ Filtros de clientes<br> | |
| ✓ Hasta 2 sesiones | |
| </p> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| if st.button("Seleccionar Starter", key="p_1", use_container_width=True): | |
| st.session_state.plan_seleccionado = {"nombre": "Starter", "precio": 799} | |
| st.rerun() | |
| with p2: | |
| st.markdown(""" | |
| <div style='border:2px solid #3B82F6;padding:15px;border-radius:15px;text-align:left;height:550px;position:relative;'> | |
| <span style='background:#3B82F6;color:white;padding:2px 10px;border-radius:10px;font-size:0.7em;position:absolute;top:-10px;left:25%;'>RECOMENDADO</span> | |
| <h4 style='text-align:center;color:#3B82F6;margin-bottom:0;'>PRO</h4> | |
| <h2 style='text-align:center;margin-top:0;font-size:1.8em;'>RD$2499<span style='font-size:0.5em;'>/mo</span></h2> | |
| <p style='font-size:0.7em; color:#166534; text-align:center; font-weight:bold;'>Pago anual: 20% más económico</p> | |
| <p style='font-size:0.8em; color:gray; text-align:center;'>Operación Comercial</p> | |
| <hr> | |
| <p style='font-size:0.82em;'> | |
| ✓ Capacidad 1,000 clientes<br> | |
| ✓ Hasta 2,500 cuentas<br> | |
| ✓ Dashboards completos<br> | |
| ✓ PDF con tu marca<br> | |
| ✓ Integración WhatsApp<br> | |
| ✓ GPS Planificador rutas<br> | |
| ✓ IA Avanzada (300 consultas)<br> | |
| ✓ Códigos QR en recibos<br> | |
| ✓ Exportación parcial<br> | |
| ✓ Hasta 5 sesiones | |
| </p> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| if st.button("Mejorar a PRO", key="p_2", use_container_width=True): | |
| st.session_state.plan_seleccionado = {"nombre": "Pro", "precio": 2499} | |
| st.rerun() | |
| with p3: | |
| st.markdown(""" | |
| <div style='border:1px solid #E2E8F0;padding:15px;border-radius:15px;text-align:left;height:550px;'> | |
| <h4 style='text-align:center;margin-bottom:0;'>ENTERPRISE</h4> | |
| <h2 style='text-align:center;margin-top:0;font-size:1.8em;'>RD$7999<span style='font-size:0.5em;'>/mo</span></h2> | |
| <p style='font-size:0.7em; color:#166534; text-align:center; font-weight:bold;'>Pago anual: 20% más económico</p> | |
| <p style='font-size:0.8em; color:gray; text-align:center;'>Infraestructura a Escala</p> | |
| <hr> | |
| <p style='font-size:0.82em;'> | |
| ✓ Capacidad 10,000 clientes<br> | |
| ✓ Hasta 50,000 cuentas<br> | |
| ✓ Dashboards predictivos<br> | |
| ✓ WhatsApp automático<br> | |
| ✓ GPS monitoreo avanzado<br> | |
| ✓ IA de alta capacidad<br> | |
| ✓ Exportación total base datos<br> | |
| ✓ Soporte dedicado 24/7<br> | |
| ✓ Backup empresarial<br> | |
| ✓ Hasta 20 sesiones | |
| </p> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| if st.button("Contactar Ventas", key="p_3", use_container_width=True): | |
| st.session_state.plan_seleccionado = {"nombre": "Enterprise", "precio": 7999} | |
| st.rerun() | |
| elif st.session_state.config_sub == "Soporte": | |
| # 1. Botón para regresar al menú de tarjetas | |
| if st.button("← Volver", key="back_to_main_config"): | |
| st.session_state.config_sub = "Principal" | |
| st.rerun() | |
| # 2. Estilos CSS (FontAwesome + Diseño de tarjetas) | |
| st.markdown(""" | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"> | |
| <style> | |
| .support-container { text-align: center; padding: 10px 0 30px 0; } | |
| .contact-grid { display: flex; justify-content: space-between; gap: 15px; margin-bottom: 30px; } | |
| .btn-card { | |
| flex: 1; background: white; border: 1px solid #E2E8F0; border-radius: 20px; | |
| padding: 25px 10px; text-decoration: none; transition: all 0.3s ease; | |
| display: flex; flex-direction: column; align-items: center; | |
| box-shadow: 0 2px 4px rgba(0,0,0,0.02); | |
| } | |
| .btn-card:hover { transform: translateY(-5px); box-shadow: 0 10px 20px rgba(0,0,0,0.06); border-color: #CBD5E1; } | |
| .btn-text { font-size: 15px; font-weight: 600; color: #1E293B; margin-top: 10px; } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| # 3. Título | |
| st.markdown(""" | |
| <div class="support-container"> | |
| <h2 style="color: #1E293B; margin-bottom:5px;">Centro de Soporte</h2> | |
| <p style="color: #64748B;">Nuestro equipo técnico está listo para ayudarte con CobroYa.</p> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # 4. Variables de contacto | |
| EMAIL_SOP = "soporte@cobroya.com" | |
| WA_SOP = "18095196889" | |
| TEL_SOP = "8095196889" | |
| # 5. Botones de contacto en HTML | |
| st.markdown(f""" | |
| <div class="contact-grid"> | |
| <a href="mailto:{EMAIL_SOP}" class="btn-card"> | |
| <i class="fas fa-envelope" style="font-size: 35px; color: #EA4335;"></i> | |
| <span class="btn-text">Email Soporte</span> | |
| </a> | |
| <a href="https://wa.me/{WA_SOP}" target="_blank" class="btn-card"> | |
| <i class="fab fa-whatsapp" style="font-size: 40px; color: #25D366;"></i> | |
| <span class="btn-text">WhatsApp</span> | |
| </a> | |
| <a href="tel:{TEL_SOP}" class="btn-card"> | |
| <i class="fas fa-phone-alt" style="font-size: 32px; color: #0EA5E9;"></i> | |
| <span class="btn-text">Llamada</span> | |
| </a> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| st.write("---") | |
| st.markdown("### 📚 Preguntas Frecuentes") | |
| # 6. Acordeones de preguntas | |
| f1, f2 = st.columns(2) | |
| with f1: | |
| with st.expander("¿Cómo agrego un cobrador?"): | |
| st.write("Ve a **Configuración > Mi Equipo** y registra su correo.") | |
| with st.expander("¿Cómo descargo mis reportes?"): | |
| st.write("En el Dashboard, usa el botón de **Exportar** para obtener un Excel.") | |
| with f2: | |
| with st.expander("¿Mi información está segura?"): | |
| st.write("Sí. Usamos infraestructura de Supabase con cifrado SSL de grado bancario.") | |
| with st.expander("¿Puedo cambiar mi plan?"): | |
| st.write("Claro, puedes gestionar tu suscripción en la sección **Mi Plan**.") | |
| # 7. Horario | |
| st.markdown(""" | |
| <div style="background: #F8FAFC; border: 1px solid #E2E8F0; padding: 15px; border-radius: 15px; margin-top: 20px; text-align: center;"> | |
| <p style="margin: 0; color: #64748B; font-size: 13px;"> | |
| <i class="fas fa-clock"></i> <b>Horario:</b> Lun-Vie 8AM-6PM | Sáb 9AM-1PM | |
| </p> | |
| </div> | |
| """, unsafe_allow_html=True) | |