pm-dashboard/dashboard.py
José Manuel Gómez 7f0b08d3dd Añade login con Google y CRUD de proyectos/tareas
Restringe el acceso a colaboradores conocidos en Airtable, añade una
vista de tareas propias filtrable por fecha, y permite crear, editar
y borrar proyectos y tareas desde el dashboard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 20:39:32 +02:00

424 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Dashboard interactivo para pm-dashboard — Streamlit."""
import streamlit as st
import pandas as pd
import sys
import os
sys.path.insert(0, os.path.dirname(__file__))
from airtable_client import AirtableClient
st.set_page_config(
page_title="PM Dashboard",
layout="wide",
initial_sidebar_state="expanded",
)
# ── Autenticación ─────────────────────────────────────────────────────────── #
try:
_auth_configured = "auth" in st.secrets
except Exception:
_auth_configured = False
if not _auth_configured:
st.title("📊 PM Dashboard")
st.error(
"Falta configurar el login. Copia `.streamlit/secrets.toml.example` a "
"`.streamlit/secrets.toml` y rellena las credenciales de tu OAuth Client de Google "
"(ver instrucciones dentro del archivo)."
)
st.stop()
if not st.user.is_logged_in:
st.title("📊 PM Dashboard")
st.button("Iniciar sesión con Google", on_click=st.login)
st.stop()
@st.cache_resource
def _get_client():
return AirtableClient()
at = _get_client()
@st.cache_data(ttl=300, show_spinner="Cargando datos de Airtable...")
def _load_data():
return {
"projects": at.get_projects(),
"tasks": at.get_tasks(),
"clients": at.get_clients(),
"areas": at.get_areas(),
}
data = _load_data()
known_collaborators = AirtableClient.get_known_collaborators(data["projects"], data["tasks"])
if st.user.email not in known_collaborators:
st.error(
f"Tu email ({st.user.email}) no está registrado como colaborador en Airtable. "
"Pide a un administrador que te asigne a un proyecto o tarea primero."
)
st.button("Cerrar sesión", on_click=st.logout)
st.stop()
st.sidebar.write(f"👤 {st.user.name or st.user.email}")
st.sidebar.button("Cerrar sesión", on_click=st.logout)
client_name_by_id = {c["id"]: (c["display_name"] or c["company_name"]) for c in data["clients"]}
client_id_by_name = {v: k for k, v in client_name_by_id.items()}
project_name_by_id = {p["id"]: p["project"] for p in data["projects"]}
project_id_by_name = {v: k for k, v in project_name_by_id.items()}
area_name_by_id = {a["id"]: a["area"] for a in data["areas"]}
area_id_by_name = {v: k for k, v in area_name_by_id.items()}
collaborator_labels = {
f"{c['name']} <{email}>": email for email, c in sorted(known_collaborators.items())
}
NONE_OPTION = "(ninguno)"
def _collaborator_fields(emails):
return [{"email": e} for e in emails]
def _parse_date(value):
if not value:
return None
try:
return pd.to_datetime(value).date()
except (ValueError, TypeError):
return None
def _confirm_delete(key_prefix, label, on_confirm):
confirmed = st.checkbox(f"Confirmar borrado de «{label}»", key=f"{key_prefix}_confirm")
if st.button("🗑️ Borrar", key=f"{key_prefix}_delete_btn", disabled=not confirmed):
on_confirm()
st.cache_data.clear()
st.success("Borrado.")
st.rerun()
st.title("📊 PM Dashboard")
tab_mis_tareas, tab_proyectos, tab_tareas, tab_clientes, tab_areas = st.tabs(
["🙋 Mis tareas", "📁 Proyectos", "✅ Tareas", "👥 Clientes", "🗂️ Áreas"]
)
# ── Mis tareas ────────────────────────────────────────────────────────────── #
with tab_mis_tareas:
df = pd.DataFrame(data["tasks"])
if df.empty:
st.info("No hay tareas.")
else:
mine = df[
df["assigned_to"].apply(lambda people: any(p["email"] == st.user.email for p in people))
].copy()
if mine.empty:
st.info("No tienes tareas asignadas.")
else:
mine["project"] = mine["project_ids"].apply(
lambda ids: ", ".join(project_name_by_id.get(i, "") for i in ids)
)
mine["area"] = mine["area_ids"].apply(
lambda ids: ", ".join(area_name_by_id.get(i, "") for i in ids)
)
mine["due_date_parsed"] = mine["due_date"].apply(_parse_date)
dated = mine[mine["due_date_parsed"].notna()]
if not dated.empty:
min_d, max_d = dated["due_date_parsed"].min(), dated["due_date_parsed"].max()
date_range = st.date_input("Rango de vencimiento (Due date)", value=(min_d, max_d))
if isinstance(date_range, tuple) and len(date_range) == 2:
start, end = date_range
mine = mine[
mine["due_date_parsed"].isna()
| ((mine["due_date_parsed"] >= start) & (mine["due_date_parsed"] <= end))
]
st.dataframe(
mine[["task", "project", "area", "status", "prioridad", "due_date", "horas_estimadas"]],
use_container_width=True,
hide_index=True,
)
st.caption(f"{len(mine)} tareas asignadas a ti")
# ── Proyectos ─────────────────────────────────────────────────────────────── #
with tab_proyectos:
df = pd.DataFrame(data["projects"])
if df.empty:
st.info("No hay proyectos.")
else:
df["client"] = df["client_ids"].apply(
lambda ids: ", ".join(client_name_by_id.get(i, "") for i in ids)
)
col1, col2 = st.columns(2)
status_options = sorted(df["status"].dropna().unique())
status_filter = col1.multiselect("Status", status_options, key="proj_status_filter")
client_filter = col2.multiselect("Cliente", sorted(client_name_by_id.values()), key="proj_client_filter")
view = df.copy()
if status_filter:
view = view[view["status"].isin(status_filter)]
if client_filter:
view = view[view["client"].apply(lambda c: any(cf in c for cf in client_filter))]
view = view.reset_index(drop=True)
selection = st.dataframe(
view[["project", "status", "client", "project_lead_name", "kickoff_date", "due_date", "budget", "internal"]],
use_container_width=True,
hide_index=True,
on_select="rerun",
selection_mode="single-row",
key="proj_table",
)
st.caption(f"{len(view)} de {len(df)} proyectos — selecciona una fila para editarla o borrarla")
with st.expander(" Nuevo proyecto"):
with st.form("new_project_form", clear_on_submit=True):
name = st.text_input("Project")
status = st.selectbox("Status", options=status_options + ["(otro)"])
if status == "(otro)":
status = st.text_input("Nuevo status")
client_sel = st.multiselect("Cliente", options=sorted(client_id_by_name))
lead_sel = st.selectbox("Project lead", options=[NONE_OPTION] + sorted(collaborator_labels))
team_sel = st.multiselect("Working team", options=sorted(collaborator_labels))
kickoff = st.date_input("Kickoff date", value=None)
due = st.date_input("Due date", value=None)
budget = st.number_input("Budget", min_value=0.0, step=100.0)
internal = st.checkbox("Internal")
code = st.text_input("Code")
submitted = st.form_submit_button("Crear proyecto")
if submitted:
if not name:
st.error("El nombre del proyecto es obligatorio.")
else:
fields = {"Project": name, "Status": status, "Internal": internal, "Code": code}
if client_sel:
fields["Client"] = [client_id_by_name[c] for c in client_sel]
if lead_sel != NONE_OPTION:
fields["Project lead"] = {"email": collaborator_labels[lead_sel]}
if team_sel:
fields["Working team"] = _collaborator_fields([collaborator_labels[t] for t in team_sel])
if kickoff:
fields["Kickoff date"] = kickoff.isoformat()
if due:
fields["Due date"] = due.isoformat()
if budget:
fields["Budget"] = budget
at.create_project(fields)
st.cache_data.clear()
st.success("Proyecto creado.")
st.rerun()
selected_rows = selection.selection.rows if selection and selection.selection else []
if selected_rows:
sel = view.iloc[selected_rows[0]]
st.markdown(f"#### ✏️ Editar «{sel['project']}»")
with st.form(f"edit_project_{sel['id']}"):
name = st.text_input("Project", value=sel["project"])
status_idx = status_options.index(sel["status"]) if sel["status"] in status_options else 0
status = st.selectbox("Status", options=status_options, index=status_idx)
current_client_names = [client_name_by_id.get(i) for i in sel["client_ids"] if client_name_by_id.get(i)]
client_sel = st.multiselect("Cliente", options=sorted(client_id_by_name), default=current_client_names)
lead_email = sel["project_lead"]["email"] if sel["project_lead"] else None
lead_options = [NONE_OPTION] + sorted(collaborator_labels)
current_lead_label = next((lbl for lbl, e in collaborator_labels.items() if e == lead_email), NONE_OPTION)
lead_sel = st.selectbox("Project lead", options=lead_options, index=lead_options.index(current_lead_label))
team_emails = {c["email"] for c in sel["working_team"]}
current_team_labels = [lbl for lbl, e in collaborator_labels.items() if e in team_emails]
team_sel = st.multiselect("Working team", options=sorted(collaborator_labels), default=current_team_labels)
kickoff = st.date_input("Kickoff date", value=_parse_date(sel["kickoff_date"]))
due = st.date_input("Due date", value=_parse_date(sel["due_date"]))
budget = st.number_input("Budget", min_value=0.0, step=100.0, value=float(sel["budget"] or 0))
internal = st.checkbox("Internal", value=bool(sel["internal"]))
code = st.text_input("Code", value=sel["code"])
save = st.form_submit_button("💾 Guardar cambios")
if save:
fields = {
"Project": name,
"Status": status,
"Client": [client_id_by_name[c] for c in client_sel],
"Project lead": {"email": collaborator_labels[lead_sel]} if lead_sel != NONE_OPTION else [],
"Working team": _collaborator_fields([collaborator_labels[t] for t in team_sel]),
"Kickoff date": kickoff.isoformat() if kickoff else None,
"Due date": due.isoformat() if due else None,
"Budget": budget,
"Internal": internal,
"Code": code,
}
at.update_project(sel["id"], fields)
st.cache_data.clear()
st.success("Proyecto actualizado.")
st.rerun()
_confirm_delete(f"proj_{sel['id']}", sel["project"], lambda rid=sel["id"]: at.delete_project(rid))
# ── Tareas ────────────────────────────────────────────────────────────────── #
with tab_tareas:
df = pd.DataFrame(data["tasks"])
if df.empty:
st.info("No hay tareas.")
else:
df["project"] = df["project_ids"].apply(
lambda ids: ", ".join(project_name_by_id.get(i, "") for i in ids)
)
df["area"] = df["area_ids"].apply(
lambda ids: ", ".join(area_name_by_id.get(i, "") for i in ids)
)
df["assigned"] = df["assigned_to_names"].apply(lambda names: ", ".join(names))
col1, col2, col3 = st.columns(3)
status_options = sorted(df["status"].dropna().unique())
prioridad_options = sorted(df["prioridad"].dropna().unique())
status_filter = col1.multiselect("Status", status_options, key="task_status_filter")
prioridad_filter = col2.multiselect("Prioridad", prioridad_options, key="task_prioridad_filter")
assigned_filter = col3.multiselect(
"Asignado a", sorted({n for names in df["assigned_to_names"] for n in names}), key="task_assigned_filter"
)
view = df.copy()
if status_filter:
view = view[view["status"].isin(status_filter)]
if prioridad_filter:
view = view[view["prioridad"].isin(prioridad_filter)]
if assigned_filter:
view = view[view["assigned"].apply(lambda a: any(af in a for af in assigned_filter))]
view = view.reset_index(drop=True)
selection = st.dataframe(
view[["task", "project", "area", "status", "prioridad", "assigned", "due_date", "horas_estimadas"]],
use_container_width=True,
hide_index=True,
on_select="rerun",
selection_mode="single-row",
key="task_table",
)
st.caption(f"{len(view)} de {len(df)} tareas — selecciona una fila para editarla o borrarla")
with st.expander(" Nueva tarea"):
with st.form("new_task_form", clear_on_submit=True):
task_name = st.text_input("Task")
project_sel = st.multiselect("Project", options=sorted(project_id_by_name))
area_sel = st.multiselect("Area", options=sorted(area_id_by_name))
status = st.selectbox("Status Task", options=status_options + ["(otro)"])
if status == "(otro)":
status = st.text_input("Nuevo status")
prioridad = st.selectbox("Prioridad", options=prioridad_options + ["(otro)"])
if prioridad == "(otro)":
prioridad = st.text_input("Nueva prioridad")
assigned_sel = st.multiselect("Assigned to", options=sorted(collaborator_labels))
kick_off = st.date_input("Kick off", value=None)
due = st.date_input("Due date", value=None)
horas = st.number_input("Horas estimadas", min_value=0.0, step=1.0)
interno_externo = st.text_input("INTERNO/EXTERNO")
recurrencia = st.text_input("Recurrencia")
submitted = st.form_submit_button("Crear tarea")
if submitted:
if not task_name:
st.error("El nombre de la tarea es obligatorio.")
else:
fields = {
"Task": task_name,
"Status Task": status,
"Prioridad": prioridad,
"Horas estimadas": horas,
"INTERNO/EXTERNO": interno_externo,
"Recurrencia": recurrencia,
}
if project_sel:
fields["Project"] = [project_id_by_name[p] for p in project_sel]
if area_sel:
fields["Area"] = [area_id_by_name[a] for a in area_sel]
if assigned_sel:
fields["Assigned to"] = _collaborator_fields([collaborator_labels[a] for a in assigned_sel])
if kick_off:
fields["Kick off"] = kick_off.isoformat()
if due:
fields["Due date"] = due.isoformat()
at.create_task(fields)
st.cache_data.clear()
st.success("Tarea creada.")
st.rerun()
selected_rows = selection.selection.rows if selection and selection.selection else []
if selected_rows:
sel = view.iloc[selected_rows[0]]
st.markdown(f"#### ✏️ Editar «{sel['task']}»")
with st.form(f"edit_task_{sel['id']}"):
task_name = st.text_input("Task", value=sel["task"])
current_project_names = [project_name_by_id.get(i) for i in sel["project_ids"] if project_name_by_id.get(i)]
project_sel = st.multiselect("Project", options=sorted(project_id_by_name), default=current_project_names)
current_area_names = [area_name_by_id.get(i) for i in sel["area_ids"] if area_name_by_id.get(i)]
area_sel = st.multiselect("Area", options=sorted(area_id_by_name), default=current_area_names)
status_idx = status_options.index(sel["status"]) if sel["status"] in status_options else 0
status = st.selectbox("Status Task", options=status_options, index=status_idx)
prioridad_idx = prioridad_options.index(sel["prioridad"]) if sel["prioridad"] in prioridad_options else 0
prioridad = st.selectbox("Prioridad", options=prioridad_options, index=prioridad_idx)
assigned_emails = {c["email"] for c in sel["assigned_to"]}
current_assigned_labels = [lbl for lbl, e in collaborator_labels.items() if e in assigned_emails]
assigned_sel = st.multiselect("Assigned to", options=sorted(collaborator_labels), default=current_assigned_labels)
kick_off = st.date_input("Kick off", value=_parse_date(sel["kick_off"]))
due = st.date_input("Due date", value=_parse_date(sel["due_date"]))
horas = st.number_input("Horas estimadas", min_value=0.0, step=1.0, value=float(sel["horas_estimadas"] or 0))
interno_externo = st.text_input("INTERNO/EXTERNO", value=sel["interno_externo"])
recurrencia = st.text_input("Recurrencia", value=sel["recurrencia"])
save = st.form_submit_button("💾 Guardar cambios")
if save:
fields = {
"Task": task_name,
"Project": [project_id_by_name[p] for p in project_sel],
"Area": [area_id_by_name[a] for a in area_sel],
"Status Task": status,
"Prioridad": prioridad,
"Assigned to": _collaborator_fields([collaborator_labels[a] for a in assigned_sel]),
"Kick off": kick_off.isoformat() if kick_off else None,
"Due date": due.isoformat() if due else None,
"Horas estimadas": horas,
"INTERNO/EXTERNO": interno_externo,
"Recurrencia": recurrencia,
}
at.update_task(sel["id"], fields)
st.cache_data.clear()
st.success("Tarea actualizada.")
st.rerun()
_confirm_delete(f"task_{sel['id']}", sel["task"], lambda rid=sel["id"]: at.delete_task(rid))
# ── Clientes ──────────────────────────────────────────────────────────────── #
with tab_clientes:
df = pd.DataFrame(data["clients"])
if df.empty:
st.info("No hay clientes.")
else:
df["n_proyectos"] = df["project_ids"].apply(len)
df["n_tareas"] = df["task_ids"].apply(len)
st.dataframe(
df[["display_name", "company_name", "n_proyectos", "n_tareas"]],
use_container_width=True,
hide_index=True,
)
# ── Áreas ─────────────────────────────────────────────────────────────────── #
with tab_areas:
df = pd.DataFrame(data["areas"])
if df.empty:
st.info("No hay áreas.")
else:
df["n_tareas"] = df["task_ids"].apply(len)
st.dataframe(
df[["area", "descripcion", "n_tareas"]],
use_container_width=True,
hide_index=True,
)