Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -18,17 +18,14 @@ st.set_page_config(
|
|
| 18 |
# Utilidades
|
| 19 |
# -----------------------------
|
| 20 |
def normalize(s: str) -> str:
|
| 21 |
-
"""Normaliza un nombre de columna: minúsculas, sin acentos, sin dobles espacios."""
|
| 22 |
s = unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode("utf-8", "ignore")
|
| 23 |
return " ".join(s.lower().split())
|
| 24 |
|
| 25 |
def find_target_column(df: pd.DataFrame, target="extskhis_emp full name") -> str | None:
|
| 26 |
-
"""Encuentra la columna objetivo, siendo tolerante a acentos/espacios/caso."""
|
| 27 |
norm_map = {col: normalize(col) for col in df.columns}
|
| 28 |
for col, norm in norm_map.items():
|
| 29 |
if norm == normalize(target):
|
| 30 |
return col
|
| 31 |
-
# fallback: columnas muy parecidas
|
| 32 |
candidates = [c for c, n in norm_map.items() if "full" in n and "name" in n]
|
| 33 |
return candidates[0] if candidates else None
|
| 34 |
|
|
@@ -46,38 +43,36 @@ def pretty_number(n: int) -> str:
|
|
| 46 |
return f"{n:,}".replace(",", " ")
|
| 47 |
|
| 48 |
# -----------------------------
|
| 49 |
-
# Estilos (UI)
|
| 50 |
# -----------------------------
|
| 51 |
CUSTOM_CSS = """
|
| 52 |
<style>
|
| 53 |
-
|
| 54 |
-
.stApp { background: linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%); }
|
| 55 |
.block-container { padding-top: 1.5rem; }
|
| 56 |
|
| 57 |
/* Tarjetas KPI */
|
| 58 |
.kpi-card {
|
| 59 |
border-radius: 14px;
|
| 60 |
padding: 18px 20px;
|
| 61 |
-
background:
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
box-shadow: 0 10px 20px -12px rgba(0,0,0,0.12);
|
| 65 |
}
|
| 66 |
-
.kpi-label { font-size: 0.85rem; color: #
|
| 67 |
-
.kpi-value { font-size: 1.6rem; font-weight: 700; color: #
|
| 68 |
|
| 69 |
-
/*
|
| 70 |
.section-card {
|
| 71 |
border-radius: 16px;
|
| 72 |
padding: 20px;
|
| 73 |
-
background: #
|
| 74 |
-
border: 1px solid #
|
| 75 |
-
box-shadow: 0 12px 24px -16px rgba(0,0,0,0.
|
| 76 |
}
|
| 77 |
|
| 78 |
/* Título con acento */
|
| 79 |
h1 span.accent {
|
| 80 |
-
background: linear-gradient(90deg, #
|
| 81 |
-webkit-background-clip: text;
|
| 82 |
-webkit-text-fill-color: transparent;
|
| 83 |
}
|
|
@@ -93,24 +88,21 @@ st.sidebar.title("⚙️ Configuración")
|
|
| 93 |
uploaded = st.sidebar.file_uploader("Sube tu archivo CSV", type=["csv"])
|
| 94 |
sample_note = st.sidebar.empty()
|
| 95 |
|
| 96 |
-
# Carga de datos: CSV subido o fallback sample
|
| 97 |
df = None
|
| 98 |
source_label = ""
|
| 99 |
if uploaded is not None:
|
| 100 |
try:
|
| 101 |
-
# Usa bytes para mantener cache estable
|
| 102 |
data_bytes = uploaded.getvalue()
|
| 103 |
df = load_csv(io.BytesIO(data_bytes))
|
| 104 |
source_label = f"Fuente: Archivo subido — **{uploaded.name}**"
|
| 105 |
except Exception as e:
|
| 106 |
st.sidebar.error(f"Error al leer el CSV: {e}")
|
| 107 |
else:
|
| 108 |
-
# intenta cargar sample
|
| 109 |
df_sample = load_sample("data/sample.csv")
|
| 110 |
if df_sample is not None:
|
| 111 |
df = df_sample
|
| 112 |
source_label = "Fuente: `data/sample.csv` (muestra)"
|
| 113 |
-
sample_note.info("No subiste archivo. Mostrando
|
| 114 |
else:
|
| 115 |
sample_note.warning("No subiste archivo y no existe `data/sample.csv`. Sube un CSV para continuar.")
|
| 116 |
|
|
@@ -125,11 +117,10 @@ if df is None or df.empty:
|
|
| 125 |
|
| 126 |
target_col = find_target_column(df, "EXTSKHIS_EMP FULL NAME")
|
| 127 |
if target_col is None:
|
| 128 |
-
st.error("No se encontró la columna
|
| 129 |
st.write("Columnas detectadas:", list(df.columns))
|
| 130 |
st.stop()
|
| 131 |
|
| 132 |
-
# Limpieza básica del campo (opcional)
|
| 133 |
df[target_col] = df[target_col].astype(str).str.strip()
|
| 134 |
|
| 135 |
# -----------------------------
|
|
@@ -138,34 +129,24 @@ df[target_col] = df[target_col].astype(str).str.strip()
|
|
| 138 |
with st.sidebar:
|
| 139 |
st.divider()
|
| 140 |
st.subheader("Filtros")
|
| 141 |
-
search = st.text_input("Filtrar por nombre
|
| 142 |
min_count = st.number_input("Mínimo de ocurrencias", min_value=1, value=1, step=1)
|
| 143 |
top_n = st.slider("Mostrar Top N", min_value=5, max_value=100, value=20, step=5)
|
| 144 |
sort_mode = st.radio("Orden", ["Por conteo (desc)", "Alfabético (A→Z)"], index=0)
|
| 145 |
|
| 146 |
-
# Aplica filtro de texto
|
| 147 |
df_filtered = df
|
| 148 |
if search:
|
| 149 |
s = search.lower()
|
| 150 |
df_filtered = df[df[target_col].str.lower().str.contains(s, na=False)]
|
| 151 |
|
| 152 |
-
|
| 153 |
-
counts = (
|
| 154 |
-
df_filtered.groupby(target_col, dropna=False)
|
| 155 |
-
.size()
|
| 156 |
-
.reset_index(name="Count")
|
| 157 |
-
)
|
| 158 |
-
|
| 159 |
-
# Filtra por mínimo de ocurrencias
|
| 160 |
counts = counts[counts["Count"] >= min_count]
|
| 161 |
|
| 162 |
-
# Ordena
|
| 163 |
if sort_mode == "Por conteo (desc)":
|
| 164 |
counts = counts.sort_values("Count", ascending=False)
|
| 165 |
else:
|
| 166 |
counts = counts.sort_values(target_col, ascending=True)
|
| 167 |
|
| 168 |
-
# Top N
|
| 169 |
counts_top = counts.head(top_n)
|
| 170 |
|
| 171 |
# -----------------------------
|
|
@@ -188,48 +169,44 @@ with c3:
|
|
| 188 |
f"<div class='kpi-value'>{pretty_number(len(counts_top))}</div>"
|
| 189 |
"</div>", unsafe_allow_html=True)
|
| 190 |
|
| 191 |
-
# Fuente de datos
|
| 192 |
st.caption(source_label)
|
| 193 |
|
| 194 |
# -----------------------------
|
| 195 |
-
# Gráfico
|
| 196 |
# -----------------------------
|
| 197 |
-
st.markdown("###
|
| 198 |
|
| 199 |
if counts_top.empty:
|
| 200 |
st.warning("No hay filas que cumplan los filtros actuales.")
|
| 201 |
else:
|
| 202 |
-
# Para mejorar legibilidad en barras, hacemos categoría ordenada
|
| 203 |
-
category_order = counts_top.sort_values(
|
| 204 |
-
"Count", ascending=False
|
| 205 |
-
)[target_col].tolist()
|
| 206 |
-
|
| 207 |
fig = px.bar(
|
| 208 |
counts_top,
|
| 209 |
-
x=
|
| 210 |
-
y=
|
| 211 |
-
orientation="h",
|
| 212 |
text="Count",
|
| 213 |
-
|
| 214 |
-
|
|
|
|
| 215 |
)
|
| 216 |
-
fig.update_traces(textposition="outside"
|
| 217 |
fig.update_layout(
|
| 218 |
-
xaxis_title="
|
| 219 |
-
yaxis_title="
|
| 220 |
-
|
| 221 |
-
|
|
|
|
|
|
|
| 222 |
)
|
| 223 |
-
st.plotly_chart(fig, use_container_width=True
|
| 224 |
|
| 225 |
# -----------------------------
|
| 226 |
-
# Tabla
|
| 227 |
# -----------------------------
|
| 228 |
with st.expander("📄 Ver tabla de conteos"):
|
| 229 |
st.dataframe(counts.reset_index(drop=True), use_container_width=True)
|
| 230 |
|
| 231 |
# -----------------------------
|
| 232 |
-
# Descargar
|
| 233 |
# -----------------------------
|
| 234 |
csv_bytes = counts.to_csv(index=False).encode("utf-8")
|
| 235 |
st.download_button(
|
|
|
|
| 18 |
# Utilidades
|
| 19 |
# -----------------------------
|
| 20 |
def normalize(s: str) -> str:
|
|
|
|
| 21 |
s = unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode("utf-8", "ignore")
|
| 22 |
return " ".join(s.lower().split())
|
| 23 |
|
| 24 |
def find_target_column(df: pd.DataFrame, target="extskhis_emp full name") -> str | None:
|
|
|
|
| 25 |
norm_map = {col: normalize(col) for col in df.columns}
|
| 26 |
for col, norm in norm_map.items():
|
| 27 |
if norm == normalize(target):
|
| 28 |
return col
|
|
|
|
| 29 |
candidates = [c for c, n in norm_map.items() if "full" in n and "name" in n]
|
| 30 |
return candidates[0] if candidates else None
|
| 31 |
|
|
|
|
| 43 |
return f"{n:,}".replace(",", " ")
|
| 44 |
|
| 45 |
# -----------------------------
|
| 46 |
+
# Estilos (UI)
|
| 47 |
# -----------------------------
|
| 48 |
CUSTOM_CSS = """
|
| 49 |
<style>
|
| 50 |
+
.stApp { background-color: #0d1117; color: #e5e7eb; }
|
|
|
|
| 51 |
.block-container { padding-top: 1.5rem; }
|
| 52 |
|
| 53 |
/* Tarjetas KPI */
|
| 54 |
.kpi-card {
|
| 55 |
border-radius: 14px;
|
| 56 |
padding: 18px 20px;
|
| 57 |
+
background: #161b22;
|
| 58 |
+
border: 1px solid #30363d;
|
| 59 |
+
box-shadow: 0 8px 16px -10px rgba(0,0,0,0.8);
|
|
|
|
| 60 |
}
|
| 61 |
+
.kpi-label { font-size: 0.85rem; color: #9ca3af; margin-bottom: 6px; }
|
| 62 |
+
.kpi-value { font-size: 1.6rem; font-weight: 700; color: #f9fafb; }
|
| 63 |
|
| 64 |
+
/* Sección principal */
|
| 65 |
.section-card {
|
| 66 |
border-radius: 16px;
|
| 67 |
padding: 20px;
|
| 68 |
+
background: #161b22;
|
| 69 |
+
border: 1px solid #30363d;
|
| 70 |
+
box-shadow: 0 12px 24px -16px rgba(0,0,0,0.9);
|
| 71 |
}
|
| 72 |
|
| 73 |
/* Título con acento */
|
| 74 |
h1 span.accent {
|
| 75 |
+
background: linear-gradient(90deg, #60a5fa, #34d399, #fbbf24);
|
| 76 |
-webkit-background-clip: text;
|
| 77 |
-webkit-text-fill-color: transparent;
|
| 78 |
}
|
|
|
|
| 88 |
uploaded = st.sidebar.file_uploader("Sube tu archivo CSV", type=["csv"])
|
| 89 |
sample_note = st.sidebar.empty()
|
| 90 |
|
|
|
|
| 91 |
df = None
|
| 92 |
source_label = ""
|
| 93 |
if uploaded is not None:
|
| 94 |
try:
|
|
|
|
| 95 |
data_bytes = uploaded.getvalue()
|
| 96 |
df = load_csv(io.BytesIO(data_bytes))
|
| 97 |
source_label = f"Fuente: Archivo subido — **{uploaded.name}**"
|
| 98 |
except Exception as e:
|
| 99 |
st.sidebar.error(f"Error al leer el CSV: {e}")
|
| 100 |
else:
|
|
|
|
| 101 |
df_sample = load_sample("data/sample.csv")
|
| 102 |
if df_sample is not None:
|
| 103 |
df = df_sample
|
| 104 |
source_label = "Fuente: `data/sample.csv` (muestra)"
|
| 105 |
+
sample_note.info("No subiste archivo. Mostrando ejemplo.")
|
| 106 |
else:
|
| 107 |
sample_note.warning("No subiste archivo y no existe `data/sample.csv`. Sube un CSV para continuar.")
|
| 108 |
|
|
|
|
| 117 |
|
| 118 |
target_col = find_target_column(df, "EXTSKHIS_EMP FULL NAME")
|
| 119 |
if target_col is None:
|
| 120 |
+
st.error("No se encontró la columna requerida.")
|
| 121 |
st.write("Columnas detectadas:", list(df.columns))
|
| 122 |
st.stop()
|
| 123 |
|
|
|
|
| 124 |
df[target_col] = df[target_col].astype(str).str.strip()
|
| 125 |
|
| 126 |
# -----------------------------
|
|
|
|
| 129 |
with st.sidebar:
|
| 130 |
st.divider()
|
| 131 |
st.subheader("Filtros")
|
| 132 |
+
search = st.text_input("Filtrar por nombre", placeholder="Ej: Maria, Juan...")
|
| 133 |
min_count = st.number_input("Mínimo de ocurrencias", min_value=1, value=1, step=1)
|
| 134 |
top_n = st.slider("Mostrar Top N", min_value=5, max_value=100, value=20, step=5)
|
| 135 |
sort_mode = st.radio("Orden", ["Por conteo (desc)", "Alfabético (A→Z)"], index=0)
|
| 136 |
|
|
|
|
| 137 |
df_filtered = df
|
| 138 |
if search:
|
| 139 |
s = search.lower()
|
| 140 |
df_filtered = df[df[target_col].str.lower().str.contains(s, na=False)]
|
| 141 |
|
| 142 |
+
counts = df_filtered.groupby(target_col, dropna=False).size().reset_index(name="Count")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
counts = counts[counts["Count"] >= min_count]
|
| 144 |
|
|
|
|
| 145 |
if sort_mode == "Por conteo (desc)":
|
| 146 |
counts = counts.sort_values("Count", ascending=False)
|
| 147 |
else:
|
| 148 |
counts = counts.sort_values(target_col, ascending=True)
|
| 149 |
|
|
|
|
| 150 |
counts_top = counts.head(top_n)
|
| 151 |
|
| 152 |
# -----------------------------
|
|
|
|
| 169 |
f"<div class='kpi-value'>{pretty_number(len(counts_top))}</div>"
|
| 170 |
"</div>", unsafe_allow_html=True)
|
| 171 |
|
|
|
|
| 172 |
st.caption(source_label)
|
| 173 |
|
| 174 |
# -----------------------------
|
| 175 |
+
# Gráfico (vertical, colorido, fondo negro)
|
| 176 |
# -----------------------------
|
| 177 |
+
st.markdown("### 🎨 Conteo por **EXTSKHIS_EMP FULL NAME**")
|
| 178 |
|
| 179 |
if counts_top.empty:
|
| 180 |
st.warning("No hay filas que cumplan los filtros actuales.")
|
| 181 |
else:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
fig = px.bar(
|
| 183 |
counts_top,
|
| 184 |
+
x=target_col,
|
| 185 |
+
y="Count",
|
|
|
|
| 186 |
text="Count",
|
| 187 |
+
color=target_col, # cada barra con color distinto
|
| 188 |
+
color_discrete_sequence=px.colors.qualitative.Bold, # paleta llamativa
|
| 189 |
+
height=600,
|
| 190 |
)
|
| 191 |
+
fig.update_traces(textposition="outside")
|
| 192 |
fig.update_layout(
|
| 193 |
+
xaxis_title="Nombre",
|
| 194 |
+
yaxis_title="Conteo",
|
| 195 |
+
plot_bgcolor="#0d1117",
|
| 196 |
+
paper_bgcolor="#0d1117",
|
| 197 |
+
font=dict(color="white"),
|
| 198 |
+
margin=dict(l=10, r=10, t=30, b=50),
|
| 199 |
)
|
| 200 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 201 |
|
| 202 |
# -----------------------------
|
| 203 |
+
# Tabla
|
| 204 |
# -----------------------------
|
| 205 |
with st.expander("📄 Ver tabla de conteos"):
|
| 206 |
st.dataframe(counts.reset_index(drop=True), use_container_width=True)
|
| 207 |
|
| 208 |
# -----------------------------
|
| 209 |
+
# Descargar
|
| 210 |
# -----------------------------
|
| 211 |
csv_bytes = counts.to_csv(index=False).encode("utf-8")
|
| 212 |
st.download_button(
|