Spaces:
Sleeping
Sleeping
| import os | |
| import io | |
| import unicodedata | |
| import pandas as pd | |
| import streamlit as st | |
| import plotly.express as px | |
| # ----------------------------- | |
| # Configuración de la página | |
| # ----------------------------- | |
| st.set_page_config( | |
| page_title="Employee Count Dashboard", | |
| page_icon="📊", | |
| layout="wide", | |
| ) | |
| # ----------------------------- | |
| # Utilidades | |
| # ----------------------------- | |
| def normalize(s: str) -> str: | |
| """Normaliza un nombre de columna: minúsculas, sin acentos, sin dobles espacios.""" | |
| s = unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode("utf-8", "ignore") | |
| return " ".join(s.lower().split()) | |
| def find_target_column(df: pd.DataFrame, target="extskhis_emp full name") -> str | None: | |
| """Encuentra la columna objetivo, siendo tolerante a acentos/espacios/caso.""" | |
| norm_map = {col: normalize(col) for col in df.columns} | |
| for col, norm in norm_map.items(): | |
| if norm == normalize(target): | |
| return col | |
| # fallback: columnas muy parecidas | |
| candidates = [c for c, n in norm_map.items() if "full" in n and "name" in n] | |
| return candidates[0] if candidates else None | |
| def load_csv(file_obj) -> pd.DataFrame: | |
| return pd.read_csv(file_obj) | |
| def load_sample(sample_path: str) -> pd.DataFrame | None: | |
| if os.path.exists(sample_path): | |
| return pd.read_csv(sample_path) | |
| return None | |
| def pretty_number(n: int) -> str: | |
| return f"{n:,}".replace(",", " ") | |
| # ----------------------------- | |
| # Estilos (UI) – look limpio | |
| # ----------------------------- | |
| CUSTOM_CSS = """ | |
| <style> | |
| /* Fondo suave y tarjetas con glass effect */ | |
| .stApp { background: linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%); } | |
| .block-container { padding-top: 1.5rem; } | |
| /* Tarjetas KPI */ | |
| .kpi-card { | |
| border-radius: 14px; | |
| padding: 18px 20px; | |
| background: rgba(255, 255, 255, 0.75); | |
| backdrop-filter: blur(6px); | |
| border: 1px solid rgba(0,0,0,0.06); | |
| box-shadow: 0 10px 20px -12px rgba(0,0,0,0.12); | |
| } | |
| .kpi-label { font-size: 0.85rem; color: #5b6573; margin-bottom: 6px; } | |
| .kpi-value { font-size: 1.6rem; font-weight: 700; color: #111827; } | |
| /* Contenedor principal */ | |
| .section-card { | |
| border-radius: 16px; | |
| padding: 20px; | |
| background: #ffffff; | |
| border: 1px solid #e5e7eb; | |
| box-shadow: 0 12px 24px -16px rgba(0,0,0,0.18); | |
| } | |
| /* Título con acento */ | |
| h1 span.accent { | |
| background: linear-gradient(90deg, #2563eb, #06b6d4); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| } | |
| </style> | |
| """ | |
| st.markdown(CUSTOM_CSS, unsafe_allow_html=True) | |
| # ----------------------------- | |
| # Sidebar (carga de datos) | |
| # ----------------------------- | |
| st.sidebar.title("⚙️ Configuración") | |
| uploaded = st.sidebar.file_uploader("Sube tu archivo CSV", type=["csv"]) | |
| sample_note = st.sidebar.empty() | |
| # Carga de datos: CSV subido o fallback sample | |
| df = None | |
| source_label = "" | |
| if uploaded is not None: | |
| try: | |
| # Usa bytes para mantener cache estable | |
| data_bytes = uploaded.getvalue() | |
| df = load_csv(io.BytesIO(data_bytes)) | |
| source_label = f"Fuente: Archivo subido — **{uploaded.name}**" | |
| except Exception as e: | |
| st.sidebar.error(f"Error al leer el CSV: {e}") | |
| else: | |
| # intenta cargar sample | |
| df_sample = load_sample("data/sample.csv") | |
| if df_sample is not None: | |
| df = df_sample | |
| source_label = "Fuente: `data/sample.csv` (muestra)" | |
| sample_note.info("No subiste archivo. Mostrando un ejemplo desde `data/sample.csv`.") | |
| else: | |
| sample_note.warning("No subiste archivo y no existe `data/sample.csv`. Sube un CSV para continuar.") | |
| # ----------------------------- | |
| # UI principal | |
| # ----------------------------- | |
| st.title("📊 Employee Count Dashboard · <span class='accent'>EXTSKHIS_EMP FULL NAME</span>", anchor=False) | |
| if df is None or df.empty: | |
| st.info("Sube un CSV con la columna **EXTSKHIS_EMP FULL NAME** para ver el dashboard.") | |
| st.stop() | |
| target_col = find_target_column(df, "EXTSKHIS_EMP FULL NAME") | |
| if target_col is None: | |
| st.error("No se encontró la columna **EXTSKHIS_EMP FULL NAME** (o equivalente). Verifica los encabezados.") | |
| st.write("Columnas detectadas:", list(df.columns)) | |
| st.stop() | |
| # Limpieza básica del campo (opcional) | |
| df[target_col] = df[target_col].astype(str).str.strip() | |
| # ----------------------------- | |
| # Controles | |
| # ----------------------------- | |
| with st.sidebar: | |
| st.divider() | |
| st.subheader("Filtros") | |
| search = st.text_input("Filtrar por nombre (contiene)", placeholder="Ej: Maria, Juan...") | |
| min_count = st.number_input("Mínimo de ocurrencias", min_value=1, value=1, step=1) | |
| top_n = st.slider("Mostrar Top N", min_value=5, max_value=100, value=20, step=5) | |
| sort_mode = st.radio("Orden", ["Por conteo (desc)", "Alfabético (A→Z)"], index=0) | |
| # Aplica filtro de texto | |
| df_filtered = df | |
| if search: | |
| s = search.lower() | |
| df_filtered = df[df[target_col].str.lower().str.contains(s, na=False)] | |
| # Agrega conteos | |
| counts = ( | |
| df_filtered.groupby(target_col, dropna=False) | |
| .size() | |
| .reset_index(name="Count") | |
| ) | |
| # Filtra por mínimo de ocurrencias | |
| counts = counts[counts["Count"] >= min_count] | |
| # Ordena | |
| if sort_mode == "Por conteo (desc)": | |
| counts = counts.sort_values("Count", ascending=False) | |
| else: | |
| counts = counts.sort_values(target_col, ascending=True) | |
| # Top N | |
| counts_top = counts.head(top_n) | |
| # ----------------------------- | |
| # KPIs | |
| # ----------------------------- | |
| c1, c2, c3 = st.columns(3) | |
| with c1: | |
| st.markdown("<div class='kpi-card'>" | |
| "<div class='kpi-label'>Registros totales</div>" | |
| f"<div class='kpi-value'>{pretty_number(len(df))}</div>" | |
| "</div>", unsafe_allow_html=True) | |
| with c2: | |
| st.markdown("<div class='kpi-card'>" | |
| "<div class='kpi-label'>Nombres únicos</div>" | |
| f"<div class='kpi-value'>{pretty_number(counts[target_col].nunique())}</div>" | |
| "</div>", unsafe_allow_html=True) | |
| with c3: | |
| st.markdown("<div class='kpi-card'>" | |
| "<div class='kpi-label'>Mostrando en gráfico</div>" | |
| f"<div class='kpi-value'>{pretty_number(len(counts_top))}</div>" | |
| "</div>", unsafe_allow_html=True) | |
| # Fuente de datos | |
| st.caption(source_label) | |
| # ----------------------------- | |
| # Gráfico | |
| # ----------------------------- | |
| st.markdown("### 🔎 Conteo por **EXTSKHIS_EMP FULL NAME**") | |
| if counts_top.empty: | |
| st.warning("No hay filas que cumplan los filtros actuales.") | |
| else: | |
| # Para mejorar legibilidad en barras, hacemos categoría ordenada | |
| category_order = counts_top.sort_values( | |
| "Count", ascending=False | |
| )[target_col].tolist() | |
| fig = px.bar( | |
| counts_top, | |
| x="Count", | |
| y=target_col, | |
| orientation="h", | |
| text="Count", | |
| category_orders={target_col: category_order}, | |
| height=600 if len(counts_top) <= 25 else 800, | |
| ) | |
| fig.update_traces(textposition="outside", cliponaxis=False) | |
| fig.update_layout( | |
| xaxis_title="Conteo", | |
| yaxis_title="Nombre", | |
| margin=dict(l=10, r=10, t=30, b=10), | |
| bargap=0.25, | |
| ) | |
| st.plotly_chart(fig, use_container_width=True, theme="streamlit") | |
| # ----------------------------- | |
| # Tabla de detalle | |
| # ----------------------------- | |
| with st.expander("📄 Ver tabla de conteos"): | |
| st.dataframe(counts.reset_index(drop=True), use_container_width=True) | |
| # ----------------------------- | |
| # Descargar resultados | |
| # ----------------------------- | |
| csv_bytes = counts.to_csv(index=False).encode("utf-8") | |
| st.download_button( | |
| "⬇️ Descargar conteos (CSV)", | |
| data=csv_bytes, | |
| file_name="employee_counts.csv", | |
| mime="text/csv", | |
| ) |