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 <noreply@anthropic.com>
This commit is contained in:
Jose Manuel 2026-07-03 19:02:41 +02:00
commit 663f058c00
6 changed files with 263 additions and 0 deletions

2
.env.example Normal file
View File

@ -0,0 +1,2 @@
AIRTABLE_TOKEN=your_airtable_personal_access_token
AIRTABLE_BASE_ID=appXXXXXXXXXXXXXX

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
.env
__pycache__/
*.pyc
.streamlit/secrets.toml
.venv/

105
airtable_client.py Normal file
View File

@ -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

14
config.py Normal file
View File

@ -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"

133
dashboard.py Normal file
View File

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

4
requirements.txt Normal file
View File

@ -0,0 +1,4 @@
pyairtable==3.3.0
python-dotenv==1.2.2
streamlit>=1.35.0
pandas>=2.0.0