Stacked/grouped bars made it hard to compare gasto vs ingreso when the two values are close, and margen barely visible against that scale. Extract a shared _eur_line_chart helper (single €-axis, fixed categorical colors, tooltip) and reuse it for the per-campaign, portfolio, and resumen daily views instead of duplicating Altair boilerplate per chart.
777 lines
32 KiB
Python
777 lines
32 KiB
Python
"""Dashboard interactivo para leads-optimizer — Streamlit."""
|
||
import streamlit as st
|
||
import pandas as pd
|
||
import altair as alt
|
||
import json
|
||
import subprocess
|
||
import sys
|
||
import os
|
||
import glob
|
||
from datetime import datetime, date
|
||
import calendar
|
||
|
||
sys.path.insert(0, os.path.dirname(__file__))
|
||
|
||
from airtable_client import AirtableClient
|
||
from analyzer import analyze
|
||
|
||
st.set_page_config(
|
||
page_title="Leads Optimizer",
|
||
layout="wide",
|
||
initial_sidebar_state="expanded",
|
||
)
|
||
|
||
# Los KPI (st.metric) por defecto son demasiado grandes y se cortan en pantallas
|
||
# más estrechas o cuando el valor es largo (p.ej. "1.234,56€"). Los reducimos.
|
||
st.markdown(
|
||
"""
|
||
<style>
|
||
[data-testid="stMetricValue"] { font-size: 1.5rem; }
|
||
[data-testid="stMetricLabel"] { font-size: 0.8rem; }
|
||
[data-testid="stMetricDelta"] { font-size: 0.8rem; }
|
||
</style>
|
||
""",
|
||
unsafe_allow_html=True,
|
||
)
|
||
|
||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||
|
||
URGENCIA_ICON = {
|
||
"PAUSAR": "⛔",
|
||
"SPRINT": "🚀",
|
||
"ACELERAR": "📈",
|
||
"FRENAR": "📉",
|
||
"EN_RITMO": "✅",
|
||
}
|
||
|
||
URGENCIA_ORDER = {"PAUSAR": 0, "SPRINT": 1, "ACELERAR": 2, "FRENAR": 3, "EN_RITMO": 4}
|
||
|
||
CRITICIDAD_ORDER = {"Crítico": 0, "Peligro": 1, "Mantener": 2}
|
||
|
||
|
||
def _eur(v: float) -> str:
|
||
return f"{v:.2f}€"
|
||
|
||
|
||
def _pct(v: float) -> str:
|
||
sign = "+" if v >= 0 else ""
|
||
return f"{sign}{v * 100:.1f}%"
|
||
|
||
|
||
def _ritmo_bar(ritmo: float) -> str:
|
||
filled = int(abs(ritmo) * 10)
|
||
bar = "█" * min(filled, 10)
|
||
return ("🟢 +" if ritmo > 0 else "🔴 ") + bar if ritmo != 0 else "⚪ —"
|
||
|
||
|
||
# ── Data loading ──────────────────────────────────────────────────────────────
|
||
|
||
@st.cache_data(ttl=300, show_spinner="Cargando datos de Airtable...")
|
||
def _load_gcm(mes: int, anio: int) -> list[dict]:
|
||
"""Lee GACampaignMes para el mes/año dado."""
|
||
at = AirtableClient()
|
||
mes_num = str(mes)
|
||
anio_str = str(anio)
|
||
formula = f"AND({{Mes}}='{mes_num}',{{Año}}='{anio_str}')"
|
||
records = at.gacampaignmes.all(
|
||
formula=formula,
|
||
fields=[
|
||
"CampaignID", "Campaign Name (from CampaignID)",
|
||
"Mes", "Año", "Status", "PPL", "CPAMax", "CapTotalMes",
|
||
"CosteMes", "ConvMes", "ConvLeadsLakeMesFinal",
|
||
"MetricasDiarias", "Consejo", "Criticidad", "Log",
|
||
],
|
||
)
|
||
campaigns_records = at.campaigns.all(fields=["CampaignID"])
|
||
at_id_to_gid = {r["id"]: str(r["fields"].get("CampaignID", "")).strip() for r in campaigns_records}
|
||
|
||
result = []
|
||
for r in records:
|
||
f = r["fields"]
|
||
at_cids = f.get("CampaignID", [])
|
||
gid = at_id_to_gid.get(at_cids[0], "") if at_cids else ""
|
||
name_list = f.get("Campaign Name (from CampaignID)", [])
|
||
name = name_list[0] if name_list else "Sin nombre"
|
||
|
||
metricas_raw = f.get("MetricasDiarias") or "{}"
|
||
try:
|
||
metricas = json.loads(metricas_raw)
|
||
except (json.JSONDecodeError, TypeError):
|
||
metricas = {}
|
||
|
||
result.append({
|
||
"airtable_id": r["id"],
|
||
"google_campaign_id": gid,
|
||
"nombre": name,
|
||
"status": f.get("Status", "Pausada"),
|
||
"ppl": float(f.get("PPL") or 0),
|
||
"cpa_max": float(f.get("CPAMax") or 0),
|
||
"cap": int(f.get("CapTotalMes") or 0),
|
||
"coste_mes": float(f.get("CosteMes") or 0),
|
||
"conv_mes": float(f.get("ConvMes") or 0),
|
||
"leads_lake": int(f.get("ConvLeadsLakeMesFinal") or 0),
|
||
"metricas": metricas,
|
||
"consejo": f.get("Consejo", ""),
|
||
"criticidad": f.get("Criticidad", "Mantener"),
|
||
"log": f.get("Log", ""),
|
||
})
|
||
return result
|
||
|
||
|
||
@st.cache_data(ttl=300, show_spinner="Cargando cappings de cursos...")
|
||
def _load_cursomes(mes: int, anio: int) -> list[dict]:
|
||
at = AirtableClient()
|
||
meses_es = {
|
||
1: "Enero", 2: "Febrero", 3: "Marzo", 4: "Abril",
|
||
5: "Mayo", 6: "Junio", 7: "Julio", 8: "Agosto",
|
||
9: "Septiembre", 10: "Octubre", 11: "Noviembre", 12: "Diciembre",
|
||
}
|
||
mes_nombre = meses_es[mes]
|
||
formula = f"AND({{Mes}}='{mes_nombre}',{{Año}}='{anio}')"
|
||
records = at.cursomes.all(
|
||
formula=formula,
|
||
fields=["CursoID", "Mes", "Año", "Caping Admitido"],
|
||
)
|
||
cursos_records = at.cursos.all(fields=["CursoID", "Nombre"])
|
||
rec_to_cursoid = {r["id"]: str(int(r["fields"].get("CursoID", 0))) for r in cursos_records if r["fields"].get("CursoID")}
|
||
rec_to_nombre = {r["id"]: r["fields"].get("Nombre", "") for r in cursos_records}
|
||
|
||
result = []
|
||
for r in records:
|
||
f = r["fields"]
|
||
curso_rec_ids = f.get("CursoID", [])
|
||
cursoid = rec_to_cursoid.get(curso_rec_ids[0], "") if curso_rec_ids else ""
|
||
nombre = rec_to_nombre.get(curso_rec_ids[0], cursoid) if curso_rec_ids else cursoid
|
||
result.append({
|
||
"airtable_id": r["id"],
|
||
"cursoid": cursoid,
|
||
"nombre": nombre,
|
||
"mes": f.get("Mes", ""),
|
||
"anio": f.get("Año", ""),
|
||
"cap": int(f.get("Caping Admitido") or 0),
|
||
})
|
||
return sorted(result, key=lambda x: x["nombre"])
|
||
|
||
|
||
def _compute_analysis(row: dict, dia_actual: int, dias_mes: int) -> dict:
|
||
"""Calcula urgencia/ritmo con los datos de Airtable (sin Google Ads API)."""
|
||
leads = row["leads_lake"] or int(row["conv_mes"])
|
||
cap = row["cap"]
|
||
ppl = row["ppl"]
|
||
cpa_max = row["cpa_max"]
|
||
gasto = row["coste_mes"]
|
||
|
||
ratio_leads = leads / cap if cap > 0 else 0
|
||
ratio_mes = dia_actual / dias_mes
|
||
ritmo = ratio_leads - ratio_mes
|
||
cpa_actual = gasto / leads if leads > 0 else 0
|
||
revenue = leads * ppl
|
||
margen = (revenue - gasto) / revenue if revenue > 0 else 0
|
||
leads_restantes = cap - leads
|
||
dias_restantes = dias_mes - dia_actual
|
||
|
||
if ratio_leads >= 1.0:
|
||
urgencia = "PAUSAR"
|
||
elif cap > 0 and ratio_leads < ratio_mes - 0.15 and dias_restantes <= 5:
|
||
urgencia = "SPRINT"
|
||
elif ritmo < -0.15:
|
||
urgencia = "ACELERAR"
|
||
elif ritmo > 0.15:
|
||
urgencia = "FRENAR"
|
||
else:
|
||
urgencia = "EN_RITMO"
|
||
|
||
return {
|
||
"leads": leads,
|
||
"cap": cap,
|
||
"ratio_leads": ratio_leads,
|
||
"ratio_mes": ratio_mes,
|
||
"ritmo": ritmo,
|
||
"urgencia": urgencia,
|
||
"cpa_actual": cpa_actual,
|
||
"rentable": cpa_actual <= cpa_max if cpa_actual > 0 else True,
|
||
"margen": margen,
|
||
"revenue": revenue,
|
||
"leads_restantes": leads_restantes,
|
||
"dias_restantes": dias_restantes,
|
||
}
|
||
|
||
|
||
_SERIE_COLORS = {
|
||
"Inversión": "#2a78d6",
|
||
"Gasto": "#2a78d6",
|
||
"Ingreso": "#1baf7a",
|
||
"Revenue": "#1baf7a",
|
||
"Margen": "#4a3aa7",
|
||
"Margen (sumatorio)": "#4a3aa7",
|
||
"Margen (PPL)": "#eb6834",
|
||
}
|
||
|
||
|
||
def _eur_line_chart(df: pd.DataFrame, series: list[str], height: int = 320):
|
||
"""Gráfico de líneas en € para comparar varias series por día. Un solo eje
|
||
(todas las series están en €), colores categóricos fijos y tooltip — evita
|
||
la ambigüedad de un gráfico de barras apiladas/agrupadas para comparar
|
||
magnitudes que pueden ser muy parecidas entre sí (p.ej. gasto vs ingreso)."""
|
||
series = [s for s in series if s in df.columns]
|
||
df_long = df.melt("Fecha", value_vars=series, var_name="Serie", value_name="Valor")
|
||
color_scale = alt.Scale(domain=series, range=[_SERIE_COLORS[s] for s in series])
|
||
chart = (
|
||
alt.Chart(df_long)
|
||
.mark_line(point=True, strokeWidth=2)
|
||
.encode(
|
||
x=alt.X("Fecha:O", title=None),
|
||
y=alt.Y("Valor:Q", title="€"),
|
||
color=alt.Color("Serie:N", scale=color_scale, sort=series, legend=alt.Legend(title=None)),
|
||
tooltip=["Fecha", "Serie", alt.Tooltip("Valor:Q", format=",.2f")],
|
||
)
|
||
.properties(height=height)
|
||
)
|
||
st.altair_chart(chart, width="stretch")
|
||
|
||
|
||
def _daily_summary(rows: list[dict]) -> pd.DataFrame:
|
||
"""Agrega Inversión/Ingreso/Margen día a día sumando las MetricasDiarias
|
||
de todas las campañas dadas, más un margen alternativo calculado con el
|
||
PPL fijo de cada campaña en lugar del ingreso reportado."""
|
||
daily = {}
|
||
for r in rows:
|
||
metricas = r.get("metricas")
|
||
if not metricas:
|
||
continue
|
||
items = metricas.items() if isinstance(metricas, dict) else [
|
||
(m.get("fecha"), m) for m in metricas
|
||
]
|
||
for fecha, m in items:
|
||
if not fecha or not isinstance(m, dict):
|
||
continue
|
||
gasto = revenue = leads = None
|
||
for k, v in m.items():
|
||
kl = k.lower()
|
||
if gasto is None and ("coste" in kl or "cost" in kl):
|
||
gasto = float(v or 0)
|
||
elif revenue is None and ("ingres" in kl or "revenue" in kl):
|
||
revenue = float(v or 0)
|
||
elif leads is None and "lead" in kl:
|
||
leads = float(v or 0)
|
||
entry = daily.setdefault(fecha, {"gasto": 0.0, "revenue": 0.0, "revenue_ppl": 0.0})
|
||
entry["gasto"] += gasto or 0.0
|
||
entry["revenue"] += revenue or 0.0
|
||
entry["revenue_ppl"] += (leads or 0.0) * r["ppl"]
|
||
|
||
if not daily:
|
||
return pd.DataFrame()
|
||
|
||
df = pd.DataFrame([{"Fecha": f, **v} for f, v in sorted(daily.items())])
|
||
return pd.DataFrame({
|
||
"Fecha": df["Fecha"],
|
||
"Inversión": df["gasto"],
|
||
"Ingreso": df["revenue"],
|
||
"Margen (sumatorio)": df["revenue"] - df["gasto"],
|
||
"Margen (PPL)": df["revenue_ppl"] - df["gasto"],
|
||
})
|
||
|
||
|
||
# ── Sidebar ───────────────────────────────────────────────────────────────────
|
||
|
||
st.sidebar.title("Leads Optimizer")
|
||
today = date.today()
|
||
|
||
col_m, col_a = st.sidebar.columns(2)
|
||
mes_sel = col_m.number_input("Mes", min_value=1, max_value=12, value=today.month)
|
||
anio_sel = col_a.number_input("Año", min_value=2024, max_value=2030, value=today.year, step=1)
|
||
|
||
if st.sidebar.button("🔄 Actualizar datos", width="stretch"):
|
||
st.cache_data.clear()
|
||
|
||
st.sidebar.divider()
|
||
|
||
# Filtros adicionales (se rellenan tras cargar datos)
|
||
urgencia_filter = st.sidebar.multiselect(
|
||
"Urgencia",
|
||
["PAUSAR", "SPRINT", "ACELERAR", "FRENAR", "EN_RITMO"],
|
||
default=[],
|
||
placeholder="Todas",
|
||
)
|
||
solo_activas = st.sidebar.checkbox("Solo campañas activas", value=True)
|
||
|
||
# ── Carga de datos ────────────────────────────────────────────────────────────
|
||
|
||
gcm_rows = _load_gcm(int(mes_sel), int(anio_sel))
|
||
|
||
dia_actual = today.day if (today.month == int(mes_sel) and today.year == int(anio_sel)) else calendar.monthrange(int(anio_sel), int(mes_sel))[1]
|
||
dias_mes = calendar.monthrange(int(anio_sel), int(mes_sel))[1]
|
||
|
||
# Enriquecer con análisis calculado
|
||
for row in gcm_rows:
|
||
row["analysis"] = _compute_analysis(row, dia_actual, dias_mes)
|
||
|
||
# Aplicar filtros
|
||
filtered = gcm_rows
|
||
if solo_activas:
|
||
filtered = [r for r in filtered if r["status"] == "Activa"]
|
||
if urgencia_filter:
|
||
filtered = [r for r in filtered if r["analysis"]["urgencia"] in urgencia_filter]
|
||
|
||
# Ordenar por urgencia
|
||
filtered.sort(key=lambda r: URGENCIA_ORDER.get(r["analysis"]["urgencia"], 9))
|
||
|
||
# ── Título ────────────────────────────────────────────────────────────────────
|
||
|
||
st.title(f"Leads Optimizer · {calendar.month_name[int(mes_sel)]} {int(anio_sel)}")
|
||
st.caption(f"Día {dia_actual} de {dias_mes} · {len(filtered)} campañas")
|
||
|
||
# ── KPIs globales ─────────────────────────────────────────────────────────────
|
||
|
||
total_gasto = sum(r["coste_mes"] for r in filtered)
|
||
total_leads = sum(r["analysis"]["leads"] for r in filtered)
|
||
total_revenue = sum(r["analysis"]["revenue"] for r in filtered)
|
||
total_margen = total_revenue - total_gasto
|
||
margen_pct = total_margen / total_revenue if total_revenue > 0 else 0
|
||
|
||
k1, k2, k3, k4, k5 = st.columns(5)
|
||
k1.metric("Gasto total", _eur(total_gasto))
|
||
k2.metric("Leads entregados", f"{total_leads:,}")
|
||
k3.metric("Revenue estimado", _eur(total_revenue))
|
||
k4.metric("Margen total", _eur(total_margen))
|
||
k5.metric("Margen %", _pct(margen_pct))
|
||
|
||
st.divider()
|
||
|
||
# ── Pestañas ──────────────────────────────────────────────────────────────────
|
||
|
||
tab_resumen, tab_campanas, tab_historico, tab_ejecucion = st.tabs([
|
||
"📊 Resumen",
|
||
"📋 Campañas",
|
||
"📈 Histórico",
|
||
"⚡ Ejecución",
|
||
])
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════════════════════ #
|
||
# Tab 1 — RESUMEN #
|
||
# ════════════════════════════════════════════════════════════════════════════ #
|
||
|
||
with tab_resumen:
|
||
# ── Conteo por urgencia ───────────────────────────────────────────────────
|
||
urgencia_counts = {}
|
||
for r in filtered:
|
||
u = r["analysis"]["urgencia"]
|
||
urgencia_counts[u] = urgencia_counts.get(u, 0) + 1
|
||
|
||
u_cols = st.columns(5)
|
||
for i, (urg, icon) in enumerate(URGENCIA_ICON.items()):
|
||
cnt = urgencia_counts.get(urg, 0)
|
||
u_cols[i].metric(f"{icon} {urg}", cnt)
|
||
|
||
st.divider()
|
||
|
||
# ── Evolución diaria del mes (inversión / ingreso / margen) ──────────────────
|
||
st.subheader("Evolución diaria del mes")
|
||
|
||
df_summary = _daily_summary(filtered)
|
||
if df_summary.empty:
|
||
st.info("Sin métricas diarias disponibles para las campañas filtradas.")
|
||
else:
|
||
_eur_line_chart(df_summary, ["Inversión", "Ingreso", "Margen (sumatorio)", "Margen (PPL)"])
|
||
st.caption("Margen (sumatorio) = Ingreso reportado − Gasto · Margen (PPL) = Leads del día × PPL de campaña − Gasto")
|
||
|
||
st.divider()
|
||
|
||
# ── Alertas activas ───────────────────────────────────────────────────────
|
||
alerts = []
|
||
for r in filtered:
|
||
if r["log"]:
|
||
alerts.append((r["nombre"], r["log"]))
|
||
a = r["analysis"]
|
||
if a["margen"] < -0.5 and a["revenue"] > 0:
|
||
alerts.append((r["nombre"], f"Margen muy negativo: {_pct(a['margen'])}"))
|
||
|
||
if alerts:
|
||
with st.expander(f"⚠️ {len(alerts)} alertas activas", expanded=True):
|
||
for nombre, msg in alerts:
|
||
st.warning(f"**{nombre}** — {msg}")
|
||
|
||
# ── Tabla de campañas ─────────────────────────────────────────────────────
|
||
st.subheader("Estado de campañas")
|
||
|
||
if not filtered:
|
||
st.info("Sin campañas con los filtros actuales.")
|
||
else:
|
||
table_rows = []
|
||
for r in filtered:
|
||
a = r["analysis"]
|
||
icon = URGENCIA_ICON.get(a["urgencia"], "")
|
||
table_rows.append({
|
||
"Urgencia": f"{icon} {a['urgencia']}",
|
||
"Campaña": r["nombre"],
|
||
"Leads": a["leads"],
|
||
"Cap": a["cap"],
|
||
"Ritmo": _pct(a["ritmo"]),
|
||
"Gasto": _eur(r["coste_mes"]),
|
||
"CPA act.": _eur(a["cpa_actual"]) if a["cpa_actual"] > 0 else "—",
|
||
"CPA máx.": _eur(r["cpa_max"]),
|
||
"Margen": _pct(a["margen"]) if a["revenue"] > 0 else "—",
|
||
"Criticidad": r["criticidad"] or "—",
|
||
"Consejo": (r["consejo"] or "")[:80] + ("…" if len(r["consejo"] or "") > 80 else ""),
|
||
})
|
||
|
||
df = pd.DataFrame(table_rows)
|
||
event = st.dataframe(
|
||
df,
|
||
width="stretch",
|
||
hide_index=True,
|
||
on_select="rerun",
|
||
selection_mode="single-row",
|
||
)
|
||
|
||
sel = event.selection.rows
|
||
if sel:
|
||
row = filtered[sel[0]]
|
||
a = row["analysis"]
|
||
st.divider()
|
||
st.subheader(row["nombre"])
|
||
c1, c2, c3, c4 = st.columns(4)
|
||
c1.metric("Leads / Cap", f"{a['leads']} / {a['cap']}")
|
||
c2.metric("Ritmo", _pct(a["ritmo"]))
|
||
c3.metric("CPA actual", _eur(a["cpa_actual"]) if a["cpa_actual"] > 0 else "—")
|
||
c4.metric("Margen", _pct(a["margen"]) if a["revenue"] > 0 else "—")
|
||
if row["consejo"]:
|
||
st.info(f"💡 {row['consejo']}")
|
||
if row["log"]:
|
||
st.warning(f"⚠️ {row['log']}")
|
||
link = f"https://ads.google.com/aw/campaigns?campaignId={row['google_campaign_id']}"
|
||
st.markdown(f"[Abrir en Google Ads ↗]({link})")
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════════════════════ #
|
||
# Tab 2 — CAMPAÑAS (editor de parámetros) #
|
||
# ════════════════════════════════════════════════════════════════════════════ #
|
||
|
||
with tab_campanas:
|
||
st.subheader("Parámetros de campañas — GACampaignMes")
|
||
st.caption("Edita PPL, CPA Máx y Cap directamente. Pulsa Guardar para escribir en Airtable.")
|
||
|
||
if not gcm_rows:
|
||
st.info("Sin datos para el mes seleccionado.")
|
||
else:
|
||
editor_data = pd.DataFrame([
|
||
{
|
||
"Campaña": r["nombre"],
|
||
"Status": r["status"],
|
||
"PPL (€)": r["ppl"],
|
||
"CPA Máx (€)": r["cpa_max"],
|
||
"Cap mes": r["cap"],
|
||
"Gasto": _eur(r["coste_mes"]),
|
||
"Leads lake": r["leads_lake"],
|
||
"_id": r["airtable_id"],
|
||
}
|
||
for r in gcm_rows
|
||
])
|
||
|
||
edited = st.data_editor(
|
||
editor_data.drop(columns=["_id"]),
|
||
width="stretch",
|
||
hide_index=True,
|
||
disabled=["Campaña", "Status", "Gasto", "Leads lake"],
|
||
column_config={
|
||
"PPL (€)": st.column_config.NumberColumn(min_value=0.0, format="%.2f"),
|
||
"CPA Máx (€)": st.column_config.NumberColumn(min_value=0.0, format="%.2f"),
|
||
"Cap mes": st.column_config.NumberColumn(min_value=0, step=1),
|
||
},
|
||
)
|
||
|
||
if st.button("💾 Guardar cambios en Airtable", type="primary"):
|
||
at = AirtableClient()
|
||
batch = []
|
||
for i, orig in enumerate(gcm_rows):
|
||
new_ppl = float(edited.at[i, "PPL (€)"])
|
||
new_cpa = float(edited.at[i, "CPA Máx (€)"])
|
||
new_cap = int(edited.at[i, "Cap mes"])
|
||
changes = {}
|
||
if new_ppl != orig["ppl"]:
|
||
changes["PPL"] = new_ppl
|
||
if new_cpa != orig["cpa_max"]:
|
||
changes["CPAMax"] = new_cpa
|
||
if new_cap != orig["cap"]:
|
||
changes["CapTotalMes"] = new_cap
|
||
if changes:
|
||
batch.append({"id": orig["airtable_id"], "fields": changes})
|
||
|
||
if batch:
|
||
for i in range(0, len(batch), 10):
|
||
at.gacampaignmes.batch_update(batch[i:i+10])
|
||
st.success(f"✅ {len(batch)} registros actualizados.")
|
||
st.cache_data.clear()
|
||
else:
|
||
st.info("Sin cambios detectados.")
|
||
|
||
st.divider()
|
||
st.subheader("Cappings por curso — CursoMes")
|
||
st.caption("Ajusta el capping mensual admitido por curso.")
|
||
|
||
cursomes_rows = _load_cursomes(int(mes_sel), int(anio_sel))
|
||
if not cursomes_rows:
|
||
st.info("Sin registros de CursoMes para el período seleccionado.")
|
||
else:
|
||
cm_data = pd.DataFrame([
|
||
{
|
||
"Curso ID": r["cursoid"],
|
||
"Nombre": r["nombre"],
|
||
"Cap admitido": r["cap"],
|
||
"_id": r["airtable_id"],
|
||
}
|
||
for r in cursomes_rows
|
||
])
|
||
|
||
cm_edited = st.data_editor(
|
||
cm_data.drop(columns=["_id"]),
|
||
width="stretch",
|
||
hide_index=True,
|
||
disabled=["Curso ID", "Nombre"],
|
||
column_config={
|
||
"Cap admitido": st.column_config.NumberColumn(min_value=0, step=1),
|
||
},
|
||
)
|
||
|
||
if st.button("💾 Guardar cappings en Airtable", type="primary", key="save_cursomes"):
|
||
at = AirtableClient()
|
||
batch = []
|
||
for i, orig in enumerate(cursomes_rows):
|
||
new_cap = int(cm_edited.at[i, "Cap admitido"])
|
||
if new_cap != orig["cap"]:
|
||
batch.append({"id": orig["airtable_id"], "fields": {"Caping Admitido": new_cap}})
|
||
if batch:
|
||
for j in range(0, len(batch), 10):
|
||
at.cursomes.batch_update(batch[j:j+10])
|
||
st.success(f"✅ {len(batch)} cappings actualizados.")
|
||
st.cache_data.clear()
|
||
else:
|
||
st.info("Sin cambios detectados.")
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════════════════════ #
|
||
# Tab 3 — HISTÓRICO #
|
||
# ════════════════════════════════════════════════════════════════════════════ #
|
||
|
||
with tab_historico:
|
||
hist_camp_tab, hist_portfolio_tab = st.tabs(["📋 Campañas", "🌐 Portfolio global"])
|
||
|
||
with hist_camp_tab:
|
||
st.subheader("Métricas diarias por campaña")
|
||
|
||
camp_names = [r["nombre"] for r in filtered if r["metricas"]]
|
||
if not camp_names:
|
||
st.info("Sin métricas diarias disponibles para las campañas filtradas.")
|
||
else:
|
||
sel_camp = st.selectbox("Campaña", camp_names, key="hist_camp")
|
||
camp_row = next((r for r in filtered if r["nombre"] == sel_camp), None)
|
||
|
||
if camp_row and camp_row["metricas"]:
|
||
metricas = camp_row["metricas"]
|
||
# MetricasDiarias puede ser dict {fecha: {coste, ingresos, margen, leads, ...}}
|
||
# o list [{fecha, coste, ...}]
|
||
if isinstance(metricas, dict):
|
||
daily = [{"fecha": k, **v} for k, v in sorted(metricas.items())]
|
||
else:
|
||
daily = sorted(metricas, key=lambda x: x.get("fecha", ""))
|
||
|
||
if daily:
|
||
df_daily = pd.DataFrame(daily)
|
||
st.caption(f"Columnas raw: {list(df_daily.columns)}")
|
||
|
||
# Normalizar nombres de columnas (pueden variar)
|
||
col_map = {}
|
||
for c in df_daily.columns:
|
||
cl = c.lower()
|
||
if c in col_map.values():
|
||
continue
|
||
if ("fecha" in cl or "date" in cl) and "Fecha" not in col_map.values():
|
||
col_map[c] = "Fecha"
|
||
elif ("coste" in cl or "cost" in cl) and "Gasto" not in col_map.values():
|
||
col_map[c] = "Gasto"
|
||
elif ("ingres" in cl or "revenue" in cl) and "Revenue" not in col_map.values():
|
||
col_map[c] = "Revenue"
|
||
elif ("margen" in cl or "margin" in cl) and "Margen" not in col_map.values():
|
||
col_map[c] = "Margen"
|
||
elif "lead" in cl and "Leads" not in col_map.values():
|
||
col_map[c] = "Leads"
|
||
df_daily = df_daily.rename(columns=col_map)
|
||
|
||
# KPIs del período
|
||
d1, d2, d3, d4 = st.columns(4)
|
||
if "Gasto" in df_daily:
|
||
d1.metric("Gasto total", _eur(df_daily["Gasto"].sum()))
|
||
if "Revenue" in df_daily:
|
||
d2.metric("Revenue total", _eur(df_daily["Revenue"].sum()))
|
||
if "Margen" in df_daily:
|
||
gasto_sum = df_daily["Gasto"].sum() if "Gasto" in df_daily else 1
|
||
rev_sum = df_daily["Revenue"].sum() if "Revenue" in df_daily else 0
|
||
margen_total = rev_sum - gasto_sum
|
||
d3.metric("Margen €", _eur(margen_total))
|
||
lead_col = next((c for c in df_daily.columns if "lead" in c.lower()), None)
|
||
if lead_col:
|
||
d4.metric("Leads totales", int(pd.to_numeric(df_daily[lead_col], errors="coerce").fillna(0).sum()))
|
||
|
||
# Gráfico de ritmo: ratio_leads vs ratio_mes día a día
|
||
if "Leads" in df_daily and camp_row["cap"] > 0:
|
||
st.markdown("**Ritmo: leads acumulados vs objetivo esperado**")
|
||
df_ritmo = df_daily[["Fecha", "Leads"]].copy() if "Fecha" in df_daily else df_daily[["Leads"]].copy()
|
||
df_ritmo["Leads acum."] = df_daily["Leads"].cumsum()
|
||
df_ritmo["Objetivo"] = [
|
||
round((i + 1) / dias_mes * camp_row["cap"], 1)
|
||
for i in range(len(df_ritmo))
|
||
]
|
||
if "Fecha" in df_ritmo.columns:
|
||
df_ritmo = df_ritmo.set_index("Fecha")
|
||
st.line_chart(df_ritmo[["Leads acum.", "Objetivo"]])
|
||
|
||
# Gráfico gasto / ingreso / margen diario
|
||
numeric_cols = [c for c in ["Gasto", "Revenue", "Margen"] if c in df_daily.columns]
|
||
if numeric_cols and "Fecha" in df_daily.columns:
|
||
st.markdown("**Gasto, ingreso y margen diario**")
|
||
_eur_line_chart(df_daily.rename(columns={"Revenue": "Ingreso"}), ["Gasto", "Ingreso", "Margen"])
|
||
|
||
# Tabla detalle
|
||
st.markdown("**Detalle diario**")
|
||
display_cols = [c for c in ["Fecha", "Leads", "Gasto", "Revenue", "Margen"] if c in df_daily.columns]
|
||
st.dataframe(df_daily[display_cols], width="stretch", hide_index=True)
|
||
else:
|
||
st.info("Sin datos diarios para esta campaña.")
|
||
else:
|
||
st.info("Sin métricas diarias para esta campaña.")
|
||
|
||
with hist_portfolio_tab:
|
||
st.subheader("Portfolio agregado (GAMes)")
|
||
|
||
@st.cache_data(ttl=300, show_spinner="Cargando portfolio...")
|
||
def _load_games(mes: int, anio: int) -> dict:
|
||
at = AirtableClient()
|
||
formula = f"AND({{Mes}}='{mes}',{{Año}}='{anio}')"
|
||
records = at.games.all(formula=formula)
|
||
if not records:
|
||
return {}
|
||
f = records[0]["fields"]
|
||
try:
|
||
metricas = json.loads(f.get("MetricasDiarias") or "{}")
|
||
except Exception:
|
||
metricas = {}
|
||
return {
|
||
"metricas": metricas,
|
||
"coste_mes": float(f.get("CosteMes") or 0),
|
||
"conv_mes": float(f.get("ConvMes") or 0),
|
||
"ppl_medio": float(f.get("PPLMedio") or 0),
|
||
"cpa_medio": float(f.get("CPAMedio") or 0),
|
||
}
|
||
|
||
games = _load_games(int(mes_sel), int(anio_sel))
|
||
if games:
|
||
g1, g2, g3, g4 = st.columns(4)
|
||
g1.metric("Gasto mes (portfolio)", _eur(games["coste_mes"]))
|
||
g2.metric("Leads mes (portfolio)", f"{games['conv_mes']:.0f}")
|
||
g3.metric("PPL medio", _eur(games["ppl_medio"]))
|
||
g4.metric("CPA medio", _eur(games["cpa_medio"]))
|
||
|
||
if games["metricas"]:
|
||
if isinstance(games["metricas"], dict):
|
||
gdf = pd.DataFrame([{"Fecha": k, **v} for k, v in sorted(games["metricas"].items())])
|
||
else:
|
||
gdf = pd.DataFrame(sorted(games["metricas"], key=lambda x: x.get("fecha", "")))
|
||
|
||
col_map = {}
|
||
for c in gdf.columns:
|
||
cl = c.lower()
|
||
if "coste" in cl or "cost" in cl:
|
||
col_map[c] = "Gasto"
|
||
elif "ingres" in cl or "revenue" in cl:
|
||
col_map[c] = "Revenue"
|
||
elif "margen" in cl:
|
||
col_map[c] = "Margen"
|
||
elif "lead" in cl or "conv" in cl:
|
||
col_map[c] = "Leads"
|
||
elif "fecha" in cl or "date" in cl:
|
||
col_map[c] = "Fecha"
|
||
gdf = gdf.rename(columns=col_map)
|
||
|
||
numeric_cols = [c for c in ["Gasto", "Revenue", "Margen"] if c in gdf.columns]
|
||
if numeric_cols and "Fecha" in gdf.columns:
|
||
st.markdown("**Portfolio: gasto, ingreso y margen diario**")
|
||
_eur_line_chart(gdf.rename(columns={"Revenue": "Ingreso"}), ["Gasto", "Ingreso", "Margen"])
|
||
else:
|
||
st.info("Sin datos de portfolio para el período seleccionado.")
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════════════════════ #
|
||
# Tab 4 — EJECUCIÓN #
|
||
# ════════════════════════════════════════════════════════════════════════════ #
|
||
|
||
with tab_ejecucion:
|
||
st.subheader("Lanzar run.py")
|
||
|
||
col_dry, col_prod = st.columns(2)
|
||
|
||
with col_dry:
|
||
st.markdown("**Dry Run** — Solo análisis, sin cambios en Google Ads")
|
||
if st.button("▶ Ejecutar Dry Run", width="stretch"):
|
||
st.session_state["run_mode"] = "dry"
|
||
st.session_state["run_started"] = True
|
||
|
||
with col_prod:
|
||
st.markdown("**Producción** — Aplica cambios reales en Google Ads")
|
||
confirm = st.checkbox("Confirmo que quiero ejecutar en producción")
|
||
prod_disabled = not confirm
|
||
if st.button("🚀 Ejecutar en Producción", width="stretch", disabled=prod_disabled):
|
||
st.session_state["run_mode"] = "prod"
|
||
st.session_state["run_started"] = True
|
||
|
||
if st.session_state.get("run_started"):
|
||
st.session_state["run_started"] = False
|
||
mode = st.session_state.get("run_mode", "dry")
|
||
env = os.environ.copy()
|
||
env["DRY_RUN"] = "true" if mode == "dry" else "false"
|
||
|
||
run_py = os.path.join(os.path.dirname(__file__), "run.py")
|
||
st.info(f"Ejecutando run.py en modo {'Dry Run' if mode == 'dry' else 'PRODUCCIÓN'}…")
|
||
log_box = st.empty()
|
||
lines = []
|
||
|
||
with st.spinner("En ejecución…"):
|
||
proc = subprocess.Popen(
|
||
[sys.executable, run_py],
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.STDOUT,
|
||
text=True,
|
||
encoding="utf-8",
|
||
errors="replace",
|
||
env=env,
|
||
cwd=os.path.dirname(__file__),
|
||
)
|
||
for line in proc.stdout:
|
||
lines.append(line.rstrip())
|
||
log_box.code("\n".join(lines[-60:]), language="")
|
||
proc.wait()
|
||
|
||
if proc.returncode == 0:
|
||
st.success("✅ Ejecución completada correctamente.")
|
||
else:
|
||
st.error(f"❌ Terminó con código {proc.returncode}.")
|
||
|
||
st.cache_data.clear()
|
||
|
||
st.divider()
|
||
st.subheader("Historial de ejecuciones")
|
||
|
||
logs_dir = os.path.join(os.path.dirname(__file__), "logs")
|
||
log_files = sorted(glob.glob(os.path.join(logs_dir, "*.log")), reverse=True)
|
||
|
||
if not log_files:
|
||
st.info("Sin logs guardados. Los logs se generan al ejecutar run.py.")
|
||
else:
|
||
log_names = [os.path.basename(f) for f in log_files]
|
||
sel_log = st.selectbox("Fichero de log", log_names)
|
||
if sel_log:
|
||
log_path = os.path.join(logs_dir, sel_log)
|
||
with open(log_path, encoding="utf-8", errors="replace") as fh:
|
||
content = fh.read()
|
||
st.code(content, language="")
|