dashboard / pages /full.py
devtools681420's picture
feat: Implement a new full dashboard page (`full.py`) featuring session control, Google Sheets data integration, and dynamic metric/chart display.
acd23bc
Raw
History Blame Contribute Delete
37.8 kB
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 &amp; 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 &amp; 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()