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>
424 lines
20 KiB
Python
424 lines
20 KiB
Python
"""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,
|
||
)
|