commit 663f058c004c95ae1066996d029e35b7b4223d8d Author: José Manuel Gómez Date: Fri Jul 3 19:02:41 2026 +0200 Initial commit: pm-dashboard scaffold conectado a Airtable Streamlit dashboard con pestañas Proyectos/Tareas/Clientes/Áreas leyendo directamente de la base de Airtable de gestión de proyectos. Co-Authored-By: Claude Sonnet 5 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..aedd1f0 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +AIRTABLE_TOKEN=your_airtable_personal_access_token +AIRTABLE_BASE_ID=appXXXXXXXXXXXXXX diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6acf036 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +__pycache__/ +*.pyc +.streamlit/secrets.toml +.venv/ diff --git a/airtable_client.py b/airtable_client.py new file mode 100644 index 0000000..37469ba --- /dev/null +++ b/airtable_client.py @@ -0,0 +1,105 @@ +from pyairtable import Api +import config + + +def _collaborator_name(value): + if isinstance(value, dict): + return value.get("name") or value.get("email") or "" + return "" + + +def _collaborator_names(values): + if not values: + return [] + return [_collaborator_name(v) for v in values] + + +class AirtableClient: + def __init__(self): + self.api = Api(config.AIRTABLE_TOKEN) + self.projects = self.api.table(config.AIRTABLE_BASE_ID, config.PROJECTS_TABLE) + self.tasks = self.api.table(config.AIRTABLE_BASE_ID, config.TASKS_TABLE) + self.clients = self.api.table(config.AIRTABLE_BASE_ID, config.CLIENTS_TABLE) + self.areas = self.api.table(config.AIRTABLE_BASE_ID, config.AREAS_TABLE) + self.products = self.api.table(config.AIRTABLE_BASE_ID, config.PRODUCTS_TABLE) + + def get_projects(self) -> list[dict]: + records = self.projects.all(fields=[ + "Project", "Tags", "Status", "Client", "Project lead", + "Working team", "Kickoff date", "Due date", "Budget", + "Internal", "Code", "📝 Tasks", + ]) + result = [] + for r in records: + f = r["fields"] + result.append({ + "id": r["id"], + "project": f.get("Project", ""), + "tags": f.get("Tags", []), + "status": f.get("Status", ""), + "client_ids": f.get("Client", []), + "project_lead": _collaborator_name(f.get("Project lead")), + "working_team": _collaborator_names(f.get("Working team")), + "kickoff_date": f.get("Kickoff date"), + "due_date": f.get("Due date"), + "budget": f.get("Budget", 0), + "internal": f.get("Internal", False), + "code": f.get("Code", ""), + "task_ids": f.get("📝 Tasks", []), + }) + return result + + def get_tasks(self) -> list[dict]: + records = self.tasks.all(fields=[ + "Task", "Project", "Area", "Status Task", "Assigned to", + "Kick off", "Due date", "Days to complete", "Horas estimadas", + "Prioridad", "INTERNO/EXTERNO", "Recurrencia", + "Client (from Project)", + ]) + result = [] + for r in records: + f = r["fields"] + result.append({ + "id": r["id"], + "task": f.get("Task", ""), + "project_ids": f.get("Project", []), + "area_ids": f.get("Area", []), + "status": f.get("Status Task", ""), + "assigned_to": _collaborator_names(f.get("Assigned to")), + "kick_off": f.get("Kick off"), + "due_date": f.get("Due date"), + "days_to_complete": f.get("Days to complete"), + "horas_estimadas": f.get("Horas estimadas", 0), + "prioridad": f.get("Prioridad", ""), + "interno_externo": f.get("INTERNO/EXTERNO", ""), + "recurrencia": f.get("Recurrencia", ""), + "client_names": f.get("Client (from Project)", []), + }) + return result + + def get_clients(self) -> list[dict]: + records = self.clients.all(fields=["Display Name", "Company Name", "Projects", "Tasks"]) + result = [] + for r in records: + f = r["fields"] + result.append({ + "id": r["id"], + "display_name": f.get("Display Name", ""), + "company_name": f.get("Company Name", ""), + "project_ids": f.get("Projects", []), + "task_ids": f.get("Tasks", []), + }) + return result + + def get_areas(self) -> list[dict]: + records = self.areas.all(fields=["Área", "Descripción", "Tasks"]) + result = [] + for r in records: + f = r["fields"] + result.append({ + "id": r["id"], + "area": f.get("Área", ""), + "descripcion": f.get("Descripción", ""), + "task_ids": f.get("Tasks", []), + }) + return result diff --git a/config.py b/config.py new file mode 100644 index 0000000..a79f1f2 --- /dev/null +++ b/config.py @@ -0,0 +1,14 @@ +import os +from dotenv import load_dotenv + +load_dotenv() + +# Airtable +AIRTABLE_TOKEN = os.environ["AIRTABLE_TOKEN"] +AIRTABLE_BASE_ID = os.environ["AIRTABLE_BASE_ID"] + +PROJECTS_TABLE = "Projects" +TASKS_TABLE = "Tasks" +CLIENTS_TABLE = "Clients" +AREAS_TABLE = "Areas" +PRODUCTS_TABLE = "Products" diff --git a/dashboard.py b/dashboard.py new file mode 100644 index 0000000..8d4f8e0 --- /dev/null +++ b/dashboard.py @@ -0,0 +1,133 @@ +"""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", +) + + +@st.cache_data(ttl=300, show_spinner="Cargando datos de Airtable...") +def _load_data(): + at = AirtableClient() + return { + "projects": at.get_projects(), + "tasks": at.get_tasks(), + "clients": at.get_clients(), + "areas": at.get_areas(), + } + + +data = _load_data() + +client_name_by_id = {c["id"]: (c["display_name"] or c["company_name"]) for c in data["clients"]} +project_name_by_id = {p["id"]: p["project"] for p in data["projects"]} +area_name_by_id = {a["id"]: a["area"] for a in data["areas"]} + +st.title("📊 PM Dashboard") + +tab_proyectos, tab_tareas, tab_clientes, tab_areas = st.tabs( + ["📁 Proyectos", "✅ Tareas", "👥 Clientes", "🗂️ Áreas"] +) + +# ── 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_filter = col1.multiselect("Status", sorted(df["status"].dropna().unique())) + client_filter = col2.multiselect("Cliente", sorted(client_name_by_id.values())) + + 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))] + + st.dataframe( + view[["project", "status", "client", "project_lead", "kickoff_date", "due_date", "budget", "internal"]], + use_container_width=True, + hide_index=True, + ) + st.caption(f"{len(view)} de {len(df)} proyectos") + +# ── 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"].apply(lambda names: ", ".join(names)) + + col1, col2, col3 = st.columns(3) + status_filter = col1.multiselect("Status", sorted(df["status"].dropna().unique())) + prioridad_filter = col2.multiselect("Prioridad", sorted(df["prioridad"].dropna().unique())) + assigned_filter = col3.multiselect( + "Asignado a", sorted({n for names in df["assigned_to"] for n in names}) + ) + + 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))] + + st.dataframe( + view[["task", "project", "area", "status", "prioridad", "assigned", "due_date", "horas_estimadas"]], + use_container_width=True, + hide_index=True, + ) + st.caption(f"{len(view)} de {len(df)} tareas") + +# ── 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, + ) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..43381dd --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +pyairtable==3.3.0 +python-dotenv==1.2.2 +streamlit>=1.35.0 +pandas>=2.0.0