feat: Implement a new full dashboard page (`full.py`) featuring session control, Google Sheets data integration, and dynamic metric/chart display.
acd23bc | import streamlit as st | |
| from streamlit_cookies_controller import CookieController | |
| import streamlit.components.v1 as components | |
| import pandas as pd | |
| import json, time | |
| from datetime import datetime | |
| from gs_client import read_ws | |
| st.set_page_config(layout="wide", initial_sidebar_state="collapsed", page_title="PMJA - Dashboard") | |
| st.markdown("""<style> | |
| #MainMenu,footer,header,[data-testid="stToolbar"],[data-testid="stDecoration"], | |
| [data-testid="stStatusWidget"]{display:none!important} | |
| .block-container,.element-container,.stMarkdown{padding:0!important;margin:0!important;max-width:100%!important} | |
| iframe{position:fixed!important;top:0!important;left:0!important; | |
| width:100vw!important;height:100dvh!important;border:none!important;z-index:9999!important} | |
| [data-testid="stHorizontalBlock"]{gap:0!important;margin:0!important;padding:0!important; | |
| height:0!important;overflow:visible!important} | |
| .main,[data-testid="stAppViewContainer"]{background:#f0faf6!important} | |
| @media(max-width:900px){ | |
| iframe{position:static!important;width:100%!important;height:7500px!important;z-index:1!important} | |
| .main,[data-testid="stAppViewContainer"]{overflow-y:auto!important;height:auto!important} | |
| .block-container{height:auto!important} | |
| } | |
| </style>""", unsafe_allow_html=True) | |
| # ── SESSION ─────────────────────────────────────────────────────────── | |
| _cc = CookieController() | |
| COOKIE_NAME = "pmja_session" | |
| def load_session(): | |
| if st.session_state.get("logged_in") and st.session_state.get("session_exp"): | |
| if datetime.now() < st.session_state.session_exp: | |
| return True | |
| st.session_state.logged_in = False | |
| return False | |
| try: | |
| c = _cc.get(COOKIE_NAME) | |
| if not c: return False | |
| if datetime.now() >= datetime.fromisoformat(c["expiry"]): | |
| _cc.remove(COOKIE_NAME); return False | |
| st.session_state.update(logged_in=True, session_uid=c["user_id"], | |
| session_usr=c["username"], session_exp=datetime.fromisoformat(c["expiry"])) | |
| return True | |
| except Exception: | |
| return False | |
| if not load_session(): | |
| st.switch_page("app.py") | |
| if 'inicio_exibicao' not in st.session_state: | |
| st.session_state.inicio_exibicao = time.time() | |
| if time.time() - st.session_state.inicio_exibicao >= 120: | |
| st.session_state.inicio_exibicao = time.time() | |
| st.switch_page("pages/atual.py") | |
| # ── LOADING ─────────────────────────────────────────────────────────── | |
| loading_placeholder = st.empty() | |
| loading_placeholder.markdown(""" | |
| <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;700;800&family=Sora:wght@800&display=swap" rel="stylesheet"> | |
| <style> | |
| #ld{position:fixed;inset:0;z-index:999999; | |
| background:linear-gradient(135deg,#003a70 0%,#0056A3 50%,#064e3b 100%); | |
| display:flex;flex-direction:column;align-items:center;justify-content:center;font-family:'DM Sans',sans-serif} | |
| #ld .lg{display:flex;align-items:center;gap:24px;margin-bottom:36px} | |
| #ld img{height:34px;filter:brightness(0) invert(1);opacity:.9} | |
| #ld .dv{width:1px;height:32px;background:rgba(255,255,255,.25)} | |
| #ld h2{font-family:'Sora',sans-serif;font-size:clamp(15px,4vw,21px);color:#fff;font-weight:800;margin-bottom:4px;text-align:center} | |
| #ld p{font-size:clamp(11px,3vw,13px);color:rgba(255,255,255,.7);font-weight:600;margin-bottom:40px;text-align:center} | |
| #ld .sp{width:32px;height:32px;border:3px solid rgba(255,255,255,.2);border-top-color:#fff; | |
| border-radius:50%;animation:sp .8s linear infinite;margin-bottom:24px} | |
| @keyframes sp{to{transform:rotate(360deg)}} | |
| #ld .bw{width:min(260px,70vw);height:4px;background:rgba(255,255,255,.15);border-radius:99px;overflow:hidden;margin-bottom:14px} | |
| #ld .bf{height:100%;width:0%;background:#fff;border-radius:99px;transition:width .4s ease} | |
| #ld .st{font-size:11px;color:rgba(255,255,255,.6);font-weight:600;text-align:center} | |
| #ld .dt span{animation:dt 1.2s infinite;opacity:0} | |
| #ld .dt span:nth-child(2){animation-delay:.2s} | |
| #ld .dt span:nth-child(3){animation-delay:.4s} | |
| @keyframes dt{0%,100%{opacity:0}50%{opacity:1}} | |
| </style> | |
| <div id="ld"> | |
| <div class="lg"> | |
| <img src="https://companieslogo.com/img/orig/AZ2.F-d26946db.png?t=1720244490"> | |
| <div class="dv"></div> | |
| <img src="https://upload.wikimedia.org/wikipedia/commons/8/8f/Logo-engie.svg"> | |
| </div> | |
| <div class="sp"></div> | |
| <h2>PMJA — Dashboard Gestão de Materiais</h2> | |
| <p>Visão Completa · Recebimento & Expedição</p> | |
| <div class="bw"><div class="bf" id="ldbf"></div></div> | |
| <div class="st" id="ldst">Conectando ao Google Sheets<span class="dt"><span>.</span><span>.</span><span>.</span></span></div> | |
| </div> | |
| <script> | |
| (function(){ | |
| var s=[{p:15,m:'Conectando ao Google Sheets'},{p:35,m:'Carregando dados de recebimento'}, | |
| {p:55,m:'Carregando dados de expedição'},{p:72,m:'Processando métricas'}, | |
| {p:88,m:'Montando gráficos'},{p:96,m:'Quase pronto'}]; | |
| var i=0; | |
| function n(){ | |
| if(i>=s.length)return; | |
| var x=s[i++]; | |
| var b=document.getElementById('ldbf'),t=document.getElementById('ldst'); | |
| if(b)b.style.width=x.p+'%'; | |
| if(t)t.innerHTML=x.m+'<span class="dt"><span>.</span><span>.</span><span>.</span></span>'; | |
| setTimeout(n,950+Math.random()*550); | |
| } | |
| setTimeout(n,300); | |
| })(); | |
| </script> | |
| """, unsafe_allow_html=True) | |
| # ── LOAD DATA ───────────────────────────────────────────────────────── | |
| df_recebimento = read_ws("recebimento") | |
| df_rec_dados = read_ws("rec_dados") | |
| df_exp_raw = read_ws("exp_dados") | |
| loading_placeholder.empty() | |
| # ── HELPERS ─────────────────────────────────────────────────────────── | |
| def fmt(n): | |
| return "0" if pd.isna(n) else "{:,}".format(int(n)).replace(',','.') | |
| def proc_exp(df_raw): | |
| if df_raw is None or df_raw.empty: return None, None | |
| df = df_raw.copy() | |
| df.rename(columns={df.columns[0]:'mes'}, inplace=True) | |
| df = df[df['mes'].notna()].copy() | |
| df['data'] = pd.to_datetime(df['mes'], format='%m/%Y', errors='coerce') | |
| df = df.dropna(subset=['data']).copy() | |
| df['ano'] = df['data'].dt.year; df['mes_num'] = df['data'].dt.month | |
| req, unid, itens = [], [], [] | |
| for col in df.columns: | |
| c = col.lower().strip().replace('.','').replace(' ',' ') | |
| if 'requisi' in c and 'unid' not in c and 'por' not in c: req.append(col) | |
| if 'unid' in c and 'iten' in c and 'por' not in c: unid.append(col) | |
| if 'iten' in c and 'por' in c and 'unid' in c: itens.append(col) | |
| for col in req+unid+itens: | |
| if col in df.columns: df.loc[:,col] = pd.to_numeric(df[col], errors='coerce').fillna(0) | |
| df['qtd_requisicoes'] = df[req].sum(axis=1) | |
| df['qtd_unidades_emitidas'] = df[unid].sum(axis=1) | |
| df['qtd_itens_total'] = df[itens].sum(axis=1) if itens else 0 | |
| cols = ['mes_num','ano','qtd_requisicoes','qtd_unidades_emitidas','qtd_itens_total'] | |
| return (df[cols].sort_values(['ano','mes_num']).reset_index(drop=True), | |
| df[['mes_num','ano']+req+unid+itens].sort_values(['ano','mes_num']).reset_index(drop=True)) | |
| def get_sistemas(df): | |
| s = set() | |
| skip = {'Volumes','Unid. Itens','Itens por unidade','mes_ano','data','mes','ano'} | |
| for col in df.columns: | |
| for m in ['Volumes','Unid. Itens','Itens por unidade']: | |
| if m in col: | |
| cat = col.replace(m,'').strip() | |
| if cat and cat not in skip: s.add(cat) | |
| break | |
| return sorted(list(s)) | |
| def ext_cat(col): | |
| for s in [' Requisições',' Requisiçoes',' Unid. Itens',' Unid Itens', | |
| ' Itens por unidade',' Items por unidade',' Volumes']: | |
| col = col.replace(s,'') | |
| return col.strip() | |
| MESES = {1:'Jan',2:'Fev',3:'Mar',4:'Abr',5:'Mai',6:'Jun',7:'Jul',8:'Ago',9:'Set',10:'Out',11:'Nov',12:'Dez'} | |
| COR_REC = ['#003a70','#007dcc','#004d80','#4da6e6','#003d66','#99ccff'] | |
| COR_EXP = ['#064e3b','#10b981','#047857','#34d399','#065f46','#6ee7b7'] | |
| COR_SIS = ['#001a33','#002d4d','#003d66','#004d80','#005d99','#006db3', | |
| '#007dcc','#1a8dd4','#3399dd','#4da6e6','#66b3ee','#80c0f5','#99ccff','#b3d9ff'] | |
| COR_SIS_EXP = ['#064e3b','#065f46','#047857','#059669','#10b981','#34d399', | |
| '#4ade80','#6ee7b7','#86efac','#a7f3d0','#bbf7d0','#d1fae5','#ecfdf5','#f0fdf4'] | |
| def build_all_frames(df_rec, df_exp, df_rec_sis, df_exp_det, sistemas_rec, | |
| anos_rec, anos_exp, mes_max, mes_max_rec, mes_max_exp, mes_max_sis): | |
| frames = [] | |
| for mes in range(1, mes_max+1): | |
| frame = {"mes": mes, "metrics": {}, "charts": {}} | |
| for col, key, src in [ | |
| ('qtd_descarregamentos','desc','rec'),('peso_recebido','peso','rec'), | |
| ('qtd_volumes_recebidos','vol','rec'),('qtd_unidades_recebidos','unid','rec'), | |
| ('qtd_itens_por_unidade','itens','rec'),('qtd_requisicoes','req','exp'), | |
| ('qtd_unidades_emitidas','uem','exp'),('qtd_itens_total','itot','exp'), | |
| ]: | |
| if src=='rec': | |
| v = df_rec[df_rec['mes']<=mes][col].sum() if mes<=mes_max_rec else df_rec[col].sum() | |
| else: | |
| v = df_exp[df_exp['mes']<=mes][col].sum() if mes<=mes_max_exp else df_exp[col].sum() | |
| frame["metrics"][key] = fmt(v) | |
| def line_traces(df, col, anos, cores, lim): | |
| traces = [] | |
| m = mes if mes<=lim else lim | |
| for i,ano in enumerate(anos): | |
| d = df[(df['ano']==ano)&(df['mes']<=m)].sort_values('mes') | |
| if d.empty: continue | |
| cor = cores[i%len(cores)] | |
| traces.append({ | |
| "x":[int(x) for x in d['mes']], | |
| "y":[float(y) for y in d[col]], | |
| "text":[fmt(v) if v>0 else "" for v in d[col]], | |
| "line_color":cor, "ano":str(ano), "total":fmt(d[col].sum()) | |
| }) | |
| return traces | |
| if mes<=mes_max_rec: | |
| frame["charts"]["r1_0"] = line_traces(df_rec,'qtd_descarregamentos',anos_rec,COR_REC,mes_max_rec) | |
| frame["charts"]["r1_1"] = line_traces(df_rec,'peso_recebido',anos_rec,COR_REC,mes_max_rec) | |
| frame["charts"]["r1_2"] = line_traces(df_rec,'qtd_volumes_recebidos',anos_rec,COR_REC,mes_max_rec) | |
| if df_rec_sis is not None and mes<=mes_max_sis and sistemas_rec: | |
| cols_map = {} | |
| for col in df_rec_sis.columns: | |
| for s in sistemas_rec: | |
| if 'Volumes' in col and not any(e in col for e in ['Unid.','unidade']) and s in col: | |
| cols_map[col] = s | |
| slices = [] | |
| for i,s in enumerate(sistemas_rec): | |
| col = next((c for c,v in cols_map.items() if v==s), None) | |
| if col and col in df_rec_sis.columns: | |
| t = float(pd.to_numeric(df_rec_sis.loc[df_rec_sis['mes']<=mes,col],errors='coerce').fillna(0).sum()) | |
| if t>0: slices.append({"label":s,"value":t,"color":COR_SIS[i%len(COR_SIS)]}) | |
| if slices: frame["charts"]["r1_3"] = slices | |
| if mes<=mes_max_rec: | |
| frame["charts"]["r2_0"] = line_traces(df_rec,'qtd_unidades_recebidos',anos_rec,COR_REC,mes_max_rec) | |
| frame["charts"]["r2_2"] = line_traces(df_rec,'qtd_itens_por_unidade',anos_rec,COR_REC,mes_max_rec) | |
| if df_rec_sis is not None and mes<=mes_max_sis and sistemas_rec: | |
| for slot, palavra, excluir in [('r2_1','Unid. Itens',['por unidade']),('r2_3','Itens por unidade',['Unid. Itens'])]: | |
| cols_map2 = {} | |
| for col in df_rec_sis.columns: | |
| for s in sistemas_rec: | |
| if palavra in col and not any(e in col for e in excluir) and s in col: | |
| cols_map2[col] = s | |
| bars = [] | |
| for i,s in enumerate(sistemas_rec): | |
| col = next((c for c,v in cols_map2.items() if v==s), None) | |
| if col and col in df_rec_sis.columns: | |
| t = float(pd.to_numeric(df_rec_sis.loc[df_rec_sis['mes']<=mes,col],errors='coerce').fillna(0).sum()) | |
| if t>0: bars.append({"label":s,"value":t,"color":COR_SIS[i%len(COR_SIS)]}) | |
| if bars: frame["charts"][slot] = bars | |
| if mes<=mes_max_exp: | |
| frame["charts"]["e1_0"] = line_traces(df_exp,'qtd_requisicoes',anos_exp,COR_EXP,mes_max_exp) | |
| frame["charts"]["e1_1"] = line_traces(df_exp,'qtd_unidades_emitidas',anos_exp,COR_EXP,mes_max_exp) | |
| frame["charts"]["e1_2"] = line_traces(df_exp,'qtd_itens_total',anos_exp,COR_EXP,mes_max_exp) | |
| if df_exp_det is not None and mes<=mes_max_exp: | |
| det = df_exp_det[df_exp_det['mes_num']<=mes].copy() | |
| req_c = [c for c in df_exp_det.columns if 'requisi' in c.lower() and 'unid' not in c.lower() and 'por' not in c.lower()] | |
| uni_c = [c for c in df_exp_det.columns if 'unid' in c.lower() and 'iten' in c.lower().replace('.','') and 'por' not in c.lower()] | |
| it_c = [c for c in df_exp_det.columns if 'iten' in c.lower().replace('.','') and 'por' in c.lower() and 'unid' in c.lower()] | |
| for slot, cols in [('es_0',req_c),('es_1',uni_c),('es_2',it_c)]: | |
| if not cols: continue | |
| tots = {} | |
| for col in cols: | |
| cat = ext_cat(col) | |
| if cat not in tots: tots[cat]={'v':0.0,'c':COR_SIS_EXP[len(tots)%len(COR_SIS_EXP)]} | |
| det.loc[:,col] = pd.to_numeric(det[col],errors='coerce').fillna(0) | |
| tots[cat]['v'] += float(det[col].sum()) | |
| dados = sorted([(k,v) for k,v in tots.items() if v['v']>0],key=lambda x:x[1]['v'],reverse=True) | |
| if dados: frame["charts"][slot] = [{"label":k,"value":v['v'],"color":v['c']} for k,v in dados] | |
| frames.append(frame) | |
| return frames | |
| # ── PROCESS DATA ────────────────────────────────────────────────────── | |
| df_exp_raw = df_exp_raw if df_exp_raw is not None and not df_exp_raw.empty else None | |
| df_recebimento = df_recebimento if df_recebimento is not None and not df_recebimento.empty else None | |
| df_rec_dados = df_rec_dados if df_rec_dados is not None and not df_rec_dados.empty else None | |
| df_expedicao, df_exp_det = proc_exp(df_exp_raw) if df_exp_raw is not None else (None, None) | |
| df_rec, mes_max_rec = None, 0 | |
| if df_recebimento is not None: | |
| df_rec = df_recebimento.copy() | |
| if len(df_rec.columns)==6: | |
| df_rec.columns = ['mes_ano','qtd_descarregamentos','qtd_volumes_recebidos', | |
| 'peso_recebido','qtd_unidades_recebidos','qtd_itens_por_unidade'] | |
| df_rec['data'] = pd.to_datetime(df_rec['mes_ano'], format='%m/%Y', errors='coerce') | |
| df_rec = df_rec.dropna(subset=['data']).sort_values('data') | |
| df_rec['ano'] = df_rec['data'].dt.year; df_rec['mes'] = df_rec['data'].dt.month | |
| for col in ['qtd_descarregamentos','qtd_volumes_recebidos','peso_recebido', | |
| 'qtd_unidades_recebidos','qtd_itens_por_unidade']: | |
| if col in df_rec.columns: | |
| df_rec.loc[:,col] = pd.to_numeric(df_rec[col], errors='coerce').fillna(0) | |
| mes_max_rec = int(df_rec['mes'].max()) | |
| df_exp, mes_max_exp = None, 0 | |
| if df_expedicao is not None and not df_expedicao.empty: | |
| df_exp = df_expedicao.copy() | |
| df_exp.rename(columns={'mes_num':'mes'}, inplace=True) | |
| for col in ['qtd_requisicoes','qtd_unidades_emitidas','qtd_itens_total']: | |
| df_exp.loc[:,col] = pd.to_numeric(df_exp[col], errors='coerce').fillna(0) | |
| mes_max_exp = int(df_exp['mes'].max()) | |
| df_rec_sis, mes_max_sis, sistemas_rec = None, 0, [] | |
| if df_rec_dados is not None: | |
| df_rec_sis = df_rec_dados.copy() | |
| df_rec_sis['data'] = pd.to_datetime(df_rec_sis.iloc[:,0], format='%m/%Y', errors='coerce') | |
| df_rec_sis = df_rec_sis.dropna(subset=['data']).sort_values('data').copy() | |
| df_rec_sis['mes'] = df_rec_sis['data'].dt.month | |
| df_rec_sis['ano'] = df_rec_sis['data'].dt.year | |
| sistemas_rec = get_sistemas(df_rec_sis) | |
| mes_max_sis = int(df_rec_sis['mes'].max()) | |
| if df_rec is not None and df_exp is not None and mes_max_rec>0 and mes_max_exp>0: | |
| anos_rec = [int(a) for a in sorted(df_rec['ano'].unique())] | |
| anos_exp = [int(a) for a in sorted(df_exp['ano'].unique())] | |
| mes_max = max(mes_max_rec, mes_max_exp, mes_max_sis if mes_max_sis>0 else 0) | |
| frames = build_all_frames(df_rec, df_exp, df_rec_sis, df_exp_det, sistemas_rec, | |
| anos_rec, anos_exp, mes_max, mes_max_rec, mes_max_exp, mes_max_sis) | |
| frames_json = json.dumps(frames, ensure_ascii=False) | |
| meses_json = json.dumps(MESES) | |
| html = """<!DOCTYPE html> | |
| <html> | |
| <head> | |
| <meta name="viewport" content="width=device-width,initial-scale=1.0"> | |
| <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;600;700;800&family=Sora:wght@700;800;900&display=swap" rel="stylesheet"> | |
| <script src="https://cdn.plot.ly/plotly-2.27.0.min.js" charset="utf-8"></script> | |
| <style> | |
| * { margin:0; padding:0; box-sizing:border-box; } | |
| html, body { width:100%; height:100%; overflow:hidden; | |
| background:linear-gradient(160deg,#f0faf6 0%,#e6f7f1 55%,#d1fae5 100%); | |
| font-family:'DM Sans',sans-serif; } | |
| ::-webkit-scrollbar { width:4px; } | |
| ::-webkit-scrollbar-thumb { background:linear-gradient(#047857,#10b981); border-radius:10px; } | |
| .root { display:grid; width:100%; height:100dvh; gap:2px; padding:3px 4px 2px; overflow:hidden; | |
| grid-template-rows:auto auto auto 1fr 1fr auto 1fr 1fr; } | |
| .hdr { background:linear-gradient(135deg,#003a70 0%,#0056A3 50%,#064e3b 100%); | |
| border-radius:6px; padding:5px 14px; display:flex; align-items:center; | |
| justify-content:space-between; box-shadow:0 3px 14px rgba(0,58,112,.18); | |
| position:relative; overflow:hidden; } | |
| .hdr::before { content:''; position:absolute; top:-50%; right:-3%; width:200px; height:200px; | |
| border-radius:50%; background:radial-gradient(circle,rgba(16,185,129,.18) 0%,transparent 70%); | |
| pointer-events:none; } | |
| .logo { height:clamp(20px,3.2dvh,36px); filter:brightness(0) invert(1); opacity:.92; } | |
| .ht { font-family:'Sora',sans-serif; font-size:clamp(12px,1.9dvh,20px); | |
| color:#fff; font-weight:800; letter-spacing:-.3px; } | |
| .hs { font-family:'DM Sans',sans-serif; font-size:clamp(9px,1.2dvh,13px); | |
| color:rgba(255,255,255,.82); font-weight:600; } | |
| .mrow { display:grid; grid-template-columns:repeat(8,1fr); gap:3px; } | |
| .mc { background:#fff; border-radius:5px; | |
| padding:clamp(3px,.5dvh,7px) clamp(4px,.5vw,10px); | |
| border-left:3px solid; box-shadow:0 1px 6px rgba(4,120,87,.07); overflow:hidden; } | |
| .ml { font-family:'DM Sans',sans-serif; font-size:clamp(6px,.85dvh,10px); | |
| color:#6b7280; font-weight:700; text-transform:uppercase; | |
| letter-spacing:.4px; white-space:nowrap; } | |
| .mv { font-family:'Sora',sans-serif; font-size:clamp(11px,1.9dvh,22px); | |
| font-weight:800; line-height:1.1; letter-spacing:-.4px; | |
| white-space:nowrap; transition:all .3s; } | |
| .div { border-radius:5px; padding:clamp(2px,.35dvh,5px) 10px; | |
| display:flex; align-items:center; justify-content:center; } | |
| .div-rec { background:linear-gradient(135deg,#003a70 0%,#0075be 100%); | |
| box-shadow:0 2px 8px rgba(0,86,163,.15); } | |
| .div-exp { background:linear-gradient(135deg,#064e3b 0%,#10b981 100%); | |
| box-shadow:0 2px 8px rgba(6,78,59,.18); } | |
| .div-lbl { font-family:'Sora',sans-serif; font-size:clamp(9px,1.3dvh,15px); | |
| color:#fff; font-weight:800; letter-spacing:.5px; } | |
| .crow { display:grid; gap:3px; min-height:0; } | |
| .crow4 { grid-template-columns:repeat(4,1fr); } | |
| .crow3 { grid-template-columns:repeat(3,1fr); } | |
| .cb { background:#fff; border-radius:6px; border:1px solid rgba(16,185,129,.12); | |
| box-shadow:0 2px 8px rgba(4,120,87,.07); overflow:hidden; min-height:0; position:relative; } | |
| #countdown-badge { position:fixed; bottom:10px; right:14px; z-index:99999; | |
| background:rgba(6,78,59,0.82); color:#fff; font-family:'DM Sans',sans-serif; | |
| font-size:11px; font-weight:700; padding:4px 10px; border-radius:20px; | |
| letter-spacing:.4px; opacity:0; transition:opacity .4s; pointer-events:none; } | |
| #countdown-badge.visible { opacity:1; } | |
| @media(max-width:900px){ | |
| html,body { overflow-y:auto!important; height:auto!important; } | |
| .root { height:auto!important; overflow:visible!important; grid-template-rows:auto!important; } | |
| .mrow { grid-template-columns:repeat(4,1fr)!important; } | |
| .crow4 { grid-template-columns:repeat(2,1fr)!important; min-height:200px; } | |
| .crow3 { grid-template-columns:repeat(2,1fr)!important; min-height:200px; } | |
| .cb { min-height:220px!important; } | |
| } | |
| @media(max-width:600px){ | |
| .mrow { grid-template-columns:repeat(2,1fr)!important; } | |
| .crow4,.crow3 { grid-template-columns:1fr!important; } | |
| .cb { min-height:200px!important; } | |
| .hs { display:none; } | |
| .hdr { padding:4px 8px!important; } | |
| .div-lbl { font-size:12px!important; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="root"> | |
| <div class="hdr"> | |
| <img class="logo" src="https://companieslogo.com/img/orig/AZ2.F-d26946db.png?t=1720244490" alt="AZ"> | |
| <div style="text-align:center;flex:1;position:relative;z-index:1"> | |
| <div class="ht">PMJA — Dashboard Gestão de Materiais</div> | |
| <div class="hs">Visão Completa · Recebimento & Expedição</div> | |
| </div> | |
| <img class="logo" src="https://upload.wikimedia.org/wikipedia/commons/8/8f/Logo-engie.svg" alt="Engie"> | |
| </div> | |
| <div class="mrow"> | |
| <div class="mc" style="border-left-color:#003a70"><div class="ml">Descarregamentos</div><div class="mv" id="m_desc" style="color:#003a70">0</div></div> | |
| <div class="mc" style="border-left-color:#005a9c"><div class="ml">Peso Recebido</div><div class="mv" id="m_peso" style="color:#005a9c">0 kg</div></div> | |
| <div class="mc" style="border-left-color:#0075be"><div class="ml">Volumes</div><div class="mv" id="m_vol" style="color:#0075be">0</div></div> | |
| <div class="mc" style="border-left-color:#4d9fd6"><div class="ml">Unidades Rec</div><div class="mv" id="m_unid" style="color:#4d9fd6">0</div></div> | |
| <div class="mc" style="border-left-color:#80b9e5"><div class="ml">Itens/Unid</div><div class="mv" id="m_itens" style="color:#80b9e5">0</div></div> | |
| <div class="mc" style="border-left-color:#047857"><div class="ml">Requisições</div><div class="mv" id="m_req" style="color:#047857">0</div></div> | |
| <div class="mc" style="border-left-color:#059669"><div class="ml">Unid. Emitidas</div><div class="mv" id="m_uem" style="color:#059669">0</div></div> | |
| <div class="mc" style="border-left-color:#10b981"><div class="ml">Total Itens</div><div class="mv" id="m_itot" style="color:#10b981">0</div></div> | |
| </div> | |
| <div class="div div-rec"><span class="div-lbl">📦 RECEBIMENTO</span></div> | |
| <div class="crow crow4"> | |
| <div class="cb" id="r1_0"></div><div class="cb" id="r1_1"></div> | |
| <div class="cb" id="r1_2"></div><div class="cb" id="r1_3"></div> | |
| </div> | |
| <div class="crow crow4"> | |
| <div class="cb" id="r2_0"></div><div class="cb" id="r2_2"></div> | |
| <div class="cb" id="r2_1"></div><div class="cb" id="r2_3"></div> | |
| </div> | |
| <div class="div div-exp"><span class="div-lbl">🚚 EXPEDIÇÃO</span></div> | |
| <div class="crow crow3"> | |
| <div class="cb" id="e1_0"></div><div class="cb" id="e1_1"></div><div class="cb" id="e1_2"></div> | |
| </div> | |
| <div class="crow crow3"> | |
| <div class="cb" id="es_0"></div><div class="cb" id="es_1"></div><div class="cb" id="es_2"></div> | |
| </div> | |
| </div> | |
| <div id="countdown-badge">⏸ Reiniciando em <span id="cd-secs">60</span>s</div> | |
| <script> | |
| var FRAMES = __FRAMES__; | |
| var MESES = __MESES__; | |
| var REC_SLOTS = ['r1_0','r1_1','r1_2','r1_3','r2_0','r2_1','r2_2','r2_3']; | |
| var LINE_SLOTS = ['r1_0','r1_1','r1_2','r2_0','r2_2','e1_0','e1_1','e1_2']; | |
| var DONUT_SLOTS = ['r1_3','es_0']; | |
| var BARH_SLOTS = ['r2_1','es_1']; | |
| var BARV_SLOTS = ['r2_3','es_2']; | |
| var EXP_LINE = ['e1_0','e1_1','e1_2']; | |
| var METRIC_IDS = { | |
| desc:'m_desc', peso:'m_peso', vol:'m_vol', unid:'m_unid', itens:'m_itens', | |
| req:'m_req', uem:'m_uem', itot:'m_itot' | |
| }; | |
| var METRIC_SUF = { peso:' kg' }; | |
| var TITLES = { | |
| r1_0:'Descarregamentos', r1_1:'Peso Recebido (kg)', r1_2:'Volumes Recebidos', | |
| r1_3:'Volumes por Sistema', | |
| r2_0:'Unidades Recebidas', r2_1:'Unidades Itens por Sistema', | |
| r2_2:'Itens por Unidade', r2_3:'Itens/Unidade por Sistema', | |
| e1_0:'Requisições', e1_1:'Unidades Emitidas', e1_2:'Total de Itens', | |
| es_0:'Requisições por Sistema', es_1:'Unidades Emitidas por Sistema', es_2:'Itens por Sistema' | |
| }; | |
| var inited = {}; | |
| var FRAME_INTERVAL_MS = 600; | |
| var PAUSE_AFTER_MS = 60000; | |
| var frameIdx = 0; | |
| var animTimer = null; | |
| var cdTimer = null; | |
| var badge = null; | |
| var cdSecs = null; | |
| function gsize(id) { | |
| var el = document.getElementById(id); | |
| if (!el) return [200,150]; | |
| return [el.clientWidth||200, el.clientHeight||150]; | |
| } | |
| var ticktext = Object.values(MESES); | |
| var tickvals = Object.keys(MESES).map(Number); | |
| function fmtNum(v) { | |
| var n = parseFloat(v); | |
| if (isNaN(n)) return v+''; | |
| var i = Math.round(n); | |
| return i.toString().replace(/\\B(?=(\\d{3})+(?!\\d))/g,'.'); | |
| } | |
| function addTitle(layout, id, isRec) { | |
| var col = isRec ? '#003a70' : '#064e3b'; | |
| layout.title = { | |
| text:'<b>'+(TITLES[id]||id)+'</b>', | |
| font:{family:'Sora',size:11,color:col}, | |
| x:0.5, xanchor:'center' | |
| }; | |
| } | |
| // ── CORRECTED makeLine ──────────────────────────────────────────────── | |
| // Uses per-series yshift annotations (like makeLineExp) to prevent | |
| // value labels from overlapping when multiple years share the same x points. | |
| function makeLine(traces, id) { | |
| var wh = gsize(id); | |
| var isRec = REC_SLOTS.indexOf(id) >= 0; | |
| var grid = isRec ? 'rgba(0,86,163,0.10)' : 'rgba(16,185,129,0.10)'; | |
| var bg = isRec ? 'rgba(240,246,255,0.4)' : 'rgba(240,250,246,0.4)'; | |
| // Alternate label offsets per series so they don't stack on top of each other | |
| var yShifts = [16, 30, 44, 58]; | |
| var data = [], anns = []; | |
| traces.forEach(function(t, i) { | |
| data.push({ | |
| type:'scatter', mode:'lines+markers', | |
| x:t.x, y:t.y, | |
| line:{width:2.2, color:t.line_color, shape:'spline'}, | |
| marker:{size:6, color:t.line_color, line:{width:1.5, color:'white'}}, | |
| cliponaxis:false, showlegend:false, name:t.ano | |
| }); | |
| // Legend row for this series (stacked vertically, one per line) | |
| anns.push({ | |
| x: i * 0.28, y: 1.13, | |
| xref:'paper', yref:'paper', | |
| showarrow:false, xanchor:'left', yanchor:'top', | |
| font:{size:9, family:'DM Sans', color:t.line_color}, | |
| text:'<b>■ ' + t.ano + '</b> ' + t.total | |
| }); | |
| // Per-point value annotations with staggered yshift to avoid overlap | |
| var sh = yShifts[i % yShifts.length]; | |
| t.x.forEach(function(mx, j) { | |
| if (!t.text[j]) return; | |
| anns.push({ | |
| x: mx, y: t.y[j], | |
| xref:'x', yref:'y', | |
| showarrow: false, | |
| yshift: sh, | |
| font:{size:8, family:'DM Sans', color: t.line_color}, | |
| bgcolor:'rgba(0,0,0,0)', | |
| xanchor:'center', yanchor:'bottom', | |
| text: '<b>' + t.text[j] + '</b>' | |
| }); | |
| }); | |
| }); | |
| return { | |
| data: data, | |
| layout: { | |
| width:wh[0], height:wh[1], autosize:false, | |
| margin:{l:38, r:12, t:52, b:24}, | |
| paper_bgcolor:'white', plot_bgcolor:bg, | |
| showlegend:false, annotations:anns, | |
| xaxis:{ | |
| tickmode:'array', tickvals:tickvals, ticktext:ticktext, | |
| tickfont:{size:8, family:'DM Sans'}, | |
| gridcolor:grid, zeroline:false | |
| }, | |
| yaxis:{ | |
| tickfont:{size:8, family:'DM Sans'}, | |
| rangemode:'tozero', | |
| gridcolor:grid, zeroline:false | |
| } | |
| } | |
| }; | |
| } | |
| // ───────────────────────────────────────────────────────────────────── | |
| function makeLineExp(traces, id) { | |
| var wh = gsize(id); | |
| var data = [], anns = []; | |
| var shifts = [18,32,46,60]; | |
| traces.forEach(function(t, i) { | |
| data.push({ | |
| type:'scatter', mode:'lines+markers', | |
| x:t.x, y:t.y, | |
| line:{width:2.2,color:t.line_color,shape:'spline'}, | |
| marker:{size:6,color:t.line_color,line:{width:1.5,color:'white'}}, | |
| cliponaxis:false, showlegend:false, name:t.ano | |
| }); | |
| anns.push({ | |
| x:i*0.28, y:1.13, xref:'paper', yref:'paper', | |
| showarrow:false, xanchor:'left', yanchor:'top', | |
| font:{size:9,family:'DM Sans',color:t.line_color}, | |
| text:'<b>■ '+t.ano+'</b> '+t.total | |
| }); | |
| var sh = shifts[i % shifts.length]; | |
| t.x.forEach(function(mx,j) { | |
| if (!t.text[j]) return; | |
| anns.push({x:mx,y:t.y[j],xref:'x',yref:'y',showarrow:false,yshift:sh, | |
| font:{size:8,family:'DM Sans',color:'#1a1a1a'},bgcolor:'rgba(0,0,0,0)', | |
| xanchor:'center',yanchor:'bottom',text:t.text[j]}); | |
| }); | |
| }); | |
| return { | |
| data:data, | |
| layout:{ | |
| width:wh[0], height:wh[1], autosize:false, | |
| margin:{l:38,r:12,t:52,b:36}, | |
| paper_bgcolor:'white', plot_bgcolor:'rgba(240,250,246,0.4)', | |
| showlegend:false, annotations:anns, | |
| xaxis:{tickmode:'array',tickvals:tickvals,ticktext:ticktext, | |
| tickfont:{size:8,family:'DM Sans'}, | |
| gridcolor:'rgba(16,185,129,0.10)',zeroline:false,ticklabelstandoff:8}, | |
| yaxis:{tickfont:{size:8,family:'DM Sans'},rangemode:'tozero', | |
| gridcolor:'rgba(16,185,129,0.10)',zeroline:false} | |
| } | |
| }; | |
| } | |
| function makePie(slices, id) { | |
| var wh = gsize(id); | |
| var total = slices.reduce(function(s,x){ return s+x.value; }, 0); | |
| var labels = slices.map(function(x) { | |
| return x.label+': '+fmtNum(x.value)+' ('+(x.value/total*100).toFixed(1)+'%)'; | |
| }); | |
| return { | |
| data:[{ | |
| type:'pie', labels:labels, values:slices.map(function(x){ return x.value; }), | |
| hole:0.38, | |
| marker:{colors:slices.map(function(x){ return x.color; }), line:{color:'white',width:2}}, | |
| textfont:{size:8,family:'DM Sans'}, textposition:'inside' | |
| }], | |
| layout:{ | |
| width:wh[0], height:wh[1], autosize:false, | |
| margin:{l:6,r:90,t:36,b:8}, paper_bgcolor:'white', | |
| showlegend:true, | |
| legend:{orientation:'v',y:0.5,x:1.01,font:{size:7,family:'DM Sans'}} | |
| } | |
| }; | |
| } | |
| function makeBarH(bars, id) { | |
| var wh = gsize(id); | |
| var isRec = REC_SLOTS.indexOf(id) >= 0; | |
| var grid = isRec ? 'rgba(0,86,163,0.08)' : 'rgba(16,185,129,0.08)'; | |
| var maxVal = Math.max.apply(null, bars.map(function(b){ return b.value; })); | |
| var thresh = maxVal * 0.15; | |
| var anns = bars.map(function(b) { | |
| var ins = b.value >= thresh; | |
| return { | |
| x: ins ? b.value*0.96 : b.value+maxVal*0.02, | |
| y: b.label, | |
| xref:'x', yref:'y', showarrow:false, | |
| xanchor: ins?'right':'left', yanchor:'middle', | |
| text:'<b>'+fmtNum(b.value)+'</b>', | |
| font:{size:9,family:'DM Sans',color:ins?'white':'#374151'} | |
| }; | |
| }); | |
| return { | |
| data:[{ | |
| type:'bar', orientation:'h', | |
| x:bars.map(function(b){ return b.value; }), | |
| y:bars.map(function(b){ return b.label; }), | |
| text:bars.map(function(){ return ''; }), | |
| marker:{color:bars.map(function(b){ return b.color; }),opacity:0.90,line:{color:'rgba(255,255,255,0)',width:0}} | |
| }], | |
| layout:{ | |
| width:wh[0], height:wh[1], autosize:false, | |
| margin:{l:96,r:60,t:36,b:12}, | |
| paper_bgcolor:'white', plot_bgcolor:'rgba(248,252,250,0.6)', | |
| showlegend:false, bargap:0.28, annotations:anns, | |
| yaxis:{tickfont:{size:8,family:'DM Sans',color:'#4b5563'},automargin:true, | |
| gridcolor:'rgba(0,0,0,0)',zeroline:false}, | |
| xaxis:{tickfont:{size:8,family:'DM Sans',color:'#9ca3af'}, | |
| gridcolor:grid,zeroline:false,range:[0,maxVal*1.25]} | |
| }, | |
| _orient:'h' | |
| }; | |
| } | |
| function makeBarV(bars, id) { | |
| var wh = gsize(id); | |
| var isRec = REC_SLOTS.indexOf(id) >= 0; | |
| var grid = isRec ? 'rgba(0,86,163,0.08)' : 'rgba(16,185,129,0.08)'; | |
| var maxVal = Math.max.apply(null, bars.map(function(b){ return b.value; })); | |
| var anns = bars.map(function(b) { | |
| return {x:b.label,y:b.value,xref:'x',yref:'y',showarrow:false, | |
| xanchor:'center',yanchor:'bottom',yshift:4, | |
| text:'<b>'+fmtNum(b.value)+'</b>', | |
| font:{size:9,family:'DM Sans',color:'#374151'}}; | |
| }); | |
| return { | |
| data:[{ | |
| type:'bar', | |
| x:bars.map(function(b){ return b.label; }), | |
| y:bars.map(function(b){ return b.value; }), | |
| text:bars.map(function(){ return ''; }), | |
| marker:{color:bars.map(function(b){ return b.color; }),opacity:0.90,line:{color:'rgba(255,255,255,0)',width:0}} | |
| }], | |
| layout:{ | |
| width:wh[0], height:wh[1], autosize:false, | |
| margin:{l:38,r:12,t:42,b:100}, | |
| paper_bgcolor:'white', plot_bgcolor:'rgba(248,252,250,0.6)', | |
| showlegend:false, bargap:0.30, annotations:anns, | |
| xaxis:{tickfont:{size:7,family:'DM Sans',color:'#4b5563'}, | |
| tickangle:-45,zeroline:false,automargin:true,ticklabelstandoff:8}, | |
| yaxis:{tickfont:{size:8,family:'DM Sans',color:'#9ca3af'}, | |
| gridcolor:grid,zeroline:false,range:[0,maxVal*1.30]} | |
| }, | |
| _orient:'v' | |
| }; | |
| } | |
| function roundBars(boxEl, r, orient) { | |
| setTimeout(function() { | |
| boxEl.querySelectorAll('.bars path').forEach(function(p) { | |
| var d = p.getAttribute('d'); | |
| if (!d) return; | |
| var tok = d.match(/[A-Za-z][^A-Za-z]*/g)||[]; | |
| var cx=0,cy=0,aX=[],aY=[]; | |
| tok.forEach(function(t) { | |
| var cmd = t[0]; | |
| var pts = t.slice(1).trim().split(/[\\s,]+/).map(Number).filter(function(n){ return !isNaN(n); }); | |
| if (cmd==='M'||cmd==='L') { aX.push(pts[0]); aY.push(pts[1]); cx=pts[0]; cy=pts[1]; } | |
| else if (cmd==='H') { aX.push(pts[0]); aY.push(cy); cx=pts[0]; } | |
| else if (cmd==='V') { aX.push(cx); aY.push(pts[0]); cy=pts[0]; } | |
| }); | |
| if (aX.length<2) return; | |
| var x0=Math.min.apply(null,aX), x1=Math.max.apply(null,aX); | |
| var y0=Math.min.apply(null,aY), y1=Math.max.apply(null,aY); | |
| var w=x1-x0, h=y1-y0; | |
| if (w<=0||h<=0) return; | |
| var rr=Math.min(r,w/2,h/2), nd; | |
| if (orient==='h') { | |
| nd='M '+x0+','+y0+' H '+(x1-rr)+' Q '+x1+','+y0+' '+x1+','+(y0+rr) | |
| +' V '+(y1-rr)+' Q '+x1+','+y1+' '+(x1-rr)+','+y1+' H '+x0+' V '+y0+' Z'; | |
| } else { | |
| nd='M '+x0+','+y1+' H '+x1+' V '+(y0+rr) | |
| +' Q '+x1+','+y0+' '+(x1-rr)+','+y0 | |
| +' H '+(x0+rr)+' Q '+x0+','+y0+' '+x0+','+(y0+rr)+' V '+y1+' Z'; | |
| } | |
| p.setAttribute('d', nd); | |
| }); | |
| }, 60); | |
| } | |
| function renderFrame(f) { | |
| Object.keys(METRIC_IDS).forEach(function(k) { | |
| var el = document.getElementById(METRIC_IDS[k]); | |
| if (el && f.metrics[k] !== undefined) el.textContent = f.metrics[k]+(METRIC_SUF[k]||''); | |
| }); | |
| Object.keys(f.charts).forEach(function(id) { | |
| var box = document.getElementById(id); | |
| if (!box) return; | |
| var d = f.charts[id]; | |
| var isRec = REC_SLOTS.indexOf(id) >= 0; | |
| var spec; | |
| if (LINE_SLOTS.indexOf(id) >= 0) { | |
| spec = EXP_LINE.indexOf(id) >= 0 ? makeLineExp(d,id) : makeLine(d,id); | |
| } else if (DONUT_SLOTS.indexOf(id) >= 0) { | |
| spec = makePie(d, id); | |
| } else if (BARH_SLOTS.indexOf(id) >= 0) { | |
| spec = makeBarH(d, id); | |
| } else if (BARV_SLOTS.indexOf(id) >= 0) { | |
| spec = makeBarV(d, id); | |
| } | |
| if (!spec) return; | |
| addTitle(spec.layout, id, isRec); | |
| var isBar = (BARH_SLOTS.indexOf(id)>=0 || BARV_SLOTS.indexOf(id)>=0); | |
| var orient = spec._orient || 'v'; | |
| if (!inited[id]) { | |
| Plotly.newPlot(box, spec.data, spec.layout, {displayModeBar:false, responsive:false}); | |
| inited[id] = true; | |
| } else { | |
| Plotly.react(box, spec.data, spec.layout); | |
| } | |
| if (isBar) roundBars(box, 7, orient); | |
| }); | |
| } | |
| function showCountdown(secs) { | |
| badge.classList.add('visible'); | |
| var rem = secs; | |
| cdSecs.textContent = rem; | |
| cdTimer = setInterval(function() { | |
| rem--; | |
| cdSecs.textContent = rem; | |
| if (rem <= 0) { | |
| clearInterval(cdTimer); | |
| badge.classList.remove('visible'); | |
| frameIdx = 0; | |
| startAnimation(); | |
| } | |
| }, 1000); | |
| } | |
| function stepFrame() { | |
| renderFrame(FRAMES[frameIdx]); | |
| frameIdx++; | |
| if (frameIdx >= FRAMES.length) { | |
| clearTimeout(animTimer); | |
| showCountdown(PAUSE_AFTER_MS / 1000); | |
| } else { | |
| animTimer = setTimeout(stepFrame, FRAME_INTERVAL_MS); | |
| } | |
| } | |
| function startAnimation() { | |
| clearTimeout(animTimer); | |
| clearInterval(cdTimer); | |
| animTimer = setTimeout(stepFrame, FRAME_INTERVAL_MS); | |
| } | |
| window.addEventListener('load', function() { | |
| badge = document.getElementById('countdown-badge'); | |
| cdSecs = document.getElementById('cd-secs'); | |
| setTimeout(function(){ frameIdx=0; startAnimation(); }, 300); | |
| }); | |
| var _rt; | |
| window.addEventListener('resize', function() { | |
| clearTimeout(_rt); | |
| _rt = setTimeout(function() { | |
| document.querySelectorAll('.cb').forEach(function(box) { | |
| if (box.data) Plotly.relayout(box, {width:box.clientWidth, height:box.clientHeight}); | |
| }); | |
| }, 200); | |
| }); | |
| </script> | |
| </body> | |
| </html>""" | |
| html = (html | |
| .replace('__FRAMES__', frames_json) | |
| .replace('__MESES__', meses_json) | |
| ) | |
| components.html(html, height=8000, scrolling=False) | |
| else: | |
| components.html("""<!DOCTYPE html><html><body style="display:flex;align-items:center; | |
| justify-content:center;height:100dvh;font-family:sans-serif;color:#064e3b;background:#f0faf6;"> | |
| <div style="text-align:center"><div style="font-size:2em">📊</div> | |
| <p>Aguardando dados... Recarregue a página.</p></div></body></html>""", | |
| height=4000, scrolling=False) | |
| time.sleep(60) | |
| st.rerun() |