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:
commit
663f058c00
2
.env.example
Normal file
2
.env.example
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
AIRTABLE_TOKEN=your_airtable_personal_access_token
|
||||||
|
AIRTABLE_BASE_ID=appXXXXXXXXXXXXXX
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.streamlit/secrets.toml
|
||||||
|
.venv/
|
||||||
105
airtable_client.py
Normal file
105
airtable_client.py
Normal 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
14
config.py
Normal 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
133
dashboard.py
Normal 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
4
requirements.txt
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
pyairtable==3.3.0
|
||||||
|
python-dotenv==1.2.2
|
||||||
|
streamlit>=1.35.0
|
||||||
|
pandas>=2.0.0
|
||||||
Loading…
x
Reference in New Issue
Block a user