"""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, )