Commit inicial: Sales Bot - Sistema de Automatización de Ventas
- Stack completo con Mattermost, NocoDB y Sales Bot - Procesamiento OCR de tickets con Tesseract - Sistema de comisiones por tubos de tinte - Comandos slash /metas y /ranking - Documentación completa del proyecto Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
724
sales-bot/app.py
Normal file
724
sales-bot/app.py
Normal file
@@ -0,0 +1,724 @@
|
||||
from flask import Flask, request, jsonify, render_template_string
|
||||
import os
|
||||
import logging
|
||||
from dotenv import load_dotenv
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
# Cargar variables de entorno
|
||||
load_dotenv()
|
||||
|
||||
# Importar módulos personalizados
|
||||
from mattermost_client import MattermostClient
|
||||
from nocodb_client import NocoDBClient
|
||||
from handlers import handle_venta_message, generar_reporte_diario
|
||||
from utils import validar_token_outgoing
|
||||
from websocket_listener import MattermostWebsocketListener
|
||||
|
||||
# Configurar logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.StreamHandler()
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Inicializar Flask
|
||||
app = Flask(__name__)
|
||||
|
||||
# Inicializar clientes
|
||||
mattermost = MattermostClient(
|
||||
url=os.getenv('MATTERMOST_URL'),
|
||||
token=os.getenv('MATTERMOST_BOT_TOKEN')
|
||||
)
|
||||
|
||||
nocodb = NocoDBClient(
|
||||
url=os.getenv('NOCODB_URL'),
|
||||
token=os.getenv('NOCODB_TOKEN')
|
||||
)
|
||||
|
||||
# Inicializar websocket listener
|
||||
ws_listener = MattermostWebsocketListener(mattermost, nocodb, handle_venta_message)
|
||||
ws_listener.start()
|
||||
logger.info("Websocket listener iniciado para escuchar mensajes de Mattermost")
|
||||
|
||||
@app.route('/health', methods=['GET'])
|
||||
def health_check():
|
||||
"""Endpoint para verificar que el bot está funcionando"""
|
||||
return jsonify({
|
||||
'status': 'healthy',
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'version': '1.0.0'
|
||||
}), 200
|
||||
|
||||
@app.route('/webhook/mattermost', methods=['POST'])
|
||||
def mattermost_webhook():
|
||||
"""
|
||||
Recibe webhooks salientes de Mattermost cuando hay mensajes en el canal de ventas
|
||||
"""
|
||||
try:
|
||||
data = request.json
|
||||
logger.info(f"Webhook recibido: {json.dumps(data, indent=2)}")
|
||||
|
||||
# Validar token
|
||||
token = data.get('token')
|
||||
if not validar_token_outgoing(token):
|
||||
logger.warning(f"Token inválido: {token}")
|
||||
return jsonify({'error': 'Token inválido'}), 403
|
||||
|
||||
# Ignorar mensajes del propio bot
|
||||
if data.get('user_name') == 'sales-bot':
|
||||
return jsonify({'status': 'ignored', 'reason': 'bot message'}), 200
|
||||
|
||||
# Obtener información del mensaje
|
||||
channel_name = data.get('channel_name')
|
||||
user_name = data.get('user_name')
|
||||
text = data.get('text', '').strip()
|
||||
|
||||
logger.info(f"Procesando mensaje de {user_name} en #{channel_name}: {text}")
|
||||
|
||||
# Verificar si es un mensaje de venta
|
||||
palabras_clave = ['venta', 'vendi', 'vendí', 'cliente', 'ticket']
|
||||
es_venta = any(palabra in text.lower() for palabra in palabras_clave)
|
||||
|
||||
if es_venta or data.get('file_ids'):
|
||||
respuesta = handle_venta_message(data, mattermost, nocodb)
|
||||
return jsonify(respuesta), 200
|
||||
|
||||
return jsonify({'status': 'ok', 'message': 'Mensaje procesado'}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error procesando webhook: {str(e)}", exc_info=True)
|
||||
return jsonify({'error': 'Error interno del servidor'}), 500
|
||||
|
||||
@app.route('/webhook/nocodb', methods=['POST'])
|
||||
def nocodb_webhook():
|
||||
"""
|
||||
Recibe webhooks de NocoDB cuando se insertan/actualizan datos
|
||||
"""
|
||||
try:
|
||||
data = request.json
|
||||
logger.info(f"Webhook NocoDB recibido: {json.dumps(data, indent=2)}")
|
||||
|
||||
# Aquí puedes agregar lógica para notificar en Mattermost
|
||||
# cuando se actualicen datos en NocoDB
|
||||
|
||||
return jsonify({'status': 'ok'}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error procesando webhook NocoDB: {str(e)}", exc_info=True)
|
||||
return jsonify({'error': 'Error interno del servidor'}), 500
|
||||
|
||||
@app.route('/comando/metas', methods=['POST'])
|
||||
def comando_metas():
|
||||
"""
|
||||
Endpoint para el comando slash /metas en Mattermost
|
||||
"""
|
||||
try:
|
||||
data = request.form.to_dict()
|
||||
logger.info(f"Comando /metas recibido de {data.get('user_name')}")
|
||||
|
||||
# Validar token
|
||||
token = data.get('token')
|
||||
expected_tokens = [
|
||||
os.getenv('MATTERMOST_SLASH_TOKEN_METAS'),
|
||||
os.getenv('MATTERMOST_OUTGOING_TOKEN')
|
||||
]
|
||||
if token not in expected_tokens:
|
||||
return jsonify({'text': 'Token inválido'}), 403
|
||||
|
||||
user_name = data.get('user_name')
|
||||
|
||||
# Obtener meta del vendedor
|
||||
meta = nocodb.get_meta_vendedor(user_name)
|
||||
|
||||
if not meta:
|
||||
mensaje = (
|
||||
f"@{user_name} Aún no tienes ventas registradas este mes.\n"
|
||||
"¡Empieza a vender y registra tus ventas!"
|
||||
)
|
||||
else:
|
||||
porcentaje = meta.get('porcentaje_completado', 0)
|
||||
total_vendido = meta.get('total_vendido', 0)
|
||||
meta_establecida = meta.get('meta_establecida', 0)
|
||||
ventas_count = meta.get('ventas_realizadas', 0)
|
||||
falta = meta_establecida - total_vendido
|
||||
|
||||
# Barra de progreso visual
|
||||
barra_length = 20
|
||||
completado = int((porcentaje / 100) * barra_length)
|
||||
barra = '█' * completado + '░' * (barra_length - completado)
|
||||
|
||||
mensaje = (
|
||||
f"📊 **Reporte de {user_name}**\n\n"
|
||||
f"`{barra}` {porcentaje:.1f}%\n\n"
|
||||
f"**Total vendido:** ${total_vendido:,.2f} MXN\n"
|
||||
f"**Meta mensual:** ${meta_establecida:,.2f} MXN\n"
|
||||
f"**Falta:** ${falta:,.2f} MXN\n"
|
||||
f"**Ventas realizadas:** {ventas_count}\n"
|
||||
)
|
||||
|
||||
if porcentaje >= 100:
|
||||
mensaje += "\n🎉 **¡Felicidades! Meta completada**"
|
||||
elif porcentaje >= 75:
|
||||
mensaje += f"\n🔥 **¡Casi llegas! Solo faltan ${falta:,.2f}**"
|
||||
|
||||
return jsonify({
|
||||
'response_type': 'ephemeral',
|
||||
'text': mensaje
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error procesando comando /metas: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
'text': f'❌ Error procesando comando: {str(e)}'
|
||||
}), 500
|
||||
|
||||
@app.route('/comando/ranking', methods=['POST'])
|
||||
def comando_ranking():
|
||||
"""
|
||||
Endpoint para el comando slash /ranking en Mattermost
|
||||
"""
|
||||
try:
|
||||
data = request.form.to_dict()
|
||||
logger.info(f"Comando /ranking recibido de {data.get('user_name')}")
|
||||
|
||||
# Validar token
|
||||
token = data.get('token')
|
||||
expected_tokens = [
|
||||
os.getenv('MATTERMOST_SLASH_TOKEN_RANKING'),
|
||||
os.getenv('MATTERMOST_OUTGOING_TOKEN')
|
||||
]
|
||||
if token not in expected_tokens:
|
||||
return jsonify({'text': 'Token inválido'}), 403
|
||||
|
||||
# Obtener ranking
|
||||
ranking = nocodb.get_ranking_vendedores()
|
||||
|
||||
if not ranking:
|
||||
mensaje = "No hay datos de ventas este mes."
|
||||
else:
|
||||
mensaje = "🏆 **Ranking de Vendedores - Mes Actual**\n\n"
|
||||
|
||||
for i, vendedor in enumerate(ranking[:10], 1):
|
||||
username = vendedor.get('vendedor_username')
|
||||
total = vendedor.get('total_vendido', 0)
|
||||
porcentaje = vendedor.get('porcentaje_completado', 0)
|
||||
ventas = vendedor.get('ventas_realizadas', 0)
|
||||
|
||||
# Medallas para top 3
|
||||
if i == 1:
|
||||
emoji = '🥇'
|
||||
elif i == 2:
|
||||
emoji = '🥈'
|
||||
elif i == 3:
|
||||
emoji = '🥉'
|
||||
else:
|
||||
emoji = f'{i}.'
|
||||
|
||||
mensaje += (
|
||||
f"{emoji} **@{username}**\n"
|
||||
f" └ ${total:,.2f} MXN ({porcentaje:.1f}%) - {ventas} ventas\n"
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'response_type': 'in_channel',
|
||||
'text': mensaje
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error procesando comando /ranking: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
'text': f'❌ Error procesando comando: {str(e)}'
|
||||
}), 500
|
||||
|
||||
@app.route('/comando/ayuda', methods=['POST'])
|
||||
def comando_ayuda():
|
||||
"""
|
||||
Endpoint para el comando slash /ayuda en Mattermost
|
||||
"""
|
||||
try:
|
||||
data = request.form.to_dict()
|
||||
logger.info(f"Comando /ayuda recibido de {data.get('user_name')}")
|
||||
|
||||
# Validar token
|
||||
token = data.get('token')
|
||||
expected_tokens = [
|
||||
os.getenv('MATTERMOST_SLASH_TOKEN_AYUDA'),
|
||||
os.getenv('MATTERMOST_OUTGOING_TOKEN')
|
||||
]
|
||||
if token not in expected_tokens:
|
||||
return jsonify({'text': 'Token inválido'}), 403
|
||||
|
||||
mensaje = (
|
||||
"🤖 **Bot de Ventas - Guía de Uso**\n\n"
|
||||
"**Para registrar una venta:**\n"
|
||||
"• `venta @monto 1500 @cliente Juan Pérez`\n"
|
||||
"• `vendí $1500 a María García`\n"
|
||||
"• También puedes adjuntar foto del ticket\n\n"
|
||||
"**Comandos disponibles:**\n"
|
||||
"• `/metas` - Ver tu progreso del mes\n"
|
||||
"• `/ranking` - Ver ranking de vendedores\n"
|
||||
"• `/ayuda` - Mostrar esta ayuda\n\n"
|
||||
"**Ejemplos de registro de ventas:**\n"
|
||||
"✅ `venta @monto 2500 @cliente Empresa ABC`\n"
|
||||
"✅ `vendí $1,200.50 a cliente Pedro`\n"
|
||||
"✅ `venta @monto 5000 @cliente Tienda XYZ`\n\n"
|
||||
"**Consejos:**\n"
|
||||
"• Registra tus ventas inmediatamente después de cerrarlas\n"
|
||||
"• Incluye el nombre del cliente para mejor seguimiento\n"
|
||||
"• Revisa tu progreso regularmente con `/metas`\n"
|
||||
"• Compite sanamente con tus compañeros en el `/ranking`\n\n"
|
||||
"¡Sigue adelante! 💪"
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'response_type': 'ephemeral',
|
||||
'text': mensaje
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error procesando comando /ayuda: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
'text': f'❌ Error procesando comando: {str(e)}'
|
||||
}), 500
|
||||
|
||||
@app.route('/reporte/diario', methods=['POST'])
|
||||
def reporte_diario_manual():
|
||||
"""
|
||||
Endpoint para generar reporte diario manualmente
|
||||
"""
|
||||
try:
|
||||
resultado = generar_reporte_diario(mattermost, nocodb)
|
||||
return jsonify(resultado), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Error generando reporte: {str(e)}", exc_info=True)
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/test/mattermost', methods=['GET'])
|
||||
def test_mattermost():
|
||||
"""Test de conexión con Mattermost"""
|
||||
try:
|
||||
resultado = mattermost.test_connection()
|
||||
return jsonify(resultado), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/test/nocodb', methods=['GET'])
|
||||
def test_nocodb():
|
||||
"""Test de conexión con NocoDB"""
|
||||
try:
|
||||
resultado = nocodb.test_connection()
|
||||
return jsonify(resultado), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
# ============== DASHBOARD ==============
|
||||
|
||||
@app.route('/api/dashboard/resumen', methods=['GET'])
|
||||
def api_dashboard_resumen():
|
||||
"""API: Resumen general del día y mes"""
|
||||
try:
|
||||
from datetime import datetime, timedelta, timezone
|
||||
TZ_MEXICO = timezone(timedelta(hours=-6))
|
||||
|
||||
hoy = datetime.now(TZ_MEXICO).strftime('%Y-%m-%d')
|
||||
mes = datetime.now(TZ_MEXICO).strftime('%Y-%m')
|
||||
|
||||
# Ventas del día
|
||||
ventas_hoy = nocodb.get_ventas_dia()
|
||||
total_hoy = sum(float(v.get('monto', 0)) for v in ventas_hoy)
|
||||
|
||||
# Ventas del mes
|
||||
ventas_mes = nocodb.get_ventas_mes()
|
||||
total_mes = sum(float(v.get('monto', 0)) for v in ventas_mes)
|
||||
|
||||
# Contar vendedores activos hoy
|
||||
vendedores_hoy = set(v.get('vendedor_username') for v in ventas_hoy)
|
||||
|
||||
return jsonify({
|
||||
'fecha': hoy,
|
||||
'mes': mes,
|
||||
'ventas_hoy': len(ventas_hoy),
|
||||
'monto_hoy': total_hoy,
|
||||
'ventas_mes': len(ventas_mes),
|
||||
'monto_mes': total_mes,
|
||||
'vendedores_activos_hoy': len(vendedores_hoy)
|
||||
}), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Error en API resumen: {str(e)}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/dashboard/ranking', methods=['GET'])
|
||||
def api_dashboard_ranking():
|
||||
"""API: Ranking de vendedores del mes"""
|
||||
try:
|
||||
ranking = nocodb.get_ranking_vendedores()
|
||||
return jsonify(ranking), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Error en API ranking: {str(e)}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/dashboard/ventas-recientes', methods=['GET'])
|
||||
def api_dashboard_ventas_recientes():
|
||||
"""API: Últimas ventas registradas con nombres de vendedores"""
|
||||
try:
|
||||
import requests
|
||||
|
||||
ventas = nocodb.get_ventas_dia()
|
||||
|
||||
# Obtener lista de vendedores para mapear usernames a nombres
|
||||
vendedores_response = requests.get(
|
||||
f"{nocodb.url}/api/v2/tables/{nocodb.table_vendedores}/records",
|
||||
headers=nocodb.headers,
|
||||
params={'limit': 100},
|
||||
timeout=10
|
||||
)
|
||||
vendedores_response.raise_for_status()
|
||||
vendedores = vendedores_response.json().get('list', [])
|
||||
|
||||
# Crear mapa de username -> nombre_completo
|
||||
nombres_map = {v.get('username'): v.get('nombre_completo', v.get('username')) for v in vendedores}
|
||||
|
||||
# Agregar nombre_completo a cada venta
|
||||
for venta in ventas:
|
||||
username = venta.get('vendedor_username', '')
|
||||
venta['nombre_completo'] = nombres_map.get(username, username)
|
||||
|
||||
# Ordenar por fecha descendente y tomar las últimas 20
|
||||
ventas_ordenadas = sorted(ventas, key=lambda x: x.get('fecha_venta', ''), reverse=True)[:20]
|
||||
return jsonify(ventas_ordenadas), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Error en API ventas recientes: {str(e)}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/dashboard/metas', methods=['GET'])
|
||||
def api_dashboard_metas():
|
||||
"""API: Estado de metas de todos los vendedores"""
|
||||
try:
|
||||
import requests
|
||||
response = requests.get(
|
||||
f"{nocodb.url}/api/v2/tables/{nocodb.table_metas}/records",
|
||||
headers=nocodb.headers,
|
||||
params={'limit': 100},
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
metas = response.json().get('list', [])
|
||||
return jsonify(metas), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Error en API metas: {str(e)}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/dashboard')
|
||||
def dashboard():
|
||||
"""Dashboard principal de ventas"""
|
||||
html = '''
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sales Bot - Dashboard</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
min-height: 100vh;
|
||||
color: #fff;
|
||||
}
|
||||
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
h1 { font-size: 28px; font-weight: 600; }
|
||||
h1 span { color: #00d4ff; }
|
||||
.fecha { color: #888; font-size: 14px; }
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.stat-card {
|
||||
background: rgba(255,255,255,0.05);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.stat-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 40px rgba(0,212,255,0.1);
|
||||
}
|
||||
.stat-card .label { color: #888; font-size: 12px; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; }
|
||||
.stat-card .value { font-size: 32px; font-weight: 700; color: #00d4ff; }
|
||||
.stat-card .subvalue { font-size: 14px; color: #666; margin-top: 4px; }
|
||||
.stat-card.green .value { color: #00ff88; }
|
||||
.stat-card.orange .value { color: #ffaa00; }
|
||||
.stat-card.purple .value { color: #aa00ff; }
|
||||
.main-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
@media (max-width: 900px) { .main-grid { grid-template-columns: 1fr; } }
|
||||
.panel {
|
||||
background: rgba(255,255,255,0.05);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
.panel h2 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.panel h2 .icon { font-size: 24px; }
|
||||
.ranking-list { list-style: none; }
|
||||
.ranking-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
}
|
||||
.ranking-item:last-child { border-bottom: none; }
|
||||
.ranking-position {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
.ranking-position.gold { background: linear-gradient(135deg, #ffd700, #ffaa00); color: #000; }
|
||||
.ranking-position.silver { background: linear-gradient(135deg, #c0c0c0, #888); color: #000; }
|
||||
.ranking-position.bronze { background: linear-gradient(135deg, #cd7f32, #8b4513); color: #fff; }
|
||||
.ranking-position.default { background: rgba(255,255,255,0.1); color: #888; }
|
||||
.ranking-info { flex: 1; }
|
||||
.ranking-name { font-weight: 600; margin-bottom: 2px; }
|
||||
.ranking-stats { font-size: 12px; color: #888; }
|
||||
.ranking-value { text-align: right; }
|
||||
.ranking-tubos { font-size: 24px; font-weight: 700; color: #00d4ff; }
|
||||
.ranking-comision { font-size: 12px; color: #00ff88; }
|
||||
.ventas-list { max-height: 400px; overflow-y: auto; }
|
||||
.venta-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.venta-info .vendedor { font-weight: 600; color: #00d4ff; }
|
||||
.venta-info .cliente { font-size: 12px; color: #888; }
|
||||
.venta-monto { font-size: 18px; font-weight: 700; color: #00ff88; }
|
||||
.refresh-btn {
|
||||
background: rgba(0,212,255,0.2);
|
||||
border: 1px solid #00d4ff;
|
||||
color: #00d4ff;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.refresh-btn:hover { background: rgba(0,212,255,0.3); }
|
||||
.loading { text-align: center; padding: 40px; color: #888; }
|
||||
.meta-progress {
|
||||
height: 8px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.meta-progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #00d4ff, #00ff88);
|
||||
border-radius: 4px;
|
||||
transition: width 0.5s;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<div>
|
||||
<h1><span>Sales</span> Bot Dashboard</h1>
|
||||
<p class="fecha" id="fecha-actual"></p>
|
||||
</div>
|
||||
<button class="refresh-btn" onclick="cargarDatos()">🔄 Actualizar</button>
|
||||
</header>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="label">Ventas Hoy</div>
|
||||
<div class="value" id="ventas-hoy">-</div>
|
||||
<div class="subvalue" id="monto-hoy">$0.00</div>
|
||||
</div>
|
||||
<div class="stat-card green">
|
||||
<div class="label">Ventas del Mes</div>
|
||||
<div class="value" id="ventas-mes">-</div>
|
||||
<div class="subvalue" id="monto-mes">$0.00</div>
|
||||
</div>
|
||||
<div class="stat-card orange">
|
||||
<div class="label">Vendedores Activos Hoy</div>
|
||||
<div class="value" id="vendedores-activos">-</div>
|
||||
</div>
|
||||
<div class="stat-card purple">
|
||||
<div class="label">Meta Diaria</div>
|
||||
<div class="value">3</div>
|
||||
<div class="subvalue">tubos por vendedor</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-grid">
|
||||
<div class="panel">
|
||||
<h2><span class="icon">🏆</span> Ranking del Mes (Tubos)</h2>
|
||||
<ul class="ranking-list" id="ranking-list">
|
||||
<li class="loading">Cargando...</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2><span class="icon">📋</span> Ventas Recientes</h2>
|
||||
<div class="ventas-list" id="ventas-list">
|
||||
<div class="loading">Cargando...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function formatMoney(amount) {
|
||||
return new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' }).format(amount);
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
async function cargarResumen() {
|
||||
try {
|
||||
const res = await fetch('/api/dashboard/resumen');
|
||||
const data = await res.json();
|
||||
|
||||
document.getElementById('ventas-hoy').textContent = data.ventas_hoy || 0;
|
||||
document.getElementById('monto-hoy').textContent = formatMoney(data.monto_hoy || 0);
|
||||
document.getElementById('ventas-mes').textContent = data.ventas_mes || 0;
|
||||
document.getElementById('monto-mes').textContent = formatMoney(data.monto_mes || 0);
|
||||
document.getElementById('vendedores-activos').textContent = data.vendedores_activos_hoy || 0;
|
||||
document.getElementById('fecha-actual').textContent = new Date().toLocaleDateString('es-MX', {
|
||||
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Error cargando resumen:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function cargarRanking() {
|
||||
try {
|
||||
const res = await fetch('/api/dashboard/ranking');
|
||||
const data = await res.json();
|
||||
const lista = document.getElementById('ranking-list');
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
lista.innerHTML = '<li class="loading">No hay datos de ventas</li>';
|
||||
return;
|
||||
}
|
||||
|
||||
lista.innerHTML = data.slice(0, 10).map((v, i) => {
|
||||
const posClass = i === 0 ? 'gold' : i === 1 ? 'silver' : i === 2 ? 'bronze' : 'default';
|
||||
const tubos = v.tubos_totales || 0;
|
||||
const comision = v.comision_total || 0;
|
||||
const ventas = v.cantidad_ventas || 0;
|
||||
|
||||
const nombre = v.nombre_completo || v.vendedor_username || v.vendedor;
|
||||
const username = v.vendedor_username || v.vendedor;
|
||||
|
||||
return `
|
||||
<li class="ranking-item">
|
||||
<div class="ranking-position ${posClass}">${i + 1}</div>
|
||||
<div class="ranking-info">
|
||||
<div class="ranking-name">${nombre}</div>
|
||||
<div class="ranking-stats">@${username} • ${ventas} ventas • ${v.dias_activos || 0} días activos</div>
|
||||
</div>
|
||||
<div class="ranking-value">
|
||||
<div class="ranking-tubos">${tubos} 🧪</div>
|
||||
${comision > 0 ? `<div class="ranking-comision">+${formatMoney(comision)}</div>` : ''}
|
||||
</div>
|
||||
</li>
|
||||
`;
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
console.error('Error cargando ranking:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function cargarVentasRecientes() {
|
||||
try {
|
||||
const res = await fetch('/api/dashboard/ventas-recientes');
|
||||
const data = await res.json();
|
||||
const lista = document.getElementById('ventas-list');
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
lista.innerHTML = '<div class="loading">No hay ventas hoy</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
lista.innerHTML = data.map(v => {
|
||||
const nombre = v.nombre_completo || v.vendedor_username;
|
||||
return `
|
||||
<div class="venta-item">
|
||||
<div class="venta-info">
|
||||
<div class="vendedor">${nombre}</div>
|
||||
<div class="cliente">${v.cliente || 'Sin cliente'} • ${formatDate(v.fecha_venta)}</div>
|
||||
</div>
|
||||
<div class="venta-monto">${formatMoney(v.monto || 0)}</div>
|
||||
</div>
|
||||
`}).join('');
|
||||
} catch (e) {
|
||||
console.error('Error cargando ventas:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function cargarDatos() {
|
||||
cargarResumen();
|
||||
cargarRanking();
|
||||
cargarVentasRecientes();
|
||||
}
|
||||
|
||||
// Cargar datos al inicio
|
||||
cargarDatos();
|
||||
|
||||
// Actualizar cada 30 segundos
|
||||
setInterval(cargarDatos, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
return render_template_string(html)
|
||||
|
||||
if __name__ == '__main__':
|
||||
port = int(os.getenv('FLASK_PORT', 5000))
|
||||
host = os.getenv('FLASK_HOST', '0.0.0.0')
|
||||
debug = os.getenv('DEBUG', 'False').lower() == 'true'
|
||||
|
||||
logger.info(f"Iniciando Sales Bot en {host}:{port}")
|
||||
app.run(host=host, port=port, debug=debug)
|
||||
Reference in New Issue
Block a user