Añade login con Google y CRUD de proyectos/tareas
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>
This commit is contained in:
parent
663f058c00
commit
7f0b08d3dd
15
.streamlit/secrets.toml.example
Normal file
15
.streamlit/secrets.toml.example
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# Copia este archivo a .streamlit/secrets.toml (ya está en .gitignore) y
|
||||||
|
# rellena los valores reales de tu OAuth Client de Google Cloud Console.
|
||||||
|
#
|
||||||
|
# Pasos en Google Cloud Console:
|
||||||
|
# 1. Crea/configura la pantalla de consentimiento OAuth ("Google Auth Platform").
|
||||||
|
# 2. Crea credenciales -> OAuth client ID -> tipo "Web application".
|
||||||
|
# 3. En "Authorized redirect URIs" añade exactamente la redirect_uri de abajo
|
||||||
|
# (debe terminar en /oauth2callback). Para producción, usa el dominio real.
|
||||||
|
|
||||||
|
[auth]
|
||||||
|
redirect_uri = "http://localhost:8501/oauth2callback"
|
||||||
|
cookie_secret = "cambia-esto-por-una-cadena-aleatoria-larga"
|
||||||
|
client_id = "tu-client-id.apps.googleusercontent.com"
|
||||||
|
client_secret = "tu-client-secret"
|
||||||
|
server_metadata_url = "https://accounts.google.com/.well-known/openid-configuration"
|
||||||
@ -2,16 +2,20 @@ from pyairtable import Api
|
|||||||
import config
|
import config
|
||||||
|
|
||||||
|
|
||||||
def _collaborator_name(value):
|
def _collaborator(value):
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
return value.get("name") or value.get("email") or ""
|
return {
|
||||||
return ""
|
"id": value.get("id", ""),
|
||||||
|
"name": value.get("name") or value.get("email") or "",
|
||||||
|
"email": value.get("email", ""),
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _collaborator_names(values):
|
def _collaborators(values):
|
||||||
if not values:
|
if not values:
|
||||||
return []
|
return []
|
||||||
return [_collaborator_name(v) for v in values]
|
return [c for c in (_collaborator(v) for v in values) if c]
|
||||||
|
|
||||||
|
|
||||||
class AirtableClient:
|
class AirtableClient:
|
||||||
@ -32,14 +36,18 @@ class AirtableClient:
|
|||||||
result = []
|
result = []
|
||||||
for r in records:
|
for r in records:
|
||||||
f = r["fields"]
|
f = r["fields"]
|
||||||
|
project_lead = _collaborator(f.get("Project lead"))
|
||||||
|
working_team = _collaborators(f.get("Working team"))
|
||||||
result.append({
|
result.append({
|
||||||
"id": r["id"],
|
"id": r["id"],
|
||||||
"project": f.get("Project", ""),
|
"project": f.get("Project", ""),
|
||||||
"tags": f.get("Tags", []),
|
"tags": f.get("Tags", []),
|
||||||
"status": f.get("Status", ""),
|
"status": f.get("Status", ""),
|
||||||
"client_ids": f.get("Client", []),
|
"client_ids": f.get("Client", []),
|
||||||
"project_lead": _collaborator_name(f.get("Project lead")),
|
"project_lead": project_lead,
|
||||||
"working_team": _collaborator_names(f.get("Working team")),
|
"project_lead_name": project_lead["name"] if project_lead else "",
|
||||||
|
"working_team": working_team,
|
||||||
|
"working_team_names": [c["name"] for c in working_team],
|
||||||
"kickoff_date": f.get("Kickoff date"),
|
"kickoff_date": f.get("Kickoff date"),
|
||||||
"due_date": f.get("Due date"),
|
"due_date": f.get("Due date"),
|
||||||
"budget": f.get("Budget", 0),
|
"budget": f.get("Budget", 0),
|
||||||
@ -59,13 +67,15 @@ class AirtableClient:
|
|||||||
result = []
|
result = []
|
||||||
for r in records:
|
for r in records:
|
||||||
f = r["fields"]
|
f = r["fields"]
|
||||||
|
assigned_to = _collaborators(f.get("Assigned to"))
|
||||||
result.append({
|
result.append({
|
||||||
"id": r["id"],
|
"id": r["id"],
|
||||||
"task": f.get("Task", ""),
|
"task": f.get("Task", ""),
|
||||||
"project_ids": f.get("Project", []),
|
"project_ids": f.get("Project", []),
|
||||||
"area_ids": f.get("Area", []),
|
"area_ids": f.get("Area", []),
|
||||||
"status": f.get("Status Task", ""),
|
"status": f.get("Status Task", ""),
|
||||||
"assigned_to": _collaborator_names(f.get("Assigned to")),
|
"assigned_to": assigned_to,
|
||||||
|
"assigned_to_names": [c["name"] for c in assigned_to],
|
||||||
"kick_off": f.get("Kick off"),
|
"kick_off": f.get("Kick off"),
|
||||||
"due_date": f.get("Due date"),
|
"due_date": f.get("Due date"),
|
||||||
"days_to_complete": f.get("Days to complete"),
|
"days_to_complete": f.get("Days to complete"),
|
||||||
@ -103,3 +113,40 @@ class AirtableClient:
|
|||||||
"task_ids": f.get("Tasks", []),
|
"task_ids": f.get("Tasks", []),
|
||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_known_collaborators(projects: list[dict], tasks: list[dict]) -> dict:
|
||||||
|
"""Colaboradores conocidos por haber aparecido en algún proyecto o tarea, indexados por email."""
|
||||||
|
known = {}
|
||||||
|
for p in projects:
|
||||||
|
candidates = list(p["working_team"])
|
||||||
|
if p["project_lead"]:
|
||||||
|
candidates.append(p["project_lead"])
|
||||||
|
for c in candidates:
|
||||||
|
if c["email"]:
|
||||||
|
known[c["email"]] = c
|
||||||
|
for t in tasks:
|
||||||
|
for c in t["assigned_to"]:
|
||||||
|
if c["email"]:
|
||||||
|
known[c["email"]] = c
|
||||||
|
return known
|
||||||
|
|
||||||
|
# ── Escritura ─────────────────────────────────────────────────────────── #
|
||||||
|
|
||||||
|
def create_project(self, fields: dict) -> dict:
|
||||||
|
return self.projects.create(fields, typecast=True)
|
||||||
|
|
||||||
|
def update_project(self, record_id: str, fields: dict) -> dict:
|
||||||
|
return self.projects.update(record_id, fields, typecast=True)
|
||||||
|
|
||||||
|
def delete_project(self, record_id: str) -> None:
|
||||||
|
self.projects.delete(record_id)
|
||||||
|
|
||||||
|
def create_task(self, fields: dict) -> dict:
|
||||||
|
return self.tasks.create(fields, typecast=True)
|
||||||
|
|
||||||
|
def update_task(self, record_id: str, fields: dict) -> dict:
|
||||||
|
return self.tasks.update(record_id, fields, typecast=True)
|
||||||
|
|
||||||
|
def delete_task(self, record_id: str) -> None:
|
||||||
|
self.tasks.delete(record_id)
|
||||||
|
|||||||
318
dashboard.py
318
dashboard.py
@ -14,10 +14,38 @@ st.set_page_config(
|
|||||||
initial_sidebar_state="expanded",
|
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...")
|
@st.cache_data(ttl=300, show_spinner="Cargando datos de Airtable...")
|
||||||
def _load_data():
|
def _load_data():
|
||||||
at = AirtableClient()
|
|
||||||
return {
|
return {
|
||||||
"projects": at.get_projects(),
|
"projects": at.get_projects(),
|
||||||
"tasks": at.get_tasks(),
|
"tasks": at.get_tasks(),
|
||||||
@ -27,17 +55,99 @@ def _load_data():
|
|||||||
|
|
||||||
|
|
||||||
data = _load_data()
|
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_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_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_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")
|
st.title("📊 PM Dashboard")
|
||||||
|
|
||||||
tab_proyectos, tab_tareas, tab_clientes, tab_areas = st.tabs(
|
tab_mis_tareas, tab_proyectos, tab_tareas, tab_clientes, tab_areas = st.tabs(
|
||||||
["📁 Proyectos", "✅ Tareas", "👥 Clientes", "🗂️ Áreas"]
|
["🙋 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 ─────────────────────────────────────────────────────────────── #
|
# ── Proyectos ─────────────────────────────────────────────────────────────── #
|
||||||
|
|
||||||
with tab_proyectos:
|
with tab_proyectos:
|
||||||
@ -50,21 +160,106 @@ with tab_proyectos:
|
|||||||
)
|
)
|
||||||
|
|
||||||
col1, col2 = st.columns(2)
|
col1, col2 = st.columns(2)
|
||||||
status_filter = col1.multiselect("Status", sorted(df["status"].dropna().unique()))
|
status_options = sorted(df["status"].dropna().unique())
|
||||||
client_filter = col2.multiselect("Cliente", sorted(client_name_by_id.values()))
|
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()
|
view = df.copy()
|
||||||
if status_filter:
|
if status_filter:
|
||||||
view = view[view["status"].isin(status_filter)]
|
view = view[view["status"].isin(status_filter)]
|
||||||
if client_filter:
|
if client_filter:
|
||||||
view = view[view["client"].apply(lambda c: any(cf in c for cf in client_filter))]
|
view = view[view["client"].apply(lambda c: any(cf in c for cf in client_filter))]
|
||||||
|
view = view.reset_index(drop=True)
|
||||||
|
|
||||||
st.dataframe(
|
selection = st.dataframe(
|
||||||
view[["project", "status", "client", "project_lead", "kickoff_date", "due_date", "budget", "internal"]],
|
view[["project", "status", "client", "project_lead_name", "kickoff_date", "due_date", "budget", "internal"]],
|
||||||
use_container_width=True,
|
use_container_width=True,
|
||||||
hide_index=True,
|
hide_index=True,
|
||||||
|
on_select="rerun",
|
||||||
|
selection_mode="single-row",
|
||||||
|
key="proj_table",
|
||||||
)
|
)
|
||||||
st.caption(f"{len(view)} de {len(df)} proyectos")
|
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 ────────────────────────────────────────────────────────────────── #
|
# ── Tareas ────────────────────────────────────────────────────────────────── #
|
||||||
|
|
||||||
@ -79,13 +274,15 @@ with tab_tareas:
|
|||||||
df["area"] = df["area_ids"].apply(
|
df["area"] = df["area_ids"].apply(
|
||||||
lambda ids: ", ".join(area_name_by_id.get(i, "") for i in ids)
|
lambda ids: ", ".join(area_name_by_id.get(i, "") for i in ids)
|
||||||
)
|
)
|
||||||
df["assigned"] = df["assigned_to"].apply(lambda names: ", ".join(names))
|
df["assigned"] = df["assigned_to_names"].apply(lambda names: ", ".join(names))
|
||||||
|
|
||||||
col1, col2, col3 = st.columns(3)
|
col1, col2, col3 = st.columns(3)
|
||||||
status_filter = col1.multiselect("Status", sorted(df["status"].dropna().unique()))
|
status_options = sorted(df["status"].dropna().unique())
|
||||||
prioridad_filter = col2.multiselect("Prioridad", sorted(df["prioridad"].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(
|
assigned_filter = col3.multiselect(
|
||||||
"Asignado a", sorted({n for names in df["assigned_to"] for n in names})
|
"Asignado a", sorted({n for names in df["assigned_to_names"] for n in names}), key="task_assigned_filter"
|
||||||
)
|
)
|
||||||
|
|
||||||
view = df.copy()
|
view = df.copy()
|
||||||
@ -95,13 +292,106 @@ with tab_tareas:
|
|||||||
view = view[view["prioridad"].isin(prioridad_filter)]
|
view = view[view["prioridad"].isin(prioridad_filter)]
|
||||||
if assigned_filter:
|
if assigned_filter:
|
||||||
view = view[view["assigned"].apply(lambda a: any(af in a for af in assigned_filter))]
|
view = view[view["assigned"].apply(lambda a: any(af in a for af in assigned_filter))]
|
||||||
|
view = view.reset_index(drop=True)
|
||||||
|
|
||||||
st.dataframe(
|
selection = st.dataframe(
|
||||||
view[["task", "project", "area", "status", "prioridad", "assigned", "due_date", "horas_estimadas"]],
|
view[["task", "project", "area", "status", "prioridad", "assigned", "due_date", "horas_estimadas"]],
|
||||||
use_container_width=True,
|
use_container_width=True,
|
||||||
hide_index=True,
|
hide_index=True,
|
||||||
|
on_select="rerun",
|
||||||
|
selection_mode="single-row",
|
||||||
|
key="task_table",
|
||||||
)
|
)
|
||||||
st.caption(f"{len(view)} de {len(df)} tareas")
|
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 ──────────────────────────────────────────────────────────────── #
|
# ── Clientes ──────────────────────────────────────────────────────────────── #
|
||||||
|
|
||||||
|
|||||||
@ -2,3 +2,5 @@ pyairtable==3.3.0
|
|||||||
python-dotenv==1.2.2
|
python-dotenv==1.2.2
|
||||||
streamlit>=1.35.0
|
streamlit>=1.35.0
|
||||||
pandas>=2.0.0
|
pandas>=2.0.0
|
||||||
|
Authlib>=1.3.2
|
||||||
|
httpx
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user