feat: implementacao inicial do sistema LeadRadar
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
# Dockerfile for LeadRadar Streamlit Frontend
|
||||
FROM python:3.11-slim
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8501
|
||||
|
||||
CMD ["streamlit", "run", "app.py", "--server.port=8501", "--server.address=0.0.0.0"]
|
||||
@@ -0,0 +1,169 @@
|
||||
import os
|
||||
import httpx
|
||||
import streamlit as st
|
||||
|
||||
BACKEND_URL = os.environ.get('BACKEND_URL', 'http://127.0.0.1:5000')
|
||||
|
||||
class APIClient:
|
||||
def __init__(self):
|
||||
self.base_url = BACKEND_URL.rstrip('/')
|
||||
|
||||
def _get_headers(self) -> dict:
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
token = st.session_state.get('access_token')
|
||||
if token:
|
||||
headers['Authorization'] = f'Bearer {token}'
|
||||
return headers
|
||||
|
||||
def login(self, email: str, password: str) -> tuple[bool, str]:
|
||||
url = f"{self.base_url}/api/v1/auth/login"
|
||||
try:
|
||||
with httpx.Client(timeout=10.0) as client:
|
||||
res = client.post(url, json={'email': email, 'password': password})
|
||||
data = res.json()
|
||||
if res.status_code == 200:
|
||||
st.session_state['access_token'] = data.get('access_token')
|
||||
st.session_state['refresh_token'] = data.get('refresh_token')
|
||||
st.session_state['user'] = data.get('user')
|
||||
return True, "Login realizado com sucesso!"
|
||||
return False, data.get('error', 'Falha ao autenticar.')
|
||||
except Exception as e:
|
||||
return False, f"Erro de conexão com o servidor API: {str(e)}"
|
||||
|
||||
def logout(self):
|
||||
st.session_state.pop('access_token', None)
|
||||
st.session_state.pop('refresh_token', None)
|
||||
st.session_state.pop('user', None)
|
||||
|
||||
def change_password(self, old_password: str, new_password: str) -> tuple[bool, str]:
|
||||
url = f"{self.base_url}/api/v1/auth/change-password"
|
||||
try:
|
||||
with httpx.Client(timeout=10.0) as client:
|
||||
res = client.post(url, headers=self._get_headers(), json={
|
||||
'old_password': old_password,
|
||||
'new_password': new_password
|
||||
})
|
||||
data = res.json()
|
||||
if res.status_code == 200:
|
||||
return True, data.get('message', 'Senha alterada com sucesso.')
|
||||
return False, data.get('error', 'Falha ao alterar senha.')
|
||||
except Exception as e:
|
||||
return False, f"Erro ao conectar com API: {str(e)}"
|
||||
|
||||
def search_maps(self, cep: str, ramo: str, max_results: int = 15) -> tuple[bool, dict]:
|
||||
url = f"{self.base_url}/api/v1/leads/search-maps"
|
||||
try:
|
||||
with httpx.Client(timeout=60.0) as client:
|
||||
res = client.post(url, headers=self._get_headers(), json={
|
||||
'cep': cep,
|
||||
'ramo': ramo,
|
||||
'max_results': max_results
|
||||
})
|
||||
data = res.json()
|
||||
if res.status_code == 200:
|
||||
return True, data
|
||||
return False, {'error': data.get('error', 'Erro durante varredura no Google Maps.')}
|
||||
except Exception as e:
|
||||
return False, {'error': f"Erro ao processar busca: {str(e)}"}
|
||||
|
||||
def get_leads(self, status_funil: str = None, cidade: str = None, ramo: str = None, search: str = None) -> list:
|
||||
url = f"{self.base_url}/api/v1/leads"
|
||||
params = {}
|
||||
if status_funil: params['status_funil'] = status_funil
|
||||
if cidade: params['cidade'] = cidade
|
||||
if ramo: params['ramo'] = ramo
|
||||
if search: params['search'] = search
|
||||
params['per_page'] = 200
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=15.0) as client:
|
||||
res = client.get(url, headers=self._get_headers(), params=params)
|
||||
if res.status_code == 200:
|
||||
return res.json().get('leads', [])
|
||||
return []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def get_lead_details(self, lead_id: str) -> dict:
|
||||
url = f"{self.base_url}/api/v1/leads/{lead_id}"
|
||||
try:
|
||||
with httpx.Client(timeout=10.0) as client:
|
||||
res = client.get(url, headers=self._get_headers())
|
||||
if res.status_code == 200:
|
||||
return res.json()
|
||||
return {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def update_lead(self, lead_id: str, payload: dict) -> tuple[bool, str]:
|
||||
url = f"{self.base_url}/api/v1/leads/{lead_id}"
|
||||
try:
|
||||
with httpx.Client(timeout=10.0) as client:
|
||||
res = client.put(url, headers=self._get_headers(), json=payload)
|
||||
if res.status_code == 200:
|
||||
return True, "Lead atualizado."
|
||||
return False, res.json().get('error', 'Falha ao atualizar lead.')
|
||||
except Exception as e:
|
||||
return False, f"Erro ao comunicar com servidor: {str(e)}"
|
||||
|
||||
def opt_out_lead(self, lead_id: str) -> tuple[bool, str]:
|
||||
url = f"{self.base_url}/api/v1/leads/{lead_id}/opt-out"
|
||||
try:
|
||||
with httpx.Client(timeout=10.0) as client:
|
||||
res = client.post(url, headers=self._get_headers())
|
||||
if res.status_code == 200:
|
||||
return True, res.json().get('message', 'Opt-out registrado com sucesso.')
|
||||
return False, res.json().get('error', 'Erro ao processar opt-out.')
|
||||
except Exception as e:
|
||||
return False, f"Erro: {str(e)}"
|
||||
|
||||
def get_users(self) -> list:
|
||||
url = f"{self.base_url}/api/v1/users"
|
||||
try:
|
||||
with httpx.Client(timeout=10.0) as client:
|
||||
res = client.get(url, headers=self._get_headers())
|
||||
if res.status_code == 200:
|
||||
return res.json()
|
||||
return []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def create_user(self, nome: str, email: str, password: str, role: str) -> tuple[bool, str]:
|
||||
url = f"{self.base_url}/api/v1/users"
|
||||
try:
|
||||
with httpx.Client(timeout=10.0) as client:
|
||||
res = client.post(url, headers=self._get_headers(), json={
|
||||
'nome': nome,
|
||||
'email': email,
|
||||
'password': password,
|
||||
'role': role
|
||||
})
|
||||
data = res.json()
|
||||
if res.status_code == 201:
|
||||
return True, f"Usuário {email} criado com sucesso!"
|
||||
return False, data.get('error', 'Erro ao criar usuário.')
|
||||
except Exception as e:
|
||||
return False, f"Erro ao conectar com API: {str(e)}"
|
||||
|
||||
def delete_user(self, user_id: str) -> tuple[bool, str]:
|
||||
url = f"{self.base_url}/api/v1/users/{user_id}"
|
||||
try:
|
||||
with httpx.Client(timeout=10.0) as client:
|
||||
res = client.delete(url, headers=self._get_headers())
|
||||
data = res.json()
|
||||
if res.status_code == 200:
|
||||
return True, data.get('message', 'Usuário excluído.')
|
||||
return False, data.get('error', 'Erro ao excluir usuário.')
|
||||
except Exception as e:
|
||||
return False, f"Erro ao conectar com API: {str(e)}"
|
||||
|
||||
def export_leads_data(self, format_type: str = 'csv') -> bytes:
|
||||
url = f"{self.base_url}/api/v1/leads/export?format={format_type}"
|
||||
try:
|
||||
with httpx.Client(timeout=15.0) as client:
|
||||
res = client.get(url, headers=self._get_headers())
|
||||
if res.status_code == 200:
|
||||
return res.content
|
||||
return b""
|
||||
except Exception:
|
||||
return b""
|
||||
@@ -0,0 +1,88 @@
|
||||
import os
|
||||
import streamlit as st
|
||||
|
||||
st.set_page_config(
|
||||
page_title="LeadRadar - Prospecção Ativa & Funil CRM",
|
||||
page_icon="📡",
|
||||
layout="wide",
|
||||
initial_sidebar_state="expanded"
|
||||
)
|
||||
|
||||
# Carregar CSS customizado Dark Tech Slate
|
||||
css_path = os.path.join(os.path.dirname(__file__), 'styles', 'style.css')
|
||||
if os.path.exists(css_path):
|
||||
with open(css_path, 'r', encoding='utf-8') as f:
|
||||
st.markdown(f"<style>{f.read()}</style>", unsafe_allow_html=True)
|
||||
|
||||
from api_client import APIClient
|
||||
from views.login_view import render_login_view
|
||||
from views.radar_busca import render_radar_busca_view
|
||||
from views.crm_kanban import render_crm_kanban_view
|
||||
from views.analytical_table import render_analytical_table_view
|
||||
from views.admin_users import render_admin_users_view
|
||||
from views.profile import render_profile_view
|
||||
|
||||
def main():
|
||||
api_client = APIClient()
|
||||
|
||||
# Verificar se o usuário está autenticado
|
||||
if not st.session_state.get('access_token'):
|
||||
render_login_view(api_client)
|
||||
return
|
||||
|
||||
user_info = st.session_state.get('user', {})
|
||||
is_admin = user_info.get('role') == 'admin'
|
||||
|
||||
# Barra Lateral (Sidebar Navigation)
|
||||
with st.sidebar:
|
||||
st.markdown(
|
||||
f"""
|
||||
<div style="text-align: center; margin-bottom: 20px;">
|
||||
<h2 style="color: #58A6FF; margin-bottom: 0;">📡 LeadRadar</h2>
|
||||
<small style="color: #8B949E;">Prospecção & Mini-CRM</small>
|
||||
</div>
|
||||
""", unsafe_allow_html=True
|
||||
)
|
||||
|
||||
st.markdown(
|
||||
f"""
|
||||
<div style="background-color: #161B22; border: 1px solid #30363D; border-radius: 8px; padding: 10px; margin-bottom: 20px;">
|
||||
<div style="font-weight: 600; color: #E6EDF3;">{user_info.get('nome', 'Usuário')}</div>
|
||||
<div style="font-size: 0.8rem; color: #8B949E;">{user_info.get('email', '')}</div>
|
||||
<span class="lead-badge" style="margin-top: 6px;">{user_info.get('role', 'user').upper()}</span>
|
||||
</div>
|
||||
""", unsafe_allow_html=True
|
||||
)
|
||||
|
||||
nav_options = [
|
||||
"📡 Radar de Busca",
|
||||
"📌 Funil CRM (Kanban)",
|
||||
"📊 Tabela Analítica"
|
||||
]
|
||||
|
||||
if is_admin:
|
||||
nav_options.append("🛡️ Usuários (Admin)")
|
||||
|
||||
nav_options.append("👤 Meu Perfil")
|
||||
|
||||
selected_page = st.radio("Navegação Principal", nav_options, index=0)
|
||||
|
||||
st.markdown("---")
|
||||
if st.button("🚪 Sair (Logout)", use_container_width=True):
|
||||
api_client.logout()
|
||||
st.rerun()
|
||||
|
||||
# Renderização da Página Selecionada
|
||||
if selected_page == "📡 Radar de Busca":
|
||||
render_radar_busca_view(api_client)
|
||||
elif selected_page == "📌 Funil CRM (Kanban)":
|
||||
render_crm_kanban_view(api_client)
|
||||
elif selected_page == "📊 Tabela Analítica":
|
||||
render_analytical_table_view(api_client)
|
||||
elif selected_page == "🛡️ Usuários (Admin)":
|
||||
render_admin_users_view(api_client)
|
||||
elif selected_page == "👤 Meu Perfil":
|
||||
render_profile_view(api_client)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,85 @@
|
||||
import streamlit as st
|
||||
from api_client import APIClient
|
||||
|
||||
STATUS_CONFIG = [
|
||||
('novo', 'Novo', '#58A6FF'),
|
||||
('contatado', 'Contatado', '#D29922'),
|
||||
('respondeu', 'Respondeu', '#A371F7'),
|
||||
('negociacao', 'Em Negociação', '#F0883E'),
|
||||
('ganho', 'Ganho', '#3FB950'),
|
||||
('perdido', 'Perdido', '#F85149')
|
||||
]
|
||||
|
||||
def render_kanban_board(leads: list, api_client: APIClient):
|
||||
cols = st.columns(len(STATUS_CONFIG))
|
||||
|
||||
# Agrupar leads por status
|
||||
leads_by_status = {key: [] for key, _, _ in STATUS_CONFIG}
|
||||
for l in leads:
|
||||
st_key = l.get('status_funil', 'novo')
|
||||
if st_key in leads_by_status:
|
||||
leads_by_status[st_key].append(l)
|
||||
|
||||
for idx, (status_key, status_label, color) in enumerate(STATUS_CONFIG):
|
||||
with cols[idx]:
|
||||
col_leads = leads_by_status[status_key]
|
||||
st.markdown(
|
||||
f"""
|
||||
<div class="kanban-col-header" style="border-bottom-color: {color};">
|
||||
<span>{status_label}</span>
|
||||
<span class="lead-badge" style="background-color: rgba(255,255,255,0.1); color: var(--text-primary);">
|
||||
{len(col_leads)}
|
||||
</span>
|
||||
</div>
|
||||
""", unsafe_allow_html=True
|
||||
)
|
||||
|
||||
for lead in col_leads:
|
||||
lead_id = lead['id']
|
||||
nome = lead.get('nome_empresa', 'Empresa')
|
||||
ramo = lead.get('ramo_atividade', '')
|
||||
tel = lead.get('telefone', '')
|
||||
tel_san = lead.get('telefone_sanitizado', '')
|
||||
rating = lead.get('google_rating', 0.0)
|
||||
reviews = lead.get('total_avaliacoes', 0)
|
||||
maps_url = lead.get('google_maps_url', '')
|
||||
|
||||
rating_str = f"⭐ {rating:.1f} ({reviews})" if rating > 0 else "Sem avaliação"
|
||||
|
||||
# Container visual do Card
|
||||
with st.container(border=True):
|
||||
st.markdown(f"**{nome}**")
|
||||
st.caption(f"🏷️ {ramo} | 📍 {lead.get('bairro', '') or lead.get('cidade', '')}")
|
||||
st.caption(f"📊 {rating_str}")
|
||||
|
||||
if tel_san:
|
||||
st.markdown(
|
||||
f'<a href="https://wa.me/{tel_san}" target="_blank" class="whatsapp-link">💬 WhatsApp ({tel})</a>',
|
||||
unsafe_allow_html=True
|
||||
)
|
||||
elif tel:
|
||||
st.caption(f"📞 {tel}")
|
||||
|
||||
if maps_url:
|
||||
st.markdown(f"[📍 Abrir no Google Maps]({maps_url})")
|
||||
|
||||
c1, c2 = st.columns([2, 1])
|
||||
with c1:
|
||||
opts = [s[1] for s in STATUS_CONFIG]
|
||||
cur_idx = [s[0] for s in STATUS_CONFIG].index(status_key)
|
||||
new_label = st.selectbox(
|
||||
"Mover para:",
|
||||
opts,
|
||||
index=cur_idx,
|
||||
key=f"st_sel_{lead_id}",
|
||||
label_visibility="collapsed"
|
||||
)
|
||||
new_st_key = [s[0] for s in STATUS_CONFIG][opts.index(new_label)]
|
||||
if new_st_key != status_key:
|
||||
api_client.update_lead(lead_id, {'status_funil': new_st_key})
|
||||
st.rerun()
|
||||
|
||||
with c2:
|
||||
if st.button("📝", key=f"btn_notes_{lead_id}", help="Editar notas e ver histórico"):
|
||||
st.session_state['selected_lead_id'] = lead_id
|
||||
st.rerun()
|
||||
@@ -0,0 +1,61 @@
|
||||
import streamlit as st
|
||||
|
||||
def render_metrics_summary(leads: list):
|
||||
total = len(leads)
|
||||
novos = sum(1 for l in leads if l.get('status_funil') == 'novo')
|
||||
contatados = sum(1 for l in leads if l.get('status_funil') == 'contatado')
|
||||
negociacao = sum(1 for l in leads if l.get('status_funil') == 'negociacao')
|
||||
ganhos = sum(1 for l in leads if l.get('status_funil') == 'ganho')
|
||||
taxa_conversao = (ganhos / total * 100) if total > 0 else 0.0
|
||||
|
||||
c1, c2, c3, c4, c5 = st.columns(5)
|
||||
|
||||
with c1:
|
||||
st.markdown(
|
||||
f"""
|
||||
<div class="metric-card">
|
||||
<div class="metric-title">Total de Leads</div>
|
||||
<div class="metric-value">{total}</div>
|
||||
</div>
|
||||
""", unsafe_allow_html=True
|
||||
)
|
||||
|
||||
with c2:
|
||||
st.markdown(
|
||||
f"""
|
||||
<div class="metric-card">
|
||||
<div class="metric-title">Leads Novos</div>
|
||||
<div class="metric-value" style="color: #58A6FF;">{novos}</div>
|
||||
</div>
|
||||
""", unsafe_allow_html=True
|
||||
)
|
||||
|
||||
with c3:
|
||||
st.markdown(
|
||||
f"""
|
||||
<div class="metric-card">
|
||||
<div class="metric-title">Em Negociação</div>
|
||||
<div class="metric-value" style="color: #F0883E;">{negociacao}</div>
|
||||
</div>
|
||||
""", unsafe_allow_html=True
|
||||
)
|
||||
|
||||
with c4:
|
||||
st.markdown(
|
||||
f"""
|
||||
<div class="metric-card">
|
||||
<div class="metric-title">Ganhos</div>
|
||||
<div class="metric-value" style="color: #3FB950;">{ganhos}</div>
|
||||
</div>
|
||||
""", unsafe_allow_html=True
|
||||
)
|
||||
|
||||
with c5:
|
||||
st.markdown(
|
||||
f"""
|
||||
<div class="metric-card">
|
||||
<div class="metric-title">Taxa Conversão</div>
|
||||
<div class="metric-value" style="color: #A371F7;">{taxa_conversao:.1f}%</div>
|
||||
</div>
|
||||
""", unsafe_allow_html=True
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
streamlit>=1.35.0
|
||||
httpx>=0.27.0
|
||||
pandas>=2.2.0
|
||||
@@ -0,0 +1,128 @@
|
||||
/* LeadRadar Dark Tech Slate Theme */
|
||||
|
||||
:root {
|
||||
--bg-primary: #0D1117;
|
||||
--bg-secondary: #161B22;
|
||||
--bg-tertiary: #21262D;
|
||||
--border-color: #30363D;
|
||||
--text-primary: #E6EDF3;
|
||||
--text-secondary: #8B949E;
|
||||
--accent-blue: #58A6FF;
|
||||
--accent-blue-hover: #1F6FEB;
|
||||
|
||||
/* Status Colors */
|
||||
--status-novo: #58A6FF;
|
||||
--status-contatado: #D29922;
|
||||
--status-respondeu: #A371F7;
|
||||
--status-negociacao: #F0883E;
|
||||
--status-ganho: #3FB950;
|
||||
--status-perdido: #F85149;
|
||||
--status-optout: #8B949E;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* Custom Metric Card */
|
||||
.metric-card {
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.metric-title {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
color: var(--text-primary);
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Kanban Board Styling */
|
||||
.kanban-col {
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.kanban-col-header {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
padding-bottom: 8px;
|
||||
margin-bottom: 12px;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.kanban-card {
|
||||
background-color: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 10px;
|
||||
transition: transform 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.kanban-card:hover {
|
||||
border-color: var(--accent-blue);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.lead-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.lead-subtitle {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.lead-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
background-color: rgba(88, 166, 255, 0.15);
|
||||
color: var(--accent-blue);
|
||||
border: 1px solid rgba(88, 166, 255, 0.3);
|
||||
}
|
||||
|
||||
.whatsapp-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #25D366;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
text-decoration: none;
|
||||
background-color: rgba(37, 211, 102, 0.1);
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(37, 211, 102, 0.25);
|
||||
}
|
||||
|
||||
.whatsapp-link:hover {
|
||||
background-color: rgba(37, 211, 102, 0.2);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import pandas as pd
|
||||
import streamlit as st
|
||||
from api_client import APIClient
|
||||
|
||||
def render_admin_users_view(api_client: APIClient):
|
||||
st.title("🛡️ Gestão de Usuários & Segurança (Admin)")
|
||||
|
||||
user_info = st.session_state.get('user', {})
|
||||
if user_info.get('role') != 'admin':
|
||||
st.error("🚫 Acesso restrito! Esta página é exclusiva para administradores.")
|
||||
return
|
||||
|
||||
c1, c2 = st.columns([2, 1])
|
||||
|
||||
with c1:
|
||||
st.subheader("👥 Operadores e Administradores")
|
||||
users = api_client.get_users()
|
||||
|
||||
if users:
|
||||
df_users = pd.DataFrame([
|
||||
{
|
||||
'ID': u['id'],
|
||||
'Nome': u['nome'],
|
||||
'E-mail': u['email'],
|
||||
'Perfil': u['role'].upper(),
|
||||
'Ativo': 'Sim' if u['ativo'] else 'Não',
|
||||
'Criado em': u['criado_em'][:10] if u.get('criado_em') else ''
|
||||
}
|
||||
for u in users
|
||||
])
|
||||
st.dataframe(df_users, use_container_width=True, hide_index=True)
|
||||
|
||||
st.markdown("### ❌ Excluir Operador")
|
||||
user_to_delete = st.selectbox(
|
||||
"Selecione um usuário para remover:",
|
||||
options=[u for u in users if u['id'] != user_info.get('id')],
|
||||
format_func=lambda u: f"{u['nome']} ({u['email']}) - {u['role'].upper()}"
|
||||
)
|
||||
if user_to_delete:
|
||||
if st.button(f"🗑️ Confirmar Exclusão de {user_to_delete['nome']}", type="secondary"):
|
||||
success, msg = api_client.delete_user(user_to_delete['id'])
|
||||
if success:
|
||||
st.success(msg)
|
||||
st.rerun()
|
||||
else:
|
||||
st.error(msg)
|
||||
else:
|
||||
st.warning("Não foi possível carregar a lista de usuários.")
|
||||
|
||||
with c2:
|
||||
with st.container(border=True):
|
||||
st.subheader("➕ Novo Usuário")
|
||||
nome = st.text_input("Nome Completo", key="add_nome")
|
||||
email = st.text_input("E-mail", key="add_email")
|
||||
password = st.text_input("Senha", type="password", key="add_pass")
|
||||
role = st.selectbox("Perfil de Acesso", ["user", "admin"], format_func=lambda r: "Operador (User)" if r == "user" else "Administrador (Admin)")
|
||||
|
||||
if st.button("✨ Criar Usuário", type="primary", use_container_width=True):
|
||||
if not nome or not email or not password:
|
||||
st.error("Preencha todos os campos obrigatórios.")
|
||||
else:
|
||||
success, msg = api_client.create_user(nome, email, password, role)
|
||||
if success:
|
||||
st.success(msg)
|
||||
st.rerun()
|
||||
else:
|
||||
st.error(msg)
|
||||
@@ -0,0 +1,106 @@
|
||||
import pandas as pd
|
||||
import streamlit as st
|
||||
from api_client import APIClient
|
||||
|
||||
def render_analytical_table_view(api_client: APIClient):
|
||||
st.title("📊 Tabela Analítica & Exportação de Leads")
|
||||
st.caption("Filtre, analise e exporte seus leads nos formatos CSV e JSON.")
|
||||
|
||||
leads = api_client.get_leads()
|
||||
|
||||
# Controles de Exportação
|
||||
exp_col1, exp_col2, _ = st.columns([1, 1, 2])
|
||||
with exp_col1:
|
||||
csv_bytes = api_client.export_leads_data('csv')
|
||||
st.download_button(
|
||||
label="📥 Exportar em CSV",
|
||||
data=csv_bytes,
|
||||
file_name="leads_leadradar.csv",
|
||||
mime="text/csv",
|
||||
use_container_width=True
|
||||
)
|
||||
|
||||
with exp_col2:
|
||||
json_bytes = api_client.export_leads_data('json')
|
||||
st.download_button(
|
||||
label="📥 Exportar em JSON",
|
||||
data=json_bytes,
|
||||
file_name="leads_leadradar.json",
|
||||
mime="application/json",
|
||||
use_container_width=True
|
||||
)
|
||||
|
||||
st.markdown("---")
|
||||
|
||||
# Filtros Avançados
|
||||
f1, f2, f3, f4 = st.columns(4)
|
||||
with f1:
|
||||
search = st.text_input("🔍 Busca Geral:", value="", key="tbl_search")
|
||||
with f2:
|
||||
statuses = ["Todos", "novo", "contatado", "respondeu", "negociacao", "ganho", "perdido", "opt_out"]
|
||||
status_sel = st.selectbox("Status Funil:", statuses, key="tbl_status")
|
||||
with f3:
|
||||
cidades = ["Todas"] + sorted(list(set(l.get('cidade', '') for l in leads if l.get('cidade'))))
|
||||
cidade_sel = st.selectbox("Cidade:", cidades, key="tbl_cidade")
|
||||
with f4:
|
||||
ramos = ["Todos"] + sorted(list(set(l.get('ramo_atividade', '') for l in leads if l.get('ramo_atividade'))))
|
||||
ramo_sel = st.selectbox("Ramo:", ramos, key="tbl_ramo")
|
||||
|
||||
# Aplicar filtros
|
||||
filtered = leads
|
||||
if search:
|
||||
s = search.lower()
|
||||
filtered = [l for l in filtered if s in l.get('nome_empresa', '').lower() or s in l.get('telefone', '').lower() or s in l.get('notas', '').lower()]
|
||||
if status_sel != "Todos":
|
||||
filtered = [l for l in filtered if l.get('status_funil') == status_sel]
|
||||
if cidade_sel != "Todas":
|
||||
filtered = [l for l in filtered if l.get('cidade') == cidade_sel]
|
||||
if ramo_sel != "Todos":
|
||||
filtered = [l for l in filtered if l.get('ramo_atividade') == ramo_sel]
|
||||
|
||||
st.write(f"**Exibindo {len(filtered)} leads de {len(leads)} totais.**")
|
||||
|
||||
if filtered:
|
||||
rows = []
|
||||
for l in filtered:
|
||||
tel_san = l.get('telefone_sanitizado', '')
|
||||
wa_link = f"https://wa.me/{tel_san}" if tel_san else ""
|
||||
rows.append({
|
||||
'ID': l.get('id'),
|
||||
'Empresa': l.get('nome_empresa'),
|
||||
'Ramo': l.get('ramo_atividade'),
|
||||
'Cidade/UF': f"{l.get('cidade', '')}/{l.get('uf', '')}",
|
||||
'Telefone': l.get('telefone'),
|
||||
'Rating': f"⭐ {l.get('google_rating')} ({l.get('total_avaliacoes')})",
|
||||
'Status': l.get('status_funil'),
|
||||
'WhatsApp': wa_link,
|
||||
'Criado em': l.get('criado_em', '')[:10] if l.get('criado_em') else ''
|
||||
})
|
||||
|
||||
df = pd.DataFrame(rows)
|
||||
st.dataframe(
|
||||
df,
|
||||
column_config={
|
||||
"WhatsApp": st.column_config.LinkColumn("WhatsApp Link", display_text="💬 Conversar")
|
||||
},
|
||||
use_container_width=True,
|
||||
hide_index=True
|
||||
)
|
||||
|
||||
st.markdown("### ⚙️ Ações Rápidas")
|
||||
col_sel, col_act = st.columns([2, 1])
|
||||
with col_sel:
|
||||
selected_lead_id = st.selectbox(
|
||||
"Selecione um Lead para Ação LGPD:",
|
||||
options=[l['id'] for l in filtered],
|
||||
format_func=lambda x: next((f"{l['nome_empresa']} ({l['status_funil']})" for l in filtered if l['id'] == x), x)
|
||||
)
|
||||
|
||||
with col_act:
|
||||
if st.button("🚫 Marcar Opt-Out (LGPD)", type="secondary", use_container_width=True):
|
||||
if selected_lead_id:
|
||||
api_client.opt_out_lead(selected_lead_id)
|
||||
st.success("Lead marcado como Opt-Out (Excluído / LGPD).")
|
||||
st.rerun()
|
||||
else:
|
||||
st.info("Nenhum lead encontrado com os filtros selecionados.")
|
||||
@@ -0,0 +1,101 @@
|
||||
import streamlit as st
|
||||
from api_client import APIClient
|
||||
from components.kanban import render_kanban_board
|
||||
from components.metrics import render_metrics_summary
|
||||
|
||||
def render_crm_kanban_view(api_client: APIClient):
|
||||
st.title("📌 Funil Commercial CRM (Kanban)")
|
||||
|
||||
# Carregar leads da API
|
||||
leads = api_client.get_leads()
|
||||
|
||||
# Barra de Filtros
|
||||
fc1, fc2, fc3 = st.columns([2, 1, 1])
|
||||
with fc1:
|
||||
search_query = st.text_input("🔍 Buscar por nome, telefone ou nota:", value="", key="kanban_search")
|
||||
with fc2:
|
||||
ramos = sorted(list(set(l.get('ramo_atividade', '') for l in leads if l.get('ramo_atividade'))))
|
||||
selected_ramo = st.selectbox("Filtrar Ramo:", ["Todos"] + ramos, key="kanban_ramo")
|
||||
with fc3:
|
||||
cidades = sorted(list(set(l.get('cidade', '') for l in leads if l.get('cidade'))))
|
||||
selected_cidade = st.selectbox("Filtrar Cidade:", ["Todas"] + cidades, key="kanban_cidade")
|
||||
|
||||
# Aplicar filtros locais
|
||||
filtered_leads = leads
|
||||
if search_query:
|
||||
sq = search_query.lower()
|
||||
filtered_leads = [
|
||||
l for l in filtered_leads if
|
||||
sq in l.get('nome_empresa', '').lower() or
|
||||
sq in l.get('telefone', '').lower() or
|
||||
sq in l.get('notas', '').lower()
|
||||
]
|
||||
if selected_ramo != "Todos":
|
||||
filtered_leads = [l for l in filtered_leads if l.get('ramo_atividade') == selected_ramo]
|
||||
if selected_cidade != "Todas":
|
||||
filtered_leads = [l for l in filtered_leads if l.get('cidade') == selected_cidade]
|
||||
|
||||
# Exibir resumo de métricas KPI
|
||||
render_metrics_summary(filtered_leads)
|
||||
st.markdown("---")
|
||||
|
||||
# Exibir Modal de Detalhes se selecionado
|
||||
selected_lead_id = st.session_state.get('selected_lead_id')
|
||||
if selected_lead_id:
|
||||
render_lead_detail_modal(selected_lead_id, api_client)
|
||||
|
||||
# Renderizar o Quadro Kanban
|
||||
render_kanban_board(filtered_leads, api_client)
|
||||
|
||||
def render_lead_detail_modal(lead_id: str, api_client: APIClient):
|
||||
lead_detail = api_client.get_lead_details(lead_id)
|
||||
if not lead_detail:
|
||||
st.session_state.pop('selected_lead_id', None)
|
||||
return
|
||||
|
||||
with st.expander(f"📝 Detalhes e Histórico de Auditoria: {lead_detail.get('nome_empresa')}", expanded=True):
|
||||
col1, col2 = st.columns([1, 1])
|
||||
|
||||
with col1:
|
||||
st.subheader("Informações do Lead")
|
||||
st.write(f"**Ramo:** {lead_detail.get('ramo_atividade')}")
|
||||
st.write(f"**Telefone:** {lead_detail.get('telefone', 'N/A')}")
|
||||
st.write(f"**Endereço:** {lead_detail.get('logradouro', '')}, {lead_detail.get('bairro', '')} - {lead_detail.get('cidade', '')}/{lead_detail.get('uf', '')}")
|
||||
st.write(f"**Status Atual:** `{lead_detail.get('status_funil')}`")
|
||||
|
||||
new_notes = st.text_area("Notas / Observações Comerciais:", value=lead_detail.get('notas', ''), height=120)
|
||||
|
||||
c_save, c_opt, c_close = st.columns([1, 1, 1])
|
||||
with c_save:
|
||||
if st.button("💾 Salvar Notas"):
|
||||
api_client.update_lead(lead_id, {'notas': new_notes})
|
||||
st.success("Notas atualizadas!")
|
||||
st.rerun()
|
||||
|
||||
with c_opt:
|
||||
if st.button("🚫 Opt-Out (LGPD)", help="Marcar lead para não contatar"):
|
||||
api_client.opt_out_lead(lead_id)
|
||||
st.warning("Lead marcado como Opt-Out LGPD.")
|
||||
st.session_state.pop('selected_lead_id', None)
|
||||
st.rerun()
|
||||
|
||||
with c_close:
|
||||
if st.button("❌ Fechar"):
|
||||
st.session_state.pop('selected_lead_id', None)
|
||||
st.rerun()
|
||||
|
||||
with col2:
|
||||
st.subheader("📜 Histórico de Interações (Auditoria)")
|
||||
interacoes = lead_detail.get('interacoes', [])
|
||||
if not interacoes:
|
||||
st.info("Nenhuma interação registrada ainda.")
|
||||
else:
|
||||
for idx in interacoes:
|
||||
st.markdown(
|
||||
f"""
|
||||
<div style="background: #21262D; border: 1px solid #30363D; border-radius: 6px; padding: 8px; margin-bottom: 6px; font-size: 0.85rem;">
|
||||
<strong>[{idx.get('tipo')}]</strong> {idx.get('descricao')}<br>
|
||||
<small style="color: #8B949E;">Por: {idx.get('usuario_nome')} em {idx.get('timestamp')[:19] if idx.get('timestamp') else ''}</small>
|
||||
</div>
|
||||
""", unsafe_allow_html=True
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
import streamlit as st
|
||||
from api_client import APIClient
|
||||
|
||||
def render_login_view(api_client: APIClient):
|
||||
st.markdown("<br><br>", unsafe_allow_html=True)
|
||||
c1, c2, c3 = st.columns([1, 2, 1])
|
||||
|
||||
with c2:
|
||||
with st.container(border=True):
|
||||
st.markdown(
|
||||
"""
|
||||
<div style="text-align: center; margin-bottom: 24px;">
|
||||
<h1 style="color: #58A6FF; margin-bottom: 4px;">📡 LeadRadar</h1>
|
||||
<p style="color: #8B949E; font-size: 0.95rem;">Prospecção Inteligente & Gestão Comercial B2B</p>
|
||||
</div>
|
||||
""", unsafe_allow_html=True
|
||||
)
|
||||
|
||||
email = st.text_input("E-mail de Acesso", placeholder="seu.email@empresa.com", key="login_email")
|
||||
password = st.text_input("Senha", type="password", placeholder="••••••••", key="login_password")
|
||||
|
||||
if st.button("🚀 Entrar no Sistema", use_container_width=True, type="primary"):
|
||||
if not email or not password:
|
||||
st.error("Por favor, preencha o e-mail e a senha.")
|
||||
else:
|
||||
with st.spinner("Autenticando credenciais..."):
|
||||
success, message = api_client.login(email, password)
|
||||
if success:
|
||||
st.success(message)
|
||||
st.rerun()
|
||||
else:
|
||||
st.error(message)
|
||||
|
||||
st.markdown(
|
||||
"""
|
||||
<div style="text-align: center; margin-top: 16px; font-size: 0.8rem; color: #8B949E;">
|
||||
LeadRadar Core v1.0 • Desenvolvido com Flask & Streamlit
|
||||
</div>
|
||||
""", unsafe_allow_html=True
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
import streamlit as st
|
||||
from api_client import APIClient
|
||||
|
||||
def render_profile_view(api_client: APIClient):
|
||||
st.title("👤 Meu Perfil & Segurança")
|
||||
|
||||
user_info = st.session_state.get('user', {})
|
||||
|
||||
c1, c2 = st.columns([1, 1])
|
||||
|
||||
with c1:
|
||||
with st.container(border=True):
|
||||
st.subheader("📋 Dados da Conta")
|
||||
st.write(f"**Nome:** {user_info.get('nome', 'N/A')}")
|
||||
st.write(f"**E-mail:** {user_info.get('email', 'N/A')}")
|
||||
st.write(f"**Perfil:** `{user_info.get('role', 'user').upper()}`")
|
||||
st.write(f"**Status da Conta:** {'🟢 Ativo' if user_info.get('ativo') else '🔴 Inativo'}")
|
||||
|
||||
with c2:
|
||||
with st.container(border=True):
|
||||
st.subheader("🔒 Alterar Minha Senha")
|
||||
old_pass = st.text_input("Senha Atual", type="password", key="pwd_old")
|
||||
new_pass = st.text_input("Nova Senha (min 6 caracteres)", type="password", key="pwd_new")
|
||||
confirm_pass = st.text_input("Confirmar Nova Senha", type="password", key="pwd_conf")
|
||||
|
||||
if st.button("🔑 Atualizar Senha", type="primary", use_container_width=True):
|
||||
if not old_pass or not new_pass or not confirm_pass:
|
||||
st.error("Preencha todos os campos de senha.")
|
||||
elif new_pass != confirm_pass:
|
||||
st.error("A nova senha e a confirmação não coincidem.")
|
||||
elif len(new_pass) < 6:
|
||||
st.error("A nova senha deve ter no mínimo 6 caracteres.")
|
||||
else:
|
||||
success, msg = api_client.change_password(old_pass, new_pass)
|
||||
if success:
|
||||
st.success("Senha alterada com sucesso! Faça login com sua nova senha.")
|
||||
else:
|
||||
st.error(msg)
|
||||
@@ -0,0 +1,76 @@
|
||||
import pandas as pd
|
||||
import streamlit as st
|
||||
from api_client import APIClient
|
||||
|
||||
def render_radar_busca_view(api_client: APIClient):
|
||||
st.title("📡 Radar de Busca & Prospecção Geolocalizada")
|
||||
st.caption("Consulte estabelecimentos comerciais no Google Maps a partir da triangulação de CEP e ramo de atividade.")
|
||||
|
||||
c1, c2 = st.columns([1, 2])
|
||||
|
||||
with c1:
|
||||
with st.container(border=True):
|
||||
st.subheader("🔍 Parâmetros de Varredura")
|
||||
|
||||
cep = st.text_input("CEP Alvo", value="01310-100", help="Digite o CEP com ou sem hífen (ex: 01310-100)")
|
||||
|
||||
ramo_sugestoes = [
|
||||
"Clínica Odontológica", "Padaria", "Restaurante", "Academia",
|
||||
"Escritório de Contabilidade", "Oficina Mecânica", "Farmácia", "Salão de Beleza"
|
||||
]
|
||||
ramo = st.selectbox("Ramo de Atividade", options=ramo_sugestoes, index=0)
|
||||
custom_ramo = st.text_input("Ou digite um ramo personalizado:", value="", placeholder="Ex: Petshop 24h")
|
||||
|
||||
ramo_final = custom_ramo.strip() if custom_ramo.strip() else ramo
|
||||
max_results = st.slider("Quantidade máxima de leads", min_value=5, max_value=50, value=15, step=5)
|
||||
|
||||
if st.button("🛰️ Disparar Prospecção Ativa", type="primary", use_container_width=True):
|
||||
if not cep:
|
||||
st.error("Informe um CEP válido.")
|
||||
else:
|
||||
progress_bar = st.progress(0, text="Iniciando triangulação de CEP via ViaCEP...")
|
||||
|
||||
with st.spinner("Extraindo estabelecimentos no Google Maps via Playwright Scraper..."):
|
||||
progress_bar.progress(30, text="Bairro e Município resolvidos. Abrindo Playwright Chromium...")
|
||||
success, data = api_client.search_maps(cep, ramo_final, max_results)
|
||||
progress_bar.progress(80, text="Deduplicando e persistindo leads no banco de dados...")
|
||||
|
||||
if success:
|
||||
progress_bar.progress(100, text="Varredura concluída!")
|
||||
st.session_state['last_search_data'] = data
|
||||
st.success("Busca executada e leads atualizados no banco de dados!")
|
||||
else:
|
||||
progress_bar.empty()
|
||||
st.error(data.get('error', 'Falha ao executar prospecção.'))
|
||||
|
||||
with c2:
|
||||
search_data = st.session_state.get('last_search_data')
|
||||
if search_data:
|
||||
summary = search_data.get('summary', {})
|
||||
loc = summary.get('location', {})
|
||||
leads = summary.get('leads', [])
|
||||
|
||||
st.subheader("📊 Resultado da Varredura")
|
||||
|
||||
mc1, mc2, mc3 = st.columns(3)
|
||||
mc1.metric("Localização", f"{loc.get('bairro', 'Bairro')}, {loc.get('cidade', 'Cidade')}-{loc.get('uf', '')}")
|
||||
mc2.metric("Novos Leads", summary.get('created_count', 0))
|
||||
mc3.metric("Reencontrados/Atualizados", summary.get('updated_count', 0))
|
||||
|
||||
if leads:
|
||||
st.markdown("### 📋 Preview dos Leads Obtidos")
|
||||
df_data = []
|
||||
for l in leads:
|
||||
df_data.append({
|
||||
'Empresa': l.get('nome_empresa'),
|
||||
'Ramo': l.get('ramo_atividade'),
|
||||
'Telefone': l.get('telefone'),
|
||||
'Avaliação': f"⭐ {l.get('google_rating')} ({l.get('total_avaliacoes')})",
|
||||
'Bairro/Cidade': f"{l.get('bairro', '')} / {l.get('cidade', '')}",
|
||||
'Status': l.get('status_funil')
|
||||
})
|
||||
df = pd.DataFrame(df_data)
|
||||
st.dataframe(df, use_container_width=True)
|
||||
st.info("💡 Acesse o menu **Funil CRM (Kanban)** para gerenciar estes leads!")
|
||||
else:
|
||||
st.info("👈 Preencha os parâmetros no painel ao lado e clique em **Disparar Prospecção Ativa** para iniciar.")
|
||||
Reference in New Issue
Block a user